Merge pull request #481 from prxnvt/pr/form-controls

Form controls: buttons, sliders and spin buttons, with Assign Action, Format Control and XLSX round trip
This commit is contained in:
FredApps authored and GitHub committed 2026-11-05 04:16:46 +01:00
commit 642980b724
83 files changed
+6175 -157

No files matched your search

+4 -2
View File
@@ -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 (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)
> **Last reviewed:** 2026-11-01 · **Last updated:** 2026-11-02 · **Change:** minor (form controls: buttons, sliders and spin buttons; earlier: 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.
@@ -22,7 +22,7 @@ This page is the summary. The detail is in:
| Number | Value | Kind |
|---|---|---|
| 2026-10-14 | Editing a cell with text that would read back as a number, boolean, date or formula (entered with a leading apostrophe) shows the apostrophe in the editor and formula bar, so Enter unchanged keeps it text (#1362) |
| **Feature breadth** (weighted) | **~75%** | estimated, anchored on two measurements: ribbon catalog 277 / 292 (94.9%), worksheet functions 513 / 523 (98.1%; 504 working, 96.4%) |
| **Feature breadth** (weighted) | **~75%** | estimated, anchored on two measurements: ribbon catalog 281 / 296 (94.9%), worksheet functions 513 / 523 (98.1%; 504 working, 96.4%) |
| **Ready for real work** | **~50%** | estimated from written weights (51.6); [target-app-parity.md](docs/target-app-parity.md) |
| **Mainstream practitioner** | **~50%** | estimated: typical analyst/finance user, weekly features, × 0.90 interaction × 0.92 stability × 0.88 file exchange; [method](docs/target-app-parity.md#mainstream-practitioner) |
| **Essentials user** | **~63%** | estimated: core features only, × 0.92 launch/stability × 0.95 clarity × 0.90 opening sent files; [method](docs/target-app-parity.md#essentials-user) |
@@ -135,6 +135,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in
| Date | What landed |
|---|---|
| 2026-11-02 | Form controls: Insert › Controls places buttons, sliders (scroll bars) and spin buttons on the sheet. A slider or spin button holds a whole number from 0 to 30000 in a linked cell (a drag or a held arrow is one undo step, and editing the cell moves the control); a button runs a macro or one command through the macro policy, asking first in a workbook opened from a file; Assign Action and Format Control edit them, and XLSX files read and write them (control properties, `<controls>` and VML), alongside the form controls GridCraft keeps unchanged |
| 2026-11-02 | Dialogs take ranges by selection on the grid: reference boxes (RefEdit) with a collapse button that shrinks the dialog while the person selects, in Goal Seek, Solver (including the Model Range of Load/Save) and Data Analysis; while collapsed, the arrow keys move the selection and Shift+arrow extends it, and F4 cycles a reference's anchors |
| 2026-11-02 | Python mode in the formula bar: Ctrl+Alt+Shift+P, `=PY` then Tab, or editing a Python cell edits its code in the formula bar, with Enter for a new line, Tab to indent and Ctrl+Enter to commit through `python.set`. The code still doesn't run |
| 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 |
@@ -257,6 +258,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in
| Date | Change | Summary |
|---|---|---|
| 2026-11-02 | minor | Form controls; catalog 277 / 292 → 281 / 296 (Developer › Controls); progress log row |
| 2026-11-02 | minor | Progress log: RefEdit reference boxes in dialogs |
| 2026-11-02 | minor | Progress log: Python mode in the formula bar |
| 2026-11-02 | minor | Progress log: Python cells as data |
+5
View File
@@ -275,6 +275,11 @@ pub const CATALOG: &[(&str, &str, &str, &str)] = &[
("automate.allScripts", "Automate", "Scripting Tools", "All Scripts"),
("automate.recordActions", "Automate", "Scripting Tools", "Record Actions"),
("automate.trustScripts", "Automate", "Scripting Tools", "Trust Scripts"),
// Developer (Insert › Controls in GridCraft)
("insert.button", "Developer", "Controls", "Button (Form Control)"),
("insert.spinButton", "Developer", "Controls", "Spin Button (Form Control)"),
("insert.slider", "Developer", "Controls", "Scroll Bar (Form Control)"),
("control.format", "Developer", "Controls", "Properties"),
// Table Design
("table.rename", "Table Design", "Properties", "Table Name"),
("table.resize", "Table Design", "Properties", "Resize Table"),
+3
View File
@@ -2,6 +2,7 @@
//! `data.sortAscending`) plus primitive editing commands (`cell.set`, `selection.set`).
pub mod analysis;
pub mod controls;
pub mod data;
pub mod draw;
pub mod edit;
@@ -165,6 +166,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
v.extend(format::specs());
v.extend(sheet::specs());
v.extend(insert::specs());
v.extend(controls::specs());
v.extend(equation::specs());
v.extend(data::specs());
v.extend(formulas::specs());
@@ -435,6 +437,7 @@ pub(crate) fn commit<R>(d: &mut DocState, f: impl FnOnce(&mut Ctx) -> Result<R>)
} else if !changed.is_empty() {
d.calc.cells_changed(&mut wb, &changed);
}
controls::sync_linked_values(&mut wb);
if let Some(t0) = t0 {
eprintln!("commit: + recalc {:?}", since(t0));
}
+711
View File
@@ -0,0 +1,711 @@
//! Form controls (Insert › Controls): buttons that run a macro or a command, and sliders and spin
//! buttons that hold a whole number from 0 to 30000 and write it to a linked cell.
//!
//! A slider or spin button's value lives on the control and in its linked cell. Setting it goes
//! through `cell.set`, so the cell's dependents recalculate and Undo takes both back as one step;
//! editing the linked cell moves the control (see [`sync_linked_values`]). Pressing a button runs
//! its action through the macro policy with the document's trust, so a button from a file someone
//! else made asks before it runs anything.
use gridcraft_core::CellRef;
use gridcraft_model::{
Anchor, AnchorMode, CONTROL_MAX, ControlAction, ControlKind, FormControl, MAX_MACRO_NAME, MacroInput, ObjKind, Sheet, Workbook,
};
use serde_json::{Value as Json, json};
use super::{
CommandSpec, EngineError, PROTECTED, Result, Session, anchor_mode_param, bad, bool_param, cell_param, cmd, edit, f64_param, find_command,
has_doc, split_sheet, stash, str_param, u32_param,
};
use crate::macros::policy::{Policy, macro_policy};
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(
"insert.button",
"Button",
["Insert", "Controls"],
None,
"{at?: \"B2\" (or cell), dx?, dy?, width?, height?, text?, macro?: name, input?: \"selection\" | \"B2:D9\", output?: \"F2\", command?: id, params?: {..}} places a button; pressing it runs the macro or the command → {control: id}",
has_doc,
insert_button
),
cmd!(
"insert.slider",
"Slider",
["Insert", "Controls"],
None,
"{at?: \"B2\" (or cell), dx?, dy?, width?, height?, link?: \"$B$1\" | \"Sheet2!$B$1\", min?: 0, max?: 100, step?: 1, page?: 10, value?, horizontal?: true} whole numbers, 0 ≤ min < max ≤ 30000, 1 ≤ step ≤ max − min, page ≥ step → {control: id}",
has_doc,
|s, p| insert_number_control(s, p, ControlKind::Slider)
),
cmd!(
"insert.spinButton",
"Spin Button",
["Insert", "Controls"],
None,
"{at?: \"B2\" (or cell), dx?, dy?, width?, height?, link?: \"$B$1\", min?: 0, max?: 100, step?: 1, value?} whole numbers, 0 ≤ min < max ≤ 30000, 1 ≤ step ≤ max − min → {control: id}",
has_doc,
|s, p| insert_number_control(s, p, ControlKind::Spin)
),
cmd!(
"control.format",
"Format Control",
[],
None,
"{id, text?, name?, link?: \"$B$1\" | \"\" (no link), min?, max?, step?, page?, value?, horizontal?, print?, locked?} → the control (as control.list)",
has_doc,
format
),
cmd!(
"control.assign",
"Assign Action",
[],
None,
"{id, macro?: name, input?: \"selection\" | \"B2:D9\", output?: \"F2\" | command?: id, params?: {..} | clear?: true} sets what a button runs → the control",
has_doc,
assign
),
cmd!(
"control.setValue",
"Change Control Value",
[],
None,
"{id, value} clamps the value to min..max, snaps it to the step, and writes it to the linked cell → {value}",
has_doc,
set_value
),
cmd!(
"control.press",
"Press Button",
[],
None,
"{id, approve?: bool, onConflict?, overwrite?} runs a button's macro or command (through the macro policy, with the workbook's trust; a workbook from a file asks first) → what the macro or command returned",
has_doc,
press
),
cmd!(query "control.list", "Form Controls", [], None, "{} → the active sheet's controls: [{id, kind, name, text, at, link?, value, min, max, step, page, horizontal, action?}]", has_doc, list),
]
}
/// The longest caption or name a control takes.
const MAX_TEXT: usize = 1_024;
fn control_json(c: &FormControl) -> Json {
let mut o = json!({
"id": c.id,
"kind": match c.kind { ControlKind::Button => "button", ControlKind::Slider => "slider", ControlKind::Spin => "spinButton" },
"name": c.display_name(),
"at": c.anchor.cell.a1(),
"width": c.anchor.width,
"height": c.anchor.height,
"print": c.print,
"locked": c.locked,
});
if c.kind == ControlKind::Button {
o["text"] = json!(c.text);
if let Some(a) = &c.action {
o["action"] = serde_json::to_value(a).unwrap_or(Json::Null);
}
} else {
o["value"] = json!(c.value);
o["min"] = json!(c.min);
o["max"] = json!(c.max);
o["step"] = json!(c.step);
if c.kind == ControlKind::Slider {
o["page"] = json!(c.page);
o["horizontal"] = json!(c.horizontal);
}
}
if let Some(l) = &c.link {
o["link"] = json!(l);
}
o
}
fn list(s: &mut Session, _: &Json) -> Result<Json> {
let sh = s.doc()?.wb.active().ok_or(EngineError::NoDocument)?;
Ok(Json::Array(sh.controls.iter().map(control_json).collect()))
}
/// A whole number from 0 to 30000 named `key`, if given. Numbers in text (`"12"`) count; anything
/// else is refused with a message that names the parameter.
fn whole(cmd: &str, p: &Json, key: &str) -> Result<Option<u32>> {
let v = match p.get(key) {
None | Some(Json::Null) => return Ok(None),
Some(Json::Number(n)) => n.as_f64(),
Some(Json::String(t)) => t.trim().parse::<f64>().ok(),
Some(_) => None,
};
match v {
Some(x) if x.is_finite() && x.fract() == 0.0 && (0.0..=CONTROL_MAX as f64).contains(&x) => Ok(Some(x as u32)),
_ => Err(bad(cmd, format!("`{key}` must be a whole number from 0 to {CONTROL_MAX}"))),
}
}
fn text_param(cmd: &str, p: &Json, key: &str) -> Result<Option<String>> {
match p.get(key) {
None | Some(Json::Null) => Ok(None),
Some(Json::String(t)) if t.chars().count() <= MAX_TEXT => Ok(Some(t.clone())),
Some(Json::String(_)) => Err(bad(cmd, format!("`{key}` is longer than {MAX_TEXT} characters"))),
Some(_) => Err(bad(cmd, format!("`{key}` must be text"))),
}
}
/// The cell a link names, from the control's sheet `host`: `(sheet index, cell)`. `None` when it
/// doesn't name one cell of an existing sheet (a link into a deleted sheet reads `#REF!`).
pub fn link_target(wb: &Workbook, host: usize, link: &str) -> Option<(usize, CellRef)> {
let text = link.trim().trim_start_matches('=');
let (sheet, body) = split_sheet(text);
let si = match sheet {
Some(n) => wb.sheet_index(&n)?,
None => host,
};
wb.sheet(si)?;
Some((si, CellRef::parse(body.trim())?))
}
/// A link parameter in its stored form: `$B$2` on the host sheet, `'My Sheet'!$B$2` elsewhere.
/// An empty text removes the link.
fn link_param(cmd: &str, wb: &Workbook, host: usize, p: &Json) -> Result<Option<Option<String>>> {
let text = match p.get("link") {
None => return Ok(None),
Some(Json::Null) => return Ok(Some(None)),
Some(Json::String(t)) => t.trim(),
Some(_) => return Err(bad(cmd, "`link` must be a cell reference such as $B$1")),
};
if text.is_empty() {
return Ok(Some(None));
}
let (si, c) = link_target(wb, host, text)
.ok_or_else(|| bad(cmd, format!("`{text}` isn't a cell of this workbook; the link must be one cell, such as $B$1")))?;
let abs = format!("${}${}", gridcraft_core::col_to_letters(c.col), c.row as u64 + 1);
Ok(Some(Some(if si == host {
abs
} else {
let name = wb.sheet(si).map(|sh| sh.name.clone()).unwrap_or_default();
format!("{}!{abs}", gridcraft_formula::quote_sheet(&name))
})))
}
/// The anchor of a new control: `at` or `cell` (default the active cell), with its size.
fn new_anchor(s: &Session, p: &Json, size: (f32, f32)) -> Result<Anchor> {
let cell = match cell_param(s, p, "at")? {
Some(c) => c,
None => cell_param(s, p, "cell")?.unwrap_or(s.doc()?.selection.active),
};
let dim = |k: &str, v: f32| f64_param(p, k).filter(|x| x.is_finite()).map_or(v, |x| x.clamp(4.0, 10_000.0) as f32);
let off = |k: &str| f64_param(p, k).filter(|x| x.is_finite()).map_or(0.0, |x| x.clamp(-10_000.0, 10_000.0) as f32);
Ok(Anchor {
cell,
dx: off("dx"),
dy: off("dy"),
width: dim("width", size.0),
height: dim("height", size.1),
mode: anchor_mode_param(p).unwrap_or(AnchorMode::MoveOnly),
})
}
fn insert_button(s: &mut Session, p: &Json) -> Result<Json> {
const CMD: &str = "insert.button";
let anchor = new_anchor(s, p, (96.0, 28.0))?;
let action = action_param(s, CMD, p)?;
let d = s.doc()?;
let id = d.wb.next_object_id();
let text = text_param(CMD, p, "text")?.unwrap_or_else(|| format!("Button {id}"));
let sheet = d.wb.active_sheet();
let control = FormControl { id, kind: ControlKind::Button, anchor, text, action, ..FormControl::default() };
edit(s, |cx| {
cx.sheet_mut(sheet)?.controls.push(control);
Ok(json!({"control": id}))
})
}
fn insert_number_control(s: &mut Session, p: &Json, kind: ControlKind) -> Result<Json> {
let cmd = if kind == ControlKind::Slider { "insert.slider" } else { "insert.spinButton" };
let horizontal = bool_param(p, "horizontal").unwrap_or(true);
let size = match kind {
ControlKind::Slider if horizontal => (144.0, 18.0),
ControlKind::Slider => (18.0, 144.0),
_ => (18.0, 36.0),
};
let anchor = new_anchor(s, p, size)?;
let d = s.doc()?;
let sheet = d.wb.active_sheet();
let id = d.wb.next_object_id();
let mut c = FormControl { id, kind, anchor, horizontal, ..FormControl::default() };
numbers(cmd, p, &mut c)?;
if let Some(link) = link_param(cmd, &d.wb, sheet, p)? {
c.link = link;
}
let given = whole(cmd, p, "value")?;
// Without a value, a control starts at the number its linked cell holds (or its minimum).
let start = given.map(f64::from).or_else(|| linked_number(&d.wb, sheet, &c)).unwrap_or(c.min as f64);
c.value = c.snap(start);
let value = c.value;
let link = c.link.clone();
s.grouped(&format!("Insert {}", kind.label()), |s| {
edit(s, |cx| {
cx.sheet_mut(sheet)?.controls.push(c);
Ok(())
})?;
if given.is_some() {
write_link(s, sheet, link.as_deref(), value)?;
}
Ok(json!({"control": id}))
})
}
/// Applies `min`, `max`, `step`, `page` and `value` from `p` to `c` and checks the result.
fn numbers(cmd: &str, p: &Json, c: &mut FormControl) -> Result<()> {
for (key, slot) in [("min", &mut c.min), ("max", &mut c.max), ("step", &mut c.step), ("page", &mut c.page)] {
if let Some(v) = whole(cmd, p, key)? {
*slot = v;
}
}
// A new slider's page follows a larger step unless it was given.
if p.get("page").is_none() && c.page < c.step {
c.page = c.step;
}
c.check_numbers().map_err(|e| bad(cmd, e))
}
/// The number the control's linked cell holds, if it holds one.
fn linked_number(wb: &Workbook, host: usize, c: &FormControl) -> Option<f64> {
let (si, cell) = link_target(wb, host, c.link.as_deref()?)?;
wb.sheet(si)?.cell(cell)?.value.as_f64()
}
/// Writes `value` to the linked cell through `cell.set` (nothing without a link, or when the cell
/// already shows `value`, formula or not: Format Control's OK sends the value back every time, and
/// a write that changes nothing must not turn a formula into a constant or fail on a locked cell
/// of a protected sheet; a real change replaces a formula with the constant, as in Excel).
fn write_link(s: &mut Session, host: usize, link: Option<&str>, value: u32) -> Result<()> {
let Some(link) = link else { return Ok(()) };
let Some((si, cell)) = link_target(&s.doc()?.wb, host, link) else { return Ok(()) };
let holds = s.doc()?.wb.sheet(si).and_then(|sh| sh.cell(cell)).and_then(|c| c.value.as_f64());
if holds == Some(value as f64) {
return Ok(());
}
s.execute("cell.set", json!({"sheet": si, "cell": cell.a1(), "input": value.to_string()}))?;
Ok(())
}
/// The control `id` names on the active sheet: (sheet, its index in `controls`, a copy).
fn find(s: &Session, cmd: &str, p: &Json) -> Result<(usize, usize, FormControl)> {
let id = u32_param(p, "id").ok_or_else(|| bad(cmd, "missing `id` (a control id from control.list)"))?;
let d = s.doc()?;
let sheet = d.wb.active_sheet();
let sh = d.wb.sheet(sheet).ok_or(EngineError::NoDocument)?;
let i = sh.controls.iter().position(|c| c.id == id).ok_or_else(|| bad(cmd, format!("there is no control {id} on this sheet")))?;
let c = sh.controls.get(i).cloned().ok_or_else(|| bad(cmd, "no such control"))?;
Ok((sheet, i, c))
}
/// Drawing objects on a protected sheet can't be changed while they are locked.
/// For the object commands (`object.move`, `object.delete`, `object.setAnchorMode`, `arrange.*`,
/// `arrange.align`): refuses to change form controls `ids` on `sheet` when one is locked and the
/// sheet is protected, as `control.format` and `control.assign` do. Other kinds pass.
pub(crate) fn check_objects_unlocked(s: &Session, sheet: usize, kind: ObjKind, ids: &[u32]) -> Result<()> {
if kind != ObjKind::Control {
return Ok(());
}
let Some(sh) = s.doc()?.wb.sheet(sheet) else { return Ok(()) };
if sh.is_protected() && sh.controls.iter().any(|c| c.locked && ids.contains(&c.id)) {
return Err(EngineError::Other(PROTECTED.into()));
}
Ok(())
}
fn check_unlocked(s: &Session, sheet: usize, c: &FormControl) -> Result<()> {
if c.locked && s.doc()?.wb.sheet(sheet).is_some_and(Sheet::is_protected) {
return Err(EngineError::Other(PROTECTED.into()));
}
Ok(())
}
fn format(s: &mut Session, p: &Json) -> Result<Json> {
const CMD: &str = "control.format";
let (sheet, i, mut c) = find(s, CMD, p)?;
check_unlocked(s, sheet, &c)?;
if let Some(t) = text_param(CMD, p, "text")? {
c.text = t;
}
if let Some(n) = text_param(CMD, p, "name")? {
c.name = n.trim().to_string();
}
for (key, slot) in [("print", &mut c.print), ("locked", &mut c.locked), ("horizontal", &mut c.horizontal)] {
if let Some(v) = bool_param(p, key) {
*slot = v;
}
}
let mut given = None;
if c.kind != ControlKind::Button {
for (key, slot) in [("min", &mut c.min), ("max", &mut c.max), ("step", &mut c.step), ("page", &mut c.page)] {
if let Some(v) = whole(CMD, p, key)? {
*slot = v;
}
}
c.check_numbers().map_err(|e| bad(CMD, e))?;
given = whole(CMD, p, "value")?;
}
if let Some(link) = link_param(CMD, &s.doc()?.wb, sheet, p)? {
c.link = link;
}
// New bounds pull the value in; a given value is snapped to them.
c.value = c.snap(given.map_or(c.value as f64, f64::from));
let number = c.kind != ControlKind::Button;
let write = number && (given.is_some() || p.get("min").is_some() || p.get("max").is_some() || p.get("step").is_some());
let (value, link) = (c.value, c.link.clone());
s.grouped("Format Control", |s| {
edit(s, |cx| {
if let Some(slot) = cx.sheet_mut(sheet)?.controls.get_mut(i) {
*slot = c;
}
Ok(())
})?;
if write {
write_link(s, sheet, link.as_deref(), value)?;
}
Ok(())
})?;
// The reply is the control as committed: a new link to a cell holding another number has
// moved its value there.
let (_, _, c) = find(s, CMD, p)?;
Ok(control_json(&c))
}
/// Commands a button never runs, beyond the macro policy's lists: a button has no way to show
/// what its command would open, so a web page is refused when the action is assigned or comes in
/// from a file. A command that turns out to open a page or a dialog when it runs is refused
/// then, by [`press`].
const BUTTON_DENIED: &[(&str, &str)] = &[("link.open", "buttons don't open web pages")];
/// The macro policy for a button's command, with the button's own refusals first.
fn button_policy(id: &str, params: &Json, trust: crate::Trust) -> Policy {
match BUTTON_DENIED.iter().find(|(d, _)| *d == id) {
Some((_, why)) => Policy::Deny((*why).into()),
None => macro_policy(id, params, trust),
}
}
/// Drops the UI requests a button's command queued from `queued` on, keeping messages for the
/// person, and says why the press is refused when one would open a dialog or a web page, which
/// buttons don't do (`name` is the button's). A cell edit the command asked for is dropped too.
pub(crate) fn take_requests(s: &mut Session, queued: usize, name: &str) -> Option<String> {
use crate::UiRequest;
let mut blocked = None;
let asked: Vec<UiRequest> = s.ui_requests.drain(queued.min(s.ui_requests.len())..).collect();
for q in asked {
match q {
UiRequest::Dialog(d, _) => blocked = Some(format!("'{name}' would open the {d} dialog; give its command the parameters it needs.")),
UiRequest::OpenUrl(_) => blocked = Some(format!("'{name}' would open a web page, which buttons don't do.")),
UiRequest::EditCell(_) | UiRequest::InsertName(_) => {}
m @ UiRequest::Message(_) => s.ui_requests.push(m),
}
}
blocked
}
/// The stored name of the workbook's macro `name` when it exists and declares no inputs: a fixed
/// input range would make every press fail (`macro.run` refuses inputs it doesn't take), so such
/// an assignment is refused when it is made and dropped to the selection when a file brings one
/// in. A macro the workbook doesn't have (yet) is not checked: the assignment is kept as given.
fn takes_no_inputs(wb: &Workbook, name: &str) -> Option<String> {
crate::macros::find(wb, name).and_then(|i| wb.macros.get(i)).filter(|m| m.inputs.is_empty()).map(|m| m.name.clone())
}
/// A button action from `macro` (with `input` and `output`) or `command` (with `params`).
fn action_param(s: &Session, cmd: &str, p: &Json) -> Result<Option<ControlAction>> {
if let Some(name) = str_param(p, "macro").map(str::trim).filter(|n| !n.is_empty()) {
if name.chars().count() > MAX_MACRO_NAME {
return Err(bad(cmd, "`macro` is not a macro name"));
}
let input = match str_param(p, "input").map(str::trim) {
None | Some("") => MacroInput::Selection,
Some(t) if t.eq_ignore_ascii_case("selection") => MacroInput::Selection,
Some(t) => {
let (_, body) = split_sheet(t);
if gridcraft_core::RangeRef::parse(body).is_none() {
return Err(bad(cmd, format!("`input` must be \"selection\" or a range such as B2:D9, not `{t}`")));
}
if let Some(stored) = takes_no_inputs(&s.doc()?.wb, name) {
return Err(bad(cmd, format!("macro '{stored}' takes no inputs, so `input` must be \"selection\"")));
}
MacroInput::Fixed(t.to_string())
}
};
let output = str_param(p, "output").map(str::trim).filter(|o| !o.is_empty()).map(str::to_string);
if let Some(o) = &output
&& gridcraft_core::RangeRef::parse(split_sheet(o).1).is_none()
{
return Err(bad(cmd, format!("`output` must be a cell such as F2, not `{o}`")));
}
return Ok(Some(ControlAction::Macro { name: name.to_string(), input, output }));
}
if let Some(id) = str_param(p, "command").map(str::trim).filter(|n| !n.is_empty()) {
if find_command(id).is_none() {
return Err(bad(cmd, format!("there is no command `{id}`")));
}
let params = p.get("params").cloned().unwrap_or_else(|| json!({}));
if !params.is_object() {
return Err(bad(cmd, "`params` must be an object"));
}
let trust = s.doc()?.trust;
if let Policy::Deny(why) = button_policy(id, &params, trust) {
return Err(EngineError::Other(format!("A button can't run {id}: {why}.")));
}
return Ok(Some(ControlAction::Command { id: id.to_string(), params }));
}
Ok(None)
}
fn assign(s: &mut Session, p: &Json) -> Result<Json> {
const CMD: &str = "control.assign";
let (sheet, i, mut c) = find(s, CMD, p)?;
check_unlocked(s, sheet, &c)?;
if c.kind != ControlKind::Button {
return Err(bad(CMD, "only buttons run actions; a slider or spin button writes its value to its linked cell"));
}
let action = action_param(s, CMD, p)?;
if action.is_none() && bool_param(p, "clear") != Some(true) {
return Err(bad(CMD, "pass `macro` or `command` (or `clear: true` to remove the action)"));
}
c.action = action;
let out = control_json(&c);
edit(s, |cx| {
if let Some(slot) = cx.sheet_mut(sheet)?.controls.get_mut(i) {
*slot = c;
}
Ok(out)
})
}
fn set_value(s: &mut Session, p: &Json) -> Result<Json> {
const CMD: &str = "control.setValue";
let (sheet, i, c) = find(s, CMD, p)?;
if c.kind == ControlKind::Button {
return Err(bad(CMD, "a button has no value; use control.press"));
}
let v = match p.get("value") {
Some(Json::Number(n)) => n.as_f64(),
Some(Json::String(t)) => t.trim().parse::<f64>().ok(),
_ => None,
}
.filter(|v| v.is_finite())
.ok_or_else(|| bad(CMD, "`value` must be a number"))?;
let value = c.snap(v);
let label = format!("Change {}", c.kind.label());
s.grouped(&label, |s| {
if c.value != value {
edit(s, |cx| {
if let Some(slot) = cx.sheet_mut(sheet)?.controls.get_mut(i) {
slot.value = value;
}
Ok(())
})?;
}
write_link(s, sheet, c.link.as_deref(), value)?;
Ok(json!({"value": value}))
})
}
fn press(s: &mut Session, p: &Json) -> Result<Json> {
const CMD: &str = "control.press";
let (_, _, c) = find(s, CMD, p)?;
if c.kind != ControlKind::Button {
return Err(bad(CMD, "only buttons can be pressed; use control.setValue for a slider or spin button"));
}
let name = c.display_name();
match c.action {
None => Err(EngineError::Other(format!("'{name}' has no action. Right-click it and choose Assign Action."))),
Some(ControlAction::Macro { name: mac, input, output }) => {
let mut params = json!({"name": mac});
if let MacroInput::Fixed(r) = input {
params["inputs"] = json!(r);
}
if let Some(o) = output {
params["output"] = json!(o);
}
for key in ["approve", "onConflict", "overwrite", "timeLimit"] {
if let Some(v) = p.get(key) {
params[key] = v.clone();
}
}
// The macro runner gates every step with the document's trust.
crate::macros::run::run(s, &mac, &params)
}
Some(ControlAction::Command { id, params }) => {
let d = s.doc()?;
let (doc, trust) = (d.uid, d.trust);
let policy = button_policy(&id, &params, trust);
let file_command = matches!(policy, Policy::NeedsPermission(_));
let why = match policy {
Policy::Allow => None,
Policy::Ask(why) | Policy::NeedsPermission(why) => Some(why),
Policy::Deny(why) => return Err(EngineError::Other(format!("'{name}' can't run {id}: {why}."))),
};
if let Some(why) = why {
let key = (doc, approval_key(&id, &params));
if !s.macro_approvals.contains(&key) {
if bool_param(p, "approve") != Some(true) {
return Err(EngineError::Approve(format!("Run {id} from '{name}'? It {why}{}.", path_note(&params))));
}
s.macro_approvals.insert(key);
}
}
// As a macro step: only an approved file command reaches the file system, so a
// command that reads or writes a file under another parameter name can't.
let _sandbox = (!file_command).then(crate::io::ScriptSandbox::enter);
s.grouped(&name, |s| {
let queued = s.ui_requests.len();
let r = s.execute(&id, params.clone());
// As a macro step: a command that would open a dialog or a web page is refused
// and its group rolled back; messages for the person stay queued.
let blocked = take_requests(s, queued, &name);
let r = r?;
match blocked {
Some(why) => Err(EngineError::Other(why)),
None => Ok(r),
}
})
}
}
}
/// The file a button's command names in its `path`, as ` (path)` for its approval question, so
/// the person sees what it reads or writes before saying yes. Empty without a `path`; a long one
/// is shortened in the middle.
fn path_note(params: &Json) -> String {
let text = match params.get("path") {
None | Some(Json::Null) => return String::new(),
Some(Json::String(t)) => t.clone(),
Some(other) => other.to_string(),
};
let text: String = text.chars().map(|c| if c.is_control() { ' ' } else { c }).collect();
let n = text.chars().count();
let shown = if n > 200 {
let head: String = text.chars().take(100).collect();
let tail: String = text.chars().skip(n - 99).collect();
format!("{head}…{tail}")
} else {
text
};
format!(" ({shown})")
}
/// The approval key of a button's command: its id and parameters, with a prefix that keeps it
/// apart from a macro's fingerprint (canonical JSON, which starts with `{`).
fn approval_key(id: &str, params: &Json) -> String {
format!("control-command\u{0}{id}\u{0}{params}")
}
/// After an edit: each slider and spin button whose linked cell holds a number takes that number
/// (clamped and snapped), so editing the cell moves the control. Sheets without such a control
/// are left untouched.
pub(crate) fn sync_linked_values(wb: &mut Workbook) {
for si in 0..wb.sheets.len() {
let updates: Vec<(usize, u32)> = match wb.sheet(si) {
Some(sh) if sh.controls.iter().any(|c| c.link.is_some()) => sh
.controls
.iter()
.enumerate()
.filter(|(_, c)| c.kind != ControlKind::Button)
.filter_map(|(i, c)| linked_number(wb, si, c).map(|n| (i, c.snap(n))).filter(|(_, v)| *v != c.value))
.collect(),
_ => continue,
};
if updates.is_empty() {
continue;
}
if let Some(sh) = wb.sheet_mut(si) {
for (i, v) in updates {
if let Some(c) = sh.controls.get_mut(i) {
c.value = v;
}
}
}
}
}
// ---------------------------------------------------------------- xlsx
/// The hidden-data key of the button actions xlsx has no place for.
pub(crate) const ACTIONS_KEY: &str = "control_actions";
/// A button's action that xlsx carries itself: a macro on the selection (`macro="[0]!Name"`).
fn plain(a: &ControlAction) -> bool {
matches!(a, ControlAction::Macro { input: MacroInput::Selection, output: None, .. })
}
/// A copy of `wb` for xlsx with the button actions xlsx can't express (commands, fixed inputs,
/// output cells) in a hidden name, keyed by sheet name and the control's place on the sheet, with
/// the button's name and anchor cell to check it is still the same button when the file comes
/// back. `None` when there is nothing to store.
pub(crate) fn with_action_names(wb: &Workbook) -> Result<Option<Workbook>> {
let mut items = Vec::new();
for sh in &wb.sheets {
for (i, c) in sh.controls.iter().enumerate() {
if let Some(a) = c.action.as_ref().filter(|a| !plain(a)) {
items.push(json!({"sheet": sh.name, "index": i, "name": c.display_name(), "at": c.anchor.cell.a1(), "action": a}));
}
}
}
let stale = stash::get(wb, ACTIONS_KEY).is_some();
if items.is_empty() && !stale {
return Ok(None);
}
let mut out = wb.clone();
stash::remove(&mut out, ACTIONS_KEY);
if !items.is_empty() {
stash::set_json(&mut out, ACTIONS_KEY, &Json::Array(items))?;
}
Ok(Some(out))
}
/// After a file is opened: gives buttons back the actions kept in the hidden name, and removes
/// it. Entries that don't parse, or don't match a button, are dropped: an entry must carry the
/// button's name and anchor cell, the button at the stored place must have that name and anchor
/// cell (another application may have added, removed or reordered controls), and it must not
/// have been given another macro since (a stored macro action comes back only on a button that
/// still runs that macro, a stored command only on one the file gives no macro). A stored fixed
/// input for a macro that takes no inputs becomes the selection (see [`takes_no_inputs`]).
pub(crate) fn load_action_names(wb: &mut Workbook) {
let Some(items) = stash::get_json(wb, ACTIONS_KEY) else { return };
stash::remove(wb, ACTIONS_KEY);
for item in items.as_array().into_iter().flatten().take(100_000) {
let (Some(sheet), Some(index)) = (item.get("sheet").and_then(Json::as_str), item.get("index").and_then(Json::as_u64)) else { continue };
let Ok(action) = serde_json::from_value::<ControlAction>(item.get("action").cloned().unwrap_or(Json::Null)) else { continue };
let valid = match &action {
ControlAction::Macro { name, .. } => !name.trim().is_empty() && name.chars().count() <= MAX_MACRO_NAME,
ControlAction::Command { id, params } => !id.is_empty() && id.len() <= 200 && params.is_object(),
};
let Some(si) = wb.sheet_index(sheet) else { continue };
let Ok(index) = usize::try_from(index) else { continue };
let text = |k: &str| item.get(k).and_then(Json::as_str);
let same = |c: &FormControl| {
c.kind == ControlKind::Button
&& text("name").is_some_and(|n| n == c.display_name())
&& text("at").is_some_and(|a| a == c.anchor.cell.a1())
&& match (&c.action, &action) {
(None, ControlAction::Command { .. }) => true,
(Some(ControlAction::Macro { name: now, .. }), ControlAction::Macro { name: kept, .. }) => now.eq_ignore_ascii_case(kept),
_ => false,
}
};
if !valid || !wb.sheet(si).is_some_and(|sh| sh.controls.get(index).is_some_and(same)) {
continue;
}
let action = match action {
// The macro takes no inputs now: the stored range would fail every press, so the
// button runs the macro on the selection, as the file itself assigns it.
ControlAction::Macro { name, input: MacroInput::Fixed(_), output } if takes_no_inputs(wb, &name).is_some() => {
ControlAction::Macro { name, input: MacroInput::Selection, output }
}
other => other,
};
if let Some(c) = wb.sheet_mut(si).and_then(|sh| sh.controls.get_mut(index)) {
c.action = Some(action);
}
}
}
+6 -2
View File
@@ -396,14 +396,15 @@ pub(crate) fn rewrite_all_formulas(wb: &mut gridcraft_model::Workbook, target: &
}
/// Runs `f` over every reference a floating object holds (a chart's series values, categories,
/// name and bubble sizes, and the source block it was made from). Every edit that moves
/// name and bubble sizes, and the source block it was made from, and a form control's linked
/// cell). Every edit that moves
/// references calls it: inserting and deleting lines and cells and cut-moves (through
/// [`rewrite_all_formulas`]), and renaming and deleting sheets. `f` maps a reference held by an
/// object on the sheet named `host` to its new form; text that does not parse is kept. A source
/// block that no longer exists is dropped.
pub(crate) fn rewrite_cell_links(wb: &mut gridcraft_model::Workbook, f: impl Fn(&str, gridcraft_formula::Expr) -> gridcraft_formula::Expr) {
for si in 0..wb.sheets.len() {
let Some(host) = wb.sheets.get(si).filter(|s| !s.charts.is_empty()).map(|s| s.name.clone()) else { continue };
let Some(host) = wb.sheets.get(si).filter(|s| !s.charts.is_empty() || !s.controls.is_empty()).map(|s| s.name.clone()) else { continue };
let Some(sh) = wb.sheet_mut(si) else { continue };
let fix = |text: &str| -> String {
match gridcraft_formula::parse(text) {
@@ -420,6 +421,9 @@ pub(crate) fn rewrite_cell_links(wb: &mut gridcraft_model::Workbook, f: impl Fn(
se.bubble_sizes = se.bubble_sizes.as_deref().map(fix);
}
}
for c in sh.controls.iter_mut() {
c.link = c.link.as_deref().map(fix);
}
}
}
+3 -3
View File
@@ -97,7 +97,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Bring Forward",
["Page Layout", "Arrange"],
None,
"{kind: chart|image|shape|slicer|equation, id, toFront?: bool}",
"{kind: chart|image|shape|slicer|equation|control, id, toFront?: bool}",
has_doc,
|s, p| arrange(s, p, true)
),
@@ -106,7 +106,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Send Backward",
["Page Layout", "Arrange"],
None,
"{kind: chart|image|shape|slicer|equation, id, toBack?: bool}",
"{kind: chart|image|shape|slicer|equation|control, id, toBack?: bool}",
has_doc,
|s, p| arrange(s, p, false)
),
@@ -116,7 +116,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Align",
["Page Layout", "Arrange"],
None,
"{kind: chart|image|shape|slicer|equation, ids: [..], align: left|center|right|top|middle|bottom}",
"{kind: chart|image|shape|slicer|equation|control, ids: [..], align: left|center|right|top|middle|bottom}",
has_doc,
align
),
+10
View File
@@ -57,6 +57,7 @@ pub(super) fn arrange(s: &mut Session, p: &Json, forward: bool) -> Result<Json>
let kind = kind_param(p, "arrange")?;
let extreme = bool_param(p, "toFront").or(bool_param(p, "toBack")).unwrap_or(false);
let si = s.doc()?.wb.active_sheet();
crate::cmd::controls::check_objects_unlocked(s, si, kind, &[id])?;
fn reorder<T>(v: &mut Vec<T>, i: usize, forward: bool, extreme: bool) {
if i >= v.len() {
return;
@@ -81,6 +82,7 @@ pub(super) fn arrange(s: &mut Session, p: &Json, forward: bool) -> Result<Json>
ObjKind::Shape | ObjKind::Equation => {
sh.shapes.iter().position(|c| c.id == id && shape_is(kind, c)).map(|i| reorder(&mut sh.shapes, i, forward, extreme))
}
ObjKind::Control => sh.controls.iter().position(|c| c.id == id).map(|i| reorder(&mut sh.controls, i, forward, extreme)),
};
found.ok_or_else(|| bad("arrange", "no such object"))?;
Ok(Json::Null)
@@ -106,6 +108,12 @@ pub(super) fn selection_pane(s: &mut Session, _: &Json) -> Result<Json> {
let name = if c.caption.is_empty() { &c.column } else { &c.caption };
v.push(json!({"kind": "slicer", "id": c.id, "name": name, "at": c.anchor.cell.a1(), "anchorMode": anchor_mode_name(c.anchor.mode)}));
}
// Form controls paint above everything else.
for c in &sh.controls {
v.push(
json!({"kind": "control", "id": c.id, "name": c.display_name(), "at": c.anchor.cell.a1(), "anchorMode": anchor_mode_name(c.anchor.mode)}),
);
}
Ok(Json::Array(v))
}
@@ -115,6 +123,7 @@ fn anchors_of(sh: &mut Sheet, kind: ObjKind) -> Vec<(u32, &mut Anchor)> {
ObjKind::Chart => sh.charts.iter_mut().map(|c| (c.id, &mut c.anchor)).collect(),
ObjKind::Image => sh.images.iter_mut().map(|c| (c.id, &mut c.anchor)).collect(),
ObjKind::Slicer => sh.slicers.iter_mut().map(|c| (c.id, &mut c.anchor)).collect(),
ObjKind::Control => sh.controls.iter_mut().map(|c| (c.id, &mut c.anchor)).collect(),
ObjKind::Shape | ObjKind::Equation => sh.shapes.iter_mut().filter(|c| shape_is(kind, c)).map(|c| (c.id, &mut c.anchor)).collect(),
}
}
@@ -133,6 +142,7 @@ pub(super) fn align(s: &mut Session, p: &Json) -> Result<Json> {
};
let how = str_param(p, "align").unwrap_or("left").to_string();
let si = s.doc()?.wb.active_sheet();
crate::cmd::controls::check_objects_unlocked(s, si, kind, &ids)?;
edit(s, |cx| {
let sheet = cx.sheet_mut(si)?;
let geo: Vec<(u32, f64, f64, f32, f32)> = {
+7 -4
View File
@@ -146,13 +146,13 @@ pub fn specs() -> Vec<CommandSpec> {
&with(p, "kind", json!("textBox"))
)),
cmd!("shape.setText", "Edit Text Box", [], None, "{id: textBox id, text: string}", has_doc, shape_set_text),
cmd!("object.delete", "Delete Object", [], None, "{kind: chart|image|shape|slicer|equation, id}", has_doc, delete_object),
cmd!("object.delete", "Delete Object", [], None, "{kind: chart|image|shape|slicer|equation|control, id}", has_doc, delete_object),
cmd!(
"object.move",
"Move Object",
[],
None,
"{kind: chart|image|shape|slicer|equation, id, at?: \"C3\", dx?, dy?, width?, height? (each at most 10,000 points, or the object's current size when a file made it larger)}",
"{kind: chart|image|shape|slicer|equation|control, id, at?: \"C3\", dx?, dy?, width?, height? (each at most 10,000 points, or the object's current size when a file made it larger)}",
has_doc,
move_object
),
@@ -161,7 +161,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Object Properties",
[],
None,
"{kind: chart|image|shape|slicer|equation, id, mode: moveAndSize|moveOnly|absolute}",
"{kind: chart|image|shape|slicer|equation|control, id, mode: moveAndSize|moveOnly|absolute}",
has_doc,
set_anchor_mode
),
@@ -1235,13 +1235,16 @@ fn shape_set_text(s: &mut Session, p: &Json) -> Result<Json> {
/// objects are errors, so a stale id never succeeds silently.
fn object_param(s: &Session, p: &Json, cmd: &str) -> Result<(usize, ObjRef)> {
let id = u32_param(p, "id").ok_or_else(|| bad(cmd, "missing `id`"))?;
let kind = str_param(p, "kind").and_then(ObjKind::parse).ok_or_else(|| bad(cmd, "kind must be chart, image, shape, slicer or equation"))?;
let kind =
str_param(p, "kind").and_then(ObjKind::parse).ok_or_else(|| bad(cmd, "kind must be chart, image, shape, slicer, equation or control"))?;
let d = s.doc()?;
let sheet = d.wb.active_sheet();
let o = ObjRef::new(kind, id);
if !d.wb.sheet(sheet).is_some_and(|sh| sh.has_object(o)) {
return Err(bad(cmd, "no such object"));
}
// A locked form control on a protected sheet stays as it is.
super::controls::check_objects_unlocked(s, sheet, kind, &[id])?;
Ok((sheet, o))
}
+18 -4
View File
@@ -28,7 +28,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Run Macro",
[],
None,
"{name, inputs?: {Input: \"B2:D9\"} | \"B2:D9\", onConflict?: ask|fail|overwrite|shiftDown|shiftRight, overwrite?: bool, approve?: bool, timeLimit?: seconds} runs as one undo step; any error rolls it all back → {macro, ran, inputs, stopped?, shifted?}",
"{name, inputs?: {Input: \"B2:D9\"} | \"B2:D9\", output?: \"F2\" (instead of the macro's output anchor), onConflict?: ask|fail|overwrite|shiftDown|shiftRight, overwrite?: bool, approve?: bool, timeLimit?: seconds} runs as one undo step; any error rolls it all back → {macro, ran, inputs, stopped?, shifted?}",
has_doc,
run
),
@@ -37,7 +37,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Check Macro",
[],
None,
"{name, inputs?, onConflict?, overwrite?, timeLimit?: seconds} validates the inputs and dry-runs the macro on the workbook (then puts everything back) → {ok (macro.run with these parameters would go ahead without a question), errors?, needsApproval?, onConflict, inputs, output?, conflicts, footprint}",
"{name, inputs?, output?: \"F2\" (as macro.run), onConflict?, overwrite?, timeLimit?: seconds} validates the inputs and dry-runs the macro on the workbook (then puts everything back) → {ok (macro.run with these parameters would go ahead without a question), errors?, needsApproval?, onConflict, inputs, output?, conflicts, footprint}",
has_doc,
check
),
@@ -55,7 +55,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Step Into",
[],
None,
"{action: start|step|continue|runTo|stop|state, name, inputs?, onConflict?, overwrite?, approve?, timeLimit?: seconds per action (start, as macro.run), step: path (runTo), keep?: bool (stop), breakpoints?: [\"3\", \"2.1\"] (steps of the macro), breakpointsOnly?: bool (the list replaces the steps' own breakpoints)} debugs a macro one step at a time; stop keeps the changes as one undo step or rolls them back. Runs on its own, never as a step of a script or macro. Until it finishes or stops, its workbook runs only macro.debug, queries, selection and sheet changes, Go To, Find, Copy, zoom, Switch Window, Close and Stop Action (other workbooks stay editable) → {state: paused|finished|stopped|failed, current: {step, kind, command?, params?, iteration?}, lastParams, executed, changed (at most 500), changedCells, atBreakpoint?, shifted?, wroteFiles?, message?, error?}",
"{action: start|step|continue|runTo|stop|state, name, inputs?, output?, overwrite?, approve?, timeLimit?: seconds per action (start, as macro.run), step: path (runTo), keep?: bool (stop), breakpoints?: [\"3\", \"2.1\"] (steps of the macro), breakpointsOnly?: bool (the list replaces the steps' own breakpoints)} debugs a macro one step at a time; stop keeps the changes as one undo step or rolls them back. Runs on its own, never as a step of a script or macro. Until it finishes or stops, its workbook runs only macro.debug, queries, selection and sheet changes, Go To, Find, Copy, zoom, Switch Window, Close and Stop Action (other workbooks stay editable) → {state: paused|finished|stopped|failed, current: {step, kind, command?, params?, iteration?}, lastParams, executed, changed (at most 500), changedCells, atBreakpoint?, shifted?, wroteFiles?, message?, error?}",
has_doc,
|s, p| crate::macros::debug::debug(s, p)
),
@@ -170,7 +170,21 @@ fn rename(s: &mut Session, p: &Json) -> Result<Json> {
renamed.name = to.clone();
// The whole check, size included: a longer name can take a macro past what a file holds.
crate::macros::check(&renamed).map_err(EngineError::Other)?;
*m = renamed;
let from = std::mem::replace(m, renamed).name;
// Buttons that run the macro follow the new name.
let runs = |c: &gridcraft_model::FormControl| matches!(&c.action, Some(gridcraft_model::ControlAction::Macro { name, .. }) if name.eq_ignore_ascii_case(&from));
for si in 0..cx.wb.sheets.len() {
if !cx.wb.sheet(si).is_some_and(|sh| sh.controls.iter().any(runs)) {
continue;
}
for c in cx.sheet_mut(si)?.controls.iter_mut() {
if let Some(gridcraft_model::ControlAction::Macro { name, .. }) = &mut c.action
&& name.eq_ignore_ascii_case(&from)
{
*name = to.clone();
}
}
}
Ok(())
})?;
Ok(json!({"name": to}))
+2 -1
View File
@@ -170,7 +170,7 @@ pub(super) fn move_sheet(s: &mut Session, p: &Json) -> Result<Json> {
// Object ids are unique across the workbook (selection and the `{kind, id}` commands
// rely on it), so the copied objects get fresh ones (free ones, even when a file used
// the largest id).
let count = sh.charts.len() + sh.images.len() + sh.shapes.len() + sh.slicers.len();
let count = sh.charts.len() + sh.images.len() + sh.shapes.len() + sh.slicers.len() + sh.controls.len();
let ids = cx.wb.free_object_ids(count);
if ids.len() < count {
return Err(bad("sheet.move", "no free object ids are left for the copied objects"));
@@ -181,6 +181,7 @@ pub(super) fn move_sheet(s: &mut Session, p: &Json) -> Result<Json> {
sh.images.iter_mut().for_each(|o| o.id = fresh());
sh.shapes.iter_mut().for_each(|o| o.id = fresh());
sh.slicers.iter_mut().for_each(|o| o.id = fresh());
sh.controls.iter_mut().for_each(|o| o.id = fresh());
let at = to.min(n);
// The copy gets its own copies of the sheet's names, referring to the copy.
let copies: Vec<gridcraft_model::DefinedName> = cx
+9 -4
View File
@@ -123,12 +123,13 @@ 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
/// and adopts the Python cells they hold (see [`adopt_python`]) within the limits of
/// [`enforce_python_limits`].
/// Parses bytes that aren't encrypted, then takes the workbook's macros, and the button actions
/// xlsx has no place for, 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));
crate::cmd::controls::load_action_names(&mut wb);
warnings.extend(adopt_python(&mut wb));
warnings.extend(enforce_python_limits(&mut wb));
Ok((wb, warnings))
@@ -493,9 +494,13 @@ pub fn save_bytes_as(wb: &Workbook, path: &str, kind: FileKind) -> Result<Vec<u8
match kind {
// The extension picks the package kind; only the macro-enabled kinds (.xlsm, .xltm) carry
// the VBA project, so any other name saves without it, as Excel does.
// Macros travel in hidden names.
// Macros, and button actions xlsx has no place for, travel in hidden names.
FileKind::Xlsx => {
let named = crate::macros::with_names(wb)?;
let named = match crate::cmd::controls::with_action_names(named.as_ref().unwrap_or(wb))? {
Some(w) => Some(w),
None => named,
};
let book = std::path::Path::new(path).file_name().and_then(std::ffi::OsStr::to_str).unwrap_or_default();
gridcraft_xlsx::write_xlsx_named(named.as_ref().unwrap_or(wb), gridcraft_xlsx::XlsxKind::from_path(path), book)
.map_err(|e| EngineError::Other(e.to_string()))
+2
View File
@@ -38,6 +38,7 @@ const DENIED: &[(&str, &str)] = &[
("view.newWindow", "macros don't open, hide or show windows"),
("view.hideWindow", "macros don't open, hide or show windows"),
("view.unhideWindow", "macros don't open, hide or show windows"),
("control.press", "macros don't press form controls"),
];
/// Commands under a denied prefix that only change the workbook.
@@ -112,6 +113,7 @@ mod tests {
"view.newWindow",
"view.hideWindow",
"view.unhideWindow",
"control.press",
] {
assert!(matches!(macro_policy(id, &none, Trust::Local), Policy::Deny(_)), "{id}");
assert!(matches!(macro_policy(id, &path, Trust::FromFile), Policy::Deny(_)), "{id}");
+31 -6
View File
@@ -23,7 +23,7 @@ use std::sync::Arc;
use gridcraft_core::{CellRef, RangeRef, Value};
use gridcraft_formula::adjust::Edit;
use gridcraft_model::{Cell, CompareOp, InputKind, Macro, OnConflict, RepeatOf, Sheet, Step, StepKind, Workbook};
use gridcraft_model::{Cell, CompareOp, InputKind, MAX_MACRO_REF, Macro, OnConflict, RepeatOf, Sheet, Step, StepKind, Workbook};
use serde::Serialize;
use serde_json::{Value as Json, json};
@@ -539,7 +539,8 @@ impl Guard {
}
objects += deleted(&ss.charts, &ps.charts, &sa.charts, |o| o.id)
+ deleted(&ss.images, &ps.images, &sa.images, |o| o.id)
+ deleted(&ss.shapes, &ps.shapes, &sa.shapes, |o| o.id);
+ deleted(&ss.shapes, &ps.shapes, &sa.shapes, |o| o.id)
+ deleted(&ss.controls, &ps.controls, &sa.controls, |o| o.id);
}
if objects > 0 {
parts.push(format!("{objects} object{}", if objects == 1 { "" } else { "s" }));
@@ -1285,7 +1286,12 @@ fn objects(b: &Sheet, a: &Sheet) -> (usize, usize, usize) {
let changed = b.iter().filter(|x| a.iter().any(|y| id(y) == id(x) && y != *x)).count();
(added, changed, removed)
}
let parts = [count(&b.charts, &a.charts, |o| o.id), count(&b.images, &a.images, |o| o.id), count(&b.shapes, &a.shapes, |o| o.id)];
let parts = [
count(&b.charts, &a.charts, |o| o.id),
count(&b.images, &a.images, |o| o.id),
count(&b.shapes, &a.shapes, |o| o.id),
count(&b.controls, &a.controls, |o| o.id),
];
parts.iter().fold((0, 0, 0), |t, p| (t.0 + p.0, t.1 + p.1, t.2 + p.2))
}
@@ -1393,6 +1399,7 @@ pub fn begin(s: &mut Session, name: &str, p: &Json) -> Result<Begun> {
check_params(p, &m)?;
let asks = gate(&m, trust)?;
approve(s, &m, doc, &asks, p.get("approve").and_then(Json::as_bool).unwrap_or(false))?;
let m = with_output(m, p);
let prepared = prepare(s, &m, p.get("inputs"))?;
let rule = conflict_rule(p, &m)?;
let time_limit_ms = time_limit(p);
@@ -1493,9 +1500,21 @@ pub fn run(s: &mut Session, name: &str, p: &Json) -> Result<Json> {
})
}
/// `m` with the run's `output` (a form button that sends the output elsewhere) in place of its own
/// output anchor, after the policy and the approval, which go by the macro as it is stored. The
/// anchor is resolved and checked by [`prepare`] like the macro's own, and the footprint, the
/// conflict rule and the per-step guard all work on the cells it names.
fn with_output(mut m: Macro, p: &Json) -> Macro {
if let Some(o) = p.get("output").and_then(Json::as_str).map(str::trim).filter(|o| !o.is_empty()) {
m.output.anchor = Some(o.to_string());
}
m
}
/// Refuses run parameters that would otherwise be ignored: an input the macro doesn't have, an
/// input given as something other than reference text, and `onConflict`, `overwrite`, `approve`
/// or `timeLimit` of the wrong kind (`null` counts as not given).
/// input given as something other than reference text, `onConflict`, `overwrite`, `approve` or
/// `timeLimit` of the wrong kind, and an `output` that isn't reference text of at most
/// `MAX_MACRO_REF` characters (`null` counts as not given).
pub fn check_params(p: &Json, m: &Macro) -> Result<()> {
let refuse = |msg: String| Err(EngineError::Other(format!("Macro '{}': {msg}", m.name)));
let names = || m.inputs.iter().map(|i| i.name.clone()).collect::<Vec<_>>().join(", ");
@@ -1528,6 +1547,12 @@ pub fn check_params(p: &Json, m: &Macro) -> Result<()> {
return refuse(format!("`{k}` must be true or false."));
}
}
if let Some(o) = given("output") {
let ok = o.as_str().map(str::trim).is_some_and(|t| !t.is_empty() && t.chars().count() <= MAX_MACRO_REF && !t.chars().any(char::is_control));
if !ok {
return refuse(format!("`output` must be reference text of at most {MAX_MACRO_REF} characters, such as \"F2\" or \"Sheet2!F2\"."));
}
}
if given("timeLimit").is_some_and(|v| !v.as_f64().is_some_and(|t| t > 0.0)) {
return refuse("`timeLimit` must be a number of seconds above 0.".into());
}
@@ -1604,7 +1629,7 @@ pub fn check(s: &mut Session, name: &str, p: &Json) -> Result<Json> {
// Nothing from an unapproved macro runs, not even in a dry run.
out["needsApproval"] = json!(asks);
}
let prepared = match prepare(s, &m, p.get("inputs")) {
let prepared = match prepare(s, &with_output(m, p), p.get("inputs")) {
Ok(pr) => pr,
Err(e) => return refused(out, e.to_string()),
};
+1
View File
@@ -5414,6 +5414,7 @@ fn a_script_cannot_switch_windows() {
assert_eq!(v(&s, "A1"), Value::number(2.0));
}
mod controls;
mod equation;
mod macros;
mod objects;
+847
View File
@@ -0,0 +1,847 @@
//! Form controls: buttons, sliders and spin buttons (insert, format, assign, setValue, press).
use gridcraft_core::Value;
use gridcraft_model::{ControlAction, ControlKind, FormControl, MacroInput};
use serde_json::{Value as Json, json};
use super::{s, v};
use crate::{EngineError, Session, Trust};
fn id(r: &Json) -> u64 {
r["control"].as_u64().unwrap()
}
fn control(s: &Session, id: u64) -> FormControl {
s.doc().unwrap().wb.active().unwrap().controls.iter().find(|c| c.id as u64 == id).cloned().unwrap()
}
fn undo_len(s: &Session) -> usize {
s.doc().unwrap().undo.len()
}
fn err(r: crate::Result<Json>) -> String {
r.unwrap_err().to_string()
}
#[test]
fn numbers_are_whole_and_each_rule_has_its_own_error() {
let mut s = s();
let ok = s.execute("insert.slider", json!({"at": "D2", "min": 10, "max": 50, "step": 5, "page": 10})).unwrap();
let c = control(&s, id(&ok));
assert_eq!((c.kind, c.min, c.max, c.step, c.page, c.value), (ControlKind::Slider, 10, 50, 5, 10, 10));
for (p, needle) in [
(json!({"min": 2.5}), "`min` must be a whole number from 0 to 30000"),
(json!({"min": -1}), "`min` must be a whole number"),
(json!({"max": 30001}), "`max` must be a whole number from 0 to 30000"),
(json!({"step": "two"}), "`step` must be a whole number"),
(json!({"max": 1e300}), "`max` must be a whole number"),
(json!({"min": 50, "max": 50}), "Maximum value (50) must be greater than Minimum value (50)"),
(json!({"min": 60, "max": 50}), "must be greater than Minimum"),
(json!({"step": 0}), "Incremental change must be at least 1"),
(json!({"min": 0, "max": 10, "step": 11}), "Incremental change (11) can be at most Maximum minus Minimum (10)"),
(json!({"step": 5, "page": 2}), "Page change (2) must be at least the incremental change (5)"),
(json!({"link": "not a cell"}), "isn't a cell of this workbook"),
(json!({"link": "Nowhere!A1"}), "isn't a cell of this workbook"),
(json!({"link": "A1:B2"}), "isn't a cell"),
] {
let e = err(s.execute("insert.slider", p.clone()));
assert!(e.contains(needle), "{p}: {e}");
}
// A spin button has no page change, so a page below the step is fine.
s.execute("insert.spinButton", json!({"step": 5, "page": 2})).unwrap();
// Numbers in text are numbers.
let n = id(&s.execute("insert.spinButton", json!({"min": "3", "max": "9"})).unwrap());
let c = control(&s, n);
assert_eq!((c.min, c.max, c.value), (3, 9, 3));
// A larger step pulls a slider's default page up with it.
let n = id(&s.execute("insert.slider", json!({"max": 1000, "step": 50})).unwrap());
let c = control(&s, n);
assert_eq!(c.page, 50);
// The model checks the same rules.
let bad = FormControl { kind: ControlKind::Spin, min: 0, max: 30_001, ..Default::default() };
assert!(bad.check_numbers().is_err());
assert!(FormControl { kind: ControlKind::Button, max: 0, ..Default::default() }.check_numbers().is_ok());
}
#[test]
fn set_value_clamps_snaps_and_writes_the_link_as_one_undo_step() {
let mut s = s();
s.execute("cell.set", json!({"cell": "C1", "input": "=B1*2"})).unwrap();
let c = id(&s.execute("insert.slider", json!({"link": "B1", "min": 10, "max": 100, "step": 5})).unwrap());
assert_eq!(control(&s, c).link.as_deref(), Some("$B$1"));
let before = undo_len(&s);
for (given, want) in [(37.0, 35.0), (38.0, 40.0), (-5.0, 10.0), (1e9, 100.0), (99.0, 100.0), (12.4, 10.0)] {
let r = s.execute("control.setValue", json!({"id": c, "value": given})).unwrap();
assert_eq!(r["value"], json!(want as u32), "{given}");
assert_eq!(v(&s, "B1"), Value::Number(want), "{given}");
assert_eq!(v(&s, "C1"), Value::Number(want * 2.0), "dependents recalculate");
assert_eq!(control(&s, c).value, want as u32);
}
assert_eq!(undo_len(&s), before + 5, "one undo step per change (99 snaps to the 100 it holds)");
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(v(&s, "B1"), Value::Number(100.0));
assert_eq!(control(&s, c).value, 100);
// Setting the value it already has adds no step.
let n = undo_len(&s);
s.execute("control.setValue", json!({"id": c, "value": 100})).unwrap();
assert_eq!(undo_len(&s), n);
for p in [json!({"id": c}), json!({"id": c, "value": "x"}), json!({"id": 999, "value": 1}), json!({"value": 1})] {
assert!(s.execute("control.setValue", p).is_err());
}
// A button has no value.
let b = id(&s.execute("insert.button", json!({})).unwrap());
assert!(err(s.execute("control.setValue", json!({"id": b, "value": 1}))).contains("control.press"));
}
#[test]
fn editing_the_linked_cell_moves_the_control() {
let mut s = s();
let c = id(&s.execute("insert.spinButton", json!({"link": "$B$2", "max": 20, "step": 2})).unwrap());
s.execute("cell.set", json!({"cell": "B2", "input": "7"})).unwrap();
assert_eq!(control(&s, c).value, 8, "snapped to the step");
s.execute("cell.set", json!({"cell": "B2", "input": "500"})).unwrap();
assert_eq!(control(&s, c).value, 20, "clamped");
s.execute("cell.set", json!({"cell": "B2", "input": "text"})).unwrap();
assert_eq!(control(&s, c).value, 20, "text leaves it where it was");
// A new control starts at what its cell holds.
s.execute("cell.set", json!({"cell": "D4", "input": "12"})).unwrap();
let d = id(&s.execute("insert.slider", json!({"link": "D4", "max": 20})).unwrap());
assert_eq!(control(&s, d).value, 12);
// A linked cell on another sheet.
s.execute("home.insertSheet", json!({"name": "Data Sheet"})).unwrap();
s.execute("sheet.activate", json!({"sheet": "Sheet1"})).unwrap();
let e = id(&s.execute("insert.slider", json!({"link": "'Data Sheet'!a1", "value": 30})).unwrap());
assert_eq!(control(&s, e).link.as_deref(), Some("'Data Sheet'!$A$1"));
let wb = &s.doc().unwrap().wb;
let other = wb.sheet(wb.sheet_index("Data Sheet").unwrap()).unwrap();
assert_eq!(other.cell(gridcraft_core::CellRef::new(0, 0)).unwrap().value, Value::Number(30.0));
}
#[test]
fn format_replies_with_the_value_a_new_link_gives() {
let mut s = s();
s.execute("cell.set", json!({"cell": "C5", "input": "30"})).unwrap();
let c = id(&s.execute("insert.slider", json!({"link": "B1", "max": 100, "value": 10})).unwrap());
// Relinked to a cell that holds another number, the control takes that number, and the reply
// says so, as control.list does.
let r = s.execute("control.format", json!({"id": c, "link": "C5"})).unwrap();
assert_eq!(control(&s, c).value, 30);
assert_eq!(r["value"], json!(30), "{r}");
assert_eq!(r["link"], json!("$C$5"), "{r}");
// A given value is written to the new link and replied as given.
let r = s.execute("control.format", json!({"id": c, "link": "D6", "value": 44})).unwrap();
assert_eq!((r["value"].clone(), v(&s, "D6")), (json!(44), Value::Number(44.0)));
}
#[test]
fn links_follow_inserted_rows() {
let mut s = s();
let c = id(&s.execute("insert.slider", json!({"at": "E2", "link": "B3", "value": 40})).unwrap());
s.execute("selection.set", json!({"range": "1:1"})).unwrap();
s.execute("home.insertRows", json!({})).unwrap();
let ctl = control(&s, c);
assert_eq!(ctl.link.as_deref(), Some("$B$4"));
assert_eq!(ctl.anchor.cell.a1(), "E3");
assert_eq!(v(&s, "B4"), Value::Number(40.0));
s.execute("control.setValue", json!({"id": c, "value": 41})).unwrap();
assert_eq!(v(&s, "B4"), Value::Number(41.0));
}
#[test]
fn format_and_assign() {
let mut s = s();
let c = id(&s.execute("insert.slider", json!({"link": "B1", "max": 100, "value": 80})).unwrap());
let r = s.execute("control.format", json!({"id": c, "max": 50, "name": "Rate", "horizontal": false})).unwrap();
assert_eq!(
(r["max"].clone(), r["value"].clone(), r["name"].clone(), r["horizontal"].clone()),
(json!(50), json!(50), json!("Rate"), json!(false))
);
assert_eq!(v(&s, "B1"), Value::Number(50.0), "the new maximum pulls the linked value in");
assert!(err(s.execute("control.format", json!({"id": c, "min": 50}))).contains("greater than Minimum"));
assert_eq!(control(&s, c).min, 0, "a refused change leaves the control as it was");
s.execute("control.format", json!({"id": c, "link": ""})).unwrap();
assert_eq!(control(&s, c).link, None);
s.execute("control.format", json!({"id": c, "value": 25})).unwrap();
assert_eq!(v(&s, "B1"), Value::Number(50.0), "without a link nothing is written");
// Buttons: text and action.
// A fixed input needs a macro that takes one.
s.execute("macro.save", json!({"name": "Fill", "steps": [{"command": "cell.set", "params": {"cell": "Input.cell(1,1)", "input": "x"}}], "inputs": [{"name": "Input", "kind": "range"}]})).unwrap();
let b = id(&s.execute("insert.button", json!({"text": "Go", "macro": "Fill", "input": "C2:D4", "output": "F2"})).unwrap());
let action = control(&s, b).action;
assert_eq!(action, Some(ControlAction::Macro { name: "Fill".into(), input: MacroInput::Fixed("C2:D4".into()), output: Some("F2".into()) }));
s.execute("control.assign", json!({"id": b, "command": "home.bold", "params": {"range": "A1"}})).unwrap();
assert!(matches!(control(&s, b).action, Some(ControlAction::Command { ref id, .. }) if id == "home.bold"));
assert!(err(s.execute("control.assign", json!({"id": b, "command": "file.saveAs"}))).contains("can't run file.saveAs"));
assert!(err(s.execute("control.assign", json!({"id": b, "command": "control.press", "params": {"id": b}}))).contains("can't run control.press"));
assert!(err(s.execute("control.assign", json!({"id": b, "command": "no.such"}))).contains("no command"));
assert!(err(s.execute("control.assign", json!({"id": b, "macro": "Fill", "input": "nonsense!!"}))).contains("`input`"));
assert!(err(s.execute("control.assign", json!({"id": b}))).contains("clear"));
assert!(err(s.execute("control.assign", json!({"id": c, "macro": "Fill"}))).contains("only buttons"));
s.execute("control.assign", json!({"id": b, "clear": true})).unwrap();
assert_eq!(control(&s, b).action, None);
// Renaming the macro keeps the button pointing at it.
s.execute("control.assign", json!({"id": b, "macro": "fill"})).unwrap();
s.execute("macro.rename", json!({"name": "Fill", "to": "Fill Block"})).unwrap();
assert!(matches!(control(&s, b).action, Some(ControlAction::Macro { ref name, .. }) if name == "Fill Block"));
let list = s.execute("control.list", json!({})).unwrap();
assert_eq!(list.as_array().map(Vec::len), Some(2));
assert_eq!(list[1]["text"], json!("Go"));
}
#[test]
fn pressing_a_button_runs_its_macro_as_one_undo_step() {
let mut s = s();
s.execute(
"macro.save",
json!({"name": "Mark", "inputs": [{"name": "Input", "kind": "range"}], "steps": [{"command": "cell.set", "params": {"cell": "Input.cell(1,1)", "input": "done"}}], "output": {"onConflict": "overwrite"}}),
)
.unwrap();
let b = id(&s.execute("insert.button", json!({"macro": "Mark"})).unwrap());
s.execute("selection.set", json!({"range": "C3:D4"})).unwrap();
let before = undo_len(&s);
let r = s.execute("control.press", json!({"id": b})).unwrap();
assert_eq!(r["macro"], json!("Mark"));
assert_eq!(v(&s, "C3"), Value::text("done"));
assert_eq!(undo_len(&s), before + 1);
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(v(&s, "C3"), Value::Empty);
// A fixed input ignores the selection.
s.execute("control.assign", json!({"id": b, "macro": "Mark", "input": "F6:G7"})).unwrap();
s.execute("control.press", json!({"id": b})).unwrap();
assert_eq!((v(&s, "F6"), v(&s, "C3")), (Value::text("done"), Value::Empty));
// No action, a missing macro, not a button.
let empty = id(&s.execute("insert.button", json!({"text": "Nothing"})).unwrap());
assert!(err(s.execute("control.press", json!({"id": empty}))).contains("has no action"));
s.execute("control.assign", json!({"id": empty, "macro": "Gone"})).unwrap();
assert!(err(s.execute("control.press", json!({"id": empty}))).contains("no macro called"));
let spin = id(&s.execute("insert.spinButton", json!({})).unwrap());
assert!(err(s.execute("control.press", json!({"id": spin}))).contains("only buttons"));
}
#[test]
fn a_command_button_goes_through_the_policy_with_the_documents_trust() {
let mut s = s();
let b = id(&s.execute("insert.button", json!({"command": "cell.set", "params": {"cell": "A1", "input": "7"}})).unwrap());
// A workbook made here runs it straight away, as one undo step.
let before = undo_len(&s);
s.execute("control.press", json!({"id": b})).unwrap();
assert_eq!(v(&s, "A1"), Value::Number(7.0));
assert_eq!(undo_len(&s), before + 1);
s.execute("edit.undo", json!({})).unwrap();
// From a file: it asks first, once.
s.doc_mut().unwrap().trust = Trust::FromFile;
match s.execute("control.press", json!({"id": b})) {
Err(EngineError::Approve(q)) => assert!(q.contains("came with a workbook you opened"), "{q}"),
other => panic!("expected a question, got {other:?}"),
}
assert_eq!(v(&s, "A1"), Value::Empty, "nothing ran");
s.execute("control.press", json!({"id": b, "approve": true})).unwrap();
assert_eq!(v(&s, "A1"), Value::Number(7.0));
s.execute("cell.set", json!({"cell": "A1", "input": ""})).unwrap();
s.execute("control.press", json!({"id": b})).unwrap();
assert_eq!(v(&s, "A1"), Value::Number(7.0), "approved for the session");
// A command that reads a file always asks.
s.doc_mut().unwrap().trust = Trust::Local;
let f = id(&s.execute("insert.button", json!({"command": "data.fromTextCsv", "params": {"path": "/nonexistent/x.csv"}})).unwrap());
assert!(matches!(s.execute("control.press", json!({"id": f})), Err(EngineError::Approve(q)) if q.contains("reads a file")));
// A denied command that a file brought in never runs.
let sh = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().wb).sheet_mut(0).unwrap();
if let Some(c) = sh.controls.iter_mut().find(|c| c.id as u64 == f) {
c.action = Some(ControlAction::Command { id: "file.saveAs".into(), params: json!({"path": "/tmp/x.xlsx"}) });
}
assert!(err(s.execute("control.press", json!({"id": f, "approve": true}))).contains("can't run file.saveAs"));
// A macro button in a file's workbook asks too (the runner's gate).
s.execute(
"macro.save",
json!({"name": "M", "steps": [{"command": "cell.set", "params": {"cell": "B1", "input": "1"}}], "output": {"onConflict": "overwrite"}}),
)
.unwrap();
let m = id(&s.execute("insert.button", json!({"macro": "M"})).unwrap());
s.doc_mut().unwrap().trust = Trust::FromFile;
assert!(matches!(s.execute("control.press", json!({"id": m})), Err(EngineError::Approve(_))));
s.execute("control.press", json!({"id": m, "approve": true})).unwrap();
assert_eq!(v(&s, "B1"), Value::Number(1.0));
}
#[test]
fn a_file_commands_approval_question_names_the_file() {
let mut s = s();
let b = id(&s.execute("insert.button", json!({"command": "file.exportCsv", "params": {"path": "/tmp/reports/q3 totals.csv"}})).unwrap());
match s.execute("control.press", json!({"id": b})) {
Err(EngineError::Approve(q)) => assert!(q.contains("writes a file (/tmp/reports/q3 totals.csv)"), "{q}"),
other => panic!("expected a question, got {other:?}"),
}
// A long path is shortened in the middle, and line breaks can't hide part of it.
let long = format!("/tmp/{}\n{}.csv", "a".repeat(300), "b".repeat(300));
let sh = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().wb).sheet_mut(0).unwrap();
if let Some(c) = sh.controls.iter_mut().find(|c| c.id as u64 == b) {
c.action = Some(ControlAction::Command { id: "file.exportCsv".into(), params: json!({"path": long}) });
}
match s.execute("control.press", json!({"id": b})) {
Err(EngineError::Approve(q)) => {
assert!(q.contains("(/tmp/aaa") && q.contains("…") && q.contains("bbb.csv)") && !q.contains('\n') && q.len() < 600, "{q}")
}
other => panic!("expected a question, got {other:?}"),
}
// Without a path the question is unchanged.
let plain = id(&s.execute("insert.button", json!({"command": "cell.set", "params": {"cell": "A1", "input": "1"}})).unwrap());
s.doc_mut().unwrap().trust = Trust::FromFile;
match s.execute("control.press", json!({"id": plain})) {
Err(EngineError::Approve(q)) => assert!(q.ends_with("came with a workbook you opened."), "{q}"),
other => panic!("expected a question, got {other:?}"),
}
}
#[test]
fn hostile_params_are_refused() {
let mut s = s();
let c = id(&s.execute("insert.slider", json!({"width": 1e30, "height": -5, "at": "XFD1048576"})).unwrap());
let a = control(&s, c).anchor;
assert_eq!((a.width, a.height), (10_000.0, 4.0));
let long = "x".repeat(2_000);
assert!(s.execute("insert.button", json!({"text": long})).is_err());
assert!(s.execute("insert.button", json!({"text": 5})).is_err());
assert!(s.execute("insert.button", json!({"command": "cell.set", "params": [1]})).is_err());
assert!(s.execute("control.format", json!({"id": c, "link": 5})).is_err());
assert!(s.execute("control.format", json!({"id": c, "value": f64::MAX})).is_err());
assert!(s.execute("control.setValue", json!({"id": c, "value": f64::MIN})).is_ok());
assert_eq!(control(&s, c).value, 0);
// Snapping keeps the maximum reachable when the step doesn't divide the range.
let odd = FormControl { kind: ControlKind::Spin, min: 0, max: 10, step: 3, ..Default::default() };
assert_eq!((odd.snap(9.4), odd.snap(9.6), odd.snap(f64::NAN)), (9, 10, 0));
}
fn link(s: &Session, sheet_i: usize, id: u64) -> String {
let sh = s.doc().unwrap().wb.sheet(sheet_i).unwrap();
sh.controls.iter().find(|c| c.id as u64 == id).and_then(|c| c.link.clone()).unwrap_or_default()
}
#[test]
fn links_follow_inserts_deletes_renames_and_cut_moves() {
let mut s = s();
s.execute("home.insertSheet", json!({"name": "Data"})).unwrap();
// A control on Data pointing back at Sheet1.
let back = id(&s.execute("insert.spinButton", json!({"at": "C3", "link": "Sheet1!$B$2"})).unwrap());
s.execute("sheet.activate", json!({"sheet": 0})).unwrap();
let local = id(&s.execute("insert.slider", json!({"at": "E2", "link": "$B$2"})).unwrap());
let remote = id(&s.execute("insert.spinButton", json!({"at": "E6", "link": "Data!$A$1"})).unwrap());
s.execute("home.insertRows", json!({"rows": "1:1"})).unwrap();
s.execute("home.insertColumns", json!({"cols": "A:A"})).unwrap();
assert_eq!(link(&s, 0, local), "$C$3");
assert_eq!(link(&s, 1, back), "Sheet1!$C$3");
assert_eq!(link(&s, 0, remote), "Data!$A$1", "edits on Sheet1 leave links into Data alone");
s.execute("cell.set", json!({"cell": "C3", "input": "7"})).unwrap();
s.execute("edit.cut", json!({"range": "C3"})).unwrap();
s.execute("edit.paste", json!({"at": "G9"})).unwrap();
assert_eq!(link(&s, 0, local), "$G$9", "a cut-move takes the link with the cell");
assert_eq!(link(&s, 1, back), "Sheet1!$G$9");
s.execute("sheet.rename", json!({"sheet": 1, "name": "Inputs"})).unwrap();
assert_eq!(link(&s, 0, remote), "Inputs!$A$1");
s.execute("home.deleteRows", json!({"rows": "9:9"})).unwrap();
assert!(link(&s, 0, local).contains("#REF!"), "{}", link(&s, 0, local));
s.execute("home.deleteSheet", json!({"sheet": 1})).unwrap();
assert!(link(&s, 0, remote).contains("#REF!"), "{}", link(&s, 0, remote));
// A link to a deleted cell writes nothing.
s.execute("control.setValue", json!({"id": local, "value": 5})).unwrap();
for _ in 0..3 {
s.execute("edit.undo", json!({})).unwrap();
}
assert_eq!((link(&s, 0, local), link(&s, 0, remote)), ("$G$9".to_string(), "Inputs!$A$1".to_string()));
}
#[test]
fn the_object_commands_leave_a_locked_control_on_a_protected_sheet_alone() {
let mut s = s();
let locked = id(&s.execute("insert.slider", json!({"at": "D2", "link": "B1"})).unwrap());
let other = id(&s.execute("insert.spinButton", json!({"at": "D8"})).unwrap());
let free = id(&s.execute("insert.button", json!({"at": "D12"})).unwrap());
s.execute("control.format", json!({"id": free, "locked": false})).unwrap();
s.execute("review.protectSheet", json!({})).unwrap();
let before = s.doc().unwrap().wb.clone();
for (cmd, p) in [
("object.move", json!({"kind": "control", "id": locked, "at": "H9"})),
("object.delete", json!({"kind": "control", "id": locked})),
("object.setAnchorMode", json!({"kind": "control", "id": locked, "mode": "absolute"})),
("arrange.bringForward", json!({"kind": "control", "id": locked, "toFront": true})),
("arrange.sendBackward", json!({"kind": "control", "id": locked})),
("arrange.align", json!({"kind": "control", "ids": [locked, other], "align": "left"})),
] {
assert!(err(s.execute(cmd, p)).contains("protected"), "{cmd}");
}
assert!(std::sync::Arc::ptr_eq(&s.doc().unwrap().wb, &before), "nothing changed");
// An unlocked control still moves, as in Excel.
s.execute("object.move", json!({"kind": "control", "id": free, "at": "H12"})).unwrap();
assert_eq!(control(&s, free).anchor.cell.a1(), "H12");
}
#[test]
fn a_locked_control_on_a_protected_sheet_cannot_be_changed() {
let mut s = s();
let c = id(&s.execute("insert.slider", json!({"link": "B1"})).unwrap());
let b = id(&s.execute("insert.button", json!({})).unwrap());
s.execute("review.protectSheet", json!({})).unwrap();
assert!(err(s.execute("control.format", json!({"id": c, "max": 50}))).contains("protected"));
assert!(err(s.execute("control.assign", json!({"id": b, "command": "home.bold"}))).contains("protected"));
}
fn reopen(s: &mut Session) {
let r = s.execute("file.saveBytes", json!({"format": "xlsx"})).unwrap();
let b64 = r["base64"].as_str().unwrap().to_string();
s.execute("file.open", json!({"name": "x.xlsx", "base64": b64})).unwrap();
}
#[test]
fn a_kept_button_action_comes_back_only_on_the_same_button() {
use crate::cmd::controls::{load_action_names, with_action_names};
let mut s = s();
s.execute("macro.save", json!({"name": "Mark", "steps": [{"command": "cell.set", "params": {"cell": "Input.cell(1,1)", "input": "m"}}], "inputs": [{"name": "Input", "kind": "range"}]})).unwrap();
s.execute("insert.button", json!({"at": "D2", "text": "Run", "command": "cell.set", "params": {"cell": "A9", "input": "cmd"}})).unwrap();
s.execute("insert.button", json!({"at": "D6", "text": "Other"})).unwrap();
s.execute("insert.button", json!({"at": "D9", "text": "Fixed", "macro": "Mark", "input": "F1:G2"})).unwrap();
let wb = (*s.doc().unwrap().wb).clone();
let stored = with_action_names(&wb).unwrap().unwrap();
let actions = |wb: &gridcraft_model::Workbook| wb.sheet(0).unwrap().controls.iter().map(|c| c.action.clone()).collect::<Vec<_>>();
let kept = |wb: &gridcraft_model::Workbook| {
let mut wb = wb.clone();
for c in &mut wb.sheet_mut(0).unwrap().controls {
// As xlsx carries them: a macro on the selection, or nothing for a command.
c.action = match c.action.take() {
Some(ControlAction::Macro { name, .. }) => Some(ControlAction::Macro { name, input: MacroInput::Selection, output: None }),
_ => None,
};
}
wb
};
// The same buttons in the same places get their actions back.
let mut same = kept(&stored);
load_action_names(&mut same);
assert_eq!(actions(&same), actions(&wb));
// Another application removed the first button: the command must not move to "Other".
let mut shifted = kept(&stored);
shifted.sheet_mut(0).unwrap().controls.remove(0);
load_action_names(&mut shifted);
assert_eq!(shifted.sheets[0].controls[0].action, None, "the command stays with the removed button");
// A button given another macro there keeps it; a command doesn't replace a macro either.
let mut reassigned = kept(&stored);
for i in [0, 2] {
reassigned.sheet_mut(0).unwrap().controls[i].action =
Some(ControlAction::Macro { name: "Other".into(), input: MacroInput::Selection, output: None });
}
load_action_names(&mut reassigned);
for i in [0, 2] {
assert!(
matches!(&reassigned.sheets[0].controls[i].action, Some(ControlAction::Macro { name, input: MacroInput::Selection, .. }) if name == "Other"),
"{i}"
);
}
// Another application cleared the macro of "Fixed": the stored action (with its fixed input)
// does not bring the assignment back. The command on a button given no macro still does.
let mut cleared = kept(&stored);
cleared.sheet_mut(0).unwrap().controls[2].action = None;
load_action_names(&mut cleared);
assert_eq!(cleared.sheets[0].controls[2].action, None, "the removed assignment stays removed");
assert_eq!(cleared.sheets[0].controls[0].action, actions(&wb)[0], "the command comes back");
}
/// An entry of the hidden name must carry the button's name and anchor cell (the writer always
/// stores both): a hand-edited entry without either, or with something else there, is dropped
/// like an unparsable one, so it cannot pin its action onto whatever button sits at that index.
#[test]
fn a_kept_action_without_the_buttons_name_or_anchor_is_dropped() {
use crate::cmd::controls::{ACTIONS_KEY, load_action_names, with_action_names};
use crate::cmd::stash;
let mut s = s();
s.execute("insert.button", json!({"at": "D2", "text": "Run", "command": "cell.set", "params": {"cell": "A9", "input": "cmd"}})).unwrap();
let stored = with_action_names(&s.doc().unwrap().wb).unwrap().unwrap();
let items = stash::get_json(&stored, ACTIONS_KEY).unwrap();
assert!(items[0]["name"].is_string() && items[0]["at"].is_string(), "the writer stores both keys: {items}");
// As the file brings a command button back: with no action.
let kept = |items: &Json| {
let mut wb = stored.clone();
wb.sheet_mut(0).unwrap().controls[0].action = None;
stash::remove(&mut wb, ACTIONS_KEY);
stash::set_json(&mut wb, ACTIONS_KEY, items).unwrap();
load_action_names(&mut wb);
assert!(stash::get(&wb, ACTIONS_KEY).is_none(), "the hidden name is removed");
wb.sheets[0].controls[0].action.clone()
};
let edited = |key: &str, value: Option<Json>| {
let mut items = items.clone();
let entry = items[0].as_object_mut().unwrap();
match value {
Some(v) => entry.insert(key.to_string(), v),
None => entry.remove(key),
};
items
};
assert_eq!(kept(&edited("name", None)), None, "no `name`");
assert_eq!(kept(&edited("at", None)), None, "no `at`");
assert_eq!(kept(&edited("name", Some(json!(1)))), None, "`name` is not text");
assert_eq!(kept(&edited("at", Some(Json::Null))), None, "`at` is null");
assert!(matches!(kept(&items), Some(ControlAction::Command { .. })), "the writer's own entry still comes back");
}
/// A fixed input range for a macro that declares no inputs would fail every press (`macro.run`
/// refuses inputs the macro doesn't take), so the assignment is refused when it is made, naming
/// the macro; a macro the workbook doesn't have yet is not checked, and a macro with an input
/// takes the range. A file that brings such a stored input in gets the macro on the selection.
#[test]
fn a_fixed_input_for_a_macro_without_inputs_is_refused_when_assigned() {
use crate::cmd::controls::{load_action_names, with_action_names};
let mut s = s();
s.execute("macro.save", json!({"name": "Fill", "steps": [{"command": "cell.set", "params": {"cell": "A1", "input": "x"}}]})).unwrap();
s.execute("macro.save", json!({"name": "Mark", "steps": [{"command": "cell.set", "params": {"cell": "Input.cell(1,1)", "input": "m"}}], "inputs": [{"name": "Input", "kind": "range"}]})).unwrap();
let want = "macro 'Fill' takes no inputs";
assert!(err(s.execute("insert.button", json!({"at": "B2", "macro": "Fill", "input": "C2:D4"}))).contains(want));
assert_eq!(s.doc().unwrap().wb.sheet(0).unwrap().controls.len(), 0, "no button was placed");
let b = id(&s.execute("insert.button", json!({"at": "B2", "macro": "Fill", "output": "F2"})).unwrap());
let e = err(s.execute("control.assign", json!({"id": b, "macro": "fill", "input": "C2:D4"})));
assert!(e.contains(want), "any letter case: {e}");
assert_eq!(control(&s, b).action, Some(ControlAction::Macro { name: "Fill".into(), input: MacroInput::Selection, output: Some("F2".into()) }));
s.execute("control.assign", json!({"id": b, "macro": "Mark", "input": "C2:D4"})).unwrap();
s.execute("control.assign", json!({"id": b, "macro": "Later", "input": "C2:D4"})).unwrap();
assert_eq!(control(&s, b).action, Some(ControlAction::Macro { name: "Later".into(), input: MacroInput::Fixed("C2:D4".into()), output: None }));
// A file's stored fixed input for Fill (another version assigned it) comes back as the selection.
s.execute("control.assign", json!({"id": b, "macro": "Fill", "output": "F2"})).unwrap();
let mut wb = (*s.doc().unwrap().wb).clone();
wb.sheet_mut(0).unwrap().controls[0].action =
Some(ControlAction::Macro { name: "Fill".into(), input: MacroInput::Fixed("C2:D4".into()), output: Some("F2".into()) });
let mut stored = with_action_names(&wb).unwrap().unwrap();
load_action_names(&mut stored);
assert_eq!(
stored.sheets[0].controls[0].action,
Some(ControlAction::Macro { name: "Fill".into(), input: MacroInput::Selection, output: Some("F2".into()) })
);
}
/// The saved xlsx bytes of `s`'s workbook.
fn saved_xlsx(s: &mut Session) -> Vec<u8> {
let r = s.execute("file.saveBytes", json!({"format": "xlsx"})).unwrap();
crate::io::base64_decode(r["base64"].as_str().unwrap()).unwrap()
}
/// `bytes` with `edit` applied to the text of every part, as another application that rewrote
/// those parts would leave the file.
fn edit_parts(bytes: &[u8], mut edit: impl FnMut(&str, String) -> String) -> Vec<u8> {
use std::io::{Cursor, Read, Write};
let mut zip = zip::ZipArchive::new(Cursor::new(bytes)).unwrap();
let mut out = zip::ZipWriter::new(Cursor::new(Vec::new()));
for i in 0..zip.len() {
let mut part = zip.by_index(i).unwrap();
let name = part.name().to_string();
let mut data = Vec::new();
part.read_to_end(&mut data).unwrap();
let data = match String::from_utf8(data) {
Ok(text) => edit(&name, text).into_bytes(),
Err(e) => e.into_bytes(),
};
out.start_file(name, zip::write::SimpleFileOptions::default()).unwrap();
out.write_all(&data).unwrap();
}
out.finish().unwrap().into_inner()
}
/// Opens `bytes` as `name`, checks the hidden name of the button actions is taken back out, and
/// lists the controls.
fn open_and_list(s: &mut Session, name: &str, bytes: &[u8]) -> Json {
s.execute("file.open", json!({"name": name, "base64": crate::io::base64_encode(bytes)})).unwrap();
assert!(s.doc().unwrap().wb.names.iter().all(|n| !n.name.contains("control_actions")), "{name}: the hidden name is taken back out");
s.execute("control.list", json!({})).unwrap()
}
/// The macro reference xlsx writes for a button that runs `Mark`.
const MARK: &str = "[0]!Mark";
/// Another application removed a button's macro assignment and kept the button with its name and
/// place: the hidden name's copy of the action (with the fixed input and the output cell) does not
/// bring the assignment back. The same file, unedited, reopens with the whole action.
#[test]
fn a_macro_assignment_removed_in_another_application_stays_removed() {
let mut s = s();
s.execute("macro.save", json!({"name": "Mark", "steps": [{"command": "cell.set", "params": {"cell": "Input.cell(1,1)", "input": "m"}}], "inputs": [{"name": "Input", "kind": "range"}], "output": {"onConflict": "overwrite"}})).unwrap();
s.execute("insert.button", json!({"at": "D2", "text": "Run", "macro": "Mark", "input": "F1:G2", "output": "H1"})).unwrap();
let before = s.execute("control.list", json!({})).unwrap()[0].clone();
let full = ControlAction::Macro { name: "Mark".into(), input: MacroInput::Fixed("F1:G2".into()), output: Some("H1".into()) };
assert_eq!(before["action"], serde_json::to_value(&full).unwrap());
let bytes = saved_xlsx(&mut s);
// xlsx assigns the macro in the sheet's `<controlPr macro>` and in the VML shape's
// `<x:FmlaMacro>` (the parts the reader takes it from); clearing the assignment drops both.
let (attr, fmla) = (format!(" macro=\"{MARK}\""), format!("<x:FmlaMacro>{MARK}</x:FmlaMacro>"));
let mut edited = Vec::new();
let cleared = edit_parts(&bytes, |name, text| {
if text.contains(&attr) || text.contains(&fmla) {
edited.push(name.to_string());
}
text.replace(&attr, "").replace(&fmla, "")
});
edited.sort();
assert_eq!(edited, ["xl/drawings/vmlDrawing1.vml", "xl/worksheets/sheet1.xml"]);
let list = open_and_list(&mut s, "cleared.xlsx", &cleared);
assert_eq!(list.as_array().unwrap().len(), 1);
for key in ["kind", "name", "at", "text"] {
assert_eq!(list[0][key], before[key], "{key}: the same button");
}
assert_eq!(list[0].get("action"), None, "the removed assignment stays removed");
// Unedited, the file still assigns the macro, and the action comes back whole.
let list = open_and_list(&mut s, "kept.xlsx", &bytes);
assert_eq!(list[0]["action"], before["action"]);
}
/// Another application assigned a macro to a button that ran a command (kept in the hidden name):
/// the file's macro wins, as it does when a macro button is given another macro.
#[test]
fn a_macro_assigned_in_another_application_replaces_a_stored_command() {
let mut s = s();
s.execute("macro.save", json!({"name": "Mark", "steps": [{"command": "cell.set", "params": {"cell": "A1", "input": "m"}}]})).unwrap();
s.execute("insert.button", json!({"at": "D2", "text": "Run", "command": "cell.set", "params": {"cell": "A9", "input": "cmd"}})).unwrap();
let bytes = saved_xlsx(&mut s);
let (pr, data) = ("<controlPr", "<x:ClientData ObjectType=\"Button\">");
let mut edited = Vec::new();
let assigned = edit_parts(&bytes, |name, text| {
if text.contains(pr) || text.contains(data) {
edited.push(name.to_string());
}
text.replace(pr, &format!("<controlPr macro=\"{MARK}\"")).replace(data, &format!("{data}<x:FmlaMacro>{MARK}</x:FmlaMacro>"))
});
edited.sort();
assert_eq!(edited, ["xl/drawings/vmlDrawing1.vml", "xl/worksheets/sheet1.xml"]);
let list = open_and_list(&mut s, "assigned.xlsx", &assigned);
let plain = ControlAction::Macro { name: "Mark".into(), input: MacroInput::Selection, output: None };
assert_eq!(list[0]["action"], serde_json::to_value(&plain).unwrap(), "the file's macro, not the stored command");
}
#[test]
fn every_kind_and_action_survives_xlsx() {
let mut s = s();
s.execute("macro.save", json!({"name": "Mark", "steps": [{"command": "cell.set", "params": {"cell": "Input.cell(1,1)", "input": "m"}}], "inputs": [{"name": "Input", "kind": "range"}], "output": {"onConflict": "overwrite"}})).unwrap();
s.execute("insert.slider", json!({"at": "D2", "link": "B1", "min": 10, "max": 200, "step": 10, "page": 50, "value": 70})).unwrap();
s.execute("insert.spinButton", json!({"at": "D6", "link": "B2", "max": 9, "value": 4})).unwrap();
s.execute("insert.button", json!({"at": "D9", "text": "Plain", "macro": "Mark"})).unwrap();
s.execute("insert.button", json!({"at": "D11", "text": "Fixed", "macro": "Mark", "input": "F1:G2", "output": "H1"})).unwrap();
s.execute("insert.button", json!({"at": "D13", "text": "Command", "command": "cell.set", "params": {"cell": "A9", "input": "cmd"}})).unwrap();
s.execute("review.newNote", json!({"cell": "A1", "text": "a note"})).unwrap();
let before: Vec<Json> = s
.execute("control.list", json!({}))
.unwrap()
.as_array()
.unwrap()
.iter()
.map(|c| {
let mut c = c.clone();
c.as_object_mut().unwrap().remove("id");
c
})
.collect();
reopen(&mut s);
let after: Vec<Json> = s
.execute("control.list", json!({}))
.unwrap()
.as_array()
.unwrap()
.iter()
.map(|c| {
let mut c = c.clone();
c.as_object_mut().unwrap().remove("id");
c
})
.collect();
assert_eq!(before.len(), after.len());
for (b, a) in before.iter().zip(&after) {
for key in ["kind", "name", "at", "text", "link", "value", "min", "max", "step", "page", "horizontal", "action", "print", "locked"] {
assert_eq!(b.get(key), a.get(key), "{key}: {b} vs {a}");
}
}
assert!(s.doc().unwrap().wb.names.iter().all(|n| !n.name.contains("control_actions")), "the hidden name is taken back out");
assert_eq!(s.doc().unwrap().trust, Trust::FromFile);
// A workbook from a file: its command button asks before it runs.
let cmd = after.iter().position(|c| c["text"] == json!("Command")).unwrap();
let id = s.execute("control.list", json!({})).unwrap()[cmd]["id"].as_u64().unwrap();
assert!(matches!(s.execute("control.press", json!({"id": id})), Err(EngineError::Approve(_))));
s.execute("control.press", json!({"id": id, "approve": true})).unwrap();
assert_eq!(v(&s, "A9"), Value::text("cmd"));
// The linked cells still move the controls.
s.execute("cell.set", json!({"cell": "B1", "input": "120"})).unwrap();
assert_eq!(s.execute("control.list", json!({})).unwrap()[0]["value"], json!(120));
}
/// A button's command goes through the macro policy when it is assigned and again when it is
/// pressed, so a file whose hidden name carries a denied command (written by another application,
/// or edited by hand) can't use a button as a way around the policy.
#[test]
fn a_denied_command_is_refused_when_assigned_and_when_a_file_brings_it_in() {
let dir = std::env::temp_dir().join(format!("gridcraft-control-deny-{}", std::process::id()));
let leak = dir.join("leak.xlsx").to_string_lossy().to_string();
let denied = [
("link.open", json!({"url": "https://example.com/?d=secret"})),
("file.saveAs", json!({"path": leak})),
("file.save", json!({})),
("file.open", json!({"path": leak})),
("app.commands", json!({})),
("macro.run", json!({"name": "Mark"})),
("automate.runScript", json!({"name": "Leak", "trusted": true})),
("control.press", json!({"id": 1})),
];
let mut s = s();
s.execute("macro.save", json!({"name": "Mark", "steps": [{"command": "cell.set", "params": {"cell": "A1", "input": "m"}}]})).unwrap();
// Neither Insert › Button nor Assign Action takes one.
let b = id(&s.execute("insert.button", json!({"at": "B2", "text": "Run"})).unwrap());
for (cmd, params) in &denied {
let want = format!("can't run {cmd}");
assert!(err(s.execute("insert.button", json!({"at": "B9", "command": cmd, "params": params}))).contains(&want), "{cmd}");
assert!(err(s.execute("control.assign", json!({"id": b, "command": cmd, "params": params}))).contains(&want), "{cmd}");
assert_eq!(control(&s, b).action, None, "{cmd}");
}
assert_eq!(s.doc().unwrap().wb.sheet(0).unwrap().controls.len(), 1, "no button was placed");
// A file can still carry them: one button per command, given its action as a file would.
for i in 1..denied.len() {
s.execute("insert.button", json!({"at": format!("B{}", 2 + 3 * i), "text": format!("Run {i}")})).unwrap();
}
let sh = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().wb).sheet_mut(0).unwrap();
for (c, (cmd, params)) in sh.controls.iter_mut().zip(&denied) {
c.action = Some(ControlAction::Command { id: (*cmd).to_string(), params: params.clone() });
}
reopen(&mut s);
assert_eq!(s.doc().unwrap().trust, Trust::FromFile);
let list = s.execute("control.list", json!({})).unwrap();
for (i, (cmd, params)) in denied.iter().enumerate() {
// The action came back through the hidden name, so the check at press time is what holds.
assert_eq!(list[i]["action"], json!({"type": "command", "id": cmd, "params": params}), "{cmd}");
let pressed = list[i]["id"].as_u64().unwrap();
let before = undo_len(&s);
assert!(err(s.execute("control.press", json!({"id": pressed, "approve": true}))).contains(&format!("can't run {cmd}")), "{cmd}");
assert_eq!(undo_len(&s), before, "{cmd}: nothing ran");
}
assert!(!std::path::Path::new(&leak).exists());
assert_eq!(v(&s, "A1"), Value::Empty, "the macro never ran");
assert!(!s.ui_requests.iter().any(|r| matches!(r, crate::UiRequest::OpenUrl(_))), "no page was opened: {:?}", s.ui_requests);
}
/// A command the policy allows may still turn out to open a dialog or a web page when it runs
/// (a command given no parameters opens its dialog): the press is refused with the reason, as a
/// macro step is, and the request is dropped, so nothing opens. Messages for the person pass.
#[test]
fn a_button_command_that_would_open_a_dialog_or_a_page_is_refused_after_it_runs() {
use crate::UiRequest;
use crate::cmd::controls::take_requests;
let mut s = s();
let b = id(&s.execute("insert.button", json!({"at": "B2", "text": "Link", "command": "insert.link", "params": {}})).unwrap());
let before = undo_len(&s);
let name = control(&s, b).display_name();
assert_eq!(
err(s.execute("control.press", json!({"id": b}))),
format!("'{name}' would open the insertLink dialog; give its command the parameters it needs.")
);
assert!(!s.ui_requests.iter().any(|r| matches!(r, UiRequest::Dialog(..))), "{:?}", s.ui_requests);
assert_eq!(undo_len(&s), before, "nothing stays done");
// The filter itself, with every kind of request a command can queue: the page is refused,
// the cell edit is dropped, the message stays, and requests queued before are left alone.
s.ui_requests.push(UiRequest::Message("earlier".into()));
let queued = s.ui_requests.len();
s.ui_requests.extend([UiRequest::EditCell(None), UiRequest::Message("saved".into()), UiRequest::OpenUrl("https://example.com/".into())]);
assert_eq!(take_requests(&mut s, queued, "Go").as_deref(), Some("'Go' would open a web page, which buttons don't do."));
assert_eq!(s.ui_requests, vec![UiRequest::Message("earlier".into()), UiRequest::Message("saved".into())]);
assert_eq!(take_requests(&mut s, 5, "Go"), None, "a mark past the queue's end drops nothing");
assert_eq!(s.ui_requests.len(), 2);
}
/// A linked cell that holds a formula: a write of the value the formula already shows is skipped,
/// so Format Control's OK (which sends the value back every time) keeps the formula, also on a
/// protected sheet with the cell locked; a changed value replaces the formula with the constant.
#[test]
fn a_formula_in_the_linked_cell_survives_a_write_of_the_value_it_shows() {
let mut s = s();
s.execute("cell.set", json!({"cell": "A1", "input": "10"})).unwrap();
s.execute("cell.set", json!({"cell": "B1", "input": "=A1*2"})).unwrap();
let formula =
|s: &Session| s.doc().unwrap().wb.sheet(0).unwrap().cell(gridcraft_core::CellRef::parse("B1").unwrap()).is_some_and(|c| c.formula.is_some());
assert!(formula(&s));
// Inserting with the value the formula shows (10 * 2 = 20) writes nothing.
let c = id(&s.execute("insert.slider", json!({"at": "D2", "link": "B1", "value": 20})).unwrap());
assert!(formula(&s), "the formula stays");
assert_eq!(v(&s, "B1"), Value::Number(20.0));
// The dialog's OK: every number sent back, the value unchanged, a new name.
s.execute("control.format", json!({"id": c, "name": "Speed", "value": 20, "min": 0, "max": 100, "step": 1, "page": 10, "locked": false}))
.unwrap();
assert!(formula(&s), "a rename keeps the formula");
// Editing A1 moves the control through the formula (15 * 2 = 30); OK with that value keeps it.
s.execute("cell.set", json!({"cell": "A1", "input": "15"})).unwrap();
assert_eq!(control(&s, c).value, 30);
s.execute("control.format", json!({"id": c, "value": 30, "min": 0, "max": 100, "step": 1})).unwrap();
assert!(formula(&s));
// On a protected sheet with B1 locked, the same OK is not refused.
s.execute("review.protectSheet", json!({})).unwrap();
s.execute("control.format", json!({"id": c, "name": "Rate", "value": 30, "min": 0, "max": 100, "step": 1})).unwrap();
assert_eq!(control(&s, c).name, "Rate");
assert!(formula(&s));
s.execute("review.unprotectSheet", json!({})).unwrap();
// A changed value writes the constant over the formula.
s.execute("control.format", json!({"id": c, "value": 40, "min": 0, "max": 100, "step": 1})).unwrap();
assert!(!formula(&s), "the formula is replaced");
assert_eq!(v(&s, "B1"), Value::Number(40.0));
}
/// Using a slider or spin button writes its linked cell through `cell.set`, so the cell's
/// protection governs it: a locked cell on a protected sheet refuses the change, and the control
/// keeps its value (the control's change and the cell write are one group, rolled back together).
/// A control's own Locked flag keeps it from being moved or edited, not from being used, as in
/// Excel, so an unlocked linked cell takes the value on a protected sheet.
#[test]
fn a_locked_linked_cell_on_a_protected_sheet_refuses_the_change_and_the_control_keeps_its_value() {
let mut s = s();
let c = id(&s.execute("insert.slider", json!({"at": "D2", "link": "B1", "value": 40})).unwrap());
s.execute("control.format", json!({"id": c, "locked": false})).unwrap();
s.execute("review.protectSheet", json!({})).unwrap();
let before = undo_len(&s);
assert!(err(s.execute("control.setValue", json!({"id": c, "value": 60}))).contains("protected"));
assert_eq!((control(&s, c).value, v(&s, "B1")), (40, Value::Number(40.0)), "nothing is half-applied");
assert_eq!(undo_len(&s), before, "no undo step is left behind");
// Format Control: a new maximum that would pull the value into the cell is refused whole.
assert!(err(s.execute("control.format", json!({"id": c, "max": 30}))).contains("protected"));
assert_eq!((control(&s, c).max, control(&s, c).value), (100, 40));
// Inserting a control with a value for a locked cell is refused and leaves no control behind.
assert!(err(s.execute("insert.spinButton", json!({"at": "D8", "link": "B2", "value": 5}))).contains("protected"));
assert_eq!(s.doc().unwrap().wb.sheet(0).unwrap().controls.len(), 1);
// The value the cell already holds writes nothing, so nothing is refused.
s.execute("control.setValue", json!({"id": c, "value": 40})).unwrap();
assert_eq!(undo_len(&s), before);
// An unlocked linked cell takes the value on the protected sheet.
s.execute("review.unprotectSheet", json!({})).unwrap();
s.execute("home.lockCell", json!({"range": "B1", "on": false})).unwrap();
s.execute("review.protectSheet", json!({})).unwrap();
s.execute("control.setValue", json!({"id": c, "value": 60})).unwrap();
assert_eq!((control(&s, c).value, v(&s, "B1")), (60, Value::Number(60.0)));
}
/// A link to a cell on another sheet obeys that sheet's protection, not the control's sheet's.
#[test]
fn a_link_to_another_sheet_obeys_that_sheets_protection() {
let mut s = s();
s.execute("home.insertSheet", json!({"name": "Data"})).unwrap();
s.execute("sheet.activate", json!({"sheet": "Sheet1"})).unwrap();
let c = id(&s.execute("insert.slider", json!({"at": "D2", "link": "Data!B1", "value": 10})).unwrap());
assert_eq!(control(&s, c).link.as_deref(), Some("Data!$B$1"));
let data = |s: &Session| {
let wb = &s.doc().unwrap().wb;
wb.sheet(wb.sheet_index("Data").unwrap()).unwrap().value(gridcraft_core::CellRef::new(0, 1))
};
assert_eq!(data(&s), Value::Number(10.0));
s.execute("review.protectSheet", json!({"sheet": "Data"})).unwrap();
let before = undo_len(&s);
assert!(err(s.execute("control.setValue", json!({"id": c, "value": 20}))).contains("protected"));
assert_eq!((control(&s, c).value, data(&s)), (10, Value::Number(10.0)));
assert_eq!(undo_len(&s), before);
assert!(err(s.execute("insert.spinButton", json!({"at": "D8", "link": "Data!B2", "value": 5}))).contains("protected"));
assert_eq!(s.doc().unwrap().wb.sheet(0).unwrap().controls.len(), 1);
// Protecting the control's own sheet instead leaves the write to the other sheet alone.
s.execute("review.unprotectSheet", json!({"sheet": "Data"})).unwrap();
s.execute("review.protectSheet", json!({})).unwrap();
s.execute("control.setValue", json!({"id": c, "value": 20})).unwrap();
assert_eq!((control(&s, c).value, data(&s)), (20, Value::Number(20.0)));
// A link to a sheet the workbook doesn't have is refused with a clear message.
s.execute("review.unprotectSheet", json!({})).unwrap();
assert!(err(s.execute("control.format", json!({"id": c, "link": "Nowhere!B1"}))).contains("isn't a cell of this workbook"));
assert_eq!(control(&s, c).link.as_deref(), Some("Data!$B$1"));
}
+80
View File
@@ -2434,3 +2434,83 @@ fn a_shortcut_taken_while_recording_is_reported_when_it_is_left_off() {
assert!(r.get("shortcutDropped").is_none(), "{r}");
assert_eq!(s.execute("macro.get", json!({"name": "Keep"})).unwrap()["shortcut"], "Ctrl+Shift+Q");
}
/// `output` (a form button's output cell) moves where the macro writes: the footprint, the
/// conflict rule and `macro.check` protect the data at the cells it names, as at the macro's own
/// anchor, and a value that names no cell is refused before anything runs.
#[test]
fn an_output_override_is_checked_and_protected_like_the_macros_own_anchor() {
let mut s = s();
s.execute("home.insertSheet", json!({"name": "Other"})).unwrap();
s.execute("sheet.activate", json!({"sheet": 0})).unwrap();
put(
&mut s,
json!({"name": "Placed", "output": {"anchor": "D1"}, "steps": [
{"command": "cell.set", "params": {"cell": "Output", "input": "out"}}
]}),
);
set(&mut s, "F1", "keep me");
let start = s.doc().unwrap().wb.clone();
// Writing over F1 asks (programmatic runs fail) and changes nothing, as it would at D1.
let r = s.execute("macro.run", json!({"name": "Placed", "output": "F1"}));
let Err(EngineError::Confirm(q)) = r else { panic!("{r:?}") };
assert!(q.contains("(F1 on Sheet1)"), "{q}");
let err = s.execute("macro.run", json!({"name": "Placed", "output": "F1", "onConflict": "fail"})).unwrap_err().to_string();
assert!(err.contains("Nothing was changed"), "{err}");
assert!(Arc::ptr_eq(&s.doc().unwrap().wb, &start));
// macro.check reports the override and its conflict.
let c = s.execute("macro.check", json!({"name": "Placed", "output": "F1"})).unwrap();
assert_eq!((&c["ok"], &c["output"], &c["conflicts"]), (&json!(false), &json!("F1"), &json!(1)), "{c}");
// On another sheet too.
s.execute("cell.set", json!({"cell": "Other!B2", "input": "theirs"})).unwrap();
let r = s.execute("macro.run", json!({"name": "Placed", "output": "Other!B2"}));
assert!(matches!(&r, Err(EngineError::Confirm(q)) if q.contains("(B2 on Other)")), "{r:?}");
// The stored anchor holding data doesn't matter when the output goes elsewhere.
set(&mut s, "D1", "stored anchor's data");
s.execute("macro.run", json!({"name": "Placed", "output": "H1"})).unwrap();
assert_eq!((v(&s, "H1").display(), v(&s, "D1").display()), ("out".to_string(), "stored anchor's data".to_string()));
// Hostile values are refused before anything runs.
let before = s.doc().unwrap().wb.clone();
for (bad, needle) in [
(json!(5), "`output` must be reference text"),
(json!({"cell": "F1"}), "`output` must be reference text"),
(json!(" "), "`output` must be reference text"),
(json!("x".repeat(1_025)), "`output` must be reference text"),
(json!("F1\nF2"), "`output` must be reference text"),
(json!("NoSuchSheet!A1"), "output anchor"),
(json!("XFE1"), "output anchor"),
(json!("A1048577"), "output anchor"),
(json!("not a cell!!"), "output anchor"),
] {
let err = s.execute("macro.run", json!({"name": "Placed", "output": bad, "onConflict": "overwrite"})).unwrap_err().to_string();
assert!(err.contains(needle), "{bad}: {err}");
let c = s.execute("macro.check", json!({"name": "Placed", "output": bad})).unwrap();
assert!(c["errors"].to_string().contains(needle), "{bad}: {c}");
}
assert!(Arc::ptr_eq(&s.doc().unwrap().wb, &before));
}
/// After a partial footprint (Solver can't be previewed), a later step that deletes a form
/// control is caught by the check of each step, as one that deletes a chart or a picture is.
#[test]
fn a_step_that_deletes_a_form_control_after_a_partial_footprint_is_caught() {
let mut s = s();
set(&mut s, "A1", "1");
set(&mut s, "C1", "=A1");
let control = s.execute("insert.spinButton", json!({"at": "E2"})).unwrap()["control"].as_u64().unwrap();
let solver = json!({"command": "data.solver", "params": {"objective": "C1", "variables": "A1", "constraints": ["A1 <= 5"], "method": "simplex", "saveModel": false}});
let delete = json!({"command": "object.delete", "params": {"kind": "control", "id": control}});
put(&mut s, json!({"name": "Drop", "inputs": [{"name": "Input"}], "steps": [solver, delete]}));
let start = s.doc().unwrap().wb.clone();
for rule in ["fail", "ask"] {
let r = s.execute("macro.run", json!({"name": "Drop", "inputs": "A1", "onConflict": rule}));
let e = match (rule, r) {
("fail", Err(EngineError::Other(e))) | ("ask", Err(EngineError::Confirm(e))) => e,
(_, r) => panic!("{rule}: {r:?}"),
};
assert!(e.contains("Step 2 (object.delete): It would write over data (1 object)"), "{e}");
assert!(Arc::ptr_eq(&s.doc().unwrap().wb, &start), "{rule}: rolled back");
}
s.execute("macro.run", json!({"name": "Drop", "inputs": "A1", "overwrite": true})).unwrap();
assert!(s.doc().unwrap().wb.active().unwrap().controls.is_empty());
}
+35 -10
View File
@@ -20,8 +20,8 @@ fn id_of(v: &serde_json::Value, key: &str) -> u32 {
}
/// A table with one object of each kind on the first sheet: a chart, a picture, a shape, a
/// slicer and an equation, in `ObjKind::ALL` order.
fn every_kind(s: &mut Session) -> [ObjRef; 5] {
/// slicer, an equation and a form control, in `ObjKind::ALL` order.
fn every_kind(s: &mut Session) -> [ObjRef; 6] {
s.execute("range.setValues", json!({"range": "A1", "values": [["Region", "Qty"], ["East", 3], ["West", 5], ["East", 2], ["North", 8]]})).unwrap();
s.execute("insert.table", json!({"range": "A1:B5", "name": "Sales"})).unwrap();
let chart = id_of(&s.execute("insert.chart", json!({"range": "B1:B5", "type": "column", "at": "D2"})).unwrap(), "chart");
@@ -30,12 +30,14 @@ fn every_kind(s: &mut Session) -> [ObjRef; 5] {
s.execute("selection.set", json!({"cell": "A2"})).unwrap();
let slicer = id_of(&s.execute("insert.slicer", json!({"column": "Region", "at": "L2"})).unwrap(), "slicer");
let equation = id_of(&s.execute("insert.equation", json!({"latex": "x^2", "cell": "L18"})).unwrap(), "equation");
let control = id_of(&s.execute("insert.slider", json!({"at": "P18", "link": "$B$7"})).unwrap(), "control");
[
ObjRef::new(ObjKind::Chart, chart),
ObjRef::new(ObjKind::Image, image),
ObjRef::new(ObjKind::Shape, shape),
ObjRef::new(ObjKind::Slicer, slicer),
ObjRef::new(ObjKind::Equation, equation),
ObjRef::new(ObjKind::Control, control),
]
}
@@ -68,9 +70,9 @@ fn every_kind_is_found_moved_and_deleted_by_kind_and_id() {
assert!(sheet(&s, 0).has_object(*later), "deleting {o:?} leaves {later:?}");
}
}
// One undo step per delete brings the equation back.
// One undo step per delete brings the control, deleted last, back.
s.execute("edit.undo", json!({})).unwrap();
assert!(sheet(&s, 0).has_object(objs[4]));
assert!(sheet(&s, 0).has_object(objs[5]));
}
#[test]
@@ -84,9 +86,11 @@ fn selection_pane_lists_and_stacks_slicers() {
for k in ObjKind::ALL {
assert!(kinds.contains(&k.name()), "{k:?} listed in {kinds:?}");
}
assert_eq!(kinds.last(), Some(&"slicer"), "slicers stack on top");
assert_eq!(kinds.last(), Some(&"control"), "form controls stack on top");
assert_eq!(kinds.get(kinds.len() - 2), Some(&"slicer"), "slicers stack above charts, pictures and shapes");
let names: Vec<&str> = items.iter().map(|o| o["name"].as_str().unwrap()).collect();
assert!(names.contains(&"Region") && names.contains(&"Qty"), "{names:?}");
assert!(names.contains(&format!("Slider {}", objs[5].id).as_str()), "{names:?}");
assert_eq!(sheet(&s, 0).slicers.first().map(|c| c.id), Some(objs[3].id));
s.execute("arrange.sendBackward", json!({"kind": "slicer", "id": second, "toBack": true})).unwrap();
assert_eq!(sheet(&s, 0).slicers.first().map(|c| c.id), Some(second));
@@ -122,17 +126,17 @@ fn cell_of(s: &Session, o: ObjRef) -> String {
fn every_kind_follows_row_and_column_edits_and_sorts() {
let mut s = s();
let objs = every_kind(&mut s);
// Anchors: chart D2, picture D18, shape H18, slicer L2, equation L18.
// Anchors: chart D2, picture D18, shape H18, slicer L2, equation L18, slider P18.
s.execute("object.setAnchorMode", json!({"kind": "shape", "id": objs[2].id, "mode": "absolute"})).unwrap();
let cells = |s: &Session| objs.map(|o| cell_of(s, o));
assert_eq!(cells(&s), ["D2", "D18", "H18", "L2", "L18"]);
assert_eq!(cells(&s), ["D2", "D18", "H18", "L2", "L18", "P18"]);
s.execute("home.insertRows", json!({"rows": "10:11"})).unwrap();
assert_eq!(cells(&s), ["D2", "D20", "H18", "L2", "L20"], "rows inserted above move the objects below them; an absolute shape stays");
assert_eq!(cells(&s), ["D2", "D20", "H18", "L2", "L20", "P20"], "rows inserted above move the objects below them; an absolute shape stays");
s.execute("home.insertColumns", json!({"cols": "C:C"})).unwrap();
assert_eq!(cells(&s), ["E2", "E20", "H18", "M2", "M20"]);
assert_eq!(cells(&s), ["E2", "E20", "H18", "M2", "M20", "Q20"]);
s.execute("home.deleteColumns", json!({"cols": "C:C"})).unwrap();
s.execute("home.deleteRows", json!({"rows": "10:11"})).unwrap();
assert_eq!(cells(&s), ["D2", "D18", "H18", "L2", "L18"]);
assert_eq!(cells(&s), ["D2", "D18", "H18", "L2", "L18", "P18"]);
// Sorting moves a picture anchored inside the sorted block with its row.
s.execute("range.setValues", json!({"range": "T1", "values": [["k"], [3], [1], [2]]})).unwrap();
let pic = ObjRef::new(ObjKind::Image, super::add_image(&mut s, "T2", AnchorMode::MoveAndSize));
@@ -261,6 +265,7 @@ fn a_copied_sheet_gives_its_objects_fresh_ids() {
.chain(sh.images.iter().map(|c| c.id))
.chain(sh.shapes.iter().map(|c| c.id))
.chain(sh.slicers.iter().map(|c| c.id))
.chain(sh.controls.iter().map(|c| c.id))
.collect()
};
let mut ids: Vec<u32> = wb.sheets.iter().flat_map(|sh| ids_of(sh)).collect();
@@ -314,6 +319,26 @@ fn object_ids_stay_unique_when_a_file_used_the_largest_id() {
assert_eq!(ids.len(), 6, "every shape id is distinct across both sheets");
}
/// A copied sheet's form controls take free ids like its other objects: a file's control can
/// carry the largest id, and the ids the copy asks for count the controls.
#[test]
fn a_copied_sheets_form_controls_take_free_ids_too() {
let mut s = s();
let slider = id_of(&s.execute("insert.slider", json!({"at": "B2", "link": "$A$1"})).unwrap(), "control");
s.execute("insert.button", json!({"at": "B6"})).unwrap();
s.execute("insert.shape", json!({"kind": "rectangle", "at": "D2"})).unwrap();
// The file's slider carries the largest id (set on the model, as a loaded file would).
std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().wb).sheet_mut(0).unwrap().controls.iter_mut().find(|o| o.id == slider).unwrap().id = u32::MAX;
s.execute("sheet.move", json!({"sheet": 0, "to": 1, "copy": true})).unwrap();
let wb = &s.doc().unwrap().wb;
let mut ids: Vec<u32> = wb.sheets.iter().flat_map(|sh| sh.controls.iter().map(|o| o.id).chain(sh.shapes.iter().map(|o| o.id))).collect();
assert_eq!(ids.len(), 6);
ids.sort_unstable();
ids.dedup();
assert_eq!(ids.len(), 6, "every control and shape id is distinct across both sheets: {ids:?}");
assert_eq!(ids.iter().filter(|&&i| i == u32::MAX).count(), 1, "only the file's slider keeps the largest id");
}
/// A file's anchors are not held to the 10,000 points `object.move` gives a new size. A picture
/// a file made 12,000 wide keeps that width when it is moved, shrinks by what a resize asks
/// (11,990 stays 11,990, no jump to 10,000) and never grows further; a dimension the file kept
+168 -1
View File
@@ -740,6 +740,171 @@ impl EquationCache {
}
}
/// What a form control on the sheet is.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum ControlKind {
#[default]
Button,
Slider,
Spin,
}
impl ControlKind {
/// The kind's name in the window: Button, Slider, Spin Button.
pub fn label(self) -> &'static str {
match self {
ControlKind::Button => "Button",
ControlKind::Slider => "Slider",
ControlKind::Spin => "Spin Button",
}
}
}
/// The largest value a slider or spin button takes (Excel's scroll bar limit). Values are whole
/// numbers from 0 to this.
pub const CONTROL_MAX: u32 = 30_000;
/// What a button runs when it is pressed.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "camelCase")]
pub enum ControlAction {
/// Runs a macro of the workbook on `input`, optionally sending its output to `output`.
Macro {
name: String,
#[serde(default)]
input: MacroInput,
#[serde(default, skip_serializing_if = "Option::is_none")]
output: Option<String>,
},
/// Runs one GridCraft command with fixed parameters.
Command {
id: String,
#[serde(default)]
params: serde_json::Value,
},
}
/// The range a button's macro runs on.
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum MacroInput {
/// The selection when the button is pressed.
#[default]
Selection,
/// Always this range (`B2:D9` or `Data!B2:D9`).
Fixed(String),
}
/// A form control (button, slider, spin button) floating over the sheet.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct FormControl {
pub id: u32,
pub kind: ControlKind,
pub anchor: Anchor,
/// The name shown in the Selection Pane (`Button 3`); empty means the kind and id.
pub name: String,
/// The caption of a button.
pub text: String,
/// The linked cell as reference text: `$B$2` on the control's own sheet, or `Sheet2!$B$2`.
/// Structural edits rewrite it like a formula reference.
pub link: Option<String>,
/// The current value of a slider or spin button (the linked cell holds it too).
pub value: u32,
pub min: u32,
pub max: u32,
/// Incremental change: one arrow click or arrow key.
pub step: u32,
/// Page change (sliders): a click on the track, or Page Up / Page Down.
pub page: u32,
/// A slider lies horizontally (false: vertically).
pub horizontal: bool,
/// What a button runs.
pub action: Option<ControlAction>,
/// Printed with the sheet.
pub print: bool,
/// Locked when the sheet is protected.
pub locked: bool,
}
impl Default for FormControl {
fn default() -> Self {
FormControl {
id: 0,
kind: ControlKind::default(),
anchor: Anchor::default(),
name: String::new(),
text: String::new(),
link: None,
value: 0,
min: 0,
max: 100,
step: 1,
page: 10,
horizontal: true,
action: None,
print: true,
locked: true,
}
}
}
impl FormControl {
/// The name shown for the control: its own, else `Button 3`, `Slider 4`, `Spin Button 5`.
pub fn display_name(&self) -> String {
if self.name.trim().is_empty() { format!("{} {}", self.kind.label(), self.id) } else { self.name.clone() }
}
/// Checks the numbers of a slider or spin button: 0 ≤ min < max ≤ 30000, 1 ≤ step ≤ max − min,
/// and (sliders) page ≥ step. Each rule has its own message.
pub fn check_numbers(&self) -> Result<(), String> {
if self.kind == ControlKind::Button {
return Ok(());
}
if self.max > CONTROL_MAX {
return Err(format!("Maximum value can be at most {CONTROL_MAX}."));
}
if self.min > CONTROL_MAX {
return Err(format!("Minimum value must be a whole number from 0 to {CONTROL_MAX}."));
}
if self.min >= self.max {
return Err(format!("Maximum value ({}) must be greater than Minimum value ({}).", self.max, self.min));
}
if self.step < 1 {
return Err("Incremental change must be at least 1.".into());
}
let span = self.max - self.min;
if self.step > span {
return Err(format!("Incremental change ({}) can be at most Maximum minus Minimum ({span}).", self.step));
}
if self.kind == ControlKind::Slider {
if self.page < self.step {
return Err(format!("Page change ({}) must be at least the incremental change ({}).", self.page, self.step));
}
if self.page > CONTROL_MAX {
return Err(format!("Page change can be at most {CONTROL_MAX}."));
}
}
Ok(())
}
/// `v` clamped to min..max and snapped to the nearest step from min (max stays reachable).
pub fn snap(&self, v: f64) -> u32 {
let (min, max) = (self.min.min(CONTROL_MAX), self.max.min(CONTROL_MAX).max(self.min.min(CONTROL_MAX)));
if !v.is_finite() || v <= min as f64 {
return min;
}
if v >= max as f64 {
return max;
}
let step = self.step.max(1) as f64;
let snapped = (min as f64 + ((v - min as f64) / step).round() * step).clamp(min as f64, max as f64);
// When the step doesn't divide the range, the maximum is a stop of its own.
if (max as f64 - v).abs() < (snapped - v).abs() { max } else { snapped as u32 }
}
}
/// The kinds of floating object a sheet holds. Commands name an object by kind and
/// workbook-unique id (`{kind: "chart", id}`); [`ObjKind::name`] is that parameter's spelling.
/// Equations are stored as shapes ([`ShapeKind::Equation`]) but select, open and delete as their
@@ -752,10 +917,11 @@ pub enum ObjKind {
Shape,
Slicer,
Equation,
Control,
}
impl ObjKind {
pub const ALL: [ObjKind; 5] = [ObjKind::Chart, ObjKind::Image, ObjKind::Shape, ObjKind::Slicer, ObjKind::Equation];
pub const ALL: [ObjKind; 6] = [ObjKind::Chart, ObjKind::Image, ObjKind::Shape, ObjKind::Slicer, ObjKind::Equation, ObjKind::Control];
/// The name used in command parameters.
pub fn name(self) -> &'static str {
@@ -765,6 +931,7 @@ impl ObjKind {
ObjKind::Shape => "shape",
ObjKind::Slicer => "slicer",
ObjKind::Equation => "equation",
ObjKind::Control => "control",
}
}
+24 -9
View File
@@ -9,8 +9,8 @@ use serde::{Deserialize, Serialize};
use crate::cell::{Cell, CellPicture};
use crate::features::{
Anchor, AutoFilter, Chart, Comment, CondFormat, Hyperlink, Image, LineInfo, ObjKind, ObjRef, PivotTable, PrintSettings, Shape, ShapeKind,
SheetProtection, Slicer, Sparkline, Table, Validation,
Anchor, AutoFilter, Chart, Comment, CondFormat, FormControl, Hyperlink, Image, LineInfo, ObjKind, ObjRef, PivotTable, PrintSettings, Shape,
ShapeKind, SheetProtection, Slicer, Sparkline, Table, Validation,
};
use crate::store::CellStore;
use crate::style::{Color, StyleId};
@@ -147,6 +147,10 @@ pub struct Sheet {
/// On-sheet slicer widgets (filter a table column by clicking value tiles).
#[serde(default)]
pub slicers: Vec<Slicer>,
/// Form controls (buttons, sliders, spin buttons). They paint above every other object and
/// take clicks first.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub controls: Vec<FormControl>,
/// PivotTables whose output lives on this sheet.
#[serde(default)]
pub pivots: Vec<PivotTable>,
@@ -219,6 +223,7 @@ impl Sheet {
shapes: vec![],
sparklines: vec![],
slicers: vec![],
controls: vec![],
pivots: vec![],
protection: None,
edit_ranges: vec![],
@@ -285,6 +290,8 @@ impl Sheet {
if s.kind == ShapeKind::Equation { ObjKind::Equation } else { ObjKind::Shape }
} else if self.slicers.iter().any(|o| o.id == id) {
ObjKind::Slicer
} else if self.controls.iter().any(|o| o.id == id) {
ObjKind::Control
} else {
return None;
};
@@ -305,6 +312,7 @@ impl Sheet {
ObjKind::Shape => self.shapes.iter().find(|c| c.id == o.id).map(|c| c.anchor),
ObjKind::Slicer => self.slicers.iter().find(|c| c.id == o.id).map(|c| c.anchor),
ObjKind::Equation => self.shapes.iter().find(|c| c.id == o.id && c.kind == ShapeKind::Equation).map(|c| c.anchor),
ObjKind::Control => self.controls.iter().find(|c| c.id == o.id).map(|c| c.anchor),
}
}
@@ -315,6 +323,7 @@ impl Sheet {
ObjKind::Shape => self.shapes.iter_mut().find(|c| c.id == o.id).map(|c| &mut c.anchor),
ObjKind::Slicer => self.slicers.iter_mut().find(|c| c.id == o.id).map(|c| &mut c.anchor),
ObjKind::Equation => self.shapes.iter_mut().find(|c| c.id == o.id && c.kind == ShapeKind::Equation).map(|c| &mut c.anchor),
ObjKind::Control => self.controls.iter_mut().find(|c| c.id == o.id).map(|c| &mut c.anchor),
}
}
@@ -331,12 +340,13 @@ impl Sheet {
ObjKind::Shape => take(&mut self.shapes, |c| c.id == o.id),
ObjKind::Slicer => take(&mut self.slicers, |c| c.id == o.id),
ObjKind::Equation => take(&mut self.shapes, |c| c.id == o.id && c.kind == ShapeKind::Equation),
ObjKind::Control => take(&mut self.controls, |c| c.id == o.id),
}
}
/// Every floating object's anchor in a fixed order (charts, images, shapes, slicers) that
/// [`Sheet::object_anchors_mut`] repeats, so a pass can compute new anchors from the old ones
/// and write them back in step.
/// Every floating object's anchor in a fixed order (charts, images, shapes, slicers, controls)
/// that [`Sheet::object_anchors_mut`] repeats, so a pass can compute new anchors from the old
/// ones and write them back in step.
pub fn object_anchors(&self) -> impl Iterator<Item = &Anchor> {
self.charts
.iter()
@@ -344,6 +354,7 @@ impl Sheet {
.chain(self.images.iter().map(|o| &o.anchor))
.chain(self.shapes.iter().map(|o| &o.anchor))
.chain(self.slicers.iter().map(|o| &o.anchor))
.chain(self.controls.iter().map(|o| &o.anchor))
}
pub fn object_anchors_mut(&mut self) -> impl Iterator<Item = &mut Anchor> {
@@ -353,6 +364,7 @@ impl Sheet {
.chain(self.images.iter_mut().map(|o| &mut o.anchor))
.chain(self.shapes.iter_mut().map(|o| &mut o.anchor))
.chain(self.slicers.iter_mut().map(|o| &mut o.anchor))
.chain(self.controls.iter_mut().map(|o| &mut o.anchor))
}
/// Displayed value of a cell: its own value, or a spilled value.
pub fn value(&self, c: CellRef) -> Value {
@@ -839,6 +851,7 @@ mod tests {
ObjKind::Slicer => {
s.slicers.push(Slicer { id, table: String::new(), column: String::new(), caption: String::new(), anchor, selected: None, columns: 1 })
}
ObjKind::Control => s.controls.push(FormControl { id, anchor, ..Default::default() }),
}
}
@@ -860,13 +873,15 @@ mod tests {
(ObjKind::Slicer, 1),
(ObjKind::Image, 4),
(ObjKind::Equation, 6),
(ObjKind::Control, 13),
(ObjKind::Shape, 12),
(ObjKind::Equation, 8),
(ObjKind::Control, 15),
];
for &(kind, id) in &inserted {
push_object(&mut s, kind, id, Anchor { cell: CellRef::new(id, 0), ..Default::default() });
}
let per_kind = s.charts.len() + s.images.len() + s.shapes.len() + s.slicers.len();
let per_kind = s.charts.len() + s.images.len() + s.shapes.len() + s.slicers.len() + s.controls.len();
assert_eq!(per_kind, inserted.len());
assert_eq!(s.object_anchors().count(), per_kind, "object_anchors visits every collection");
assert_eq!(s.object_anchors_mut().count(), per_kind, "object_anchors_mut visits every collection");
@@ -877,10 +892,10 @@ mod tests {
}
let marks: Vec<f32> = s.object_anchors().map(|a| a.dx).collect();
assert_eq!(marks, (0..per_kind).map(|i| i as f32).collect::<Vec<f32>>());
// The sequence is the documented one (charts, images, shapes, slicers, each in insertion
// order; equations are shapes) and every object appears in it once.
// The sequence is the documented one (charts, images, shapes, slicers, controls, each in
// insertion order; equations are shapes) and every object appears in it once.
let collection = |k: ObjKind| if k == ObjKind::Equation { ObjKind::Shape } else { k };
let documented: Vec<u32> = [ObjKind::Chart, ObjKind::Image, ObjKind::Shape, ObjKind::Slicer]
let documented: Vec<u32> = [ObjKind::Chart, ObjKind::Image, ObjKind::Shape, ObjKind::Slicer, ObjKind::Control]
.iter()
.flat_map(|&k| inserted.iter().filter(move |(kind, _)| collection(*kind) == k).map(|&(_, id)| id))
.collect();
+19 -1
View File
@@ -506,7 +506,7 @@ impl Workbook {
}
}
/// A free id for a new chart, picture, shape, table or slicer: one more than the largest in
/// A free id for a new chart, picture, shape, table, slicer or form control: one more than the largest in
/// use, or, when that is `u32::MAX` (a file can carry any id), the lowest free one.
pub fn next_object_id(&self) -> u32 {
self.free_object_ids(1).first().copied().unwrap_or(u32::MAX)
@@ -527,6 +527,7 @@ impl Workbook {
.chain(s.shapes.iter().map(|o| o.id))
.chain(s.tables.iter().map(|o| o.id))
.chain(s.slicers.iter().map(|o| o.id))
.chain(s.controls.iter().map(|o| o.id))
})
.collect();
used.sort_unstable();
@@ -613,6 +614,10 @@ mod tests {
sh.images.push(image(7));
}
assert_eq!(wb.next_object_id(), 8);
if let Some(sh) = wb.sheet_mut(0) {
sh.controls.push(crate::FormControl { id: 12, ..Default::default() });
}
assert_eq!(wb.next_object_id(), 13, "form controls take ids from the same sequence");
if let Some(sh) = wb.sheet_mut(0) {
sh.images.push(image(u32::MAX));
}
@@ -631,6 +636,19 @@ mod tests {
assert_eq!(wb.free_object_ids(3), vec![1, 2, u32::MAX], "the top first, then from 1");
}
/// A form control's id is in use like any object's: the free ids skip it, also when it is the
/// largest id a file could carry.
#[test]
fn free_object_ids_skip_a_form_controls_id() {
let mut wb = Workbook::new();
if let Some(sh) = wb.sheet_mut(0) {
sh.controls.push(crate::FormControl { id: 3, ..Default::default() });
sh.controls.push(crate::FormControl { id: u32::MAX, ..Default::default() });
}
assert_eq!(wb.free_object_ids(4), vec![1, 2, 4, 5], "3 and the largest id are taken");
assert_eq!(wb.next_object_id(), 1);
}
/// A file can use the largest id and the one below it: nothing is free above them, so the free
/// ids are the lowest unused ones from 1 (0 is never an object's id), and a large request is
/// served in time linear in the count and the ids in use.
+22 -4
View File
@@ -1,5 +1,5 @@
//! Floating objects on the sheet: charts (rendered by `gridcraft-chart` into primitives that
//! we paint with egui), pictures, shapes, equations and slicers.
//! we paint with egui), pictures, shapes, equations, slicers and form controls (`form_controls.rs`).
use egui::epaint::{PathShape, PathStroke};
use egui::{Align2, Color32, FontId, Painter, Pos2, Rect, Stroke, StrokeKind, pos2, vec2};
@@ -381,6 +381,18 @@ pub(crate) fn paint_objects(app: &SheetApp, p: &Painter, geo: &Geo, wb: &Workboo
done(start, r);
}
paint_slicers(p, geo, wb, si, sh);
// Form controls sit above every other object.
let hover = p.ctx().input(|i| i.pointer.hover_pos()).map(|h| geo.mirror_pos(h)).filter(|h| geo.in_cells(*h));
for c in &sh.controls {
let r = anchor_rect(geo, sh, &c.anchor);
// A slider's value shows beside it while it is dragged.
if !r.expand(24.0).intersects(p.clip_rect()) {
continue;
}
let start = p.add(egui::Shape::Noop);
crate::form_controls::paint(p, r, c, geo.z, crate::form_controls::look(app, geo, c, r, hover));
done(start, r);
}
// The selected object's frame and handles go over every object, and move with it on a
// right-to-left sheet.
if let Some(r) = app.selected_object.and_then(|o| object_rect(geo, sh, o))
@@ -523,13 +535,19 @@ pub fn selected_handle(app: &SheetApp, geo: &Geo, sh: &Sheet, p: Pos2) -> Option
crate::objects::handle_at(r, p).map(|h| (o, r, h))
}
/// The topmost object under `p` and its rect: slicers paint on top, so they are hit first, then
/// charts, pictures and shapes (equations included), each from the top of its stack. Nothing is hit outside the cell
/// area or while objects are hidden.
/// The topmost object under `p` and its rect: form controls paint on top, so they are hit first,
/// then slicers, charts, pictures and shapes (equations included), each from the top of its stack.
/// Nothing is hit outside the cell area or while objects are hidden.
pub fn hit(app: &SheetApp, geo: &Geo, sh: &Sheet, p: Pos2) -> Option<(ObjRef, Rect)> {
if !geo.cells.contains(p) || app.session.active().is_some_and(|d| objects_hidden(&d.wb)) {
return None;
}
for c in sh.controls.iter().rev() {
let r = anchor_rect(geo, sh, &c.anchor);
if r.contains(p) {
return Some((ObjRef::new(ObjKind::Control, c.id), r));
}
}
for sl in sh.slicers.iter().rev() {
let r = anchor_rect(geo, sh, &sl.anchor);
if r.contains(p) {
+569
View File
@@ -0,0 +1,569 @@
//! Form controls in the window, apart from drawing them: placing a new control (draw it on the
//! grid, or click for the default size), pressing a button and answering its questions, the
//! control's context menu (Edit Text, Assign Action, Format Control, Delete), and the Assign
//! Action and Format Control dialogs. Everything goes through the `insert.*` and `control.*`
//! commands.
use egui::{Pos2, Rect, Stroke, StrokeKind, Ui, vec2};
use gridcraft_engine::core::CellRef;
use gridcraft_engine::macros::policy::{Policy, macro_policy};
use gridcraft_engine::model::{CONTROL_MAX, ControlAction, ControlKind, FormControl, MacroInput, ObjKind, ObjRef, Sheet};
use serde_json::{Value as Json, json};
use crate::SheetApp;
use crate::dialogs::Dialog;
use crate::grid::Geo;
use crate::theme::Tokens;
/// The control `id` on the active sheet.
pub fn control(app: &SheetApp, id: u32) -> Option<FormControl> {
app.session.active()?.wb.active()?.controls.iter().find(|c| c.id == id).cloned()
}
/// Whether the control `id` on the active sheet is a button (its menu adds Edit Text and Assign
/// Action).
pub fn is_button(app: &SheetApp, id: u32) -> bool {
app.session.active().and_then(|d| d.wb.active()).is_some_and(|sh| sh.controls.iter().any(|c| c.id == id && c.kind == ControlKind::Button))
}
fn insert_id(kind: ControlKind) -> &'static str {
match kind {
ControlKind::Button => "insert.button",
ControlKind::Slider => "insert.slider",
ControlKind::Spin => "insert.spinButton",
}
}
/// The cell under a logical grid position and the offset into it, in sheet points.
fn cell_point(geo: &Geo, sh: &Sheet, p: Pos2) -> (CellRef, f32, f32) {
let (col, row) = (geo.col_at(sh, p.x), geo.row_at(sh, p.y));
let dx = ((p.x - geo.x(sh, col)) / geo.z).max(0.0);
let dy = ((p.y - geo.y(sh, row)) / geo.z).max(0.0);
(CellRef::new(row, col), dx, dy)
}
/// Insert › Controls placement: while a kind is armed, a drag on the grid draws the control and a
/// click places it at its default size. Escape disarms. True when it used the pointer.
pub fn place(app: &mut SheetApp, ui: &Ui, resp: &egui::Response, geo: &Geo, sh: &Sheet) -> bool {
let Some(kind) = app.grid.placing else {
app.grid.place_start = None;
return false;
};
let ctx = ui.ctx().clone();
if ctx.input(|i| i.key_pressed(egui::Key::Escape)) {
app.grid.placing = None;
app.grid.place_start = None;
return true;
}
let pointer = ctx.input(|i| i.pointer.clone());
// Logical positions (see `Geo::rtl`).
let pos = pointer.interact_pos().or(pointer.hover_pos()).map(|p| geo.mirror_pos(p));
if pos.is_some_and(|p| geo.in_cells(p)) {
ctx.set_cursor_icon(egui::CursorIcon::Crosshair);
}
if pointer.primary_pressed()
&& resp.contains_pointer()
&& let Some(p) = pointer.press_origin().map(|p| geo.mirror_pos(p)).or(pos).filter(|p| geo.in_cells(*p))
{
app.grid.place_start = Some(p);
}
let Some(start) = app.grid.place_start else { return resp.contains_pointer() && pointer.any_down() };
let rect = Rect::from_two_pos(start, pos.unwrap_or(start));
if pointer.primary_down() {
let t = Tokens::get(&ctx);
ui.painter().rect_stroke(geo.mirror_rect(rect), 2.0, Stroke::new(1.0, t.sheet_control_border), StrokeKind::Inside);
return true;
}
app.grid.place_start = None;
app.grid.placing = None;
let (cell, dx, dy) = cell_point(geo, sh, rect.min);
let mut params = json!({"at": cell.a1(), "dx": dx, "dy": dy});
// A drag sets the size (and a slider's orientation); a click keeps the default size.
if rect.width() >= 6.0 || rect.height() >= 6.0 {
params["width"] = json!((rect.width() / geo.z).max(8.0));
params["height"] = json!((rect.height() / geo.z).max(8.0));
if kind == ControlKind::Slider {
params["horizontal"] = json!(rect.width() >= rect.height());
}
}
match app.run(insert_id(kind), params) {
Ok(v) => {
if let Some(id) = v["control"].as_u64().and_then(|id| u32::try_from(id).ok()) {
app.selected_object = Some(ObjRef::new(ObjKind::Control, id));
// A new button asks what it should run, as Excel's Assign Macro does.
if kind == ControlKind::Button {
app.open_dialog("assignAction", json!({"id": id}));
}
}
}
Err(e) => app.message = Some(("Insert Control".into(), crate::clean_error(&e))),
}
true
}
/// Opens a control's editor (double-click): a button's text, else Format Control.
pub fn edit(app: &mut SheetApp, id: u32) {
match control(app, id).map(|c| c.kind) {
Some(ControlKind::Button) => app.open_dialog("formatControl", json!({"id": id, "textOnly": true})),
Some(_) => app.open_dialog("formatControl", json!({"id": id})),
None => {}
}
}
// ---------------------------------------------------------------- pressing a button
/// The question in an `approve: …` error, if it is one.
fn approve_text(e: &str) -> Option<String> {
let rest = e.split_once("approve: ")?.1;
Some(rest.split_once(" (run again with").map_or(rest, |(q, _)| q).to_string())
}
/// The question in a `confirm: …` error, if it is one.
fn confirm_text(e: &str) -> Option<String> {
let rest = e.split_once("confirm: ")?.1;
Some(rest.split_once(" (run again with").map_or(rest, |(q, _)| q).to_string())
}
/// What a press of the button `params` names would run, as text: the workbook, the sheet, the
/// button, its action, the content of the macro it runs, and the selection (a macro on the
/// selection, or a command without a range, works there). `None` when the active sheet has no
/// such button. A question is answered only while this is unchanged, so an answer never applies
/// to an action other than the one it asked about.
fn press_binding(app: &SheetApp, params: &Json) -> Option<String> {
let id = params.get("id").and_then(Json::as_u64).and_then(|i| u32::try_from(i).ok())?;
let d = app.session.active()?;
let sheet = d.wb.active_sheet();
let sh = d.wb.sheet(sheet)?;
let c = sh.controls.iter().find(|c| c.id == id && c.kind == ControlKind::Button)?;
let mac = match &c.action {
Some(ControlAction::Macro { name, .. }) => {
gridcraft_engine::macros::find(&d.wb, name).and_then(|i| d.wb.macros.get(i)).map(gridcraft_engine::macros::fingerprint)
}
_ => None,
};
Some(
json!({
"doc": d.uid,
"sheet": sheet,
"sheetName": sh.name,
"control": id,
"action": c.action,
"macro": mac,
"selection": d.selection,
})
.to_string(),
)
}
/// Presses a button (`control.press` with `params`). A question (approve the action, or make
/// room for a macro's output) opens a prompt whose buttons press it again with the answer, bound
/// to what the question was about (see [`press_binding`]); any other error shows in a message
/// box.
pub fn press(app: &mut SheetApp, params: Json) {
let bound = press_binding(app, &params);
let Err(e) = app.run("control.press", params.clone()) else { return };
let (text, choices) = if let Some(q) = approve_text(&e) {
(q, json!([["Run", {"approve": true}]]))
} else if let Some(q) = confirm_text(&e) {
let what = q.split_once(" Overwrite them").map_or(q.as_str(), |(a, _)| a).trim_end_matches(['.', ' ']).to_string();
(
format!("{what}. Overwrite them, or move the data out of the way?"),
json!([["Overwrite", {"onConflict": "overwrite"}], ["Shift Down", {"onConflict": "shiftDown"}], ["Shift Right", {"onConflict": "shiftRight"}]]),
)
} else {
app.message = Some(("Button".into(), crate::clean_error(&e)));
return;
};
app.open_dialog("controlQuestion", json!({"text": text, "params": params, "choices": choices, "bound": bound}));
}
pub fn open_question(params: &Json) -> Option<Dialog> {
let text = params.get("text").and_then(Json::as_str)?;
let v = json!({
"text": text,
"params": params.get("params").cloned().unwrap_or_else(|| json!({})),
"choices": params.get("choices").cloned().unwrap_or_else(|| json!([])),
"bound": params.get("bound").and_then(Json::as_str),
});
Some(Dialog::custom("controlQuestion", "Button", v))
}
/// The prompt [`press`] opens: the question, a button per answer, and Cancel.
pub fn question_dialog(app: &mut SheetApp, ui: &mut Ui, d: &mut Dialog, open: &mut bool) {
ui.label(val(d, "text"));
ui.add_space(8.0);
let choices: Vec<(String, Json)> = d
.values
.get("choices")
.and_then(Json::as_array)
.map(|a| a.iter().filter_map(|c| Some((c.get(0)?.as_str()?.to_string(), c.get(1)?.clone()))).collect())
.unwrap_or_default();
let mut answer = None;
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if ui.button(" Cancel ").clicked() {
*open = false;
}
for (label, extra) in choices.iter().rev() {
if ui.button(format!(" {label} ")).clicked() {
answer = Some(extra.clone());
}
}
});
if let Some(extra) = answer {
*open = false;
let mut params = d.values.get("params").cloned().unwrap_or_else(|| json!({}));
// The answer is for the action the question named: if the workbook, the sheet, the
// button, its action, its macro or the selection changed meanwhile (an agent reassigned
// it), nothing runs, and the next press asks about what it runs now.
let asked = d.values.get("bound").and_then(Json::as_str);
let now = press_binding(app, &params);
if asked.is_none() || asked != now.as_deref() {
let why = if now.is_none() {
"The button is gone, so nothing ran."
} else {
"The button changed while the question was open, so nothing ran. Press it again to see what it runs now."
};
app.message = Some(("Button".into(), why.into()));
return;
}
if let (Some(p), Some(x)) = (params.as_object_mut(), extra.as_object()) {
for (k, v) in x {
p.insert(k.clone(), v.clone());
}
}
press(app, params);
}
}
// ---------------------------------------------------------------- Assign Action
fn val(d: &Dialog, key: &str) -> String {
d.values.get(key).and_then(Json::as_str).unwrap_or("").to_string()
}
/// The workbook's macros: (name, description).
fn macros(app: &SheetApp) -> Vec<(String, String)> {
app.session.active().map(|d| d.wb.macros.iter().map(|m| (m.name.clone(), m.description.clone())).collect()).unwrap_or_default()
}
/// Assign Action: a macro (on the selection or a fixed range, with an optional output cell) or a
/// command with its parameters.
pub fn open_assign(app: &SheetApp, params: &Json) -> Option<Dialog> {
let id = params.get("id").and_then(Json::as_u64).and_then(|i| u32::try_from(i).ok())?;
let c = control(app, id)?;
let mut v =
json!({"id": id, "mode": "macro", "macro": "", "input": "selection", "range": "", "output": "", "command": "", "params": "{}", "search": ""});
match &c.action {
Some(ControlAction::Macro { name, input, output }) => {
v["macro"] = json!(name);
if let MacroInput::Fixed(r) = input {
v["input"] = json!("fixed");
v["range"] = json!(r);
}
v["output"] = json!(output.clone().unwrap_or_default());
}
Some(ControlAction::Command { id, params }) => {
v["mode"] = json!("command");
v["command"] = json!(id);
v["params"] = json!(serde_json::to_string_pretty(params).unwrap_or_else(|_| "{}".into()));
}
None => {
// A new button suggests the workbook's first macro.
v["macro"] = json!(macros(app).first().map(|m| m.0.clone()).unwrap_or_default());
}
}
Some(Dialog::custom("assignAction", &format!("Assign Action: {}", c.display_name()), v))
}
/// The `control.assign` params the dialog describes, or why it can't yet.
pub fn assign_params(d: &Dialog) -> Result<Json, String> {
let id = d.values.get("id").cloned().unwrap_or(Json::Null);
if val(d, "mode") == "command" {
let command = val(d, "command");
if command.trim().is_empty() {
return Err("Choose a command.".into());
}
let text = val(d, "params");
let params: Json = if text.trim().is_empty() {
json!({})
} else {
serde_json::from_str(&text).map_err(|e| format!("The parameters aren't valid JSON: {e}"))?
};
if !params.is_object() {
return Err("The parameters must be a JSON object, such as {\"range\": \"A1:B2\"}.".into());
}
return Ok(json!({"id": id, "command": command.trim(), "params": params}));
}
let name = val(d, "macro");
if name.trim().is_empty() {
return Err("Type or choose a macro name.".into());
}
let input = if val(d, "input") == "fixed" { val(d, "range") } else { "selection".into() };
if input.trim().is_empty() {
return Err("Type the range the macro always runs on.".into());
}
Ok(json!({"id": id, "macro": name.trim(), "input": input.trim(), "output": val(d, "output").trim()}))
}
/// Commands a button may run whose id or label contains `search` (every one when it is empty):
/// (id, label). File commands are left out: the ones a button may run only produce bytes.
fn command_choices(search: &str) -> Vec<(&'static str, &'static str)> {
let q = search.trim().to_lowercase();
gridcraft_engine::command_specs()
.iter()
.filter(|c| c.journal && !c.id.starts_with("file."))
.filter(|c| !matches!(macro_policy(c.id, &json!({}), gridcraft_engine::Trust::Local), Policy::Deny(_)))
.filter(|c| q.is_empty() || c.id.to_lowercase().contains(&q) || c.label.to_lowercase().contains(&q))
.map(|c| (c.id, c.label))
.take(200)
.collect()
}
fn text_field(ui: &mut Ui, d: &mut Dialog, key: &str, width: f32) {
let mut s = val(d, key);
ui.add(egui::TextEdit::singleline(&mut s).desired_width(width).id_salt(("control_dialog", key.to_string())));
d.values.insert(key.into(), json!(s));
}
pub fn assign_dialog(app: &mut SheetApp, ui: &mut Ui, d: &mut Dialog, open: &mut bool) {
let t = Tokens::get(ui.ctx());
let mut mode = val(d, "mode");
ui.horizontal(|ui| {
ui.radio_value(&mut mode, "macro".to_string(), "Run a macro");
ui.radio_value(&mut mode, "command".to_string(), "Run a command");
});
d.values.insert("mode".into(), json!(mode));
ui.add_space(4.0);
if mode == "macro" {
let macros = macros(app);
let mut name = val(d, "macro");
ui.label("Macro name:");
ui.add(egui::TextEdit::singleline(&mut name).desired_width(f32::INFINITY).id_salt("assign_macro"));
egui::Frame::NONE.fill(t.input_bg).stroke(Stroke::new(1.0, t.input_border)).inner_margin(2.0).show(ui, |ui| {
ui.set_min_size(vec2(ui.available_width(), 110.0));
egui::ScrollArea::vertical().max_height(110.0).show(ui, |ui| {
if macros.is_empty() {
ui.label(egui::RichText::new("This workbook has no macros yet.").italics().color(t.text_dim));
}
for (n, desc) in &macros {
let r =
ui.add(egui::Button::selectable(n.eq_ignore_ascii_case(name.trim()), n.as_str()).min_size(vec2(ui.available_width(), 20.0)));
let r = if desc.is_empty() { r } else { r.on_hover_text(desc) };
if r.clicked() {
name = n.clone();
}
}
});
});
d.values.insert("macro".into(), json!(name));
ui.add_space(4.0);
let mut input = val(d, "input");
ui.label("Run it on:");
ui.radio_value(&mut input, "selection".to_string(), "The selection when the button is pressed");
ui.horizontal(|ui| {
ui.radio_value(&mut input, "fixed".to_string(), "Always this range:");
if input == "fixed" {
text_field(ui, d, "range", 160.0);
}
});
d.values.insert("input".into(), json!(input));
ui.horizontal(|ui| {
ui.label("Output to (optional):");
text_field(ui, d, "output", 120.0);
});
} else {
let mut search = val(d, "search");
let command = val(d, "command");
ui.label("Command:");
ui.add(egui::TextEdit::singleline(&mut search).hint_text("Search commands").desired_width(f32::INFINITY).id_salt("assign_search"));
egui::Frame::NONE.fill(t.input_bg).stroke(Stroke::new(1.0, t.input_border)).inner_margin(2.0).show(ui, |ui| {
ui.set_min_size(vec2(ui.available_width(), 130.0));
egui::ScrollArea::vertical().max_height(130.0).show(ui, |ui| {
for (cid, label) in command_choices(&search) {
let text = format!("{label} ({cid})");
if ui.add(egui::Button::selectable(cid == command, text).min_size(vec2(ui.available_width(), 20.0))).clicked() {
d.values.insert("command".into(), json!(cid));
}
}
});
});
d.values.insert("search".into(), json!(search));
let doc = gridcraft_engine::find_command(val(d, "command").trim()).map(|c| c.params).unwrap_or("");
if !doc.is_empty() {
ui.label(egui::RichText::new(doc).small().color(t.text_dim));
}
ui.label("Parameters (JSON):");
let mut params = val(d, "params");
ui.add(egui::TextEdit::multiline(&mut params).code_editor().desired_rows(3).desired_width(f32::INFINITY).id_salt("assign_params"));
d.values.insert("params".into(), json!(params));
}
let problem = assign_params(d).err();
if let Some(p) = &problem {
ui.label(egui::RichText::new(p).color(t.danger));
}
let (ok, cancel) = ok_cancel(ui, problem.is_none());
if cancel || (ok && assign(app, d)) {
*open = false;
}
}
/// OK (offered while `enabled`) and Cancel at the bottom right, as the other dialogs place them:
/// (OK clicked, Cancel clicked).
fn ok_cancel(ui: &mut Ui, enabled: bool) -> (bool, bool) {
let (mut ok, mut cancel) = (false, false);
ui.add_space(8.0);
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
let t = Tokens::get(ui.ctx());
ok = ui.add_enabled(enabled, egui::Button::new(egui::RichText::new(" OK ").color(t.on_accent)).fill(t.accent)).clicked();
cancel = ui.button(" Cancel ").clicked();
});
(ok, cancel)
}
/// Runs `control.assign` from the dialog. False (with the error shown) when it failed.
fn assign(app: &mut SheetApp, d: &Dialog) -> bool {
let result = assign_params(d).and_then(|params| app.run("control.assign", params).map_err(|e| crate::clean_error(&e)));
match result {
Ok(_) => true,
Err(e) => {
app.message = Some(("Assign Action".into(), e));
false
}
}
}
// ---------------------------------------------------------------- Format Control
/// Format Control: a slider's or spin button's cell link, value and limits (orientation and page
/// change for sliders); a button's text. `textOnly` shows just the text (Edit Text).
pub fn open_format(app: &SheetApp, params: &Json) -> Option<Dialog> {
let id = params.get("id").and_then(Json::as_u64).and_then(|i| u32::try_from(i).ok())?;
let c = control(app, id)?;
let text_only = params.get("textOnly").and_then(Json::as_bool) == Some(true) && c.kind == ControlKind::Button;
let v = json!({
"id": id,
"textOnly": text_only,
"text": c.text,
"name": c.name,
"value": c.value.to_string(),
"min": c.min.to_string(),
"max": c.max.to_string(),
"step": c.step.to_string(),
"page": c.page.to_string(),
"link": c.link.clone().unwrap_or_default(),
"horizontal": c.horizontal,
"print": c.print,
"locked": c.locked,
});
let title = if text_only { "Edit Text".to_string() } else { format!("Format Control: {}", c.display_name()) };
Some(Dialog::custom("formatControl", &title, v))
}
fn whole(text: &str) -> Option<u32> {
text.trim().parse::<u32>().ok().filter(|v| *v <= CONTROL_MAX)
}
/// The `control.format` params the dialog describes, or the first problem with them (the same
/// rules the engine checks, so OK is only offered when it will work).
pub fn format_params(app: &SheetApp, d: &Dialog) -> Result<Json, String> {
let id = d.values.get("id").and_then(Json::as_u64).and_then(|i| u32::try_from(i).ok()).ok_or("The control is gone.")?;
let c = control(app, id).ok_or("The control is gone.")?;
let flag = |k: &str| d.values.get(k).and_then(Json::as_bool).unwrap_or(false);
if d.values.get("textOnly").and_then(Json::as_bool) == Some(true) {
return Ok(json!({"id": id, "text": val(d, "text")}));
}
let mut out = json!({"id": id, "name": val(d, "name"), "print": flag("print"), "locked": flag("locked")});
if c.kind == ControlKind::Button {
out["text"] = json!(val(d, "text"));
return Ok(out);
}
let mut draft = c.clone();
for (key, label, slot) in [
("min", "Minimum value", &mut draft.min),
("max", "Maximum value", &mut draft.max),
("step", "Incremental change", &mut draft.step),
("page", "Page change", &mut draft.page),
] {
if key == "page" && c.kind != ControlKind::Slider {
continue;
}
*slot = whole(&val(d, key)).ok_or_else(|| format!("{label} must be a whole number from 0 to {CONTROL_MAX}."))?;
out[key] = json!(*slot);
}
draft.check_numbers()?;
let value = whole(&val(d, "value")).ok_or_else(|| format!("Current value must be a whole number from 0 to {CONTROL_MAX}."))?;
if value < draft.min || value > draft.max {
return Err(format!("Current value must be between {} and {}.", draft.min, draft.max));
}
out["value"] = json!(value);
out["link"] = json!(val(d, "link").trim());
if c.kind == ControlKind::Slider {
out["horizontal"] = json!(flag("horizontal"));
}
Ok(out)
}
pub fn format_dialog(app: &mut SheetApp, ui: &mut Ui, d: &mut Dialog, open: &mut bool) {
let t = Tokens::get(ui.ctx());
let Some(c) = d.values.get("id").and_then(Json::as_u64).and_then(|i| u32::try_from(i).ok()).and_then(|id| control(app, id)) else {
*open = false;
return;
};
let text_only = d.values.get("textOnly").and_then(Json::as_bool) == Some(true);
if c.kind == ControlKind::Button {
let mut text = val(d, "text");
ui.label("Text:");
ui.add(egui::TextEdit::multiline(&mut text).desired_rows(2).desired_width(f32::INFINITY).id_salt("control_text"));
d.values.insert("text".into(), json!(text));
} else {
egui::Grid::new("format_control_grid").num_columns(2).spacing([12.0, 6.0]).show(ui, |ui| {
let mut fields = vec![("value", "Current value:"), ("min", "Minimum value:"), ("max", "Maximum value:"), ("step", "Incremental change:")];
if c.kind == ControlKind::Slider {
fields.push(("page", "Page change:"));
}
fields.push(("link", "Cell link:"));
for (key, label) in fields {
ui.label(label);
text_field(ui, d, key, if key == "link" { 160.0 } else { 90.0 });
ui.end_row();
}
});
if c.kind == ControlKind::Slider {
let mut h = d.values.get("horizontal").and_then(Json::as_bool).unwrap_or(true);
ui.horizontal(|ui| {
ui.label("Orientation:");
ui.radio_value(&mut h, true, "Horizontal");
ui.radio_value(&mut h, false, "Vertical");
});
d.values.insert("horizontal".into(), json!(h));
}
}
if !text_only {
ui.add_space(4.0);
ui.horizontal(|ui| {
ui.label("Name:");
let mut name = val(d, "name");
ui.add(egui::TextEdit::singleline(&mut name).hint_text(c.display_name()).desired_width(200.0).id_salt("control_name"));
d.values.insert("name".into(), json!(name));
});
for (key, label) in [("locked", "Locked (when the sheet is protected)"), ("print", "Print object")] {
let mut on = d.values.get(key).and_then(Json::as_bool).unwrap_or(true);
ui.checkbox(&mut on, label);
d.values.insert(key.into(), json!(on));
}
}
let params = format_params(app, d);
if let Err(p) = &params {
ui.label(egui::RichText::new(p).color(t.danger));
}
let (ok, cancel) = ok_cancel(ui, params.is_ok());
if cancel {
*open = false;
}
if ok && let Ok(p) = params {
match app.run("control.format", p) {
Ok(_) => *open = false,
Err(e) => app.message = Some(("Format Control".into(), crate::clean_error(&e))),
}
}
}
+3
View File
@@ -1095,6 +1095,9 @@ impl Dialog {
dialog
}
"pickList" => Dialog::custom("pickList", "Pick From List", json!({})),
"assignAction" => return crate::control_ui::open_assign(app, &params),
"formatControl" => return crate::control_ui::open_format(app, &params),
"controlQuestion" => return crate::control_ui::open_question(&params),
"saveCopy" => Dialog::custom("saveCopy", "Save a Copy", json!({"format": p("format")})),
"saveChanges" => Dialog::custom("saveChanges", "Save Changes?", json!({"title": p("title"), "quit": p("quit")})),
"start" => Dialog::custom("start", "GridCraft", json!({})),
+4
View File
@@ -154,6 +154,7 @@ pub fn show(app: &mut SheetApp, ctx: &egui::Context) {
"recommendedCharts" => 600.0,
"analysisToolPak" => 420.0,
"solver" => 460.0,
"assignAction" => 440.0,
"zoom" => 220.0,
"pasteSpecial" => 480.0,
_ => 380.0,
@@ -203,6 +204,9 @@ pub fn show(app: &mut SheetApp, ctx: &egui::Context) {
"solver" => solver(app, ui, &mut d, &mut confirm, &mut open),
"solverResults" => solver_results(app, ui, &mut d, &mut open),
"spelling" => spelling(app, ui, &mut d, &mut open),
"assignAction" => crate::control_ui::assign_dialog(app, ui, &mut d, &mut open),
"formatControl" => crate::control_ui::format_dialog(app, ui, &mut d, &mut open),
"controlQuestion" => crate::control_ui::question_dialog(app, ui, &mut d, &mut open),
"agents" => {
ui.label("GridCraft is fully drivable by agents. Every menu item, button and gesture is a command:");
ui.add_space(4.0);
+463
View File
@@ -0,0 +1,463 @@
//! Form controls on the sheet: drawing buttons, sliders and spin buttons, and working them with
//! the pointer and the keyboard. A click operates a control; Ctrl or Cmd+click, or a right-click,
//! selects it instead (to move, resize or format it). Values change through `control.setValue`,
//! and a whole drag or a held arrow is one undo step.
//!
//! Positions are logical (see `Geo::rtl`): on a right-to-left sheet a control moves as a whole
//! but is not mirrored, so a point inside it is reflected back with [`inner`] first.
use egui::epaint::PathShape;
use egui::{Painter, Pos2, Rect, Stroke, StrokeKind, pos2, vec2};
use gridcraft_engine::model::{ControlKind, FormControl, ObjKind, ObjRef, Sheet};
use serde_json::json;
use crate::SheetApp;
use crate::grid::Geo;
use crate::theme::{self, Tokens};
/// How long an arrow or the track waits before it repeats, and then between repeats (seconds).
const REPEAT_DELAY: f64 = 0.4;
const REPEAT_EVERY: f64 = 0.06;
/// The part of a control under the pointer.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Part {
Button,
/// A spin button's arrow that adds a step.
Up,
/// A spin button's arrow that takes a step away.
Down,
Thumb,
/// The track on the minimum's side of the thumb (a page down).
TrackBefore,
/// The track on the maximum's side of the thumb (a page up).
TrackAfter,
}
/// A control being worked with the pointer.
#[derive(Clone, Debug, PartialEq)]
pub struct Gesture {
pub id: u32,
pub part: Part,
/// Thumb drags: where along the track the pointer grabbed the thumb, from its centre.
pub grab: f32,
/// The undo stack's length when the gesture began: everything it changes is one step.
pub undo_start: usize,
/// When a held arrow or track repeats next (egui time, seconds).
pub next_repeat: f64,
}
/// What a control looks like this frame.
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub struct Look {
pub hover: Option<Part>,
pub pressed: Option<Part>,
pub focused: bool,
/// Show the value next to the thumb (while dragging).
pub show_value: bool,
}
fn fraction(c: &FormControl) -> f32 {
let span = c.max.saturating_sub(c.min).max(1) as f32;
(c.value.saturating_sub(c.min) as f32 / span).clamp(0.0, 1.0)
}
/// A slider's track and thumb in `r`. A horizontal slider has its minimum on the left; a vertical
/// one has it at the top (as Excel's scroll bars do).
pub fn slider_parts(r: Rect, c: &FormControl, z: f32) -> (Rect, Rect) {
let f = fraction(c);
if c.horizontal {
let knob = (r.height() - 4.0 * z).clamp(6.0 * z, 16.0 * z).min(r.width() / 2.0).max(1.0);
let track = Rect::from_center_size(r.center(), vec2((r.width() - knob).max(1.0), (4.0 * z).min(r.height())));
let x = track.left() + f * track.width();
(track, Rect::from_center_size(pos2(x, r.center().y), vec2(knob, (r.height() - 4.0 * z).max(4.0))))
} else {
let knob = (r.width() - 4.0 * z).clamp(6.0 * z, 16.0 * z).min(r.height() / 2.0).max(1.0);
let track = Rect::from_center_size(r.center(), vec2((4.0 * z).min(r.width()), (r.height() - knob).max(1.0)));
let y = track.top() + f * track.height();
(track, Rect::from_center_size(pos2(r.center().x, y), vec2((r.width() - 4.0 * z).max(4.0), knob)))
}
}
/// A spin button's halves: (the arrow that adds, the arrow that subtracts). Tall spin buttons
/// stack them (up on top); wide ones put them side by side (up on the right).
pub fn spin_parts(r: Rect) -> (Rect, Rect) {
if r.height() >= r.width() {
let (top, bottom) = r.split_top_bottom_at_fraction(0.5);
(top, bottom)
} else {
let (left, right) = r.split_left_right_at_fraction(0.5);
(right, left)
}
}
/// A logical point `p` inside control rect `r`, as the control is drawn: on a right-to-left
/// sheet the control moves as a whole without mirroring, so `p` is reflected within `r`.
pub fn inner(geo: &Geo, r: Rect, p: Pos2) -> Pos2 {
if geo.rtl { pos2(r.left() + r.right() - p.x, p.y) } else { p }
}
/// The part of control `c` (drawn in `r`) under `p`.
pub fn part_at(r: Rect, c: &FormControl, z: f32, p: Pos2) -> Option<Part> {
if !r.contains(p) {
return None;
}
Some(match c.kind {
ControlKind::Button => Part::Button,
ControlKind::Spin => {
let (up, _) = spin_parts(r);
if up.contains(p) { Part::Up } else { Part::Down }
}
ControlKind::Slider => {
let (_, thumb) = slider_parts(r, c, z);
if thumb.expand(2.0).contains(p) {
Part::Thumb
} else {
let before = if c.horizontal { p.x < thumb.center().x } else { p.y < thumb.center().y };
if before { Part::TrackBefore } else { Part::TrackAfter }
}
}
})
}
/// The value a thumb dragged to `p` stands for (`grab`: the offset from the thumb's centre where
/// it was grabbed).
pub fn value_at(r: Rect, c: &FormControl, z: f32, p: Pos2, grab: f32) -> f64 {
let (track, _) = slider_parts(r, c, z);
let f = if c.horizontal { (p.x - grab - track.left()) / track.width().max(1.0) } else { (p.y - grab - track.top()) / track.height().max(1.0) };
c.min as f64 + f.clamp(0.0, 1.0) as f64 * c.max.saturating_sub(c.min) as f64
}
/// Paints control `c` in `r`, in the sheet's control colours.
pub fn paint(p: &Painter, r: Rect, c: &FormControl, z: f32, look: Look) {
let t = Tokens::get(p.ctx());
let border = Stroke::new(1.0, t.sheet_control_border);
let bg = |part: Part| {
if look.pressed == Some(part) {
t.sheet_control_pressed
} else if look.hover == Some(part) {
t.sheet_control_hover
} else {
t.sheet_control_bg
}
};
let clip = p.with_clip_rect(r.expand(2.0 * z.max(1.0) + 3.0).intersect(p.clip_rect()));
match c.kind {
ControlKind::Button => {
let fill = bg(Part::Button);
clip.rect_filled(r, 3.0 * z, fill);
clip.rect_stroke(r, 3.0 * z, border, StrokeKind::Inside);
let shift = if look.pressed == Some(Part::Button) { vec2(0.0, 1.0) } else { vec2(0.0, 0.0) };
let g = clip.layout(c.text.clone(), theme::ui_font(12.0 * z), t.sheet_control_text, (r.width() - 8.0 * z).max(1.0));
clip.with_clip_rect(r.shrink(1.0).intersect(clip.clip_rect())).galley(r.center() - g.size() / 2.0 + shift, g, t.sheet_control_text);
}
ControlKind::Slider => {
clip.rect_filled(r, 2.0 * z, t.sheet_control_bg);
clip.rect_stroke(r, 2.0 * z, border, StrokeKind::Inside);
let (track, thumb) = slider_parts(r, c, z);
clip.rect_filled(track, 2.0 * z, t.sheet_track);
let filled = if c.horizontal {
Rect::from_min_max(track.min, pos2(thumb.center().x, track.max.y))
} else {
Rect::from_min_max(track.min, pos2(track.max.x, thumb.center().y))
};
clip.rect_filled(filled, 2.0 * z, t.sheet_fill);
let knob = if look.pressed == Some(Part::Thumb) || look.hover == Some(Part::Thumb) { t.sheet_control_text } else { t.sheet_thumb };
clip.rect_filled(thumb, 3.0 * z, knob);
clip.rect_stroke(thumb, 3.0 * z, Stroke::new(1.0, t.sheet_control_bg), StrokeKind::Inside);
if look.show_value {
let g = clip.layout_no_wrap(c.value.to_string(), theme::ui_font(11.0 * z), t.sheet_control_text);
let at = if c.horizontal {
pos2(thumb.center().x - g.size().x / 2.0, r.top() - g.size().y - 4.0 * z)
} else {
pos2(r.right() + 4.0 * z, thumb.center().y - g.size().y / 2.0)
};
let card = Rect::from_min_size(at, g.size()).expand(2.0 * z);
p.rect_filled(card, 2.0 * z, t.sheet_control_bg);
p.rect_stroke(card, 2.0 * z, border, StrokeKind::Inside);
p.galley(at, g, t.sheet_control_text);
}
}
ControlKind::Spin => {
let (up, down) = spin_parts(r);
let tall = r.height() >= r.width();
for (half, part) in [(up, Part::Up), (down, Part::Down)] {
clip.rect_filled(half, 2.0 * z, bg(part));
clip.rect_stroke(half, 2.0 * z, border, StrokeKind::Inside);
let s = (half.width().min(half.height()) * 0.28).max(1.5);
let m = half.center();
let tri = match (tall, part) {
(true, Part::Up) => vec![pos2(m.x, m.y - s * 0.6), pos2(m.x - s, m.y + s * 0.5), pos2(m.x + s, m.y + s * 0.5)],
(true, _) => vec![pos2(m.x, m.y + s * 0.6), pos2(m.x + s, m.y - s * 0.5), pos2(m.x - s, m.y - s * 0.5)],
(false, Part::Up) => vec![pos2(m.x + s * 0.6, m.y), pos2(m.x - s * 0.5, m.y + s), pos2(m.x - s * 0.5, m.y - s)],
(false, _) => vec![pos2(m.x - s * 0.6, m.y), pos2(m.x + s * 0.5, m.y - s), pos2(m.x + s * 0.5, m.y + s)],
};
clip.add(PathShape::convex_polygon(tri, t.sheet_control_text, Stroke::NONE));
}
}
}
if look.focused {
p.rect_stroke(r.expand(2.0), 3.0 * z, Stroke::new(2.0, t.sheet_focus), StrokeKind::Outside);
}
}
/// The control with `id` on `sh`.
pub fn find(sh: &Sheet, id: u32) -> Option<&FormControl> {
sh.controls.iter().find(|c| c.id == id)
}
/// How control `c` looks this frame, from the gesture, the focus and the pointer (`hover`, a
/// logical position).
pub fn look(app: &SheetApp, geo: &Geo, c: &FormControl, r: Rect, hover: Option<Pos2>) -> Look {
let gesture = app.grid.control.as_ref().filter(|g| g.id == c.id);
let selected = app.selected_object == Some(ObjRef::new(ObjKind::Control, c.id));
let hover = if selected { None } else { hover.and_then(|p| part_at(r, c, geo.z, inner(geo, r, p))) };
let pressed = gesture.map(|g| g.part).filter(|part| *part == Part::Thumb || hover == Some(*part));
Look { hover, pressed, focused: app.grid.focused_control == Some(c.id) && !selected, show_value: gesture.is_some_and(|g| g.part == Part::Thumb) }
}
impl SheetApp {
/// Sets a slider or spin button's value; inside a gesture, keeps it one undo step.
fn control_set(&mut self, id: u32, value: f64) -> bool {
match self.session.run("control.setValue", json!({"id": id, "value": value})) {
Ok(_) => {
if let Some(start) = self.grid.control.as_ref().map(|g| g.undo_start)
&& let Some(d) = self.session.active_mut()
&& d.undo.len() > start + 1
{
d.undo.truncate(start + 1);
}
true
}
Err(e) => {
self.grid.control = None;
self.message = Some(("Form Control".into(), crate::clean_error(&e)));
false
}
}
}
/// Moves a slider or spin button by `steps` steps (negative: toward the minimum), or by pages
/// when `page` is set.
pub fn control_nudge(&mut self, id: u32, steps: i64, page: bool) -> bool {
let Some(c) = self.session.active().and_then(|d| d.wb.active()).and_then(|sh| find(sh, id)).cloned() else { return false };
if c.kind == ControlKind::Button {
return false;
}
let by = if page && c.kind == ControlKind::Slider { c.page } else { c.step } as f64;
self.control_set(id, c.value as f64 + steps as f64 * by)
}
/// Runs a button's action. Questions (approve it, make room for a macro's output) become a
/// prompt.
pub fn press_button(&mut self, id: u32) {
crate::control_ui::press(self, json!({"id": id}));
}
}
/// Pointer handling for controls on the grid. Returns true when it used the pointer this frame
/// (so the grid neither selects cells nor objects).
pub fn interact(app: &mut SheetApp, ctx: &egui::Context, resp: &egui::Response, geo: &Geo, sh: &Sheet) -> bool {
let (pointer, mods, time) = ctx.input(|i| (i.pointer.clone(), i.modifiers, i.time));
let pos = pointer.interact_pos().or(pointer.hover_pos()).map(|p| geo.mirror_pos(p));
// A control that no longer exists loses its gesture and focus.
if let Some(id) = app.grid.focused_control
&& find(sh, id).is_none()
{
app.grid.focused_control = None;
}
if let Some(g) = app.grid.control.clone() {
let Some(c) = find(sh, g.id).cloned() else {
app.grid.control = None;
return true;
};
let r = crate::chartview::anchor_rect(geo, sh, &c.anchor);
if pointer.primary_down() {
match g.part {
Part::Thumb => {
if let Some(p) = pos {
let v = c.snap(value_at(r, &c, geo.z, inner(geo, r, p), g.grab));
if v != c.value {
app.control_set(c.id, v as f64);
}
}
}
Part::Button => {}
part => {
// Held arrows and track repeat while the pointer stays on them.
let over = pos.and_then(|p| part_at(r, &c, geo.z, inner(geo, r, p))) == Some(part);
if over && time >= g.next_repeat {
step_part(app, &c, part);
if let Some(gg) = app.grid.control.as_mut() {
gg.next_repeat = time + REPEAT_EVERY;
}
}
ctx.request_repaint_after(std::time::Duration::from_millis(30));
}
}
return true;
}
// Released: a button runs when the pointer is still on it.
app.grid.control = None;
if g.part == Part::Button && pos.is_some_and(|p| r.contains(p)) {
app.press_button(c.id);
}
resp.request_focus();
return true;
}
if !pointer.primary_pressed() || !resp.contains_pointer() {
// Any other press, on the grid (a right-click on a cell) or off it, moves the focus off
// the control, so a later Enter or Space doesn't work it.
if pointer.any_pressed() && app.grid.focused_control.is_some() {
app.grid.focused_control = None;
}
return false;
}
let Some(p) = pointer.press_origin().map(|p| geo.mirror_pos(p)).or(pos) else { return false };
// The selected object's handles reach past its frame and win over a control beneath them.
if let Some(r) = app.selected_object.and_then(|o| crate::chartview::object_rect(geo, sh, o))
&& crate::objects::handle_at(r, p).is_some()
{
return false;
}
let hit = crate::chartview::hit(app, geo, sh, p).filter(|(o, _)| o.kind == ObjKind::Control);
// Ctrl or Cmd+click selects; a selected control moves and resizes instead of working.
let Some((o, r)) = hit.filter(|(o, _)| !mods.command && !mods.ctrl && app.selected_object != Some(*o)) else {
app.grid.focused_control = None;
return false;
};
let Some(c) = find(sh, o.id).cloned() else { return false };
let p = inner(geo, r, p);
let Some(part) = part_at(r, &c, geo.z, p) else { return false };
app.selected_object = None;
app.grid.focused_control = Some(c.id);
let undo_start = app.session.active().map_or(0, |d| d.undo.len());
let grab = if part == Part::Thumb {
let (_, thumb) = slider_parts(r, &c, geo.z);
if c.horizontal { p.x - thumb.center().x } else { p.y - thumb.center().y }
} else {
0.0
};
app.grid.control = Some(Gesture { id: c.id, part, grab, undo_start, next_repeat: time + REPEAT_DELAY });
if !matches!(part, Part::Button | Part::Thumb) {
step_part(app, &c, part);
}
resp.request_focus();
true
}
/// One click on an arrow or the track.
fn step_part(app: &mut SheetApp, c: &FormControl, part: Part) {
match part {
Part::Up => app.control_nudge(c.id, 1, false),
Part::Down => app.control_nudge(c.id, -1, false),
Part::TrackBefore => app.control_nudge(c.id, -1, true),
Part::TrackAfter => app.control_nudge(c.id, 1, true),
Part::Button | Part::Thumb => false,
};
}
/// Whether the focused control is a button. Space presses one, so the space it types must not
/// start a cell edit; a slider or spin button doesn't take Space, and its space reaches the grid
/// like any other character.
pub fn focused_button(app: &SheetApp, sh: &Sheet) -> bool {
app.grid.focused_control.and_then(|id| find(sh, id)).is_some_and(|c| c.kind == ControlKind::Button)
}
/// The grid takes a key while a control is focused: a button gives the keyboard back, so a later
/// Enter or Space doesn't press it again and Space starts a cell edit; a slider or spin button
/// keeps it, as the control last worked, which the arrows still step.
pub fn key_to_grid(app: &mut SheetApp, sh: &Sheet) {
if focused_button(app, sh) {
app.grid.focused_control = None;
}
}
/// Keys for the focused control: arrows move by a step, Page Up and Page Down by a page, Home and
/// End to the ends; Space or Enter presses a button. True when the key was used. `repeat` is a key
/// held down: it steps a slider or spin button again, but a button runs once per press.
pub fn key(app: &mut SheetApp, sh: &Sheet, key: egui::Key, repeat: bool) -> bool {
use egui::Key;
let Some(c) = app.grid.focused_control.and_then(|id| find(sh, id)).cloned() else { return false };
if app.selected_object.is_some() {
return false;
}
match (c.kind, key) {
(_, Key::Escape) => {
app.grid.focused_control = None;
true
}
(ControlKind::Button, Key::Space | Key::Enter) => {
if !repeat {
app.press_button(c.id);
}
true
}
(ControlKind::Button, _) => false,
// A spin button's up arrow adds; a slider's minimum is at its left or top.
(ControlKind::Spin, Key::ArrowUp | Key::ArrowRight) | (ControlKind::Slider, Key::ArrowDown | Key::ArrowRight) => {
app.control_nudge(c.id, 1, false)
}
(ControlKind::Spin, Key::ArrowDown | Key::ArrowLeft) | (ControlKind::Slider, Key::ArrowUp | Key::ArrowLeft) => {
app.control_nudge(c.id, -1, false)
}
(ControlKind::Spin, Key::PageUp) | (ControlKind::Slider, Key::PageDown) => app.control_nudge(c.id, 1, true),
(ControlKind::Spin, Key::PageDown) | (ControlKind::Slider, Key::PageUp) => app.control_nudge(c.id, -1, true),
(_, Key::Home) => app.control_set(c.id, c.min as f64),
(_, Key::End) => app.control_set(c.id, c.max as f64),
_ => false,
}
}
#[cfg(test)]
mod tests {
use super::*;
fn slider(value: u32, horizontal: bool) -> FormControl {
FormControl { kind: ControlKind::Slider, min: 0, max: 100, value, horizontal, ..Default::default() }
}
#[test]
fn the_thumb_sits_at_the_value_and_dragging_reads_it_back() {
let r = Rect::from_min_size(pos2(10.0, 10.0), vec2(200.0, 20.0));
for v in [0, 25, 50, 100] {
let c = slider(v, true);
let (_, thumb) = slider_parts(r, &c, 1.0);
assert!((value_at(r, &c, 1.0, thumb.center(), 0.0) - v as f64).abs() < 0.5, "{v}");
assert_eq!(part_at(r, &c, 1.0, thumb.center()), Some(Part::Thumb));
}
let c = slider(50, true);
assert_eq!(part_at(r, &c, 1.0, pos2(15.0, 20.0)), Some(Part::TrackBefore));
assert_eq!(part_at(r, &c, 1.0, pos2(205.0, 20.0)), Some(Part::TrackAfter));
assert_eq!(part_at(r, &c, 1.0, pos2(5.0, 20.0)), None);
// Past the ends clamps.
assert_eq!(value_at(r, &c, 1.0, pos2(-500.0, 20.0), 0.0), 0.0);
assert_eq!(value_at(r, &c, 1.0, pos2(900.0, 20.0), 0.0), 100.0);
// Vertical: the minimum is at the top.
let r = Rect::from_min_size(pos2(0.0, 0.0), vec2(20.0, 200.0));
let c = slider(0, false);
let (_, thumb) = slider_parts(r, &c, 1.0);
assert!(thumb.center().y < 20.0);
assert_eq!(part_at(r, &slider(50, false), 1.0, pos2(10.0, 190.0)), Some(Part::TrackAfter));
}
#[test]
fn spin_halves() {
let tall = Rect::from_min_size(pos2(0.0, 0.0), vec2(18.0, 36.0));
let c = FormControl { kind: ControlKind::Spin, ..Default::default() };
assert_eq!(part_at(tall, &c, 1.0, pos2(9.0, 5.0)), Some(Part::Up));
assert_eq!(part_at(tall, &c, 1.0, pos2(9.0, 30.0)), Some(Part::Down));
let wide = Rect::from_min_size(pos2(0.0, 0.0), vec2(40.0, 18.0));
assert_eq!(part_at(wide, &c, 1.0, pos2(35.0, 9.0)), Some(Part::Up));
assert_eq!(part_at(wide, &c, 1.0, pos2(5.0, 9.0)), Some(Part::Down));
}
#[test]
fn degenerate_ranges_do_not_divide_by_zero() {
let r = Rect::from_min_size(pos2(0.0, 0.0), vec2(1.0, 1.0));
let c = FormControl { kind: ControlKind::Slider, min: 5, max: 5, value: 5, ..Default::default() };
let (track, thumb) = slider_parts(r, &c, 1.0);
assert!(track.width().is_finite() && thumb.center().x.is_finite());
assert!(value_at(r, &c, 1.0, pos2(0.5, 0.5), 0.0).is_finite());
}
}
+8
View File
@@ -297,6 +297,14 @@ pub struct GridState {
pub hf_start: bool,
/// The grid geometry of the last frame (hit-testing for agents and tests).
pub geo: Option<Geo>,
/// A form control being worked with the pointer.
pub control: Option<crate::form_controls::Gesture>,
/// The form control that takes the arrow keys (the one last worked).
pub focused_control: Option<u32>,
/// Insert › Controls: the kind a click or drag on the grid places, and where the drag began
/// (logical position).
pub placing: Option<gridcraft_engine::model::ControlKind>,
pub place_start: Option<Pos2>,
/// The horizontal scrollbar's track in the sheet tab bar (hit-testing for tests).
pub hscroll: Option<Rect>,
/// The handle between the sheet tabs and the horizontal scrollbar (hit-testing for tests).
+12 -3
View File
@@ -1,3 +1,5 @@
use gridcraft_engine::model::ObjKind;
use super::geo::Geo;
use super::interact::{col_edge, freeze_handles, hidden_col_edge, hidden_row_edge, on_border, row_edge};
use super::{CellRef, Color32, CursorIcon, Drag, Pos2, RangeRef, Rect, Sheet, SheetApp, Stroke, pos2, vec2};
@@ -21,8 +23,9 @@ pub(super) fn update_cursor(app: &mut SheetApp, ui: &egui::Ui, resp: &egui::Resp
{
app.grid.hover_cell = in_cells.then(|| geo.cell_at(sh, p));
}
// Draw tools own the cursor over cells, including while recording a stroke.
if !app.session.draw_tool.is_empty() && in_cells {
// Draw tools and Insert › Controls placement own the cursor over cells, including while
// recording a stroke or drawing the control.
if (!app.session.draw_tool.is_empty() || app.grid.placing.is_some()) && in_cells {
ctx.set_cursor_icon(CursorIcon::Crosshair);
return;
}
@@ -79,9 +82,15 @@ fn hover_cursor(app: &SheetApp, ctx: &egui::Context, resp: &egui::Response, poin
// The selected object's handles resize it; any object's body moves it.
let idle = app.grid.drag == Drag::None && app.editor.is_none();
let object_handle = if idle { crate::chartview::selected_handle(app, geo, sh, p).map(|(.., h)| h) } else { None };
let on_object = idle && crate::chartview::hit(app, geo, sh, p).is_some();
let hit = if idle { crate::chartview::hit(app, geo, sh, p) } else { None };
// A form control works on click unless it is selected (or Ctrl or Cmd is held).
let mods = ctx.input(|i| i.modifiers);
let works = hit.is_some_and(|(o, _)| o.kind == ObjKind::Control && app.selected_object != Some(o) && !mods.command && !mods.ctrl);
let on_object = hit.is_some();
if let Some(h) = object_handle {
ctx.set_cursor_icon(h.cursor(geo.rtl));
} else if works {
ctx.set_cursor_icon(CursorIcon::PointingHand);
} else if on_object {
ctx.set_cursor_icon(CursorIcon::Move);
} else if near_col_edge || (in_row_header && row_edge(geo, sh, p.y).is_some()) {
+32 -6
View File
@@ -64,6 +64,15 @@ pub(super) fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Respo
let in_col_header = pos.is_some_and(|p| p.y < geo.cells.top() && p.y >= geo.head_top() && p.x >= geo.cells.left());
let in_row_header = pos.is_some_and(|p| p.x < geo.cells.left() && p.x >= geo.head_left() && p.y >= geo.cells.top());
let in_corner = pos.is_some_and(|p| p.x < geo.cells.left() && p.y < geo.cells.top() && p.x >= geo.head_left() && p.y >= geo.head_top());
// Insert › Controls places a control; a click works a form control (Ctrl or Cmd+click, or a
// right-click, selects it instead).
if app.editor.is_none() && crate::control_ui::place(app, ui, resp, geo, sh) {
return;
}
if app.editor.is_none() && app.session.draw_tool.is_empty() && crate::form_controls::interact(app, &ctx, resp, geo, sh) {
keyboard(app, &ctx, resp, geo, sh);
return;
}
let sel = app.session.active().map(|d| d.selection.clone()).unwrap_or_default();
let cur_rect = geo.range_rect(sh, sel.current());
let handle = Rect::from_center_size(cur_rect.right_bottom(), vec2(9.0, 9.0));
@@ -100,10 +109,14 @@ pub(super) fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Respo
return;
}
// A plain click on a slicer value tile toggles it (Ctrl/Cmd for multi-select); the clear
// button resets it. Runs before the generic object select/drag so tiles stay clickable.
// button resets it. Runs before the generic object select/drag so tiles stay clickable, but
// only when the slicer is what the press lands on: a form control over a tile (Ctrl or
// Cmd+clicked, or clicked while selected, so `form_controls::interact` left the click) and a
// selected object's handle keep the generic path, which selects, moves or resizes them.
if primary_clicked
&& !resp.drag_started()
&& let Some(p) = pos
&& object_under(app, geo, sh, p).is_some_and(|(o, _, handle)| o.kind == ObjKind::Slicer && handle.is_none())
&& let Some((id, act)) = crate::chartview::slicer_click(geo, sh, wb, wb.active_sheet(), p)
{
app.selected_object = Some(ObjRef::new(ObjKind::Slicer, id));
@@ -126,10 +139,10 @@ pub(super) fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Respo
// The selected object's handles reach a little outside it, so they win over hit-testing.
// Only they resize: a press anywhere on another object selects and moves it, as the
// hover cursor shows.
let on_handle = at.and_then(|p| crate::chartview::selected_handle(app, geo, sh, p));
let handle = on_handle.map(|(.., h)| h);
let under = at.and_then(|p| object_under(app, geo, sh, p));
let handle = under.and_then(|(.., h)| h);
if let Some(p) = at
&& let Some((obj, _)) = on_handle.map(|(o, r, _)| (o, r)).or_else(|| crate::chartview::hit(app, geo, sh, p))
&& let Some((obj, _, _)) = under
{
app.selected_object = Some(obj);
if resp.double_clicked() && app.open_object(obj) != crate::objects::OpenAction::Select {
@@ -146,14 +159,18 @@ pub(super) fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Respo
}
app.selected_object = None;
}
// A right-click on a chart, picture, shape or slicer opens the object's menu, not the cell's
// under it; a right press that wobbles into a drag counts too (#2067).
// A right-click on a chart, picture, shape, slicer or form control opens the object's menu, not
// the cell's under it; a right press that wobbles into a drag counts too (#2067). On a form
// control it selects the control without running it.
let secondary_drag = resp.drag_started_by(egui::PointerButton::Secondary);
if (resp.secondary_clicked() || secondary_drag)
&& let Some(p) = if secondary_drag { pointer.press_origin().map(|p| geo.mirror_pos(p)).or(pos) } else { pos }
&& let Some((obj, _)) = crate::chartview::hit(app, geo, sh, p)
{
app.selected_object = Some(obj);
if obj.kind == ObjKind::Control {
app.grid.focused_control = None;
}
app.grid.object_menu = Some((geo.mirror_pos(p), obj.kind.name(), obj.id));
return;
}
@@ -544,6 +561,15 @@ fn handle_cell_double_click(app: &mut SheetApp, input: &InteractionInput<'_>, c:
false
}
/// What a press at `p` lands on, for slicer tiles and the generic object handling alike, so the
/// z-order decides once: the selected object's handle (handles reach a little past the frame and
/// win over hit-testing), else the topmost object under `p` and its rect.
fn object_under(app: &SheetApp, geo: &Geo, sh: &Sheet, p: Pos2) -> Option<(ObjRef, Rect, Option<crate::objects::Handle>)> {
crate::chartview::selected_handle(app, geo, sh, p)
.map(|(o, r, h)| (o, r, Some(h)))
.or_else(|| crate::chartview::hit(app, geo, sh, p).map(|(o, r)| (o, r, None)))
}
fn handle_context_menu_and_keyboard(app: &mut SheetApp, input: &InteractionInput<'_>) {
let (geo, sh, sel, pos) = (input.geo, input.sheet, &input.selection, input.position);
let resp = input.response;
+33 -1
View File
@@ -32,7 +32,7 @@ pub(super) fn keyboard(app: &mut SheetApp, ctx: &egui::Context, resp: &egui::Res
ctx.memory_mut(|m| {
m.set_focus_lock_filter(resp.id, egui::EventFilter { tab: true, horizontal_arrows: true, vertical_arrows: true, escape: true })
});
let events = ctx.input(|i| i.events.clone());
let events = grid_events(app, ctx, sh);
// A shortcut key that also types a character (Shift+Space sends Text(" ")) must not start an edit.
let shortcut_pressed = events.iter().any(|e| {
matches!(e, egui::Event::Key { pressed: true, modifiers: m, key, .. }
@@ -155,3 +155,35 @@ pub(super) fn keyboard(app: &mut SheetApp, ctx: &egui::Context, resp: &egui::Res
}
}
}
/// This frame's input events, without those the focused form control took.
fn grid_events(app: &mut SheetApp, ctx: &egui::Context, sh: &Sheet) -> Vec<egui::Event> {
let events = ctx.input(|i| i.events.clone());
events.into_iter().filter(|ev| !form_control_event(app, sh, ev)).collect()
}
/// Whether the focused form control takes `ev`: arrows, Page Up and Down, Home, End, Space and
/// Enter without Ctrl, Cmd or Alt, and the space a Space press types when it must not start a
/// cell edit: for a button, which Space presses, and on a checkbox cell, which the Space key
/// toggles (a slider or spin button doesn't take Space, so Space on a cell does what it does with
/// no control focused: it toggles a checkbox cell, or its space starts a cell edit like any other
/// character). A key press the control doesn't take goes to the grid, and a focused button gives
/// the keyboard back with it.
fn form_control_event(app: &mut SheetApp, sh: &Sheet, ev: &egui::Event) -> bool {
match ev {
egui::Event::Text(text) => {
text == " "
&& app.selected_object.is_none()
&& app.grid.focused_control.is_some()
&& (crate::form_controls::focused_button(app, sh) || is_checkbox(sh, app))
}
egui::Event::Key { key, pressed: true, repeat, modifiers: m, .. } => {
if !m.command && !m.ctrl && !m.alt && crate::form_controls::key(app, sh, *key, *repeat) {
return true;
}
crate::form_controls::key_to_grid(app, sh);
false
}
_ => false,
}
}
+13 -2
View File
@@ -385,14 +385,22 @@ pub(super) fn fill_menu(app: &mut SheetApp, ui: &mut egui::Ui, was_open: bool) {
}
}
/// The right-click menu of a chart, picture, shape or slicer.
/// The right-click menu of a chart, picture, shape, slicer or form control (Edit Text and Assign
/// Action for a button, and Format Control, before Delete).
pub(super) fn object_menu(app: &mut SheetApp, ui: &mut egui::Ui) {
let Some((at, kind, id)) = app.grid.object_menu else { return };
let mut close = false;
let resp = egui::Area::new(egui::Id::unique("object_context")).fixed_pos(at).order(egui::Order::Foreground).show(ui.ctx(), |ui| {
egui::Frame::popup(ui.style()).show(ui, |ui| {
ui.set_min_width(200.0);
let mut items = vec![("Delete", "object.delete")];
let mut items = vec![];
if kind == "control" {
if crate::control_ui::is_button(app, id) {
items.extend([("Edit Text", "ui:editText"), ("Assign Action…", "ui:assignAction")]);
}
items.extend([("Format Control…", "ui:formatControl"), ("-", "")]);
}
items.push(("Delete", "object.delete"));
if kind == "chart" {
items.extend([("-", ""), ("Select Data…", "ui:selectData"), ("Format Chart Area…", "ui:formatChart")]);
}
@@ -407,6 +415,9 @@ pub(super) fn object_menu(app: &mut SheetApp, ui: &mut egui::Ui) {
match action {
"ui:selectData" => app.open_dialog("selectData", json!({"chart": id})),
"ui:formatChart" => app.grid.pane = Some("formatChart".into()),
"ui:editText" => app.open_dialog("formatControl", json!({"id": id, "textOnly": true})),
"ui:assignAction" => app.open_dialog("assignAction", json!({"id": id})),
"ui:formatControl" => app.open_dialog("formatControl", json!({"id": id})),
_ => {
app.run_or_alert("object.delete", json!({"kind": kind, "id": id}));
app.selected_object = None;
+9
View File
@@ -246,6 +246,15 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Convert to Comments" => "Převést na komentáře",
"Checkbox" => "Zaškrtávací políčko",
"Insert checkboxes in the selected cells" => "Vložit zaškrtávací políčka do vybraných buněk",
"Button" => "Tlačítko",
"Slider" => "Posuvník",
"Spin Button" => "Číselník",
"Draw a button that runs a macro or a command" => "Nakreslit tlačítko, které spustí makro nebo příkaz",
"Draw a slider that sets a whole number in a linked cell" => "Nakreslit posuvník, který nastaví celé číslo v propojené buňce",
"Draw a spin button that steps a whole number in a linked cell" => "Nakreslit číselník, který po krocích mění celé číslo v propojené buňce",
"Edit Text" => "Upravit text",
"Assign Action…" => "Přiřadit akci…",
"Format Control…" => "Formát ovládacího prvku…",
"Sheet\nRight-to-Left" => "List zprava\ndoleva",
"Sheet Right-to-Left" => "List zprava doleva",
"Data\nAnalysis" => "Analýza\ndat",
+13
View File
@@ -260,6 +260,19 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Convert to Comments" => "In Kommentare konvertieren",
"Checkbox" => "Kontrollkästchen",
"Insert checkboxes in the selected cells" => "Kontrollkästchen in die ausgewählten Zellen einfügen",
"Button" => "Schaltfläche",
"Slider" => "Schieberegler",
"Spin Button" => "Drehfeld",
"Draw a button that runs a macro or a command" => "Eine Schaltfläche zeichnen, die ein Makro oder einen Befehl ausführt",
"Draw a slider that sets a whole number in a linked cell" => {
"Einen Schieberegler zeichnen, der eine ganze Zahl in einer verknüpften Zelle festlegt"
}
"Draw a spin button that steps a whole number in a linked cell" => {
"Ein Drehfeld zeichnen, das eine ganze Zahl in einer verknüpften Zelle schrittweise ändert"
}
"Edit Text" => "Text bearbeiten",
"Assign Action…" => "Aktion zuweisen…",
"Format Control…" => "Steuerelement formatieren…",
"Sheet\nRight-to-Left" => "Blatt von rechts\nnach links",
"Sheet Right-to-Left" => "Blatt von rechts nach links",
"Data\nAnalysis" => "Daten-\nanalyse",
+11
View File
@@ -239,6 +239,17 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Convert to Comments" => "Μετατροπή σε σχόλια",
"Checkbox" => "Πλαίσιο ελέγχου",
"Insert checkboxes in the selected cells" => "Εισαγωγή πλαισίων ελέγχου στα επιλεγμένα κελιά",
"Button" => "Κουμπί",
"Slider" => "Ρυθμιστικό",
"Spin Button" => "Κουμπί περιστροφής",
"Draw a button that runs a macro or a command" => "Σχεδίαση κουμπιού που εκτελεί μια μακροεντολή ή μια εντολή",
"Draw a slider that sets a whole number in a linked cell" => "Σχεδίαση ρυθμιστικού που ορίζει έναν ακέραιο αριθμό σε ένα συνδεδεμένο κελί",
"Draw a spin button that steps a whole number in a linked cell" => {
"Σχεδίαση κουμπιού περιστροφής που αλλάζει κατά βήματα έναν ακέραιο αριθμό σε ένα συνδεδεμένο κελί"
}
"Edit Text" => "Επεξεργασία κειμένου",
"Assign Action…" => "Αντιστοίχιση ενέργειας…",
"Format Control…" => "Μορφοποίηση στοιχείου ελέγχου…",
"Sheet\nRight-to-Left" => "Φύλλο από\nδεξιά προς αριστερά",
"Sheet Right-to-Left" => "Φύλλο από δεξιά προς αριστερά",
"Data\nAnalysis" => "Ανάλυση\nδεδομένων",
+13
View File
@@ -249,6 +249,19 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Convert to Comments" => "Convertir en comentarios",
"Checkbox" => "Casilla",
"Insert checkboxes in the selected cells" => "Insertar casillas en las celdas seleccionadas",
"Button" => "Botón",
"Slider" => "Control deslizante",
"Spin Button" => "Control de número",
"Draw a button that runs a macro or a command" => "Dibujar un botón que ejecuta una macro o un comando",
"Draw a slider that sets a whole number in a linked cell" => {
"Dibujar un control deslizante que establece un número entero en una celda vinculada"
}
"Draw a spin button that steps a whole number in a linked cell" => {
"Dibujar un control de número que cambia un número entero en una celda vinculada"
}
"Edit Text" => "Modificar texto",
"Assign Action…" => "Asignar acción…",
"Format Control…" => "Formato de control…",
"Sheet\nRight-to-Left" => "Hoja de derecha\na izquierda",
"Sheet Right-to-Left" => "Hoja de derecha a izquierda",
"Data\nAnalysis" => "Análisis\nde datos",
+11
View File
@@ -120,6 +120,17 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Sparklines" => "Courbes de tendance",
"Checkbox" => "Case à cocher",
"Insert checkboxes in the selected cells" => "Insérer des cases à cocher dans les cellules sélectionnées",
"Button" => "Bouton",
"Slider" => "Curseur",
"Spin Button" => "Toupie",
"Draw a button that runs a macro or a command" => "Dessiner un bouton qui exécute une macro ou une commande",
"Draw a slider that sets a whole number in a linked cell" => "Dessiner un curseur qui définit un nombre entier dans une cellule liée",
"Draw a spin button that steps a whole number in a linked cell" => {
"Dessiner une toupie qui modifie par pas un nombre entier dans une cellule liée"
}
"Edit Text" => "Modifier le texte",
"Assign Action…" => "Affecter une action…",
"Format Control…" => "Format de contrôle…",
"Header & Footer" => "En-tête et pied de page",
"Draw with ink" => "Dessiner à l'encre",
"Eraser" => "Gomme",
+13
View File
@@ -113,6 +113,19 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Sparklines" => "Sparkline",
"Checkbox" => "Casella di controllo",
"Insert checkboxes in the selected cells" => "Inserisci caselle di controllo nelle celle selezionate",
"Button" => "Pulsante",
"Slider" => "Dispositivo di scorrimento",
"Spin Button" => "Pulsante di selezione",
"Draw a button that runs a macro or a command" => "Disegna un pulsante che esegue una macro o un comando",
"Draw a slider that sets a whole number in a linked cell" => {
"Disegna un dispositivo di scorrimento che imposta un numero intero in una cella collegata"
}
"Draw a spin button that steps a whole number in a linked cell" => {
"Disegna un pulsante di selezione che modifica a passi un numero intero in una cella collegata"
}
"Edit Text" => "Modifica testo",
"Assign Action…" => "Assegna azione…",
"Format Control…" => "Formato controllo…",
"Header & Footer" => "Intestazione e piè di pagina",
"Draw with ink" => "Disegna a mano libera",
"Eraser" => "Gomma",
+9
View File
@@ -117,6 +117,15 @@ pub(super) const CORE: &[(&str, &str)] = &[
("Sparklines", "スパークライン"),
("Checkbox", "チェックボックス"),
("Insert checkboxes in the selected cells", "選択したセルにチェックボックスを挿入"),
("Button", "ボタン"),
("Slider", "スライダー"),
("Spin Button", "スピン ボタン"),
("Draw a button that runs a macro or a command", "マクロまたはコマンドを実行するボタンを描画"),
("Draw a slider that sets a whole number in a linked cell", "リンクされたセルに整数を設定するスライダーを描画"),
("Draw a spin button that steps a whole number in a linked cell", "リンクされたセルの整数を増減するスピン ボタンを描画"),
("Edit Text", "テキストの編集"),
("Assign Action…", "動作の登録…"),
("Format Control…", "コントロールの書式設定…"),
("Header & Footer", "ヘッダーとフッター"),
("Draw with ink", "インクで描画"),
("Eraser", "消しゴム"),
+9
View File
@@ -115,6 +115,15 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Sparklines" => "스파크라인",
"Checkbox" => "확인란",
"Insert checkboxes in the selected cells" => "선택한 셀에 확인란 삽입",
"Button" => "단추",
"Slider" => "슬라이더",
"Spin Button" => "스핀 단추",
"Draw a button that runs a macro or a command" => "매크로나 명령을 실행하는 단추 그리기",
"Draw a slider that sets a whole number in a linked cell" => "연결된 셀에 정수를 설정하는 슬라이더 그리기",
"Draw a spin button that steps a whole number in a linked cell" => "연결된 셀의 정수를 증감하는 스핀 단추 그리기",
"Edit Text" => "텍스트 편집",
"Assign Action…" => "동작 지정…",
"Format Control…" => "컨트롤 서식…",
"Header & Footer" => "머리글 및 바닥글",
"Draw with ink" => "잉크로 그리기",
"Eraser" => "지우개",
+11
View File
@@ -114,6 +114,17 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Sparklines" => "Sparklines",
"Checkbox" => "Checkbox",
"Insert checkboxes in the selected cells" => "Checkboxes invoegen in de geselecteerde cellen",
"Button" => "Knop",
"Slider" => "Schuifregelaar",
"Spin Button" => "Kringveld",
"Draw a button that runs a macro or a command" => "Een knop tekenen die een macro of een opdracht uitvoert",
"Draw a slider that sets a whole number in a linked cell" => "Een schuifregelaar tekenen die een geheel getal in een gekoppelde cel instelt",
"Draw a spin button that steps a whole number in a linked cell" => {
"Een kringveld tekenen dat een geheel getal in een gekoppelde cel stapsgewijs wijzigt"
}
"Edit Text" => "Tekst bewerken",
"Assign Action…" => "Actie toewijzen…",
"Format Control…" => "Besturingselement opmaken…",
"Header & Footer" => "Koptekst & voettekst",
"Draw with ink" => "Tekenen met inkt",
"Eraser" => "Gum",
+11
View File
@@ -109,6 +109,17 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Sparklines" => "Wykresy przebiegu w czasie",
"Checkbox" => "Pole wyboru",
"Insert checkboxes in the selected cells" => "Wstaw pola wyboru w zaznaczonych komórkach",
"Button" => "Przycisk",
"Slider" => "Suwak",
"Spin Button" => "Pokrętło",
"Draw a button that runs a macro or a command" => "Narysuj przycisk, który uruchamia makro lub polecenie",
"Draw a slider that sets a whole number in a linked cell" => "Narysuj suwak, który ustawia liczbę całkowitą w połączonej komórce",
"Draw a spin button that steps a whole number in a linked cell" => {
"Narysuj pokrętło, które zmienia o krok liczbę całkowitą w połączonej komórce"
}
"Edit Text" => "Edytuj tekst",
"Assign Action…" => "Przypisz akcję…",
"Format Control…" => "Formatuj formant…",
"Header & Footer" => "Nagłówek i stopka",
"Draw with ink" => "Rysuj piórem",
"Eraser" => "Gumka",
+13
View File
@@ -112,6 +112,19 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Sparklines" => "Minigráficos",
"Checkbox" => "Caixa de Seleção",
"Insert checkboxes in the selected cells" => "Inserir caixas de seleção nas células selecionadas",
"Button" => "Botão",
"Slider" => "Controle Deslizante",
"Spin Button" => "Botão de Rotação",
"Draw a button that runs a macro or a command" => "Desenhar um botão que executa uma macro ou um comando",
"Draw a slider that sets a whole number in a linked cell" => {
"Desenhar um controle deslizante que define um número inteiro em uma célula vinculada"
}
"Draw a spin button that steps a whole number in a linked cell" => {
"Desenhar um botão de rotação que altera um número inteiro em uma célula vinculada"
}
"Edit Text" => "Editar Texto",
"Assign Action…" => "Atribuir Ação…",
"Format Control…" => "Formatar Controle…",
"Header & Footer" => "Cabeçalho e Rodapé",
"Draw with ink" => "Desenhar com Tinta",
"Eraser" => "Borracha",
+11
View File
@@ -116,6 +116,17 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Sparklines" => "Diagrame sparkline",
"Checkbox" => "Casetă de selectare",
"Insert checkboxes in the selected cells" => "Inserați casete de selectare în celulele selectate",
"Button" => "Buton",
"Slider" => "Glisor",
"Spin Button" => "Buton rotativ",
"Draw a button that runs a macro or a command" => "Desenați un buton care rulează o macrocomandă sau o comandă",
"Draw a slider that sets a whole number in a linked cell" => "Desenați un glisor care setează un număr întreg într-o celulă legată",
"Draw a spin button that steps a whole number in a linked cell" => {
"Desenați un buton rotativ care modifică pas cu pas un număr întreg într-o celulă legată"
}
"Edit Text" => "Editare text",
"Assign Action…" => "Atribuire acțiune…",
"Format Control…" => "Formatare control…",
"Header & Footer" => "Antet și subsol",
"Draw with ink" => "Desenare cu cerneală",
"Eraser" => "Radieră",
+11
View File
@@ -115,6 +115,17 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Sparklines" => "Спарклайны",
"Checkbox" => "Флажок",
"Insert checkboxes in the selected cells" => "Вставить флажки в выбранные ячейки",
"Button" => "Кнопка",
"Slider" => "Ползунок",
"Spin Button" => "Счётчик",
"Draw a button that runs a macro or a command" => "Нарисовать кнопку, которая запускает макрос или команду",
"Draw a slider that sets a whole number in a linked cell" => "Нарисовать ползунок, который задаёт целое число в связанной ячейке",
"Draw a spin button that steps a whole number in a linked cell" => {
"Нарисовать счётчик, который изменяет целое число в связанной ячейке с шагом"
}
"Edit Text" => "Изменить текст",
"Assign Action…" => "Назначить действие…",
"Format Control…" => "Формат элемента управления…",
"Header & Footer" => "Верхний и нижний колонтитулы",
"Draw with ink" => "Рисование от руки",
"Eraser" => "Ластик",
+11
View File
@@ -116,6 +116,17 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Sparklines" => "Mini grafikoni",
"Checkbox" => "Polje za potvrdu",
"Insert checkboxes in the selected cells" => "Umetni polja za potvrdu u izabrane ćelije",
"Button" => "Dugme",
"Slider" => "Klizač",
"Spin Button" => "Dugme za okretanje",
"Draw a button that runs a macro or a command" => "Nacrtaj dugme koje pokreće makro ili komandu",
"Draw a slider that sets a whole number in a linked cell" => "Nacrtaj klizač koji postavlja ceo broj u povezanoj ćeliji",
"Draw a spin button that steps a whole number in a linked cell" => {
"Nacrtaj dugme za okretanje koje menja ceo broj u povezanoj ćeliji po koracima"
}
"Edit Text" => "Uredi tekst",
"Assign Action…" => "Dodeli radnju…",
"Format Control…" => "Oblikuj kontrolu…",
"Header & Footer" => "Zaglavlje i podnožje",
"Draw with ink" => "Crtaj mastilom",
"Eraser" => "Gumica",
+11
View File
@@ -245,6 +245,17 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Convert to Comments" => "Açıklamalara Dönüştür",
"Checkbox" => "Onay Kutusu",
"Insert checkboxes in the selected cells" => "Seçili hücrelere onay kutuları ekle",
"Button" => "Düğme",
"Slider" => "Kaydırıcı",
"Spin Button" => "Döndürme Düğmesi",
"Draw a button that runs a macro or a command" => "Makro veya komut çalıştıran bir düğme çiz",
"Draw a slider that sets a whole number in a linked cell" => "Bağlı bir hücrede tamsayı ayarlayan bir kaydırıcı çiz",
"Draw a spin button that steps a whole number in a linked cell" => {
"Bağlı bir hücredeki tamsayıyı adım adım değiştiren bir döndürme düğmesi çiz"
}
"Edit Text" => "Metni Düzenle",
"Assign Action…" => "Eylem Ata…",
"Format Control…" => "Denetimi Biçimlendir…",
"Sheet\nRight-to-Left" => "Sayfa\nSağdan Sola",
"Sheet Right-to-Left" => "Sayfa Sağdan Sola",
"Data\nAnalysis" => "Veri\nAnalizi",
+11
View File
@@ -111,6 +111,17 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Sparklines" => "Спарклайни",
"Checkbox" => "Прапорець",
"Insert checkboxes in the selected cells" => "Вставити прапорці у виділені комірки",
"Button" => "Кнопка",
"Slider" => "Повзунок",
"Spin Button" => "Лічильник",
"Draw a button that runs a macro or a command" => "Намалювати кнопку, яка запускає макрос або команду",
"Draw a slider that sets a whole number in a linked cell" => "Намалювати повзунок, який задає ціле число у зв’язаній комірці",
"Draw a spin button that steps a whole number in a linked cell" => {
"Намалювати лічильник, який змінює ціле число у зв’язаній комірці з кроком"
}
"Edit Text" => "Редагувати текст",
"Assign Action…" => "Призначити дію…",
"Format Control…" => "Формат елемента керування…",
"Header & Footer" => "Верхній і нижній колонтитули",
"Draw with ink" => "Малювання від руки",
"Eraser" => "Гумка",
+9
View File
@@ -117,6 +117,15 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Sparklines" => "Biểu đồ thu nhỏ",
"Checkbox" => "Hộp kiểm",
"Insert checkboxes in the selected cells" => "Chèn hộp kiểm vào các ô đã chọn",
"Button" => "Nút",
"Slider" => "Thanh trượt",
"Spin Button" => "Nút xoay",
"Draw a button that runs a macro or a command" => "Vẽ một nút chạy macro hoặc lệnh",
"Draw a slider that sets a whole number in a linked cell" => "Vẽ một thanh trượt đặt số nguyên trong ô được liên kết",
"Draw a spin button that steps a whole number in a linked cell" => "Vẽ một nút xoay thay đổi từng bước số nguyên trong ô được liên kết",
"Edit Text" => "Sửa văn bản",
"Assign Action…" => "Gán hành động…",
"Format Control…" => "Định dạng điều khiển…",
"Header & Footer" => "Đầu trang & Chân trang",
"Draw with ink" => "Vẽ bằng mực",
"Eraser" => "Tẩy",
+9
View File
@@ -115,6 +115,15 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Sparklines" => "迷你图",
"Checkbox" => "复选框",
"Insert checkboxes in the selected cells" => "在所选单元格中插入复选框",
"Button" => "按钮",
"Slider" => "滑块",
"Spin Button" => "数值调节钮",
"Draw a button that runs a macro or a command" => "绘制运行宏或命令的按钮",
"Draw a slider that sets a whole number in a linked cell" => "绘制在链接单元格中设置整数的滑块",
"Draw a spin button that steps a whole number in a linked cell" => "绘制按步长调整链接单元格中整数的数值调节钮",
"Edit Text" => "编辑文字",
"Assign Action…" => "指定操作…",
"Format Control…" => "设置控件格式…",
"Header & Footer" => "页眉和页脚",
"Draw with ink" => "墨迹绘制",
"Eraser" => "橡皮擦",
+23
View File
@@ -108,6 +108,12 @@ pub enum Icon {
Equation,
Sparkline,
Checkbox,
/// A form button with a pointer.
Button,
/// A form slider (scroll bar).
Slider,
/// A form spin button.
Spin,
Function,
Name,
Trace,
@@ -534,6 +540,23 @@ fn paint_with(p: &Painter, rect: Rect, icon: Icon, c: Color32, pal: &Palette) {
g.rect(2.0, 2.0, 12.0, 12.0, None, Some(c));
g.line(&[(4.5, 8.0), (7.0, 10.5), (11.5, 5.0)], c, 1.4);
}
Icon::Button => {
let frame = Rect::from_min_max(g.pt(1.5, 3.0), g.pt(14.5, 11.0));
g.p.rect_filled(frame, 2.0 * g.s, paper);
g.p.rect_stroke(frame, 2.0 * g.s, Stroke::new(1.0, c), egui::StrokeKind::Inside);
g.poly(&[(9.0, 7.5), (9.0, 15.0), (13.5, 12.5)], c);
}
Icon::Slider => {
g.line(&[(2.0, 8.0), (14.0, 8.0)], light, 1.6);
g.line(&[(2.0, 8.0), (7.0, 8.0)], blue, 1.6);
g.circle(7.0, 8.0, 2.4, Some(paper), Some(c));
}
Icon::Spin => {
g.rect(4.0, 1.5, 8.0, 13.0, Some(paper), Some(c));
g.stroke(&[(4.0, 8.0), (12.0, 8.0)], c);
g.poly(&[(8.0, 3.2), (10.5, 6.3), (5.5, 6.3)], c);
g.poly(&[(5.5, 9.7), (10.5, 9.7), (8.0, 12.8)], c);
}
Icon::Fx => g.text(8.0, 8.0, "fx", 10.5, c, (false, true)),
Icon::Plus => {
g.line(&[(8.0, 3.0), (8.0, 13.0)], c, 1.3);
@@ -153,6 +153,7 @@ pub const SEQUENCES: &[(&str, Entry)] = &[
("HW", Entry::Continue), // Excel: Wrap Text Menu; group level: containment(Wrap Text)
("HWW", Entry::Finish(Action::Command("home.wrapText"))), // Excel: Wrap Text
("HX", Entry::Finish(Action::Command("edit.cut"))), // Excel: Cut
("NBU", Entry::Finish(Action::Command("insert.button"))), // Excel: Button (Form Control)
("NC1", Entry::Finish(Action::Control("insert.chart.column"))), // Excel: Column Chart; Column chart (kind TBD)
("NC2", Entry::Finish(Action::Command("review.newComment"))), // Excel: New Comment
("NCM", Entry::Finish(Action::Dialog("comment"))), // Excel: Excel: New Comment (modern)
@@ -180,10 +181,12 @@ pub const SEQUENCES: &[(&str, Entry)] = &[
("NQ", Entry::Finish(Action::Control("insert.chart.pie"))), // Excel: Pie Chart; Pie (kind TBD)
("NR", Entry::Finish(Action::Command("insert.recommendedCharts"))), // Excel: Recommended Charts
("NSA", Entry::Finish(Action::Control("insert.chart.statistic"))), // Excel: Statistical Chart; Statistical chart (kind TBD)
("NSB", Entry::Finish(Action::Command("insert.spinButton"))), // Excel: Spin Button (Form Control)
("NSC", Entry::Finish(Action::Pending("insert.screenshot"))), // pending; Excel: Screenshot Menu; Screenshot; reserved until the command lands
("NSD", Entry::Finish(Action::Command("insert.chart"))), // Excel: Combo Chart; Combo (kind TBD)
("NSF", Entry::Finish(Action::Command("insert.slicer"))), // Excel: Slicer; insert.slicer missing (gap #8); reserved until the command lands
("NSH", Entry::Finish(Action::Command("insert.shape"))), // Excel: Shapes
("NSL", Entry::Finish(Action::Command("insert.slider"))), // Excel: Scroll Bar (Form Control)
("NSP", Entry::Finish(Action::Command("insert.recommendedPivotTables"))), // Excel: Recommended PivotTables
("NST", Entry::Finish(Action::Pending("insert.timeline"))), // pending; Excel: Timeline; insert.timeline missing (gap #8); reserved until the command lands
("NSZ", Entry::Finish(Action::Command("insert.pivotChart"))), // Excel: PivotChart; PivotChart; reserved until the command lands
+21
View File
@@ -1045,6 +1045,13 @@
"excel": "Cut",
"note": ""
},
{
"seq": "NBU",
"kind": "command",
"target": "insert.button",
"excel": "Button (Form Control)",
"note": ""
},
{
"seq": "NC1",
"kind": "control",
@@ -1231,6 +1238,13 @@
"excel": "Statistical Chart",
"note": "Statistical chart (kind TBD)"
},
{
"seq": "NSB",
"kind": "command",
"target": "insert.spinButton",
"excel": "Spin Button (Form Control)",
"note": ""
},
{
"seq": "NSC",
"kind": "command",
@@ -1261,6 +1275,13 @@
"excel": "Shapes",
"note": ""
},
{
"seq": "NSL",
"kind": "command",
"target": "insert.slider",
"excel": "Scroll Bar (Form Control)",
"note": ""
},
{
"seq": "NSP",
"kind": "command",
+2
View File
@@ -21,6 +21,7 @@ mod cell_pictures;
pub mod chartview;
pub mod code_editor;
pub mod control;
pub mod control_ui;
mod craft_fonts_policy;
pub mod credits;
pub mod dialogs;
@@ -31,6 +32,7 @@ pub mod editor;
pub mod equation;
mod floating_pictures;
pub mod fonts;
pub mod form_controls;
pub mod formula_bar;
mod formula_locale;
pub mod function_library;
+4
View File
@@ -116,6 +116,8 @@ pub enum OpenAction {
EditText(u32),
/// The Format Chart pane.
FormatChart(u32),
/// A form control's editor: a button's text, else Format Control.
EditControl(u32),
/// The equation editor.
EditEquation(u32),
/// Nothing beyond selecting it.
@@ -127,6 +129,7 @@ pub fn open_action(sh: &Sheet, o: ObjRef) -> OpenAction {
ObjKind::Chart => OpenAction::FormatChart(o.id),
ObjKind::Equation => OpenAction::EditEquation(o.id),
ObjKind::Shape if sh.shapes.iter().any(|s| s.id == o.id && s.kind == ShapeKind::TextBox) => OpenAction::EditText(o.id),
ObjKind::Control => OpenAction::EditControl(o.id),
ObjKind::Shape | ObjKind::Image | ObjKind::Slicer => OpenAction::Select,
}
}
@@ -142,6 +145,7 @@ impl SheetApp {
self.begin_text_box_edit(id);
}
OpenAction::FormatChart(_) => self.grid.pane = Some("formatChart".into()),
OpenAction::EditControl(id) => crate::control_ui::edit(self, id),
OpenAction::EditEquation(id) => self.open_dialog("equation", json!({"id": id})),
OpenAction::Select => {}
}
+3 -1
View File
@@ -8,7 +8,9 @@ use crate::SheetApp;
use crate::icons::{self, Icon};
use crate::keytips::keytip_table;
use crate::theme::{self, Cat, Tokens};
use crate::widgets::{big_button, big_split_button, color_palette, icon_button, small_button, small_split_button, split_button, toggle_button};
use crate::widgets::{
big_button, big_split_button, color_palette, icon_button, small_button, small_split_button, small_toggle_button, split_button, toggle_button,
};
mod automate;
mod contextual;
+24 -2
View File
@@ -1,7 +1,9 @@
use super::controls::{act, anchor_keytip, control_keytip, invoke, popup_command, sep};
use super::{Icon, Sense, SheetApp, Stroke, StrokeKind, Tokens, Ui, big_button, big_split_button, icons, json, small_button, vec2};
use super::{
Icon, Sense, SheetApp, Stroke, StrokeKind, Tokens, Ui, big_button, big_split_button, icons, json, small_button, small_toggle_button, vec2,
};
/// Draws the Insert tab: PivotTable, Table, Pictures, Shapes, Icons, charts, slicer, sparklines, checkbox, links, comments, text box, header/footer and symbols.
/// Draws the Insert tab: PivotTable, Table, Pictures, Shapes, Icons, charts, slicer, sparklines, form controls (button, slider, spin button), checkbox, links, comments, text box, header/footer and symbols.
pub(super) fn insert(app: &mut SheetApp, ui: &mut Ui) {
let __k = big_button(ui, Icon::PivotTable, "PivotTable", "PivotTable", false);
control_keytip(app, ui, &__k, "insert.pivotTable");
@@ -165,6 +167,26 @@ pub(super) fn insert(app: &mut SheetApp, ui: &mut Ui) {
}
});
sep(ui);
// Form controls: draw a button, slider or spin button on the sheet (a click places it at its
// default size). Their key tips run the command at the active cell.
use gridcraft_engine::model::ControlKind;
let placing = app.grid.placing;
ui.vertical(|ui| {
for (icon, kind, label, tip, id) in [
(Icon::Button, ControlKind::Button, "Button", "Draw a button that runs a macro or a command", "insert.button"),
(Icon::Slider, ControlKind::Slider, "Slider", "Draw a slider that sets a whole number in a linked cell", "insert.slider"),
(Icon::Spin, ControlKind::Spin, "Spin Button", "Draw a spin button that steps a whole number in a linked cell", "insert.spinButton"),
] {
let on = placing == Some(kind);
let r = small_toggle_button(ui, icon, label, tip, on);
control_keytip(app, ui, &r, id);
if r.clicked() {
app.grid.placing = if placing == Some(kind) { None } else { Some(kind) };
app.selected_object = None;
}
}
});
sep(ui);
// Cell Controls.
if big_button(ui, Icon::Checkbox, "Checkbox", "Insert checkboxes in the selected cells", false).clicked() {
act(app, "insert.checkbox", json!({}));
+30 -1
View File
@@ -104,8 +104,17 @@ pub struct Tokens {
pub sheet_control_bg: Color32,
pub sheet_control_border: Color32,
pub sheet_control_text: Color32,
/// A placeholder fill on the sheet (a picture that has not loaded).
/// A placeholder fill on the sheet (a picture that has not loaded), and a form slider's track.
pub sheet_track: Color32,
/// A form slider's thumb.
pub sheet_thumb: Color32,
/// A form button or spin arrow under the pointer, and pressed.
pub sheet_control_hover: Color32,
pub sheet_control_pressed: Color32,
/// The filled part of a form slider's track (from the minimum to the thumb).
pub sheet_fill: Color32,
/// The ring around the form control that takes the arrow keys.
pub sheet_focus: Color32,
/// The macro recording dot (Record Macro, the status bar).
pub recording: Color32,
/// A breakpoint dot in the macro step editor's gutter.
@@ -181,6 +190,11 @@ 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);
const SHEET_THUMB: Color32 = rgb(0x6B6B6B);
const SHEET_CONTROL_HOVER: Color32 = rgb(0xE4E4E4);
const SHEET_CONTROL_PRESSED: Color32 = rgb(0xCFCFCF);
const SHEET_FILL: Color32 = rgb(0x107C41);
const SHEET_FOCUS: Color32 = rgb(0x107C41);
/// The PY tag on Python cells: a deep green, white text on it.
const SHEET_PY_TAG: Color32 = rgb(0x1E6E4E);
@@ -257,6 +271,11 @@ impl Tokens {
sheet_control_border: SHEET_CONTROL_BORDER,
sheet_control_text: SHEET_CONTROL_TEXT,
sheet_track: SHEET_TRACK,
sheet_thumb: SHEET_THUMB,
sheet_control_hover: SHEET_CONTROL_HOVER,
sheet_control_pressed: SHEET_CONTROL_PRESSED,
sheet_fill: SHEET_FILL,
sheet_focus: SHEET_FOCUS,
recording: pick(dark, rgb(0xC42B1C), rgb(0xF1705F)),
breakpoint: pick(dark, rgb(0xD13438), rgb(0xF1707A)),
sheet_macro_input: SHEET_MACRO_INPUT,
@@ -410,6 +429,12 @@ mod contrast_tests {
add("selection border on sheet", t.sel_border, t.grid_bg, GLYPH);
add("sheet control text", t.sheet_control_text, t.sheet_control_bg, TEXT);
add("sheet control border on sheet", t.sheet_control_border, t.grid_bg, GLYPH);
add("slider thumb on track", t.sheet_thumb, t.sheet_track, GLYPH);
add("sheet control text on hover", t.sheet_control_text, t.sheet_control_hover, TEXT);
add("sheet control text when pressed", t.sheet_control_text, t.sheet_control_pressed, TEXT);
add("slider fill on sheet", t.sheet_fill, t.grid_bg, GLYPH);
add("slider fill on track", t.sheet_fill, t.sheet_track, GLYPH);
add("control focus ring on sheet", t.sheet_focus, t.grid_bg, GLYPH);
add("recording dot on ribbon", t.recording, t.ribbon, GLYPH);
add("recording dot on status bar", t.recording, t.status_bar, GLYPH);
for (name, c) in [
@@ -467,6 +492,10 @@ mod contrast_tests {
(l.sheet_control_bg, l.sheet_control_border, l.sheet_control_text, l.sheet_track),
(d.sheet_control_bg, d.sheet_control_border, d.sheet_control_text, d.sheet_track)
);
assert_eq!(
(l.sheet_thumb, l.sheet_control_hover, l.sheet_control_pressed, l.sheet_fill, l.sheet_focus),
(d.sheet_thumb, d.sheet_control_hover, d.sheet_control_pressed, d.sheet_fill, d.sheet_focus)
);
assert_eq!(
(l.sheet_macro_input, l.sheet_macro_footprint, l.sheet_macro_conflict, l.sheet_macro_target),
(d.sheet_macro_input, d.sheet_macro_footprint, d.sheet_macro_conflict, d.sheet_macro_target)
+726
View File
@@ -0,0 +1,726 @@
//! Form controls through real pointer and keyboard input: dragging a slider (one undo step),
//! paging on the track, clicking and holding spin arrows, keyboard steps, a button running its
//! macro, Cmd+click or right-click selecting a control without running it, right-to-left
//! sheets, a button's questions (an answer runs only the action it asked about), and the Insert ›
//! Controls buttons, context menu and dialogs.
use egui::{Event, Key, Modifiers, PointerButton, Pos2, Rect, vec2};
use gridcraft_engine::Session;
use gridcraft_engine::core::{CellRef, Value};
use gridcraft_engine::model::{FormControl, ObjKind, ObjRef};
use gridcraft_ui_egui::form_controls::{Part, part_at, slider_parts};
use gridcraft_ui_egui::{SheetApp, chartview, grid::Geo};
use serde_json::json;
type Harness = egui_kittest::Harness<'static, SheetApp>;
struct Ids {
slider: u32,
spin: u32,
button: u32,
}
fn fixture() -> (Harness, Ids) {
let mut s = Session::new();
s.new_workbook();
s.execute("cell.set", json!({"cell": "B1", "input": "20"})).unwrap();
s.execute("cell.set", json!({"cell": "C1", "input": "=B1*10"})).unwrap();
let id = |v: serde_json::Value| v["control"].as_u64().unwrap() as u32;
let slider =
id(s.execute("insert.slider", json!({"at": "E3", "width": 200, "height": 20, "link": "B1", "max": 100, "step": 1, "page": 10})).unwrap());
let spin =
id(s.execute("insert.spinButton", json!({"at": "E8", "width": 20, "height": 40, "link": "B2", "max": 50, "step": 2, "value": 10})).unwrap());
s.execute(
"macro.save",
json!({"name": "Mark", "steps": [{"command": "cell.set", "params": {"cell": "H1", "input": "pressed"}}], "output": {"onConflict": "overwrite"}}),
)
.unwrap();
let button = id(s.execute("insert.button", json!({"at": "E12", "text": "Mark it", "macro": "Mark"})).unwrap());
s.execute("selection.set", json!({"cell": "A20"})).unwrap();
let mut h = egui_kittest::Harness::builder()
.with_size(vec2(1400.0, 900.0))
.with_step_dt(1.0 / 60.0)
.with_os(egui::os::OperatingSystem::Mac)
.build_ui_state(
|ui, app: &mut SheetApp| {
let ctx = ui.ctx().clone();
app.logic(&ctx);
app.ui(ui);
},
SheetApp::new(s, Default::default()),
);
h.run_steps(4);
(h, Ids { slider, spin, button })
}
fn control(h: &Harness, id: u32) -> FormControl {
h.state().session.doc().unwrap().wb.active().unwrap().controls.iter().find(|c| c.id == id).cloned().unwrap()
}
fn rect(h: &Harness, id: u32) -> Rect {
let app = h.state();
let sh = app.session.doc().unwrap().wb.active().unwrap();
chartview::object_rect(&Geo::new(sh, app.grid.rect.unwrap(), app.view().scroll), sh, ObjRef::new(ObjKind::Control, id)).unwrap()
}
fn cell(h: &Harness, a: &str) -> Value {
h.state().session.doc().unwrap().wb.active().unwrap().value(CellRef::parse(a).unwrap())
}
fn undo_len(h: &Harness) -> usize {
h.state().session.doc().unwrap().undo.len()
}
fn button(h: &mut Harness, p: Pos2, pressed: bool, which: PointerButton, modifiers: Modifiers) {
h.input_mut().events.push(Event::ModifiersChanged(modifiers));
h.input_mut().events.push(Event::PointerMoved(p));
h.input_mut().events.push(Event::PointerButton { pos: p, button: which, pressed, modifiers });
h.step();
}
fn click_with(h: &mut Harness, p: Pos2, modifiers: Modifiers) {
h.run_steps(30);
button(h, p, true, PointerButton::Primary, modifiers);
button(h, p, false, PointerButton::Primary, modifiers);
h.input_mut().events.push(Event::ModifiersChanged(Modifiers::NONE));
h.run_steps(2);
}
fn click(h: &mut Harness, p: Pos2) {
click_with(h, p, Modifiers::NONE);
}
fn key(h: &mut Harness, key: Key) {
key_with(h, key, Modifiers::NONE);
}
fn key_with(h: &mut Harness, key: Key, modifiers: Modifiers) {
h.input_mut().events.push(Event::ModifiersChanged(modifiers));
h.input_mut().events.push(Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers });
h.input_mut().events.push(Event::Key { key, physical_key: None, pressed: false, repeat: false, modifiers });
h.input_mut().events.push(Event::ModifiersChanged(Modifiers::NONE));
h.run_steps(2);
}
/// A Space press as the system sends it: the key and the space it types.
fn space(h: &mut Harness) {
h.input_mut().events.push(Event::Key { key: Key::Space, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE });
h.input_mut().events.push(Event::Text(" ".into()));
h.input_mut().events.push(Event::Key { key: Key::Space, physical_key: None, pressed: false, repeat: false, modifiers: Modifiers::NONE });
h.run_steps(2);
}
fn active(h: &Harness) -> String {
h.state().session.doc().unwrap().selection.active.a1()
}
fn thumb(h: &Harness, id: u32) -> Rect {
let c = control(h, id);
slider_parts(rect(h, id), &c, 1.0).1
}
#[test]
fn dragging_the_slider_is_one_undo_step() {
let (mut h, ids) = fixture();
let start = thumb(&h, ids.slider).center();
assert_eq!(control(&h, ids.slider).value, 20);
let before = undo_len(&h);
let r = rect(&h, ids.slider);
let (track, _) = slider_parts(r, &control(&h, ids.slider), 1.0);
h.run_steps(30);
button(&mut h, start, true, PointerButton::Primary, Modifiers::NONE);
for k in 1..=6 {
let x = start.x + (track.left() + track.width() * 0.7 - start.x) * k as f32 / 6.0;
h.input_mut().events.push(Event::PointerMoved(Pos2::new(x, start.y + 3.0)));
h.step();
}
let mid = control(&h, ids.slider).value;
assert!((68..=72).contains(&mid), "the thumb follows the pointer: {mid}");
assert_eq!(cell(&h, "B1"), Value::Number(mid as f64), "the linked cell follows while dragging");
assert_eq!(cell(&h, "C1"), Value::Number(mid as f64 * 10.0), "and its dependents");
button(&mut h, Pos2::new(track.left() + track.width() * 0.7, start.y), false, PointerButton::Primary, Modifiers::NONE);
h.run_steps(2);
assert_eq!(undo_len(&h), before + 1, "the whole drag is one undo step");
assert_eq!(h.state().selected_object, None, "working a control doesn't select it");
h.state_mut().run("edit.undo", json!({})).unwrap();
assert_eq!(cell(&h, "B1"), Value::Number(20.0));
assert_eq!(control(&h, ids.slider).value, 20);
}
#[test]
fn the_track_pages_and_editing_the_cell_moves_the_thumb() {
let (mut h, ids) = fixture();
let r = rect(&h, ids.slider);
let after = Pos2::new(r.right() - 4.0, r.center().y);
assert_eq!(part_at(r, &control(&h, ids.slider), 1.0, after), Some(Part::TrackAfter));
click(&mut h, after);
assert_eq!(cell(&h, "B1"), Value::Number(30.0), "a click on the track moves a page");
click(&mut h, Pos2::new(r.left() + 3.0, r.center().y));
assert_eq!(cell(&h, "B1"), Value::Number(20.0));
let was = thumb(&h, ids.slider).center().x;
h.state_mut().run("cell.set", json!({"cell": "B1", "input": "90"})).unwrap();
h.run_steps(2);
assert_eq!(control(&h, ids.slider).value, 90);
assert!(thumb(&h, ids.slider).center().x > was + 100.0, "editing the linked cell moves the thumb");
}
#[test]
fn spin_arrows_step_and_repeat_while_held() {
let (mut h, ids) = fixture();
let r = rect(&h, ids.spin);
let up = Pos2::new(r.center().x, r.top() + 5.0);
let down = Pos2::new(r.center().x, r.bottom() - 5.0);
click(&mut h, up);
assert_eq!(cell(&h, "B2"), Value::Number(12.0));
click(&mut h, down);
click(&mut h, down);
assert_eq!(cell(&h, "B2"), Value::Number(8.0));
// Held: after a pause it repeats, and the whole hold is one undo step.
let before = undo_len(&h);
h.run_steps(30);
button(&mut h, up, true, PointerButton::Primary, Modifiers::NONE);
h.run_steps(60);
button(&mut h, up, false, PointerButton::Primary, Modifiers::NONE);
let v = control(&h, ids.spin).value;
assert!(v >= 14, "holding the arrow repeats: {v}");
assert_eq!(undo_len(&h), before + 1);
// It stops at the maximum.
h.run_steps(30);
button(&mut h, up, true, PointerButton::Primary, Modifiers::NONE);
h.run_steps(400);
button(&mut h, up, false, PointerButton::Primary, Modifiers::NONE);
assert_eq!(control(&h, ids.spin).value, 50);
}
#[test]
fn the_keyboard_steps_the_control_last_worked() {
let (mut h, ids) = fixture();
let r = rect(&h, ids.spin);
click(&mut h, Pos2::new(r.center().x, r.top() + 5.0));
assert_eq!(h.state().grid.focused_control, Some(ids.spin));
key(&mut h, Key::ArrowUp);
key(&mut h, Key::ArrowUp);
assert_eq!(control(&h, ids.spin).value, 16);
key(&mut h, Key::ArrowDown);
assert_eq!(control(&h, ids.spin).value, 14);
key(&mut h, Key::End);
assert_eq!(control(&h, ids.spin).value, 50);
let tr = rect(&h, ids.slider);
click(&mut h, Pos2::new(tr.right() - 4.0, tr.center().y));
assert_eq!(h.state().grid.focused_control, Some(ids.slider));
key(&mut h, Key::ArrowRight);
assert_eq!(control(&h, ids.slider).value, 31);
key(&mut h, Key::PageDown);
assert_eq!(control(&h, ids.slider).value, 41);
key(&mut h, Key::PageUp);
key(&mut h, Key::ArrowLeft);
assert_eq!(control(&h, ids.slider).value, 30);
key(&mut h, Key::Home);
assert_eq!(cell(&h, "B1"), Value::Number(0.0));
// Escape lets go; the arrows move the cell cursor again.
key(&mut h, Key::Escape);
assert_eq!(h.state().grid.focused_control, None);
key(&mut h, Key::ArrowDown);
assert_eq!(control(&h, ids.slider).value, 0);
assert_eq!(h.state().session.doc().unwrap().selection.active.a1(), "A21");
}
#[test]
fn a_button_runs_its_macro_and_cmd_or_right_click_only_selects() {
let (mut h, ids) = fixture();
let r = rect(&h, ids.button);
click_with(&mut h, r.center(), Modifiers::COMMAND);
assert_eq!(h.state().selected_object, Some(ObjRef::new(ObjKind::Control, ids.button)));
assert_eq!(cell(&h, "H1"), Value::Empty, "Cmd+click selects without running");
// A click on a selected control moves it rather than running it.
click(&mut h, r.center());
assert_eq!(cell(&h, "H1"), Value::Empty);
// Click elsewhere, then right-click: selects, doesn't run.
click(&mut h, Pos2::new(r.right() + 300.0, r.bottom() + 100.0));
assert_eq!(h.state().selected_object, None);
h.run_steps(30);
button(&mut h, r.center(), true, PointerButton::Secondary, Modifiers::NONE);
button(&mut h, r.center(), false, PointerButton::Secondary, Modifiers::NONE);
h.run_steps(2);
assert_eq!(h.state().selected_object, Some(ObjRef::new(ObjKind::Control, ids.button)));
assert_eq!(cell(&h, "H1"), Value::Empty);
assert_eq!(h.state().grid.object_menu.map(|(_, kind, id)| (kind, id)), Some(("control", ids.button)));
// Escape closes the menu (which would otherwise take the next click at the pointer), then
// the selection is dropped, so the click below reaches the button itself.
key(&mut h, Key::Escape);
assert!(h.state().grid.object_menu.is_none(), "Escape closes the control's menu");
h.state_mut().selected_object = None;
// A plain click runs the macro, as one undo step.
let before = undo_len(&h);
click(&mut h, r.center());
assert_eq!(cell(&h, "H1"), Value::text("pressed"));
assert_eq!(undo_len(&h), before + 1);
assert!(h.state().message.is_none(), "{:?}", h.state().message);
// Pressing and sliding off before letting go doesn't run it.
h.state_mut().run("edit.undo", json!({})).unwrap();
h.run_steps(30);
button(&mut h, r.center(), true, PointerButton::Primary, Modifiers::NONE);
button(&mut h, r.right_bottom() + vec2(80.0, 80.0), false, PointerButton::Primary, Modifiers::NONE);
h.run_steps(2);
assert_eq!(cell(&h, "H1"), Value::Empty);
}
#[test]
fn holding_enter_or_space_presses_a_focused_button_once() {
let (mut h, ids) = fixture();
let r = rect(&h, ids.button);
click(&mut h, r.center());
assert_eq!(h.state().grid.focused_control, Some(ids.button));
for k in [Key::Enter, Key::Space] {
let before = undo_len(&h);
// A held key: one press, then the system's repeats, then the release.
h.input_mut().events.push(Event::Key { key: k, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE });
h.run_steps(1);
for _ in 0..5 {
h.input_mut().events.push(Event::Key { key: k, physical_key: None, pressed: true, repeat: true, modifiers: Modifiers::NONE });
h.run_steps(1);
}
h.input_mut().events.push(Event::Key { key: k, physical_key: None, pressed: false, repeat: false, modifiers: Modifiers::NONE });
h.run_steps(2);
assert_eq!(undo_len(&h), before + 1, "{k:?} ran the macro once");
assert_eq!(cell(&h, "H1"), Value::text("pressed"));
assert_eq!(cell(&h, "A20"), Value::Empty, "{k:?} typed nothing into the cell");
}
}
/// A button holds the keyboard only until the grid takes a key: after an arrow (or Cmd+arrow)
/// moves the cursor, Enter moves it again instead of pressing the button a second time, and
/// Space starts a cell edit.
#[test]
fn a_button_gives_the_keyboard_back_when_the_grid_takes_a_key() {
let (mut h, ids) = fixture();
let r = rect(&h, ids.button);
click(&mut h, r.center());
assert_eq!(cell(&h, "H1"), Value::text("pressed"));
let runs = undo_len(&h);
assert_eq!(active(&h), "A20");
key(&mut h, Key::ArrowDown);
assert_eq!(active(&h), "A21", "the arrow moved the cursor");
assert_eq!(h.state().grid.focused_control, None, "and took the keyboard from the button");
key(&mut h, Key::Enter);
assert_eq!(undo_len(&h), runs, "Enter did not press the button again");
assert_eq!(active(&h), "A22", "it moved the cursor");
// Space after an arrow starts a cell edit.
click(&mut h, r.center());
assert_eq!(undo_len(&h), runs + 1, "the click pressed it");
key(&mut h, Key::ArrowDown);
space(&mut h);
assert!(h.state().editor.is_some(), "Space starts editing the cell");
assert_eq!(undo_len(&h), runs + 1, "Space did not press the button");
key(&mut h, Key::Escape);
assert!(h.state().editor.is_none());
// A key with a modifier the grid takes (Cmd+arrow jumps to the edge) takes the keyboard too.
click(&mut h, r.center());
assert_eq!(h.state().grid.focused_control, Some(ids.button));
key_with(&mut h, Key::ArrowUp, Modifiers::COMMAND);
assert_eq!(h.state().grid.focused_control, None);
assert_eq!(active(&h), "A1");
}
/// Space is a key a slider or spin button doesn't take, so it reaches the grid like any other
/// character: a cell edit starts with a space and the control's value is unchanged. The control
/// keeps the keyboard, as the control last worked: after the edit, the arrows still step it.
#[test]
fn space_starts_a_cell_edit_while_a_slider_or_spin_button_is_focused() {
let (mut h, ids) = fixture();
let spin = rect(&h, ids.spin);
for (id, at, step) in
[(ids.slider, thumb(&h, ids.slider).center(), Key::ArrowRight), (ids.spin, Pos2::new(spin.center().x, spin.top() + 5.0), Key::ArrowUp)]
{
click(&mut h, at);
assert_eq!(h.state().grid.focused_control, Some(id));
let value = control(&h, id).value;
space(&mut h);
assert_eq!(h.state().editor.as_ref().map(|e| e.text.as_str()), Some(" "), "Space starts editing the cell with a space");
assert_eq!(control(&h, id).value, value, "the control is unchanged");
assert_eq!(h.state().grid.focused_control, Some(id), "the control last worked keeps the keyboard");
key(&mut h, Key::Escape);
assert!(h.state().editor.is_none(), "Escape cancels the edit");
key(&mut h, step);
assert_eq!(control(&h, id).value, value + control(&h, id).step, "the arrows still step it");
key(&mut h, Key::Escape);
assert_eq!(h.state().grid.focused_control, None);
}
}
/// With a slider or spin button focused, Space on a cell does what it does with no control
/// focused and nothing more: a checkbox cell toggles once and no edit starts (the typed space is
/// dropped, as the toggle is what Space does there), and the control's value is unchanged.
#[test]
fn space_on_a_checkbox_cell_toggles_it_once_while_a_slider_is_focused() {
let (mut h, ids) = fixture();
h.state_mut().run("insert.checkbox", json!({"range": "A20"})).unwrap();
h.run_steps(2);
assert_eq!(cell(&h, "A20"), Value::Bool(false));
let at = thumb(&h, ids.slider).center();
click(&mut h, at);
assert_eq!(h.state().grid.focused_control, Some(ids.slider));
let value = control(&h, ids.slider).value;
space(&mut h);
assert_eq!(cell(&h, "A20"), Value::Bool(true), "Space toggled the box once");
assert!(h.state().editor.is_none(), "and started no edit");
assert_eq!(control(&h, ids.slider).value, value, "the slider is unchanged");
assert_eq!(h.state().grid.focused_control, Some(ids.slider), "and keeps the keyboard");
space(&mut h);
assert_eq!(cell(&h, "A20"), Value::Bool(false), "a second Space toggles it back");
assert!(h.state().editor.is_none());
}
/// A right-click on a cell takes the keyboard from a button, like any other click on the grid:
/// the Enter that follows does not press it again.
#[test]
fn a_right_click_on_a_cell_takes_the_keyboard_from_a_button() {
let (mut h, ids) = fixture();
let r = rect(&h, ids.button);
click(&mut h, r.center());
let runs = undo_len(&h);
assert_eq!(h.state().grid.focused_control, Some(ids.button));
let on_a_cell = Pos2::new(r.right() + 300.0, r.bottom() + 100.0);
h.run_steps(30);
button(&mut h, on_a_cell, true, PointerButton::Secondary, Modifiers::NONE);
button(&mut h, on_a_cell, false, PointerButton::Secondary, Modifiers::NONE);
h.run_steps(2);
assert_eq!(h.state().selected_object, None, "a cell, not an object");
assert_eq!(h.state().grid.focused_control, None, "the right-click took the keyboard");
key(&mut h, Key::Enter);
assert_eq!(undo_len(&h), runs, "Enter did not press the button again");
assert_eq!(cell(&h, "H1"), Value::text("pressed"));
}
#[test]
fn a_button_from_a_file_asks_before_it_runs() {
use egui_kittest::kittest::Queryable as _;
let (mut h, ids) = fixture();
h.state_mut().session.doc_mut().unwrap().trust = gridcraft_engine::Trust::FromFile;
let r = rect(&h, ids.button);
click(&mut h, r.center());
assert_eq!(cell(&h, "H1"), Value::Empty, "nothing runs before the person says so");
let d = h.state().dialog.clone().expect("a prompt");
assert_eq!(d.name(), "controlQuestion");
let text = d.values["text"].as_str().unwrap_or("").to_string();
assert!(text.contains("came with a workbook you opened"), "{text}");
h.get_by_label(" Run ").click();
h.run_steps(2);
assert_eq!(cell(&h, "H1"), Value::text("pressed"), "Run presses it again with the approval");
assert!(h.state().dialog.is_none());
}
/// A file this test may write, removed first: `<temp>/gridcraft-press-<pid>-<name>`.
fn scratch(name: &str) -> std::path::PathBuf {
let p = std::env::temp_dir().join(format!("gridcraft-press-{}-{name}", std::process::id()));
let _ = std::fs::remove_file(&p);
p
}
/// Points the fixture's button at `file.exportCsv` to `path` and presses it: the question about
/// the file opens.
fn ask_export(h: &mut Harness, button: u32, path: &std::path::Path) {
h.state_mut().run("control.assign", json!({"id": button, "command": "file.exportCsv", "params": {"path": path}})).unwrap();
h.state_mut().press_button(button);
h.run_steps(4);
let d = h.state().dialog.clone().expect("a prompt");
assert_eq!(d.name(), "controlQuestion");
let text = d.values["text"].as_str().unwrap_or("").to_string();
assert!(text.contains(&path.display().to_string()), "{text}");
}
/// Clicks `label` in the open prompt.
fn answer(h: &mut Harness, label: &str) {
use egui_kittest::kittest::Queryable as _;
h.get_by_label(label).click();
h.run_steps(2);
}
/// An agent reassigns the button while its question is open: the answer was about the old file,
/// so neither file is written, and the next press asks about the new one.
#[test]
fn an_answer_does_not_run_an_action_reassigned_while_the_question_was_open() {
let (mut h, ids) = fixture();
let (a, b) = (scratch("a.csv"), scratch("b.csv"));
ask_export(&mut h, ids.button, &a);
h.state_mut().session.execute("control.assign", json!({"id": ids.button, "command": "file.exportCsv", "params": {"path": b}})).unwrap();
answer(&mut h, " Run ");
assert!(!a.exists() && !b.exists(), "the stale answer ran nothing");
assert!(h.state().dialog.as_ref().is_none_or(|d| d.name() != "controlQuestion"));
let msg = h.state().message.clone().map(|m| m.1).unwrap_or_default();
assert!(msg.contains("changed while the question was open"), "{msg}");
// The approval was not cached for the new file either: pressing again asks about it.
h.state_mut().message = None;
h.state_mut().press_button(ids.button);
h.run_steps(4);
let text = h.state().dialog.clone().map(|d| d.values["text"].as_str().unwrap_or("").to_string()).unwrap_or_default();
assert!(text.contains(&b.display().to_string()), "{text}");
answer(&mut h, " Run ");
assert!(b.exists() && !a.exists(), "the new question's answer runs the new action");
let _ = std::fs::remove_file(&b);
// A change to a different command, and to a macro, is refused the same way.
let c = scratch("c.csv");
ask_export(&mut h, ids.button, &c);
h.state_mut().session.execute("control.assign", json!({"id": ids.button, "macro": "Mark"})).unwrap();
answer(&mut h, " Run ");
assert!(!c.exists());
assert_eq!(cell(&h, "H1"), Value::Empty, "the macro put in its place did not run");
}
/// Reassigning the same action (identical parameters) changes nothing the question asked about,
/// so the answer still runs it.
#[test]
fn an_answer_runs_when_the_action_is_reassigned_unchanged() {
let (mut h, ids) = fixture();
let a = scratch("same.csv");
ask_export(&mut h, ids.button, &a);
h.state_mut().session.execute("control.assign", json!({"id": ids.button, "command": "file.exportCsv", "params": {"path": a}})).unwrap();
answer(&mut h, " Run ");
assert!(a.exists(), "{:?}", h.state().message);
assert!(h.state().message.is_none(), "{:?}", h.state().message);
let _ = std::fs::remove_file(&a);
}
/// The button deleted, its workbook closed, or another workbook (with an identical button)
/// active while the question is open: nothing runs.
#[test]
fn an_answer_runs_nothing_when_the_button_or_its_workbook_is_gone() {
let (mut h, ids) = fixture();
let a = scratch("gone.csv");
ask_export(&mut h, ids.button, &a);
h.state_mut().session.execute("object.delete", json!({"kind": "control", "id": ids.button})).unwrap();
answer(&mut h, " Run ");
assert!(!a.exists());
let msg = h.state().message.clone().map(|m| m.1).unwrap_or_default();
assert!(msg.contains("gone"), "{msg}");
// Another workbook with the same button (same id, same action) becomes active.
let (mut h, ids) = fixture();
ask_export(&mut h, ids.button, &a);
let s = &mut h.state_mut().session;
s.new_workbook();
for at in ["A1", "A5"] {
s.execute("insert.button", json!({"at": at})).unwrap();
}
let id = s.execute("insert.button", json!({"at": "E12", "command": "file.exportCsv", "params": {"path": a}})).unwrap()["control"].as_u64();
assert_eq!(id, Some(u64::from(ids.button)), "the other workbook's button has the same id");
answer(&mut h, " Run ");
assert!(!a.exists(), "the answer belongs to the first workbook's button");
// The workbook closed.
let (mut h, ids) = fixture();
ask_export(&mut h, ids.button, &a);
h.state_mut().session.execute("file.close", json!({"force": true, "document": true})).unwrap();
assert!(h.state().session.doc().is_err(), "no workbook is open");
answer(&mut h, " Run ");
assert!(!a.exists());
}
/// The overwrite question is about the cells the macro would write: when the macro (or the
/// selection it runs on) changes while it is open, Overwrite writes over nothing.
#[test]
fn an_overwrite_answer_applies_only_to_the_output_it_asked_about() {
let (mut h, ids) = fixture();
let s = &mut h.state_mut().session;
s.execute("macro.save", json!({"name": "Note", "steps": [{"command": "cell.set", "params": {"cell": "H2", "input": "noted"}}]})).unwrap();
s.execute("cell.set", json!({"cell": "H2", "input": "keep"})).unwrap();
s.execute("cell.set", json!({"cell": "H3", "input": "keep too"})).unwrap();
s.execute("control.assign", json!({"id": ids.button, "macro": "Note"})).unwrap();
let ask = |h: &mut Harness| {
h.state_mut().press_button(ids.button);
h.run_steps(4);
let text = h.state().dialog.clone().map(|d| d.values["text"].as_str().unwrap_or("").to_string()).unwrap_or_default();
assert!(text.contains("Overwrite them"), "{text}");
};
ask(&mut h);
// The macro now writes H3 instead.
h.state_mut()
.session
.execute(
"macro.save",
json!({"name": "Note", "replace": true, "steps": [{"command": "cell.set", "params": {"cell": "H3", "input": "noted"}}]}),
)
.unwrap();
answer(&mut h, " Overwrite ");
assert_eq!((cell(&h, "H2"), cell(&h, "H3")), (Value::text("keep"), Value::text("keep too")));
// The output cell given to the button changes.
h.state_mut().message = None;
ask(&mut h);
h.state_mut().session.execute("control.assign", json!({"id": ids.button, "macro": "Note", "output": "H2"})).unwrap();
answer(&mut h, " Overwrite ");
assert_eq!((cell(&h, "H2"), cell(&h, "H3")), (Value::text("keep"), Value::text("keep too")));
// The selection the macro runs on changes.
h.state_mut().message = None;
h.state_mut().session.execute("control.assign", json!({"id": ids.button, "macro": "Note"})).unwrap();
ask(&mut h);
h.state_mut().session.execute("selection.set", json!({"cell": "A21"})).unwrap();
answer(&mut h, " Overwrite ");
assert_eq!(cell(&h, "H3"), Value::text("keep too"));
// Nothing changed: Overwrite overwrites.
h.state_mut().message = None;
ask(&mut h);
answer(&mut h, " Overwrite ");
assert_eq!(cell(&h, "H3"), Value::text("noted"));
}
#[test]
fn on_a_right_to_left_sheet_a_control_works_where_it_is_drawn() {
let (mut h, ids) = fixture();
h.state_mut().run("view.rightToLeft", json!({"on": true})).unwrap();
h.run_steps(3);
let screen = |h: &Harness, id: u32| {
let app = h.state();
let sh = app.session.doc().unwrap().wb.active().unwrap();
let geo = Geo::new(sh, app.grid.rect.unwrap(), app.view().scroll);
geo.mirror_rect(chartview::object_rect(&geo, sh, ObjRef::new(ObjKind::Control, id)).unwrap())
};
// The slider moves as a whole and keeps its minimum on the left.
let r = screen(&h, ids.slider);
click(&mut h, Pos2::new(r.right() - 4.0, r.center().y));
assert_eq!(cell(&h, "B1"), Value::Number(30.0), "the right end of the track pages up");
let r = screen(&h, ids.spin);
click(&mut h, Pos2::new(r.center().x, r.top() + 5.0));
assert_eq!(cell(&h, "B2"), Value::Number(12.0), "the upper arrow adds a step");
}
// ---------------------------------------------------------------- ribbon, menus and dialogs
fn insert_tab() -> Harness {
let mut s = Session::new();
s.new_workbook();
let mut app = SheetApp::new(s, Default::default());
app.ui.ribbon_tab = "Insert".into();
let mut h = egui_kittest::Harness::builder()
.with_size(vec2(1400.0, 900.0))
.with_step_dt(1.0 / 60.0)
.with_os(egui::os::OperatingSystem::Mac)
.build_ui_state(
|ui, app: &mut SheetApp| {
let ctx = ui.ctx().clone();
app.logic(&ctx);
app.ui(ui);
},
app,
);
h.run_steps(4);
h
}
fn controls(h: &Harness) -> Vec<FormControl> {
h.state().session.doc().unwrap().wb.active().unwrap().controls.clone()
}
fn cells_rect(h: &Harness) -> Rect {
h.state().grid.cells_rect.unwrap()
}
#[test]
fn the_controls_group_places_by_click_or_drag() {
use egui_kittest::kittest::Queryable as _;
let mut h = insert_tab();
// A click places a slider at its default size.
h.get_by_label("Slider").click();
h.run_steps(2);
assert_eq!(h.state().grid.placing, Some(gridcraft_engine::model::ControlKind::Slider));
let at = cells_rect(&h).min + vec2(300.0, 120.0);
click(&mut h, at);
let list = controls(&h);
assert_eq!(list.len(), 1);
assert_eq!((list[0].anchor.width, list[0].anchor.height, list[0].horizontal), (144.0, 18.0, true));
assert_eq!(h.state().grid.placing, None, "placing is one-shot");
assert_eq!(h.state().selected_object, Some(ObjRef::new(ObjKind::Control, list[0].id)), "the new control is selected");
// A tall drag draws a vertical slider of that size.
h.get_by_label("Slider").click();
h.run_steps(2);
let from = cells_rect(&h).min + vec2(600.0, 100.0);
h.run_steps(30);
button(&mut h, from, true, PointerButton::Primary, Modifiers::NONE);
h.input_mut().events.push(Event::PointerMoved(from + vec2(10.0, 60.0)));
h.step();
button(&mut h, from + vec2(20.0, 150.0), false, PointerButton::Primary, Modifiers::NONE);
h.run_steps(2);
let s = controls(&h).pop().unwrap();
// The drawn size is in sheet points (screen pixels over the zoom).
let drawn = rect(&h, s.id);
assert!(!s.horizontal && (drawn.width() - 20.0).abs() < 1.5 && (drawn.height() - 150.0).abs() < 1.5, "{drawn:?}");
// Escape disarms.
h.get_by_label("Spin Button").click();
h.run_steps(2);
key(&mut h, Key::Escape);
assert_eq!(h.state().grid.placing, None);
// A button asks what to run.
h.get_by_label("Button").click();
h.run_steps(2);
let spot = cells_rect(&h).min + vec2(100.0, 300.0);
click(&mut h, spot);
assert_eq!(h.state().dialog.as_ref().map(|d| d.name.as_str()), Some("assignAction"));
}
#[test]
fn assign_action_and_format_control_dialogs() {
use egui_kittest::kittest::Queryable as _;
let (mut h, ids) = fixture();
// Right-click opens the control's menu; Assign Action picks a command.
let r = rect(&h, ids.button);
h.run_steps(30);
button(&mut h, r.center(), true, PointerButton::Secondary, Modifiers::NONE);
button(&mut h, r.center(), false, PointerButton::Secondary, Modifiers::NONE);
h.run_steps(2);
assert_eq!(h.state().grid.object_menu.map(|(_, kind, id)| (kind, id)), Some(("control", ids.button)));
// The ribbon has a Delete button of its own.
for item in ["Edit Text", "Assign Action…", "Format Control…", "Delete"] {
assert!(h.query_all_by_label(item).next().is_some(), "{item}");
}
h.get_by_label("Assign Action…").click();
h.run_steps(2);
assert_eq!(h.state().dialog.as_ref().map(|d| d.name.as_str()), Some("assignAction"));
assert!(h.state().grid.object_menu.is_none(), "choosing an item closes the menu");
// The dialog settles its size (and so its place) over the first frames.
h.run_steps(30);
let radio = h.get_by_label("Run a command").rect();
click(&mut h, radio.center());
assert_eq!(h.state().dialog.as_ref().map(|d| d.values["mode"].clone()), Some(json!("command")));
{
let d = h.state_mut().dialog.as_mut().unwrap();
d.values.insert("command".into(), json!("cell.set"));
d.values.insert("params".into(), json!("{\"cell\": \"H2\", \"input\": \"cmd\"}"));
}
h.run_steps(2);
h.get_by_label(" OK ").click();
h.run_steps(2);
assert!(h.state().dialog.is_none(), "{:?}", h.state().message);
assert!(matches!(control(&h, ids.button).action, Some(gridcraft_engine::model::ControlAction::Command { ref id, .. }) if id == "cell.set"));
h.state_mut().selected_object = None;
click(&mut h, r.center());
assert_eq!(cell(&h, "H2"), Value::text("cmd"), "{:?}", h.state().message);
// Format Control: refuses broken limits, then applies good ones and writes the link.
h.state_mut().open_dialog("formatControl", json!({"id": ids.spin}));
h.run_steps(2);
assert!(h.query_by_label("Incremental change:").is_some());
assert!(h.query_by_label("Page change:").is_none(), "a spin button has no page change");
{
let d = h.state_mut().dialog.as_mut().unwrap();
d.values.insert("max".into(), json!("0"));
}
h.run_steps(2);
assert!(h.query_by_label_contains("must be greater than Minimum").is_some());
{
let d = h.state_mut().dialog.as_mut().unwrap();
d.values.insert("max".into(), json!("40"));
d.values.insert("value".into(), json!("30"));
d.values.insert("link".into(), json!("$D$1"));
}
h.run_steps(2);
h.get_by_label(" OK ").click();
h.run_steps(2);
let c = control(&h, ids.spin);
assert_eq!((c.max, c.value, c.link.as_deref()), (40, 30, Some("$D$1")));
assert_eq!(cell(&h, "D1"), Value::Number(30.0));
// Double-clicking a selected button edits its text.
h.state_mut().selected_object = Some(ObjRef::new(ObjKind::Control, ids.button));
h.state_mut().open_object(ObjRef::new(ObjKind::Control, ids.button));
h.run_steps(2);
assert_eq!(h.state().dialog.as_ref().map(|d| d.title.as_str()), Some("Edit Text"));
}
+22 -1
View File
@@ -1102,6 +1102,13 @@ fn keytip_hx_() {
assert!(h.state().session.journal.iter().any(|(id, _)| id == "edit.cut"), "HX must journal edit.cut");
}
#[test]
fn keytip_nbu_() {
let mut h = harness(blank());
sequence(&mut h, "NBU", false);
assert!(h.state().session.journal.iter().any(|(id, _)| id == "insert.button"), "NBU must journal insert.button");
}
#[test]
fn keytip_nc1_() {
let mut h = harness(blank());
@@ -1306,6 +1313,13 @@ fn keytip_nsa_() {
assert_eq!(h.state().keytips.prefix(), None);
}
#[test]
fn keytip_nsb_() {
let mut h = harness(blank());
sequence(&mut h, "NSB", false);
assert!(h.state().session.journal.iter().any(|(id, _)| id == "insert.spinButton"), "NSB must journal insert.spinButton");
}
#[test]
fn keytip_nsc_() {
let mut h = harness(blank());
@@ -1337,6 +1351,13 @@ fn keytip_nsh_() {
assert!(h.state().session.journal.iter().any(|(id, _)| id == "insert.shape"), "NSH must journal insert.shape");
}
#[test]
fn keytip_nsl_() {
let mut h = harness(blank());
sequence(&mut h, "NSL", false);
assert!(h.state().session.journal.iter().any(|(id, _)| id == "insert.slider"), "NSL must journal insert.slider");
}
#[test]
fn keytip_nsp_() {
let mut h = harness(blank());
@@ -3068,7 +3089,7 @@ fn keytip_q_() {
#[test]
fn generated_test_count_matches_the_table() {
const EXPECTED: usize = 410;
const EXPECTED: usize = 413;
assert_eq!(gridcraft_ui_egui::keytips::keytip_table::SEQUENCES.len(), EXPECTED);
}
+1
View File
@@ -42,6 +42,7 @@ mod find_dialog_focus;
mod floating_pictures;
mod font_box_filter;
mod font_size_box;
mod form_controls;
mod format_chart_type;
mod format_painter;
mod formula_bar;
+60
View File
@@ -111,6 +111,18 @@ fn click(h: &mut Harness, p: Pos2) {
h.run_steps(2);
}
fn click_with(h: &mut Harness, p: Pos2, modifiers: Modifiers) {
h.run_steps(30);
for pressed in [true, false] {
h.input_mut().events.push(Event::ModifiersChanged(modifiers));
h.input_mut().events.push(Event::PointerMoved(p));
h.input_mut().events.push(Event::PointerButton { pos: p, button: PointerButton::Primary, pressed, modifiers });
h.step();
}
h.input_mut().events.push(Event::ModifiersChanged(Modifiers::NONE));
h.run_steps(2);
}
fn double_click(h: &mut Harness, p: Pos2) {
// Separate the gesture from earlier clicks (egui's triple-click window).
h.run_steps(45);
@@ -139,6 +151,54 @@ fn kept(h: &Harness) -> Value {
h.state().session.doc().unwrap().wb.active().unwrap().value(at("R30"))
}
/// A form control over a slicer's value tile is the topmost object there, so a click that
/// `form_controls::interact` leaves to the grid (Cmd+click selects the control; a click on a
/// selected control moves it) takes the generic object path and never toggles the tile beneath.
#[test]
fn a_control_over_a_slicer_tile_keeps_the_generic_path_on_a_selecting_click() {
let (mut h, o) = fixture();
let slicer = rect(&h, o.slicer);
// A button exactly over the slicer's first value tile: the tile in screen units at the
// grid's scale, then the button's anchor in sheet units from the slicer's own.
let (z, anchor, n, columns) = {
let app = h.state();
let d = app.session.doc().unwrap();
let sh = d.wb.active().unwrap();
let sl = sh.slicers.iter().find(|s| s.id == o.slicer.id).unwrap();
let geo = Geo::new(sh, app.grid.rect.unwrap(), app.view().scroll);
(geo.z, sl.anchor, gridcraft_engine::cmd::slicer::slicer_items(&d.wb, d.wb.active_sheet(), sl).len(), sl.columns)
};
let layout = chartview::slicer_layout(slicer, z, n, columns);
let tile = layout.tiles[0];
let params = json!({
"at": anchor.cell.a1(),
"dx": anchor.dx + (tile.left() - slicer.left()) / z,
"dy": anchor.dy + (tile.top() - slicer.top()) / z,
"width": tile.width() / z,
"height": tile.height() / z,
"text": "Over",
});
let button = ObjRef::new(ObjKind::Control, id(&h.state_mut().run("insert.button", params).unwrap(), "control"));
h.run_steps(2);
assert!(rect(&h, button).contains(tile.center()), "the button covers the tile: {:?} vs {tile:?}", rect(&h, button));
let filter =
|h: &Harness| h.state().session.doc().unwrap().wb.active().unwrap().slicers.iter().find(|s| s.id == o.slicer.id).unwrap().selected.clone();
assert_eq!(filter(&h), None);
// Cmd+click selects the control without working it, and the tile beneath stays as it was.
click_with(&mut h, tile.center(), Modifiers::COMMAND);
assert_eq!(h.state().selected_object, Some(button), "the control is selected");
assert_eq!(filter(&h), None, "the tile under the control was not toggled");
// A click on the selected control is for moving it, not for the tile beneath either.
click(&mut h, tile.center());
assert_eq!(h.state().selected_object, Some(button));
assert_eq!(filter(&h), None, "still no toggle");
// The tile itself, clear of the control, still toggles.
let free = layout.tiles[1].center();
h.state_mut().selected_object = None;
click(&mut h, free);
assert!(filter(&h).is_some(), "a click on an uncovered tile toggles it");
}
#[test]
fn a_click_selects_every_kind_and_slicers_win_over_charts() {
let (mut h, o) = fixture();
+576
View File
@@ -0,0 +1,576 @@
//! Form controls (buttons, scroll bars, spin buttons) in xlsx, from the published ECMA-376 and
//! MS-XLSX formats. A control is written three ways, as spreadsheet applications expect:
//!
//! - a control properties part (`xl/ctrlProps/ctrlPropN.xml`, `formControlPr`) with its type,
//! linked cell, limits and value;
//! - a `<control>` in the sheet's `<controls>`, inside `mc:AlternateContent` (it needs the 2010
//! extensions), naming the properties part, the VML shape and the anchor;
//! - a legacy VML shape with `x:ClientData`, in the sheet's VML drawing (shared with notes).
//!
//! Reading prefers the properties part and falls back to the VML. Values are clamped to the
//! 0..30000 whole numbers sliders and spin buttons take. Controls of other kinds (check boxes,
//! list boxes, drop-downs, ActiveX controls) are left to pass-through (`passthrough.rs`), which
//! keeps them unchanged; a sheet can hold both, in one `<controls>` element and one VML drawing.
use std::collections::{HashMap, HashSet};
use std::fmt::Write as _;
use gridcraft_core::CellRef;
use gridcraft_model::{Anchor, AnchorMode, CONTROL_MAX, ControlAction, ControlKind, FormControl, MacroInput, Sheet};
use crate::package::Rel;
use crate::read::Ctx;
use crate::tables::EMU_PER_PX;
use crate::write::{Out, Rels};
use crate::xml::{El, esc, esc_attr};
pub const CT_CTRL_PROP: &str = "application/vnd.ms-excel.controlproperties+xml";
/// The worksheet relationship to a control properties part (MS-XLSX), as Excel writes it.
pub const REL_CTRL_PROP: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/ctrlProp";
const NS_X14: &str = "http://schemas.microsoft.com/office/spreadsheetml/2009/9/main";
const NS_MC: &str = "http://schemas.openxmlformats.org/markup-compatibility/2006";
const NS_XDR: &str = "http://schemas.openxmlformats.org/drawingml/2006/spreadsheetDrawing";
/// The most controls read from one sheet.
const MAX_CONTROLS: usize = 10_000;
fn object_type(k: ControlKind) -> &'static str {
match k {
ControlKind::Button => "Button",
ControlKind::Slider => "Scroll",
ControlKind::Spin => "Spin",
}
}
fn kind_of(t: &str) -> Option<ControlKind> {
match t.trim().to_ascii_lowercase().as_str() {
"button" => Some(ControlKind::Button),
"scroll" => Some(ControlKind::Slider),
"spin" => Some(ControlKind::Spin),
_ => None,
}
}
/// The macro a button runs, as Excel writes it: `[0]!Name` (this workbook).
fn macro_ref(c: &FormControl) -> Option<String> {
match &c.action {
Some(ControlAction::Macro { name, .. }) if !name.trim().is_empty() => Some(format!("[0]!{}", name.trim())),
_ => None,
}
}
/// The macro name in `[0]!Name`, `Book1.xlsm!Name` or `Name`.
fn macro_name(text: &str) -> Option<String> {
let name = text.rsplit_once('!').map_or(text, |(_, n)| n).trim().trim_matches('\'');
(!name.is_empty() && name.chars().count() <= gridcraft_model::MAX_MACRO_NAME).then(|| name.to_string())
}
/// The cell under a sheet position and the offset into it: (cell, dx, dy).
fn cell_at(sheet: &Sheet, x: f64, y: f64) -> (CellRef, f64, f64) {
let (col, row) = (sheet.col_at(x), sheet.row_at(y));
(CellRef::new(row, col), x - sheet.col_left(col), y - sheet.row_top(row))
}
/// The two corners of a control: ((from, dx, dy), (to, dx, dy)), offsets in sheet pixels.
fn corners(sheet: &Sheet, a: &Anchor) -> ((CellRef, f64, f64), (CellRef, f64, f64)) {
let x1 = sheet.col_left(a.cell.col) + a.dx.max(0.0) as f64;
let y1 = sheet.row_top(a.cell.row) + a.dy.max(0.0) as f64;
let from = (a.cell, a.dx.max(0.0) as f64, a.dy.max(0.0) as f64);
(from, cell_at(sheet, x1 + a.width.max(0.0) as f64, y1 + a.height.max(0.0) as f64))
}
/// The sheet's controls written out: the `<control>` entries of the worksheet's `<controls>`
/// element (each in its own `mc:AlternateContent`, with the namespaces it needs, so it can also
/// join a kept `<controls>` element) and the VML shapes for its drawing.
pub struct Written {
pub entries: String,
pub vml: String,
}
/// The `<controls>` element for `entries` written by [`write`].
pub fn controls_element(entries: &str) -> String {
format!(
"<mc:AlternateContent xmlns:mc=\"{NS_MC}\"><mc:Choice xmlns:x14=\"{NS_X14}\" Requires=\"x14\"><controls>{entries}</controls></mc:Choice></mc:AlternateContent>"
)
}
/// Writes the properties parts and relationships of the sheet's controls. `shape_id(i)` is
/// control `i`'s VML shape id.
pub fn write(out: &mut Out, rels: &mut Rels, sheet: &Sheet, shape_id: impl Fn(usize) -> u64) -> Written {
let mut xml = String::new();
let mut vml = String::new();
for (i, c) in sheet.controls.iter().enumerate() {
// Kept properties parts of another sheet may hold the next name.
let n = loop {
out.ctrl_props = out.ctrl_props.saturating_add(1);
if out.kept.reserve(&format!("xl/ctrlProps/ctrlProp{}.xml", out.ctrl_props)) || out.ctrl_props == u32::MAX {
break out.ctrl_props;
}
};
let part = format!("xl/ctrlProps/ctrlProp{n}.xml");
out.part(&part, Some(CT_CTRL_PROP), ctrl_prop(c).into_bytes());
let rid = rels.add_uri(REL_CTRL_PROP, &format!("../ctrlProps/ctrlProp{n}.xml"));
let id = shape_id(i);
let ((from, fdx, fdy), (to, tdx, tdy)) = corners(sheet, &c.anchor);
let emu = |v: f64| (v.max(0.0) * EMU_PER_PX).round() as i64;
let marker = |tag: &str, cell: CellRef, dx: f64, dy: f64| {
format!(
"<{tag}><xdr:col>{}</xdr:col><xdr:colOff>{}</xdr:colOff><xdr:row>{}</xdr:row><xdr:rowOff>{}</xdr:rowOff></{tag}>",
cell.col,
emu(dx),
cell.row,
emu(dy)
)
};
let mut pr = String::from(" defaultSize=\"0\" autoPict=\"0\"");
if !c.print {
pr.push_str(" print=\"0\"");
}
if !c.locked {
pr.push_str(" locked=\"0\"");
}
if let Some(m) = macro_ref(c) {
let _ = write!(pr, " macro=\"{}\"", esc_attr(&m));
}
let (move_cells, size_cells) = match c.anchor.mode {
AnchorMode::MoveAndSize => (true, true),
AnchorMode::MoveOnly => (true, false),
AnchorMode::Absolute => (false, false),
};
let name = esc_attr(&c.display_name());
let _ = write!(
xml,
"<mc:AlternateContent xmlns:mc=\"{NS_MC}\"><mc:Choice xmlns:x14=\"{NS_X14}\" Requires=\"x14\"><control shapeId=\"{id}\" r:id=\"{rid}\" name=\"{name}\"><controlPr{pr}><anchor{}{} xmlns:xdr=\"{NS_XDR}\">{}{}</anchor></controlPr></control></mc:Choice><mc:Fallback><control shapeId=\"{id}\" r:id=\"{rid}\" name=\"{name}\"/></mc:Fallback></mc:AlternateContent>",
if move_cells { " moveWithCells=\"1\"" } else { "" },
if size_cells { " sizeWithCells=\"1\"" } else { "" },
marker("from", from, fdx, fdy),
marker("to", to, tdx, tdy),
);
vml.push_str(&vml_shape(sheet, c, id, i));
}
Written { entries: xml, vml }
}
/// The control properties part.
fn ctrl_prop(c: &FormControl) -> String {
let mut s = format!("{}<formControlPr xmlns=\"{NS_X14}\" objectType=\"{}\"", crate::write::XML_DECL, object_type(c.kind));
match c.kind {
ControlKind::Button => {
s.push_str(" lockText=\"1\"");
}
ControlKind::Slider | ControlKind::Spin => {
s.push_str(" dx=\"22\"");
if let Some(l) = &c.link {
let _ = write!(s, " fmlaLink=\"{}\"", esc_attr(l));
}
if c.kind == ControlKind::Slider && c.horizontal {
s.push_str(" horiz=\"1\"");
}
let _ = write!(s, " inc=\"{}\" max=\"{}\" min=\"{}\"", c.step, c.max, c.min);
if c.kind == ControlKind::Slider {
let _ = write!(s, " page=\"{}\"", c.page);
}
let _ = write!(s, " val=\"{}\"", c.value);
}
}
s.push_str("/>");
s
}
/// A control's legacy VML shape.
fn vml_shape(sheet: &Sheet, c: &FormControl, id: u64, z: usize) -> String {
let ((from, fdx, fdy), (to, tdx, tdy)) = corners(sheet, &c.anchor);
let pt = |px: f64| px * 0.75;
let x = sheet.col_left(from.col) + fdx;
let y = sheet.row_top(from.row) + fdy;
let mut data = format!(
"<x:ClientData ObjectType=\"{}\">{}{}<x:Anchor>{}, {}, {}, {}, {}, {}, {}, {}</x:Anchor>",
object_type(c.kind),
// These two elements mean the object does *not* move (size) with its cells.
if c.anchor.mode == AnchorMode::Absolute { "<x:MoveWithCells/>" } else { "" },
if c.anchor.mode == AnchorMode::MoveAndSize { "" } else { "<x:SizeWithCells/>" },
from.col,
fdx.round() as i64,
from.row,
fdy.round() as i64,
to.col,
tdx.round() as i64,
to.row,
tdy.round() as i64
);
if !c.print {
data.push_str("<x:PrintObject>False</x:PrintObject>");
}
if !c.locked {
data.push_str("<x:Locked>False</x:Locked>");
}
data.push_str("<x:AutoFill>False</x:AutoFill>");
match c.kind {
ControlKind::Button => {
if let Some(m) = macro_ref(c) {
let _ = write!(data, "<x:FmlaMacro>{}</x:FmlaMacro>", esc(&m));
}
data.push_str("<x:TextHAlign>Center</x:TextHAlign><x:TextVAlign>Center</x:TextVAlign>");
}
ControlKind::Slider | ControlKind::Spin => {
if let Some(l) = &c.link {
let _ = write!(data, "<x:FmlaLink>{}</x:FmlaLink>", esc(l));
}
let _ = write!(data, "<x:Val>{}</x:Val><x:Min>{}</x:Min><x:Max>{}</x:Max><x:Inc>{}</x:Inc>", c.value, c.min, c.max, c.step);
if c.kind == ControlKind::Slider {
let _ = write!(data, "<x:Page>{}</x:Page>", c.page);
if c.horizontal {
data.push_str("<x:Horiz/>");
}
}
data.push_str("<x:Dx>22</x:Dx>");
}
}
data.push_str("</x:ClientData>");
let text = if c.kind == ControlKind::Button {
format!("<v:textbox style=\"mso-direction-alt:auto\" o:singleclick=\"f\"><div style=\"text-align:center\">{}</div></v:textbox>", esc(&c.text))
} else {
String::new()
};
format!(
"<v:shape id=\"_x0000_s{id}\" type=\"#_x0000_t201\" style=\"position:absolute;margin-left:{:.2}pt;margin-top:{:.2}pt;width:{:.2}pt;height:{:.2}pt;z-index:{}\"{} o:insetmode=\"auto\"><o:lock v:ext=\"edit\" rotation=\"t\"/>{text}{data}</v:shape>",
pt(x),
pt(y),
pt(c.anchor.width.max(0.0) as f64),
pt(c.anchor.height.max(0.0) as f64),
z + 1,
if c.kind == ControlKind::Button { " o:button=\"t\"" } else { " stroked=\"f\" filled=\"f\"" },
)
}
/// The VML shape type form controls use.
pub const SHAPETYPE: &str = "<v:shapetype id=\"_x0000_t201\" coordsize=\"21600,21600\" o:spt=\"201\" path=\"m,l,21600r21600,l21600,xe\"><v:stroke joinstyle=\"miter\"/><v:path shadowok=\"f\" o:extrusionok=\"f\" strokeok=\"f\" fillok=\"f\" o:connecttype=\"rect\"/><o:lock v:ext=\"edit\" shapetype=\"t\"/></v:shapetype>";
// ---------------------------------------------------------------- reading
/// What a control's properties say, from `formControlPr` or VML `x:ClientData`.
#[derive(Clone, Default)]
struct Props {
kind: Option<ControlKind>,
link: Option<String>,
min: Option<i64>,
max: Option<i64>,
inc: Option<i64>,
page: Option<i64>,
val: Option<i64>,
horiz: Option<bool>,
macro_: Option<String>,
text: String,
print: Option<bool>,
locked: Option<bool>,
}
fn props_from_ctrl_prop(e: &El) -> Props {
let int = |k: &str| e.attr_f64(k).map(|v| v.round() as i64);
Props {
kind: e.attr("objectType").and_then(kind_of),
link: e.attr("fmlaLink").map(str::to_string),
min: int("min"),
max: int("max"),
inc: int("inc"),
page: int("page"),
val: int("val"),
horiz: e.attr_bool("horiz"),
macro_: None,
text: String::new(),
print: None,
locked: None,
}
}
fn props_from_vml(shape: &El) -> Option<Props> {
let data = shape.child("ClientData")?;
let kind = data.attr("ObjectType").and_then(kind_of)?;
let text_of = |n: &str| data.child(n).map(|c| c.text.trim().to_string());
let int = |n: &str| text_of(n).and_then(|t| t.parse::<f64>().ok()).filter(|v| v.is_finite()).map(|v| v.round() as i64);
let truth = |n: &str| text_of(n).map(|t| !t.eq_ignore_ascii_case("false"));
let text = shape.child("textbox").map(|t| all_text(t, 0)).unwrap_or_default().trim().to_string();
Some(Props {
kind: Some(kind),
link: text_of("FmlaLink").filter(|t| !t.is_empty()),
min: int("Min"),
max: int("Max"),
inc: int("Inc"),
page: int("Page"),
val: int("Val"),
horiz: data.child("Horiz").map(|_| true),
macro_: text_of("FmlaMacro"),
text,
print: truth("PrintObject"),
locked: truth("Locked"),
})
}
/// The text inside `e` and its descendants (a VML text box holds `<div>` and `<font>`), up to a
/// caption's length; `<br>` is a line break.
fn all_text(e: &El, depth: usize) -> String {
let mut s = e.text.clone();
if depth < 16 {
for k in &e.children {
if k.name == "br" {
s.push('\n');
}
s.push_str(&all_text(k, depth + 1));
if s.len() > 4_096 {
break;
}
}
}
s
}
/// A control from what the file says, its numbers clamped to whole numbers 0..30000 that pass
/// the model's rules (a hostile or odd file can't give a control it couldn't have).
fn build(p: Props, kind: ControlKind, anchor: Anchor, name: Option<&str>, id: u32) -> FormControl {
let clamp = |v: Option<i64>, d: u32| v.map_or(d, |v| v.clamp(0, CONTROL_MAX as i64) as u32);
let mut c = FormControl { id, kind, anchor, ..FormControl::default() };
c.min = clamp(p.min, 0);
c.max = clamp(p.max, 100);
if c.min >= c.max {
if c.min >= CONTROL_MAX {
c.min = CONTROL_MAX - 1;
}
c.max = c.min + 1;
}
c.step = clamp(p.inc, 1).clamp(1, c.max - c.min);
c.page = clamp(p.page, 10).max(c.step);
c.value = c.snap(clamp(p.val, c.min) as f64);
// Only sliders have an orientation; `horiz` is left out for a vertical one.
c.horizontal = kind != ControlKind::Slider || p.horiz.unwrap_or(false);
c.link = p.link.map(|l| l.trim().trim_start_matches('=').to_string()).filter(|l| !l.is_empty() && l.chars().count() <= 300);
c.text = p.text.chars().take(1_024).collect();
if let Some(n) = name {
c.name = n.chars().take(255).collect();
}
c.print = p.print.unwrap_or(true);
c.locked = p.locked.unwrap_or(true);
if kind == ControlKind::Button
&& let Some(m) = p.macro_.as_deref().and_then(macro_name)
{
c.action = Some(ControlAction::Macro { name: m, input: MacroInput::Selection, output: None });
}
c
}
/// The `<control>` elements one child of a `<controls>` element holds: the child itself, or those
/// inside its `mc:AlternateContent` (the Choice, else the Fallback).
fn controls_of_child(k: &El) -> Vec<&El> {
match k.name.as_str() {
"control" => vec![k],
"AlternateContent" => k
.child("Choice")
.filter(|c| c.child("control").is_some())
.or_else(|| k.child("Fallback"))
.map(|inner| inner.kids("control").collect())
.unwrap_or_default(),
_ => Vec::new(),
}
}
/// The `<control>` elements under a `<controls>` element, looking through `mc:AlternateContent`
/// (the Choice, else the Fallback).
fn controls_in(e: &El) -> Vec<&El> {
e.children.iter().flat_map(controls_of_child).collect()
}
/// The `<controls>` element of a worksheet child (`controls`, or one inside AlternateContent).
pub fn controls_of(e: &El) -> Option<&El> {
match e.name.as_str() {
"controls" => Some(e),
"AlternateContent" => e.child("Choice").and_then(|c| c.child("controls")).or_else(|| e.child("Fallback").and_then(|c| c.child("controls"))),
_ => None,
}
}
/// The shape number of a VML shape id (`_x0000_s1025` → 1025) or a `shapeId` attribute.
pub(crate) fn shape_number(id: &str) -> Option<u64> {
id.trim().trim_start_matches("_x0000_s").parse().ok()
}
/// A VML shape's number: from its `id` (`_x0000_s1025`), else from `o:spid`, where producers that
/// give the shape a named id (`id="Button 1"`) keep it.
pub(crate) fn number_of(shape: &El) -> Option<u64> {
shape.attr("id").and_then(shape_number).or_else(|| shape.attr("spid").and_then(shape_number))
}
/// What [`read`] took from a sheet's form controls, for pass-through.
#[derive(Debug, Default)]
pub struct ReadControls {
/// The VML shape numbers of the controls read into the model: GridCraft writes those, so they
/// are left out of the kept content.
pub modelled: HashSet<u64>,
/// The VML drawing also holds a control that the sheet doesn't list and the model doesn't
/// hold (a check box only the drawing has): the drawing is kept for it even when no
/// `<controls>` element is left to keep.
pub vml_only_unmodelled: bool,
}
/// Whether a VML shape the sheet doesn't list is a control that only pass-through can keep: it
/// has client data that is neither a note (GridCraft writes notes) nor an embedded object's
/// picture (`Pict`, which means nothing without its sheet entry).
fn unlisted_kept(shape: &El) -> bool {
object_type_of(shape).is_some_and(|t| !t.eq_ignore_ascii_case("note") && !t.eq_ignore_ascii_case("pict"))
}
/// A VML shape's client data `ObjectType`, trimmed (`Note`, `Button`, `Checkbox`).
fn object_type_of(shape: &El) -> Option<&str> {
shape.child("ClientData").and_then(|d| d.attr("ObjectType")).map(str::trim)
}
/// Whether a VML shape's client data is a note's: its `ObjectType` is `Note` in any case and with
/// spaces around it. Only that attribute counts, not text elsewhere in the shape (a caption).
pub(crate) fn is_note_data(data: &El) -> bool {
data.attr("ObjectType").is_some_and(|t| t.trim().eq_ignore_ascii_case("note"))
}
/// Reads a sheet's buttons, scroll bars and spin buttons: each `<control>` with its properties
/// part, else its VML shape, and VML shapes the sheet doesn't list (files that only have VML).
/// The shapes come from the VML drawing with relationship id `vml_rid` (`<legacyDrawing r:id>`).
/// Returns the shape numbers of the controls it read, so pass-through keeps only the others.
pub fn read(cx: &mut Ctx<'_>, controls: Option<&El>, vml_rid: Option<&str>, rels: &[Rel], sheet: &mut Sheet) -> ReadControls {
let mut read = HashSet::new();
let vml = match vml_rid.and_then(|id| rels.iter().find(|r| r.id == id && r.kind == "vmlDrawing")) {
Some(r) => cx.pkg.read_xml(&r.target).ok().flatten(),
None => None,
};
let shapes: Vec<&El> = vml.as_ref().map(|v| v.kids("shape").collect()).unwrap_or_default();
// Looked up once per listed control: indexes keep a sheet of many controls linear.
let mut by_number: HashMap<u64, &El> = HashMap::with_capacity(shapes.len());
for s in &shapes {
if let Some(n) = number_of(s) {
by_number.entry(n).or_insert(s);
}
}
let shape_of = |n: u64| by_number.get(&n).copied();
// The relationships likewise: one per control, looked up by id (the first of a repeated id wins,
// as a scan would find it).
let mut rel_by_id: HashMap<&str, &Rel> = HashMap::with_capacity(rels.len());
for r in rels {
rel_by_id.entry(r.id.as_str()).or_insert(r);
}
// Each properties part is read and parsed once, however many controls name it: every read is
// charged to the package's budget, so a file whose controls all point at one part would
// otherwise spend it on that part alone. A part that fails to read, or isn't a
// `formControlPr`, is remembered as nothing and not read again.
let mut props_by_part: HashMap<&str, Option<Props>> = HashMap::new();
// Every shape the sheet lists, read here or not.
let mut listed: HashSet<u64> = HashSet::new();
for ctl in controls.map(controls_in).unwrap_or_default() {
let Some(n) = ctl.attr("shapeId").and_then(shape_number) else { continue };
listed.insert(n);
if sheet.controls.len() >= MAX_CONTROLS {
continue;
}
let shape = shape_of(n);
let vml_props = shape.and_then(props_from_vml);
let prop_part = ctl.attr("id").and_then(|id| rel_by_id.get(id)).map(|r| r.target.as_str());
let file_props = match prop_part {
Some(t) => props_by_part
.entry(t)
.or_insert_with(|| cx.pkg.read_xml(t).ok().flatten().filter(|e| e.name == "formControlPr").map(|e| props_from_ctrl_prop(&e)))
.clone(),
None => None,
};
let pr = ctl.child("controlPr");
let mut p = match (file_props, vml_props) {
(Some(mut f), v) => {
// The properties part has no caption; the VML text box does.
if let Some(v) = v {
f.text = v.text;
f.print = f.print.or(v.print);
f.locked = f.locked.or(v.locked);
f.macro_ = v.macro_;
}
f
}
(None, Some(v)) => v,
(None, None) => continue,
};
if let Some(pr) = pr {
if let Some(m) = pr.attr("macro").filter(|m| !m.trim().is_empty()) {
p.macro_ = Some(m.to_string());
}
p.print = pr.attr_bool("print").or(p.print);
p.locked = pr.attr_bool("locked").or(p.locked);
}
// Other kinds (a check box, a list box) stay with pass-through.
let Some(kind) = p.kind else { continue };
let anchor =
pr.and_then(|pr| pr.child("anchor")).and_then(|a| anchor_from_markers(a, sheet)).or_else(|| shape.and_then(|s| vml_anchor(s, sheet)));
let Some(anchor) = anchor else { continue };
let id = cx.next_object_id();
sheet.controls.push(build(p, kind, anchor, ctl.attr("name"), id));
read.insert(n);
}
// Controls known only from the VML. The ones not read (a check box, or past the limit) stay
// in the kept drawing.
let mut vml_only_unmodelled = false;
for s in &shapes {
let n = number_of(s);
if n.is_some_and(|n| listed.contains(&n)) {
continue;
}
let modelled = n.filter(|_| sheet.controls.len() < MAX_CONTROLS).and_then(|n| {
let p = props_from_vml(s)?;
let (kind, anchor) = (p.kind?, vml_anchor(s, sheet)?);
let id = cx.next_object_id();
sheet.controls.push(build(p, kind, anchor, None, id));
read.insert(n);
Some(())
});
if modelled.is_none() && unlisted_kept(s) {
vml_only_unmodelled = true;
}
}
ReadControls { modelled: read, vml_only_unmodelled }
}
fn anchor_from_markers(a: &El, sheet: &Sheet) -> Option<Anchor> {
let (cell, dx, dy) = crate::drawing::marker(a.child("from")?);
let (to, tdx, tdy) = a.child("to").map(crate::drawing::marker).unwrap_or((cell, dx + 64.0, dy + 20.0));
let x1 = sheet.col_left(cell.col) + dx as f64;
let y1 = sheet.row_top(cell.row) + dy as f64;
let x2 = sheet.col_left(to.col) + tdx as f64;
let y2 = sheet.row_top(to.row) + tdy as f64;
let mode = match (a.flag("moveWithCells", false), a.flag("sizeWithCells", false)) {
(_, true) => AnchorMode::MoveAndSize,
(true, false) => AnchorMode::MoveOnly,
_ => AnchorMode::Absolute,
};
Some(Anchor { cell, dx, dy, width: (x2 - x1).clamp(1.0, 10_000.0) as f32, height: (y2 - y1).clamp(1.0, 10_000.0) as f32, mode })
}
/// A VML `x:Anchor` (`LeftColumn, LeftOffset, TopRow, TopOffset, RightColumn, RightOffset,
/// BottomRow, BottomOffset`, offsets in pixels).
fn vml_anchor(shape: &El, sheet: &Sheet) -> Option<Anchor> {
let data = shape.child("ClientData")?;
let nums: Vec<i64> = data.child("Anchor")?.text.split(',').filter_map(|t| t.trim().parse::<i64>().ok()).collect();
let [c1, dx1, r1, dy1, c2, dx2, r2, dy2] = nums.as_slice() else { return None };
let col = |c: i64| c.clamp(0, gridcraft_core::MAX_COLS as i64 - 1) as u32;
let row = |r: i64| r.clamp(0, gridcraft_core::MAX_ROWS as i64 - 1) as u32;
let off = |v: i64| v.clamp(0, 10_000) as f64;
let cell = CellRef::new(row(*r1), col(*c1));
let x1 = sheet.col_left(cell.col) + off(*dx1);
let y1 = sheet.row_top(cell.row) + off(*dy1);
let x2 = sheet.col_left(col(*c2)) + off(*dx2);
let y2 = sheet.row_top(row(*r2)) + off(*dy2);
// The elements are there when the object does *not* move (size) with its cells.
let mode = match (data.child("MoveWithCells").is_none(), data.child("SizeWithCells").is_none()) {
(_, true) => AnchorMode::MoveAndSize,
(true, false) => AnchorMode::MoveOnly,
_ => AnchorMode::Absolute,
};
Some(Anchor {
cell,
dx: off(*dx1) as f32,
dy: off(*dy1) as f32,
width: (x2 - x1).clamp(1.0, 10_000.0) as f32,
height: (y2 - y1).clamp(1.0, 10_000.0) as f32,
mode,
})
}
+1 -1
View File
@@ -24,7 +24,7 @@ fn emu_px(v: Option<i64>) -> f32 {
v.map(|e| (e as f64 / EMU_PER_PX).clamp(-1e7, 1e7) as f32).unwrap_or(0.0)
}
fn marker(e: &El) -> (CellRef, f32, f32) {
pub(crate) fn marker(e: &El) -> (CellRef, f32, f32) {
let num = |n: &str| e.child(n).and_then(|c| c.text.trim().parse::<i64>().ok());
let col = num("col").unwrap_or(0).clamp(0, MAX_COLS as i64 - 1) as u32;
let row = num("row").unwrap_or(0).clamp(0, MAX_ROWS as i64 - 1) as u32;
+1
View File
@@ -15,6 +15,7 @@ mod array_formula;
mod cfb;
mod chart;
mod chartex;
mod controls;
mod crypto;
mod csv;
mod drawing;
+8
View File
@@ -52,6 +52,14 @@ impl<'a> Package<'a> {
Ok(Package { zip, names, total: 0 })
}
/// Every part's (normalized) name, sorted.
#[cfg(test)]
pub fn names(&self) -> Vec<String> {
let mut v: Vec<String> = self.names.keys().cloned().collect();
v.sort();
v
}
pub fn has(&self, part: &str) -> bool {
self.names.contains_key(&normalize(part))
}
+2 -2
View File
@@ -4,8 +4,8 @@
//! relationship graph, per owner: the package root (custom document properties, ribbon
//! customisation, add-in task panes), the workbook (custom XML, links to external workbooks with
//! their `<externalReferences>`, data connections, XML maps) and each sheet (query tables, cells
//! mapped to XML elements, form controls, ActiveX controls, OLE objects, the sheet background and
//! printer settings, with the sheet's `<picture>`, `<oleObjects>` and `<controls>` XML, the `r:id`
//! mapped to XML elements, form controls other than the buttons, scroll bars and spin buttons
//! `controls.rs` models, ActiveX controls, OLE objects, the sheet background and printer settings, with the sheet's `<picture>`, `<oleObjects>` and `<controls>` XML, the `r:id`
//! of `<pageSetup>`, the legacy VML shapes those controls need and the pictures of the headers and
//! footers, `&G`, with their `<legacyDrawingHF>`). The workbook also keeps its
//! `<fileVersion>` and the calcFeatures extension, which Excel needs to trust the saved values.
+186 -28
View File
@@ -283,8 +283,10 @@ impl KeptReader {
(!p.is_empty()).then(|| Arc::new(p))
}
/// Sheet-level content of the sheet part `part`.
pub fn sheet(&mut self, cx: &mut Ctx<'_>, part: &str, name: &str) -> Option<Arc<Preserved>> {
/// Sheet-level content of the sheet part `part`. `controls` says which form controls the
/// sheet reader modelled (their VML shapes are left out of the kept content) and whether the
/// VML drawing holds others that only it lists.
pub fn sheet(&mut self, cx: &mut Ctx<'_>, part: &str, name: &str, controls: &crate::controls::ReadControls) -> Option<Arc<Preserved>> {
if !self.on {
return None;
}
@@ -305,13 +307,24 @@ impl KeptReader {
// Notes have one VML drawing per sheet; another one holds the header and footer pictures.
let vml_drawings = rels.iter().filter(|r| is_kind(r, &["vmlDrawing"])).count();
let notes = rels.iter().filter(|r| is_kind(r, &["comments"])).count();
if rels.iter().any(|r| is_kind(r, SHEET_ELEMENT_KINDS)) || vml_drawings > notes {
self.sheet_elements(cx, part, name, &rels, &mut p, &mut seen);
if rels.iter().any(|r| is_kind(r, SHEET_ELEMENT_KINDS)) || vml_drawings > notes || controls.vml_only_unmodelled {
self.sheet_elements(cx, part, name, &rels, &mut p, &mut seen, controls);
}
(!p.is_empty()).then(|| Arc::new(p))
}
fn sheet_elements(&mut self, cx: &mut Ctx<'_>, part: &str, name: &str, rels: &[PreservedRel], p: &mut Preserved, seen: &mut HashSet<String>) {
#[allow(clippy::too_many_arguments)]
fn sheet_elements(
&mut self,
cx: &mut Ctx<'_>,
part: &str,
name: &str,
rels: &[PreservedRel],
p: &mut Preserved,
seen: &mut HashSet<String>,
controls: &crate::controls::ReadControls,
) {
let read_controls = &controls.modelled;
let scan = match cx.pkg.read(part) {
Ok(Some(bytes)) => scan_sheet(&bytes),
_ => None,
@@ -344,10 +357,16 @@ impl KeptReader {
Err(why) => cx.warn(format!("sheet \"{name}\": header and footer pictures were dropped: {why}")),
}
}
let mut frags = scan.fragments;
// Controls and OLE objects are drawn from shapes in the legacy VML drawing.
if frags.iter().any(|(slot, _)| slot != "picture") {
match self.vml(cx, rels, scan.legacy_drawing.as_deref(), seen) {
// Form controls the sheet reader modelled are written from the model; the others stay.
let mut frags: Vec<(String, String)> = scan
.fragments
.into_iter()
.filter_map(|(slot, x)| if slot == "controls" { without_controls(&x, read_controls).map(|x| (slot, x)) } else { Some((slot, x)) })
.collect();
// Controls and OLE objects are drawn from shapes in the legacy VML drawing. Controls only
// the drawing lists need it too, whether or not a `<controls>` element is left.
if frags.iter().any(|(slot, _)| slot != "picture") || controls.vml_only_unmodelled {
match self.vml(cx, rels, scan.legacy_drawing.as_deref(), seen, read_controls) {
Ok((vml, parts)) => {
p.parts.extend(parts);
p.vml = Some(vml);
@@ -376,21 +395,22 @@ impl KeptReader {
Err(why) => cx.warn(format!("sheet \"{name}\": {slot} were dropped: {why}")),
}
}
if p.elements.iter().any(|e| e.slot == "oleObjects" || e.slot == "controls") {
if p.elements.iter().any(|e| e.slot == "oleObjects" || e.slot == "controls") || (controls.vml_only_unmodelled && p.vml.is_some()) {
cx.warn(format!("sheet \"{name}\": embedded objects and form controls are not shown; they are kept for saving"));
} else {
p.vml = None;
}
}
/// The sheet's VML drawing without its notes (GridCraft writes those), and the parts its
/// shapes refer to (preview images).
/// The sheet's VML drawing without its notes and the form controls read into the model
/// (GridCraft writes those), and the parts its shapes refer to (preview images).
fn vml(
&mut self,
cx: &mut Ctx<'_>,
rels: &[PreservedRel],
rid: Option<&str>,
seen: &mut HashSet<String>,
read_controls: &HashSet<u64>,
) -> Result<(PreservedPart, Vec<PreservedPart>), String> {
let rel = rid.and_then(|id| rels.iter().find(|r| r.id == id && !r.external)).ok_or("the sheet has no VML drawing")?;
// Charged to the kept total like any other part: many sheets can point at one highly
@@ -407,7 +427,7 @@ impl KeptReader {
};
self.budget = self.budget.saturating_sub(data.len());
let text = String::from_utf8(data).map_err(|_| "the VML drawing is not UTF-8".to_string())?;
let text = strip_notes(&text);
let text = strip_shapes(&text, read_controls);
let vrels = raw_rels(cx, &rel.target)?;
let mut q = Preserved::default();
let roots: Vec<PreservedRel> = vrels.iter().filter(|r| !r.external).cloned().collect();
@@ -557,13 +577,20 @@ fn shape_spans(vml: &str) -> Vec<(usize, usize)> {
spans
}
/// VML without its note shapes (`ObjectType="Note"`).
fn strip_notes(vml: &str) -> String {
/// VML without its note shapes and the shapes numbered in `drop`, each judged as the control
/// reader judges it, from the shape's own tags and never from text elsewhere in it: a note by its
/// client data's `ObjectType` ([`crate::controls::is_note_data`]), its number from its `id`, else
/// its `o:spid` ([`crate::controls::number_of`]). Each tag is read on its own ([`xml::first_start`]),
/// so a caption's unclosed `<br>` doesn't hide the client data after it. A shape with no number
/// that isn't a note is kept.
fn strip_shapes(vml: &str, drop: &HashSet<u64>) -> String {
let mut out = String::with_capacity(vml.len());
let mut last = 0;
for (a, b) in shape_spans(vml) {
let shape = vml.get(a..b).unwrap_or("");
if shape.contains("ObjectType=\"Note\"") {
let shape = vml.get(a..b).unwrap_or("").as_bytes();
let note = xml::first_start(shape, "ClientData").is_some_and(|d| crate::controls::is_note_data(&d));
let numbered = xml::first_start(shape, "shape").and_then(|s| crate::controls::number_of(&s)).is_some_and(|n| drop.contains(&n));
if note || numbered {
out.push_str(vml.get(last..a).unwrap_or(""));
last = b;
}
@@ -595,19 +622,32 @@ pub(super) fn vml_block_max(vml: &str) -> u64 {
used.max(claimed)
}
/// Kept VML (controls, OLE objects) plus GridCraft's note shapes: the notes get ids in a fresh
/// shape block so they can't collide with the kept shapes the sheet XML refers to.
/// The first shape block free in kept VML: GridCraft's own shapes (notes, then form controls) are
/// numbered from `block * 1024 + 1` when they join it ([`merge_vml`]).
pub(super) fn first_free_block(kept: &str) -> u64 {
shape_numbers(kept).into_iter().max().unwrap_or(0) / 1024 + 1
}
/// Kept VML (controls, OLE objects) plus GridCraft's note and form control shapes: ours get ids
/// in fresh shape blocks so they can't collide with the kept shapes the sheet XML refers to.
pub(super) fn merge_vml(kept: &str, ours: &str) -> String {
let Some(close) = kept.rfind("</xml>") else { return ours.to_string() };
let block = shape_numbers(kept).into_iter().max().unwrap_or(0) / 1024 + 1;
let block = first_free_block(kept);
let mut add = String::new();
if !kept.contains("id=\"_x0000_t202\"")
&& let Some(i) = ours.find("<v:shapetype")
&& let Some(j) = ours.get(i..).and_then(|s| s.find("</v:shapetype>"))
{
add.push_str(ours.get(i..i + j + "</v:shapetype>".len()).unwrap_or(""));
// Our shape types (notes, form controls) that the kept drawing doesn't define.
let mut from = 0;
while let Some(i) = ours.get(from..).and_then(|s| s.find("<v:shapetype")).map(|i| i + from) {
let Some(j) = ours.get(i..).and_then(|s| s.find("</v:shapetype>")).map(|j| i + j + "</v:shapetype>".len()) else { break };
let ty = ours.get(i..j).unwrap_or("");
let id = attr_value(ty, "id").unwrap_or("");
if !id.is_empty() && !kept.contains(&format!("id=\"{id}\"")) && !kept.contains(&format!("id='{id}'")) {
add.push_str(ty);
}
from = j;
}
for (k, (a, b)) in shape_spans(ours).into_iter().enumerate() {
let spans = shape_spans(ours);
let blocks = (spans.len() as u64 + 1).div_ceil(1024).max(1);
for (k, (a, b)) in spans.into_iter().enumerate() {
let shape = ours.get(a..b).unwrap_or("");
let id = block.saturating_mul(1024).saturating_add(1 + k as u64);
match (shape.find("id=\"_x0000_s"), shape.find("id=\"_x0000_s").and_then(|i| shape.get(i + 4..)?.find('"').map(|j| i + 4 + j))) {
@@ -618,16 +658,134 @@ pub(super) fn merge_vml(kept: &str, ours: &str) -> String {
}
}
let mut head = kept.get(..close).unwrap_or("").to_string();
// Claim the block in the drawing's id map.
// Claim the blocks in the drawing's id map.
if let Some(i) = head.find("<o:idmap")
&& let Some(d) = head.get(i..).and_then(|s| s.find("data=\"")).map(|d| i + d + "data=\"".len())
&& let Some(e) = head.get(d..).and_then(|s| s.find('"')).map(|e| d + e)
{
head.insert_str(e, &format!(",{block}"));
let claimed: String = (block..block.saturating_add(blocks)).map(|b| format!(",{b}")).collect();
head.insert_str(e, &claimed);
}
format!("{head}{add}{}", kept.get(close..).unwrap_or(""))
}
/// The value of the first attribute `name` in XML text `x`, in either quote style (`name="v"` or
/// `name='v'`), when it has one. The name must start a token (`id` is not the end of `spid`).
fn attr_value<'a>(x: &'a str, name: &str) -> Option<&'a str> {
let mut from = 0;
while let Some(i) = x.get(from..)?.find(name).map(|i| i + from) {
from = i + name.len();
let starts_token = x.get(..i).is_some_and(|before| before.ends_with(|c: char| c.is_whitespace() || c == '<'));
let Some(rest) = x.get(from..).map(str::trim_start).and_then(|r| r.strip_prefix('=')).map(str::trim_start) else { continue };
if !starts_token {
continue;
}
if let Some(q) = rest.chars().next().filter(|q| *q == '"' || *q == '\'') {
let body = rest.get(q.len_utf8()..)?;
return body.find(q).and_then(|e| body.get(..e));
}
}
None
}
/// A kept `<controls>` fragment without the controls whose `shapeId` is in `drop` (they are
/// written from the model): a bare `<control>` child goes when it is one of them, and within an
/// `mc:AlternateContent` child only those controls go, in each of its branches, the wrapper
/// staying while a control remains. `None` when nothing is left. A fragment that doesn't parse
/// is returned unchanged.
fn without_controls(frag: &str, drop: &HashSet<u64>) -> Option<String> {
if drop.is_empty() {
return Some(frag.to_string());
}
let mut reader = Reader::from_str(frag);
let mut out = String::with_capacity(frag.len());
let (mut last, mut left, mut inside) = (0usize, 0usize, false);
loop {
let Ok(start) = usize::try_from(reader.buffer_position()) else { return Some(frag.to_string()) };
let ev = match reader.read_event() {
Ok(ev) => ev,
Err(_) => return Some(frag.to_string()),
};
let child = match ev {
Event::Eof => break,
Event::Start(e) if e.local_name().as_ref() == b"controls" => {
inside = true;
continue;
}
Event::End(e) if e.local_name().as_ref() == b"controls" => {
inside = false;
continue;
}
Event::Start(e) if inside => {
if reader.read_to_end(e.name()).is_err() {
return Some(frag.to_string());
}
true
}
Event::Empty(_) if inside => true,
_ => false,
};
if !child {
continue;
}
let Ok(end) = usize::try_from(reader.buffer_position()) else { return Some(frag.to_string()) };
let text = frag.get(start..end).unwrap_or("");
let Some((kept, controls, dropped)) = controls_kept(text, drop) else { return Some(frag.to_string()) };
if dropped == 0 {
// Nothing of ours in it: the child stays as it is.
left += 1;
} else {
out.push_str(frag.get(last..start).unwrap_or(""));
if controls > 0 {
out.push_str(&kept);
left += 1;
}
last = end;
}
}
out.push_str(frag.get(last..).unwrap_or(""));
(left > 0).then_some(out)
}
/// One child of a `<controls>` element with the `<control>` elements numbered in `drop` taken
/// out, wherever they are in it (a bare control, or the Choice and Fallback branches of an
/// `mc:AlternateContent`), as (the text, the controls that remain, the controls taken out). The
/// `shapeId` is read as the control reader reads it (an attribute, in either quote style). `None`
/// when the child doesn't parse.
fn controls_kept(child: &str, drop: &HashSet<u64>) -> Option<(String, usize, usize)> {
let mut reader = Reader::from_str(child);
let mut out = String::with_capacity(child.len());
let (mut last, mut kept, mut dropped) = (0usize, 0usize, 0usize);
loop {
let start = usize::try_from(reader.buffer_position()).ok()?;
let (e, has_end) = match reader.read_event().ok()? {
Event::Eof => break,
Event::Start(e) => (e, true),
Event::Empty(e) => (e, false),
_ => continue,
};
if e.local_name().as_ref() != b"control" {
// A wrapper or a branch: its controls are the events that follow.
continue;
}
let number =
e.try_get_attribute("shapeId").ok().flatten().and_then(|a| std::str::from_utf8(&a.value).ok().and_then(crate::controls::shape_number));
if has_end {
reader.read_to_end(e.name()).ok()?;
}
let end = usize::try_from(reader.buffer_position()).ok()?;
if number.is_some_and(|n| drop.contains(&n)) {
out.push_str(child.get(last..start)?);
last = end;
dropped += 1;
} else {
kept += 1;
}
}
out.push_str(child.get(last..)?);
Some((out, kept, dropped))
}
/// Rewrites relationship-id attributes (`r:id`, `r:embed`…: any attribute in the relationships
/// namespace) of an XML fragment through `map`. `None` when the fragment is damaged or `map` has
/// no id for one of them.
+67 -9
View File
@@ -1,4 +1,4 @@
use super::read::merge_vml;
use super::read::{first_free_block, merge_vml};
use super::write::{NameMap, attach, part_rels_xml, remap, write_parts};
use super::*;
@@ -28,7 +28,15 @@ impl<'a> SheetKept<'a> {
}
}
/// GridCraft's notes VML for part `name`, merged with the kept control and object shapes.
/// The number GridCraft's own VML shapes start after when they join the kept drawing
/// ([`SheetKept::notes_vml`] numbers them `base + 1`, `base + 2`…); `None` without one.
pub fn shape_base(&self) -> Option<u64> {
let vml = self.vml()?;
Some(first_free_block(&String::from_utf8_lossy(&vml.data)).saturating_mul(1024))
}
/// GridCraft's notes and form controls VML for part `name`, merged with the kept control and
/// object shapes.
pub fn notes_vml(&self, ours: String, name: &str, out: &mut Out) -> String {
let Some(vml) = self.vml() else { return ours };
self.write_vml_rels(vml, name, out);
@@ -50,21 +58,71 @@ impl<'a> SheetKept<'a> {
self.ids.get(old).map(String::as_str)
}
/// The kept sheet elements, in schema order.
pub fn elements(&self) -> String {
let Some(p) = self.p else { return String::new() };
/// The kept sheet elements, in schema order, with GridCraft's own form control entries
/// (`controls`, see `controls::write`) in the sheet's one `<controls>` element.
pub fn elements(&self, controls: &str) -> String {
let mut s = String::new();
for slot in SHEET_SLOTS {
for e in p.elements.iter().filter(|e| e.slot == *slot) {
if let Some(x) = remap(&e.xml, &self.ids) {
s.push_str(&x);
let mut placed = controls.is_empty();
if let Some(p) = self.p {
for slot in SHEET_SLOTS {
for e in p.elements.iter().filter(|e| e.slot == *slot) {
if let Some(mut x) = remap(&e.xml, &self.ids) {
if *slot == "controls"
&& !placed
&& let Some(i) = controls_insert_at(&x)
{
x.insert_str(i, controls);
placed = true;
}
s.push_str(&x);
}
}
}
}
// `<controls>` comes last of the kept elements.
if !placed {
s.push_str(&crate::controls::controls_element(controls));
}
s
}
}
/// Where GridCraft's control entries go in a kept `<controls>` fragment: just before the close
/// tag of the `<controls>` element the reader reads (`controls::controls_of`), whatever its
/// namespace prefix. In an `mc:AlternateContent` with separate `<controls>` elements in its
/// Choice and Fallback branches that is the Choice's, the one an application that knows the
/// x14 extensions reads; the Fallback's is left as the file had it. With no Choice, or a Choice
/// without a `<controls>` element, it is the first `<controls>` element (a plain fragment, or a
/// Fallback alone).
fn controls_insert_at(x: &str) -> Option<usize> {
let from = open_tag(x, "Choice").unwrap_or(0);
close_tag_from(x, from, "controls")
}
/// The local name of the tag whose text starts at `name` (`x:controls` → `controls`).
fn local_name(name: &str) -> &str {
name.rsplit(':').next().unwrap_or(name)
}
/// The byte offset of the first open tag of `local` in `x`, with any namespace prefix.
fn open_tag(x: &str, local: &str) -> Option<usize> {
x.match_indices('<').find_map(|(i, _)| {
let rest = x.get(i + 1..)?;
let end = rest.find(|c: char| c.is_whitespace() || c == '>' || c == '/')?;
(local_name(rest.get(..end)?) == local).then_some(i)
})
}
/// The byte offset of the first close tag of `local` at or after `from` in `x`, with any
/// namespace prefix (`</controls>`, `</x:controls>`).
fn close_tag_from(x: &str, from: usize, local: &str) -> Option<usize> {
x.get(from..)?.match_indices("</").find_map(|(i, _)| {
let rest = x.get(from + i + 2..)?;
let name = rest.get(..rest.find('>')?)?.trim_end();
(local_name(name) == local).then_some(from + i)
})
}
// ---------------------------------------------------------------- linked workbooks
/// `wb` as it is saved into the folder `to` (absolute): the relative paths of its linked
+6
View File
@@ -15,6 +15,12 @@ pub struct KeptOut {
}
impl KeptOut {
/// Reserves a part name GridCraft writes itself, so kept parts don't take it. False when it is
/// taken.
pub fn reserve(&mut self, name: &str) -> bool {
self.used.insert(name.to_ascii_lowercase())
}
/// Reserves the VML shape blocks the kept header and footer drawings of `wb` use, so that the
/// notes drawings GridCraft writes don't reuse their shape ids.
pub fn reserve_vml_blocks(&mut self, wb: &Workbook) {
+3 -3
View File
@@ -327,12 +327,12 @@ pub fn read_xlsx<'a>(bytes: &'a [u8], known_function: Option<&'a dyn Fn(&str) ->
continue;
}
let name = names.unique(&name, i);
let mut sheet = match crate::sheet_read::read_sheet(&mut cx, &rel.target, &name) {
let (mut sheet, read_controls) = match crate::sheet_read::read_sheet(&mut cx, &rel.target, &name) {
Ok(s) => s,
Err(IoError::TooLarge(m)) => return Err(IoError::TooLarge(m)),
Err(e) => {
cx.warn(format!("sheet \"{name}\" could not be read: {e}"));
Sheet::new(name.clone())
(Sheet::new(name.clone()), Default::default())
}
};
match crate::pivot::read_pivots(&mut cx, &mut pivot_caches, &rel.target, &name) {
@@ -340,7 +340,7 @@ pub fn read_xlsx<'a>(bytes: &'a [u8], known_function: Option<&'a dyn Fn(&str) ->
Err(IoError::TooLarge(m)) => return Err(IoError::TooLarge(m)),
Err(e) => cx.warn(format!("PivotTables on sheet \"{name}\" could not be read: {e}")),
}
sheet.preserved = kept.sheet(&mut cx, &rel.target, &name);
sheet.preserved = kept.sheet(&mut cx, &rel.target, &name, &read_controls);
sheet.file_id = se.attr_u32("sheetId").unwrap_or(0);
sheet.visibility = match se.attr("state") {
Some("hidden") => Visibility::Hidden,
+15 -2
View File
@@ -157,7 +157,9 @@ fn read_sheet_format(e: &El, sheet: &mut Sheet) {
}
}
pub fn read_sheet(cx: &mut Ctx<'_>, part: &str, name: &str) -> Result<Sheet, IoError> {
/// Reads the sheet part `part`, with the VML shape numbers of the form controls read into the
/// model (pass-through keeps the other controls).
pub fn read_sheet(cx: &mut Ctx<'_>, part: &str, name: &str) -> Result<(Sheet, crate::controls::ReadControls), IoError> {
let bytes = cx.pkg.read(part)?.ok_or_else(|| IoError::Format(format!("missing part {part}")))?;
let rels = cx.pkg.rels(part)?;
let mut sheet = Sheet::new(name);
@@ -309,6 +311,7 @@ pub fn read_sheet(cx: &mut Ctx<'_>, part: &str, name: &str) -> Result<Sheet, IoE
_ => {}
}
}
let read_controls = read_form_controls(cx, &root, &rels, &mut sheet);
// Comments are related to the sheet without an element in it.
for rel in rels.iter().filter(|r| r.kind == "comments") {
if let Some(x) = cx.optional_xml(&rel.target)? {
@@ -330,7 +333,17 @@ pub fn read_sheet(cx: &mut Ctx<'_>, part: &str, name: &str) -> Result<Sheet, IoE
}
}
}
Ok(sheet)
Ok((sheet, read_controls))
}
/// Buttons, scroll bars and spin buttons: the `<controls>` list (also inside
/// `mc:AlternateContent`) with its properties parts, else the shapes of the VML drawing that
/// `<legacyDrawing r:id>` names (not the header and footer drawing of `<legacyDrawingHF>`).
/// Returns the VML shape numbers read, so pass-through keeps only the others.
fn read_form_controls(cx: &mut Ctx<'_>, root: &El, rels: &[Rel], sheet: &mut Sheet) -> crate::controls::ReadControls {
let controls = root.children.iter().filter_map(crate::controls::controls_of).next_back();
let vml = root.child("legacyDrawing").and_then(|e| e.attr("id"));
if controls.is_some() || vml.is_some() { crate::controls::read(cx, controls, vml, rels, sheet) } else { Default::default() }
}
/// A protection password hash: the iterated attributes (`algorithmName`, `hashValue`,
+38 -16
View File
@@ -281,29 +281,51 @@ fn write_sheet_extensions(s: &mut String, sheet: &Sheet, prio: &[u32]) {
}
fn write_sheet_notes(s: &mut String, sheet: &Sheet, si: usize, kept: &crate::passthrough::SheetKept<'_>, rels: &mut Rels, out: &mut Out) {
// Notes: comments part + VML so Excel shows them. Threaded comments also get a legacy note
// (its text with the replies as lines, for readers without threaded comments) linked by id.
if !sheet.comments.is_empty() {
out.comments += 1;
let n = out.comments;
let ids = thread_ids(sheet, si);
out.part(&format!("xl/comments{n}.xml"), Some(CT_COMMENTS), comments_xml(sheet, &ids).into_bytes());
if !ids.is_empty() {
let x = threaded_xml(sheet, &ids, &mut out.persons);
out.part(&format!("xl/threadedComments/threadedComment{n}.xml"), Some(CT_THREADED_COMMENTS), x.into_bytes());
rels.add_uri(REL_THREADED_COMMENT, &format!("../threadedComments/threadedComment{n}.xml"));
// Notes and form controls share one legacy VML drawing: notes need it for Excel to show them,
// and controls carry their `x:ClientData` there. Shape ids come from 1024-id blocks listed in
// the drawing's idmap (after the blocks kept header and footer drawings use), notes first, so
// a control's `shapeId` names its VML shape. Threaded comments also get a legacy note (its
// text with the replies as lines, for readers without threaded comments) linked by id.
let mut controls = String::new();
if !sheet.comments.is_empty() || !sheet.controls.is_empty() {
let shapes = (sheet.comments.len() as u64).saturating_add(sheet.controls.len() as u64);
let blocks = u32::try_from(shapes.saturating_add(1).div_ceil(1024)).unwrap_or(u32::MAX).max(1);
let first = out.kept.notes_block(out.vml_block.saturating_add(1));
out.vml_block = out.vml_block.saturating_add(blocks);
let idmap = (first..first.saturating_add(blocks)).map(|b| b.to_string()).collect::<Vec<_>>().join(",");
// With a kept drawing, our shapes join it in a block of their own.
let base = kept.shape_base().unwrap_or(u64::from(first) * 1024);
let notes = sheet.comments.len() as u64;
let mut control_shapes = String::new();
if !sheet.controls.is_empty() {
let w = crate::controls::write(out, rels, sheet, |i| base + 1 + notes + i as u64);
controls = w.entries;
control_shapes = format!("{}{}", crate::controls::SHAPETYPE, w.vml);
}
if !sheet.comments.is_empty() {
out.comments += 1;
let n = out.comments;
let ids = thread_ids(sheet, si);
out.part(&format!("xl/comments{n}.xml"), Some(CT_COMMENTS), comments_xml(sheet, &ids).into_bytes());
if !ids.is_empty() {
let x = threaded_xml(sheet, &ids, &mut out.persons);
out.part(&format!("xl/threadedComments/threadedComment{n}.xml"), Some(CT_THREADED_COMMENTS), x.into_bytes());
rels.add_uri(REL_THREADED_COMMENT, &format!("../threadedComments/threadedComment{n}.xml"));
}
rels.add("comments", &format!("../comments{n}.xml"));
}
out.vmls += 1;
let v = out.vmls;
out.default_type("vml", "application/vnd.openxmlformats-officedocument.vmlDrawing");
let vml_name = format!("xl/drawings/vmlDrawing{n}.vml");
let vml = kept.notes_vml(vml_xml(sheet, out.kept.notes_block(n)), &vml_name, out);
let vml_name = format!("xl/drawings/vmlDrawing{v}.vml");
let vml = kept.notes_vml(vml_xml(sheet, &idmap, base, &control_shapes), &vml_name, out);
out.part(&vml_name, None, vml.into_bytes());
rels.add("comments", &format!("../comments{n}.xml"));
let rid = rels.add("vmlDrawing", &format!("../drawings/vmlDrawing{n}.vml"));
let rid = rels.add("vmlDrawing", &format!("../drawings/vmlDrawing{v}.vml"));
let _ = write!(s, "<legacyDrawing r:id=\"{rid}\"/>");
} else if let Some(rid) = kept.lone_vml(rels, out) {
let _ = write!(s, "<legacyDrawing r:id=\"{rid}\"/>");
}
s.push_str(&kept.elements());
s.push_str(&kept.elements(&controls));
}
fn write_sheet_drawing(s: &mut String, wb: &Workbook, si: usize, sheet: &Sheet, rels: &mut Rels, out: &mut Out) {
+6 -4
View File
@@ -358,13 +358,14 @@ fn note_anchor(cell: CellRef, b: Option<&NoteBox>) -> (String, (f64, f64)) {
(format!("{}, {}", corner(&b.from), corner(&b.to)), b.size_pt.unwrap_or(DEFAULT_NOTE_SIZE_PT))
}
/// Legacy VML shapes Excel uses to display notes.
pub(super) fn vml_xml(sheet: &Sheet, n: u32) -> String {
/// Legacy VML shapes Excel uses to display notes (numbered from `base + 1`), followed by the
/// form control shapes `controls`. `idmap` lists the shape blocks the drawing uses.
pub(super) fn vml_xml(sheet: &Sheet, idmap: &str, base: u64, controls: &str) -> String {
let mut s = format!(
"<xml xmlns:v=\"urn:schemas-microsoft-com:vml\" xmlns:o=\"urn:schemas-microsoft-com:office:office\" xmlns:x=\"urn:schemas-microsoft-com:office:excel\"><o:shapelayout v:ext=\"edit\"><o:idmap v:ext=\"edit\" data=\"{n}\"/></o:shapelayout><v:shapetype id=\"_x0000_t202\" coordsize=\"21600,21600\" o:spt=\"202\" path=\"m,l,21600r21600,l21600,xe\"><v:stroke joinstyle=\"miter\"/><v:path gradientshapeok=\"t\" o:connecttype=\"rect\"/></v:shapetype>"
"<xml xmlns:v=\"urn:schemas-microsoft-com:vml\" xmlns:o=\"urn:schemas-microsoft-com:office:office\" xmlns:x=\"urn:schemas-microsoft-com:office:excel\"><o:shapelayout v:ext=\"edit\"><o:idmap v:ext=\"edit\" data=\"{idmap}\"/></o:shapelayout><v:shapetype id=\"_x0000_t202\" coordsize=\"21600,21600\" o:spt=\"202\" path=\"m,l,21600r21600,l21600,xe\"><v:stroke joinstyle=\"miter\"/><v:path gradientshapeok=\"t\" o:connecttype=\"rect\"/></v:shapetype>"
);
for (i, (cell, c)) in sheet.comments.iter().enumerate() {
let id = n as u64 * 1024 + 1 + i as u64;
let id = base + 1 + i as u64;
let vis = if c.visible { "visible" } else { "hidden" };
let (anchor, (w, h)) = note_anchor(*cell, c.note_box.as_ref());
let _ = write!(
@@ -378,6 +379,7 @@ pub(super) fn vml_xml(sheet: &Sheet, n: u32) -> String {
if c.visible { "<x:Visible/>" } else { "" }
);
}
s.push_str(controls);
s.push_str("</xml>");
s
}
+1
View File
@@ -7,6 +7,7 @@ mod chart_bool_default;
mod chart_combo_kinds;
mod chart_data_table;
mod chart_ref_offsets;
mod controls_tests;
mod csv_tests;
mod encrypted_tests;
mod equation_tests;
+832
View File
@@ -0,0 +1,832 @@
//! Form controls: the three parts written for each control agree, they read back, and files with
//! only VML or with odd values give valid controls.
use gridcraft_core::CellRef;
use gridcraft_model::{Anchor, AnchorMode, Comment, ControlAction, ControlKind, FormControl, MacroInput, Sheet, Workbook};
use super::minimal;
use crate::package::Package;
use crate::{read_xlsx, write_xlsx};
fn at(a: &str) -> CellRef {
CellRef::parse(a).unwrap()
}
fn anchor(cell: &str, width: f32, height: f32) -> Anchor {
Anchor { cell: at(cell), dx: 4.0, dy: 3.0, width, height, mode: AnchorMode::MoveOnly }
}
fn note(author: &str, text: &str) -> Comment {
Comment {
author: author.into(),
text: text.into(),
replies: vec![],
threaded: false,
resolved: false,
visible: false,
dates: vec![],
note_box: None,
}
}
fn book() -> Workbook {
let mut wb = Workbook::default();
wb.sheets.clear();
let mut sh = Sheet::new("Data");
sh.comments.insert(at("A1"), note("Me", "a note"));
sh.controls.push(FormControl {
id: 1,
kind: ControlKind::Slider,
anchor: anchor("C3", 150.0, 20.0),
link: Some("$B$1".into()),
min: 5,
max: 500,
step: 5,
page: 50,
value: 35,
name: "Rate".into(),
..Default::default()
});
sh.controls.push(FormControl {
id: 2,
kind: ControlKind::Spin,
anchor: Anchor { mode: AnchorMode::Absolute, ..anchor("F3", 20.0, 40.0) },
link: Some("'Other Sheet'!$A$2".into()),
max: 30_000,
step: 100,
value: 30_000,
print: false,
..Default::default()
});
sh.controls.push(FormControl {
id: 3,
kind: ControlKind::Button,
anchor: Anchor { mode: AnchorMode::MoveAndSize, ..anchor("C8", 100.0, 28.0) },
text: "Run <it> & go".into(),
action: Some(ControlAction::Macro { name: "Fill Down".into(), input: MacroInput::Selection, output: None }),
locked: false,
..Default::default()
});
sh.controls.push(FormControl { id: 4, kind: ControlKind::Slider, anchor: anchor("H3", 20.0, 120.0), horizontal: false, ..Default::default() });
wb.sheets.push(sh.into());
wb.sheets.push(Sheet::new("Other Sheet").into());
wb
}
fn text(pkg: &mut Package<'_>, part: &str) -> String {
String::from_utf8(pkg.read(part).unwrap().unwrap_or_else(|| panic!("missing {part}"))).unwrap()
}
#[test]
fn the_three_parts_agree_and_read_back() {
let wb = book();
let bytes = write_xlsx(&wb).unwrap();
let mut pkg = Package::open(&bytes).unwrap();
let sheet = text(&mut pkg, "xl/worksheets/sheet1.xml");
let vml = text(&mut pkg, "xl/drawings/vmlDrawing1.vml");
let types = text(&mut pkg, "[Content_Types].xml");
assert!(sheet.contains("<mc:Choice xmlns:x14=") && sheet.contains("<controls>"), "{sheet}");
// Each control's shape id is a VML shape of the same kind; the note keeps its own id.
let ids: Vec<String> = sheet.split("<control shapeId=\"").skip(1).map(|s| s.split('"').next().unwrap().to_string()).collect();
assert_eq!(ids.len(), 8, "a Choice and a Fallback per control");
for (i, kind) in ["Scroll", "Spin", "Button", "Scroll"].iter().enumerate() {
let id = &ids[i * 2];
let shape = vml.split(&format!("id=\"_x0000_s{id}\"")).nth(1).unwrap_or_else(|| panic!("no shape {id}"));
assert!(shape.starts_with(" type=\"#_x0000_t201\""), "{shape}");
assert!(shape.split("</v:shape>").next().unwrap().contains(&format!("ObjectType=\"{kind}\"")), "{id}");
}
assert!(vml.contains("id=\"_x0000_s1025\"") && vml.contains("ObjectType=\"Note\""), "the note is shape 1025");
assert!(!ids.contains(&"1025".to_string()));
for n in 1..=4 {
assert!(types.contains(&format!("/xl/ctrlProps/ctrlProp{n}.xml")), "content type {n}");
}
let prop = text(&mut pkg, "xl/ctrlProps/ctrlProp1.xml");
assert!(
prop.contains("objectType=\"Scroll\"") && prop.contains("fmlaLink=\"$B$1\"") && prop.contains("page=\"50\"") && prop.contains("horiz=\"1\""),
"{prop}"
);
assert!(sheet.contains("macro=\"[0]!Fill Down\""));
let (back, report) = read_xlsx(&bytes).unwrap();
assert!(report.warnings.is_empty(), "{:?}", report.warnings);
let got = &back.sheet(0).unwrap().controls;
assert_eq!(got.len(), 4);
for (a, b) in wb.sheet(0).unwrap().controls.iter().zip(got) {
let same = |x: &FormControl| {
(x.kind, x.link.clone(), x.min, x.max, x.step, x.value, x.horizontal, x.print, x.locked, x.action.clone(), x.anchor.cell, x.anchor.mode)
};
assert_eq!(same(a), same(b));
assert!(
(a.anchor.width - b.anchor.width).abs() < 1.0
&& (a.anchor.height - b.anchor.height).abs() < 1.0
&& (a.anchor.dx - b.anchor.dx).abs() < 1.0
);
if a.kind == ControlKind::Slider {
assert_eq!(a.page, b.page);
}
}
assert_eq!(got[0].name, "Rate");
assert_eq!(got[2].text, "Run <it> & go");
assert_eq!(back.sheet(0).unwrap().comments.len(), 1, "notes still read");
}
#[test]
fn many_shapes_take_several_id_blocks() {
let mut wb = Workbook::default();
wb.sheets.clear();
let mut sh = Sheet::new("Data");
for i in 0..1100u32 {
sh.controls.push(FormControl { id: i + 1, kind: ControlKind::Spin, anchor: anchor("C3", 20.0, 30.0), ..Default::default() });
}
wb.sheets.push(sh.into());
wb.sheets.push(Sheet::new("Next").into());
if let Some(s) = wb.sheet_mut(1) {
s.comments.insert(at("B2"), note("x", "y"));
}
let bytes = write_xlsx(&wb).unwrap();
let mut pkg = Package::open(&bytes).unwrap();
assert!(text(&mut pkg, "xl/drawings/vmlDrawing1.vml").contains("data=\"1,2\""));
let second = text(&mut pkg, "xl/drawings/vmlDrawing2.vml");
assert!(second.contains("data=\"3\"") && second.contains("_x0000_s3073"), "the next sheet's ids don't collide");
let (back, _) = read_xlsx(&bytes).unwrap();
assert_eq!(back.sheet(0).unwrap().controls.len(), 1100);
}
const VML_ONLY: &str = r##"<xml xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office" xmlns:x="urn:schemas-microsoft-com:office:excel">
<v:shape id="_x0000_s1025" type="#_x0000_t201"><x:ClientData ObjectType="Scroll"><x:Anchor>2, 0, 3, 0, 5, 0, 4, 0</x:Anchor><x:FmlaLink>$B$1</x:FmlaLink><x:Val>50000</x:Val><x:Min>-5</x:Min><x:Max>99999</x:Max><x:Inc>0</x:Inc><x:Page>-3</x:Page><x:Horiz/></x:ClientData></v:shape>
<v:shape id="_x0000_s1026" type="#_x0000_t201"><x:ClientData ObjectType="Spin"><x:Anchor>1, 0, 1, 0, 1, 20, 3, 0</x:Anchor><x:Val>7</x:Val><x:Min>9</x:Min><x:Max>9</x:Max></x:ClientData></v:shape>
<v:shape id="_x0000_s1027" o:button="t"><v:textbox><div>Go</div></v:textbox><x:ClientData ObjectType="Button"><x:Anchor>1, 0, 6, 0, 3, 0, 7, 0</x:Anchor><x:FmlaMacro>[0]!Tidy</x:FmlaMacro></x:ClientData></v:shape>
<v:shape id="_x0000_s1028"><x:ClientData ObjectType="Drop"><x:Anchor>1, 0, 1, 0, 2, 0, 2, 0</x:Anchor></x:ClientData></v:shape>
<v:shape id="_x0000_s1029"><x:ClientData ObjectType="Scroll"><x:Anchor>junk</x:Anchor></x:ClientData></v:shape>
</xml>"##;
#[test]
fn vml_only_controls_are_read_and_clamped() {
let rels = r#"<?xml version="1.0"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId9" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/vmlDrawing" Target="../drawings/vmlDrawing1.vml"/></Relationships>"#;
let bytes = minimal(
r#"<sheetData/><legacyDrawing r:id="rId9"/>"#,
&[("xl/worksheets/_rels/sheet1.xml.rels", rels), ("xl/drawings/vmlDrawing1.vml", VML_ONLY)],
"",
"",
);
let (wb, report) = read_xlsx(&bytes).unwrap();
assert!(report.warnings.iter().all(|w| !w.contains("form controls are not supported")), "{:?}", report.warnings);
let c = &wb.sheet(0).unwrap().controls;
assert_eq!(c.len(), 3, "the drop-down and the shape without an anchor are skipped");
assert_eq!((c[0].kind, c[0].min, c[0].max, c[0].step, c[0].value, c[0].horizontal), (ControlKind::Slider, 0, 30_000, 1, 30_000, true));
assert!(c[0].page >= c[0].step && c[0].check_numbers().is_ok());
assert_eq!((c[1].min, c[1].max), (9, 10), "a range with no width gets one");
assert!(c[1].check_numbers().is_ok());
assert_eq!(c[2].text, "Go");
assert_eq!(c[2].action, Some(ControlAction::Macro { name: "Tidy".into(), input: MacroInput::Selection, output: None }));
}
/// A producer that quotes `shapeId` with single quotes: the control is read, and the kept
/// `<controls>` fragment drops it as it drops a double-quoted one, so a save holds the control
/// once and no kept copy points at a VML shape that was taken out.
#[test]
fn a_single_quoted_shape_id_is_dropped_from_the_kept_controls_too() {
let rels = r#"<?xml version="1.0"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId9" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/vmlDrawing" Target="../drawings/vmlDrawing1.vml"/></Relationships>"#;
let vml = r##"<xml xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office" xmlns:x="urn:schemas-microsoft-com:office:excel"><v:shape id="_x0000_s1025" type="#_x0000_t201"><x:ClientData ObjectType="Scroll"><x:Anchor>2, 0, 3, 0, 5, 0, 4, 0</x:Anchor><x:FmlaLink>$B$1</x:FmlaLink><x:Val>40</x:Val><x:Min>0</x:Min><x:Max>100</x:Max><x:Inc>1</x:Inc><x:Page>10</x:Page></x:ClientData></v:shape></xml>"##;
let bytes = minimal(
r#"<sheetData/><legacyDrawing r:id="rId9"/><controls><control shapeId='1025' name='Scroll 1'/></controls>"#,
&[("xl/worksheets/_rels/sheet1.xml.rels", rels), ("xl/drawings/vmlDrawing1.vml", vml)],
"",
"",
);
let (wb, report) = read_xlsx(&bytes).unwrap();
// The minimal package has no styles part; nothing about the control is reported.
assert!(report.warnings.iter().all(|w| w == "no styles part"), "{:?}", report.warnings);
let sh = wb.sheet(0).unwrap();
assert_eq!(sh.controls.len(), 1);
assert_eq!((sh.controls[0].kind, sh.controls[0].value, sh.controls[0].name.as_str()), (ControlKind::Slider, 40, "Scroll 1"));
let kept = sh.preserved.as_ref().and_then(|k| k.elements.iter().find(|e| e.slot == "controls")).map(|e| e.xml.clone());
assert_eq!(kept, None, "nothing of the modelled control is kept for pass-through");
// The saved file holds the control once, with its one VML shape, and reads back once.
let out = write_xlsx(&wb).unwrap();
let mut pkg = Package::open(&out).unwrap();
let sheet = text(&mut pkg, "xl/worksheets/sheet1.xml");
// One <controls> element with one control (each control is written as a Choice with a
// Fallback inside the element's own AlternateContent), and no kept copy.
assert_eq!(sheet.matches("<controls").count(), 1, "{sheet}");
assert_eq!(sheet.matches("<mc:Fallback>").count(), 1, "{sheet}");
assert!(!sheet.contains("shapeId='1025'"), "the kept copy would point at a shape that is gone: {sheet}");
assert_eq!(text(&mut pkg, "xl/drawings/vmlDrawing1.vml").matches("<v:shape ").count(), 1);
let (back, report) = read_xlsx(&out).unwrap();
assert!(report.warnings.is_empty(), "{:?}", report.warnings);
assert_eq!(back.sheet(0).unwrap().controls.len(), 1);
}
/// One `mc:AlternateContent` holds a scroll bar, which is modelled, and a list box, which stays
/// with pass-through: only the scroll bar leaves the kept fragment, the list box keeps its
/// worksheet entry, its VML shape and its properties part through a save, and the file reads
/// back with the one modelled control and the kept one.
#[test]
fn an_unsupported_control_beside_a_modelled_one_in_one_alternate_content_is_kept() {
let rels = r#"<?xml version="1.0"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId9" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/vmlDrawing" Target="../drawings/vmlDrawing1.vml"/><Relationship Id="rIdA" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/ctrlProp" Target="../ctrlProps/a.xml"/><Relationship Id="rIdB" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/ctrlProp" Target="../ctrlProps/b.xml"/></Relationships>"#;
let prop_a = r#"<?xml version="1.0"?><formControlPr xmlns="http://schemas.microsoft.com/office/spreadsheetml/2009/9/main" objectType="Scroll" fmlaLink="$B$1" min="0" max="100" inc="1" page="10" val="40"/>"#;
let prop_b = r#"<?xml version="1.0"?><formControlPr xmlns="http://schemas.microsoft.com/office/spreadsheetml/2009/9/main" objectType="List" fmlaRange="$D$1:$D$3" sel="2"/>"#;
let vml = r##"<xml xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office" xmlns:x="urn:schemas-microsoft-com:office:excel"><v:shape id="_x0000_s1025" type="#_x0000_t201"><x:ClientData ObjectType="Scroll"><x:Anchor>2, 0, 3, 0, 5, 0, 4, 0</x:Anchor><x:FmlaLink>$B$1</x:FmlaLink><x:Val>40</x:Val><x:Min>0</x:Min><x:Max>100</x:Max><x:Inc>1</x:Inc><x:Page>10</x:Page></x:ClientData></v:shape><v:shape id="_x0000_s1026" type="#_x0000_t201"><x:ClientData ObjectType="List"><x:Anchor>2, 0, 6, 0, 5, 0, 9, 0</x:Anchor><x:FmlaRange>$D$1:$D$3</x:FmlaRange><x:Sel>2</x:Sel></x:ClientData></v:shape></xml>"##;
let anchor = |from: u32, to: u32| {
format!(
r#"<controlPr><anchor><from><xdr:col>2</xdr:col><xdr:colOff>0</xdr:colOff><xdr:row>{from}</xdr:row><xdr:rowOff>0</xdr:rowOff></from><to><xdr:col>5</xdr:col><xdr:colOff>0</xdr:colOff><xdr:row>{to}</xdr:row><xdr:rowOff>0</xdr:rowOff></to></anchor></controlPr>"#
)
};
let controls = format!(
r#"<mc:AlternateContent xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"><mc:Choice Requires="x14" xmlns:xdr="http://schemas.openxmlformats.org/drawingml/2006/spreadsheetDrawing"><controls><mc:AlternateContent><mc:Choice Requires="x14"><control shapeId="1025" r:id="rIdA" name="Scroll 1">{}</control><control shapeId="1026" r:id="rIdB" name="List 1">{}</control></mc:Choice><mc:Fallback><control shapeId="1025" r:id="rIdA" name="Scroll 1"/><control shapeId="1026" r:id="rIdB" name="List 1"/></mc:Fallback></mc:AlternateContent></controls></mc:Choice></mc:AlternateContent>"#,
anchor(3, 4),
anchor(6, 9)
);
let bytes = minimal(
&format!(r#"<sheetData/><legacyDrawing r:id="rId9"/>{controls}"#),
&[
("xl/worksheets/_rels/sheet1.xml.rels", rels),
("xl/ctrlProps/a.xml", prop_a),
("xl/ctrlProps/b.xml", prop_b),
("xl/drawings/vmlDrawing1.vml", vml),
],
"",
"",
);
let (wb, _) = read_xlsx(&bytes).unwrap();
let sh = wb.sheet(0).unwrap();
assert_eq!(sh.controls.len(), 1, "the scroll bar is modelled, the list box is not");
assert_eq!((sh.controls[0].kind, sh.controls[0].name.as_str()), (ControlKind::Slider, "Scroll 1"));
let kept = sh.preserved.as_ref().and_then(|k| k.elements.iter().find(|e| e.slot == "controls")).map(|e| e.xml.clone()).unwrap_or_default();
assert!(kept.contains("List 1") && kept.contains("<mc:Fallback>"), "the list box stays, in its wrapper: {kept}");
assert!(!kept.contains("Scroll 1") && !kept.contains("shapeId=\"1025\""), "the scroll bar leaves every branch: {kept}");
// Saved: the scroll bar once (one Fallback of ours), the list box in the kept wrapper with its
// shape and its properties part.
let out = write_xlsx(&wb).unwrap();
let mut pkg = Package::open(&out).unwrap();
let sheet = text(&mut pkg, "xl/worksheets/sheet1.xml");
assert_eq!(sheet.matches("<controls").count(), 1, "{sheet}");
assert_eq!(sheet.matches("name=\"Scroll 1\"").count(), 2, "the modelled control, in its Choice and Fallback: {sheet}");
assert_eq!(sheet.matches("name=\"List 1\"").count(), 2, "the kept control, in its Choice and Fallback: {sheet}");
assert_eq!(sheet.matches("shapeId=\"1026\"").count(), 2, "{sheet}");
let vml_out = text(&mut pkg, "xl/drawings/vmlDrawing1.vml");
assert!(vml_out.contains("_x0000_s1026") && vml_out.contains("ObjectType=\"List\""), "the list box's shape is kept: {vml_out}");
assert_eq!(vml_out.matches("ObjectType=\"Scroll\"").count(), 1, "{vml_out}");
// Part names come back lower-cased.
let props: Vec<String> = pkg.names().into_iter().filter(|n| n.starts_with("xl/ctrlprops/")).collect();
assert_eq!(props.len(), 2, "ours and the kept one: {props:?}");
assert!(props.iter().any(|n| text(&mut pkg, n).contains("objectType=\"List\"")), "the list box's properties part survives: {props:?}");
// Read back: the one modelled control and the kept one.
let (back, _) = read_xlsx(&out).unwrap();
let sh = back.sheet(0).unwrap();
assert_eq!(sh.controls.len(), 1);
let kept = sh.preserved.as_ref().and_then(|k| k.elements.iter().find(|e| e.slot == "controls")).map(|e| e.xml.clone()).unwrap_or_default();
assert!(kept.contains("List 1") && !kept.contains("Scroll 1"), "{kept}");
}
/// A producer that prefixes the main namespace (`<x:controls>`): the kept element is still the
/// sheet's one `<controls>` element, so a modelled control is written into it rather than into a
/// second one, which the worksheet schema does not allow.
#[test]
fn a_modelled_control_joins_a_kept_controls_element_with_a_namespace_prefix() {
use gridcraft_model::{Anchor, ControlKind, FormControl};
let rels = r#"<?xml version="1.0"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId9" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/vmlDrawing" Target="../drawings/vmlDrawing1.vml"/><Relationship Id="rIdB" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/ctrlProp" Target="../ctrlProps/b.xml"/></Relationships>"#;
let prop_b = r#"<?xml version="1.0"?><formControlPr xmlns="http://schemas.microsoft.com/office/spreadsheetml/2009/9/main" objectType="List" fmlaRange="$D$1:$D$3" sel="2"/>"#;
let vml = r##"<xml xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office" xmlns:x="urn:schemas-microsoft-com:office:excel"><v:shape id="_x0000_s1026" type="#_x0000_t201"><x:ClientData ObjectType="List"><x:Anchor>2, 0, 6, 0, 5, 0, 9, 0</x:Anchor><x:FmlaRange>$D$1:$D$3</x:FmlaRange><x:Sel>2</x:Sel></x:ClientData></v:shape></xml>"##;
let controls = r#"<x:controls xmlns:x="http://schemas.openxmlformats.org/spreadsheetml/2006/main" xmlns:xdr="http://schemas.openxmlformats.org/drawingml/2006/spreadsheetDrawing"><x:control shapeId="1026" r:id="rIdB" name="List 1"><x:controlPr><x:anchor><x:from><xdr:col>2</xdr:col><xdr:colOff>0</xdr:colOff><xdr:row>6</xdr:row><xdr:rowOff>0</xdr:rowOff></x:from><x:to><xdr:col>5</xdr:col><xdr:colOff>0</xdr:colOff><xdr:row>9</xdr:row><xdr:rowOff>0</xdr:rowOff></x:to></x:anchor></x:controlPr></x:control></x:controls>"#;
let bytes = minimal(
&format!(r#"<sheetData/><legacyDrawing r:id="rId9"/>{controls}"#),
&[("xl/worksheets/_rels/sheet1.xml.rels", rels), ("xl/ctrlProps/b.xml", prop_b), ("xl/drawings/vmlDrawing1.vml", vml)],
"",
"",
);
let (mut wb, _) = read_xlsx(&bytes).unwrap();
assert!(wb.sheet(0).unwrap().controls.is_empty(), "the list box stays with pass-through");
let kept = wb
.sheet(0)
.unwrap()
.preserved
.as_ref()
.and_then(|k| k.elements.iter().find(|e| e.slot == "controls"))
.map(|e| e.xml.clone())
.unwrap_or_default();
assert!(kept.contains("</x:controls>"), "kept with its prefix: {kept}");
let anchor = Anchor { cell: gridcraft_core::CellRef::new(2, 1), width: 120.0, height: 20.0, ..Default::default() };
wb.sheet_mut(0).unwrap().controls.push(FormControl {
id: 900,
kind: ControlKind::Slider,
anchor,
link: Some("$B$1".into()),
value: 40,
..Default::default()
});
let out = write_xlsx(&wb).unwrap();
let mut pkg = Package::open(&out).unwrap();
let sheet = text(&mut pkg, "xl/worksheets/sheet1.xml");
let opened = sheet.matches("<controls").count() + sheet.matches("<x:controls").count();
assert_eq!(opened, 1, "one controls element: {sheet}");
let start = sheet.find("<x:controls").or_else(|| sheet.find("<controls")).unwrap();
let end = sheet.rfind("controls>").unwrap();
let inner = &sheet[start..end];
assert!(inner.contains("name=\"List 1\"") && inner.contains("<mc:Fallback>"), "both controls inside the one element: {sheet}");
let (back, _) = read_xlsx(&out).unwrap();
assert_eq!(back.sheet(0).unwrap().controls.len(), 1, "the slider reads back; the list box stays kept");
}
/// A kept `mc:AlternateContent` with separate `<controls>` elements in its Choice and Fallback:
/// a modelled control is written into the Choice's element, the one the reader (and an
/// application that knows x14) reads, so it comes back with its name and its properties part;
/// the Fallback's element is left as the file had it.
#[test]
fn a_modelled_control_joins_the_choice_controls_element_of_a_kept_alternate_content() {
use gridcraft_model::{Anchor, ControlKind, FormControl};
let rels = r#"<?xml version="1.0"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId9" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/vmlDrawing" Target="../drawings/vmlDrawing1.vml"/><Relationship Id="rIdB" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/ctrlProp" Target="../ctrlProps/b.xml"/></Relationships>"#;
let prop_b = r#"<?xml version="1.0"?><formControlPr xmlns="http://schemas.microsoft.com/office/spreadsheetml/2009/9/main" objectType="List" fmlaRange="$D$1:$D$3" sel="2"/>"#;
let vml = r##"<xml xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office" xmlns:x="urn:schemas-microsoft-com:office:excel"><v:shape id="_x0000_s1026" type="#_x0000_t201"><x:ClientData ObjectType="List"><x:Anchor>2, 0, 6, 0, 5, 0, 9, 0</x:Anchor><x:FmlaRange>$D$1:$D$3</x:FmlaRange><x:Sel>2</x:Sel></x:ClientData></v:shape></xml>"##;
let list = r#"<control shapeId="1026" r:id="rIdB" name="List 1"><controlPr><anchor><from><xdr:col>2</xdr:col><xdr:colOff>0</xdr:colOff><xdr:row>6</xdr:row><xdr:rowOff>0</xdr:rowOff></from><to><xdr:col>5</xdr:col><xdr:colOff>0</xdr:colOff><xdr:row>9</xdr:row><xdr:rowOff>0</xdr:rowOff></to></anchor></controlPr></control>"#;
let controls = format!(
r#"<mc:AlternateContent xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:xdr="http://schemas.openxmlformats.org/drawingml/2006/spreadsheetDrawing"><mc:Choice Requires="x14"><controls>{list}</controls></mc:Choice><mc:Fallback><controls><control shapeId="1026" r:id="rIdB" name="List 1"/></controls></mc:Fallback></mc:AlternateContent>"#
);
let bytes = minimal(
&format!(r#"<sheetData/><legacyDrawing r:id="rId9"/>{controls}"#),
&[("xl/worksheets/_rels/sheet1.xml.rels", rels), ("xl/ctrlProps/b.xml", prop_b), ("xl/drawings/vmlDrawing1.vml", vml)],
"",
"",
);
let (mut wb, _) = read_xlsx(&bytes).unwrap();
assert!(wb.sheet(0).unwrap().controls.is_empty(), "the list box stays with pass-through");
let anchor = Anchor { cell: gridcraft_core::CellRef::new(2, 1), width: 120.0, height: 20.0, ..Default::default() };
wb.sheet_mut(0).unwrap().controls.push(FormControl {
id: 900,
kind: ControlKind::Slider,
anchor,
name: "Speed".into(),
link: Some("$B$1".into()),
value: 40,
..Default::default()
});
let out = write_xlsx(&wb).unwrap();
let mut pkg = Package::open(&out).unwrap();
let sheet = text(&mut pkg, "xl/worksheets/sheet1.xml");
// The Choice's <controls> comes first in the sheet, the Fallback's last.
let (first, last) = (sheet.find("<controls").unwrap(), sheet.rfind("<controls").unwrap());
assert!(first < last, "two controls elements, the kept Choice's and Fallback's: {sheet}");
let choice = &sheet[first..last];
assert!(choice.contains("name=\"Speed\"") && choice.contains("name=\"List 1\""), "the Choice holds both: {sheet}");
assert!(!sheet[last..].contains("name=\"Speed\""), "the Fallback is as the file had it: {sheet}");
let (back, _) = read_xlsx(&out).unwrap();
let sh = back.sheet(0).unwrap();
assert_eq!(sh.controls.len(), 1);
assert_eq!(
(sh.controls[0].name.as_str(), sh.controls[0].link.as_deref(), sh.controls[0].value),
("Speed", Some("$B$1"), 40),
"read from its worksheet entry and properties part"
);
let kept = sh.preserved.as_ref().and_then(|k| k.elements.iter().find(|e| e.slot == "controls")).map(|e| e.xml.clone()).unwrap_or_default();
assert!(kept.contains("List 1") && !kept.contains("Speed"), "{kept}");
}
/// Shapes with a named `id` and their number in `o:spid`, as some producers write them.
const VML_SPID: &str = r##"<xml xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office" xmlns:x="urn:schemas-microsoft-com:office:excel">
<v:shape id="Button 1" o:spid="_x0000_s1025" o:button="t"><v:textbox><div>Go</div></v:textbox><x:ClientData ObjectType="Button"><x:Anchor>1, 0, 6, 0, 3, 0, 7, 0</x:Anchor><x:FmlaMacro>[0]!Tidy</x:FmlaMacro></x:ClientData></v:shape>
<v:shape id="Spinner 2" o:spid="_x0000_s1026"><x:ClientData ObjectType="Spin"><x:Anchor>1, 0, 1, 0, 1, 20, 3, 0</x:Anchor><x:Val>7</x:Val><x:Max>9</x:Max></x:ClientData></v:shape>
</xml>"##;
#[test]
fn vml_shapes_are_also_found_by_their_spid() {
let rels = r#"<?xml version="1.0"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId9" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/vmlDrawing" Target="../drawings/vmlDrawing1.vml"/></Relationships>"#;
// The button is listed in <controls> without a properties part; the spin button is only in
// the VML.
let bytes = minimal(
r#"<sheetData/><legacyDrawing r:id="rId9"/><controls><control shapeId="1025" name="Go Button"/></controls>"#,
&[("xl/worksheets/_rels/sheet1.xml.rels", rels), ("xl/drawings/vmlDrawing1.vml", VML_SPID)],
"",
"",
);
let (wb, _) = read_xlsx(&bytes).unwrap();
let c = &wb.sheet(0).unwrap().controls;
assert_eq!(c.len(), 2, "{c:?}");
assert_eq!((c[0].kind, c[0].name.as_str(), c[0].text.as_str()), (ControlKind::Button, "Go Button", "Go"));
assert_eq!(c[0].action, Some(ControlAction::Macro { name: "Tidy".into(), input: MacroInput::Selection, output: None }));
assert_eq!((c[1].kind, c[1].value, c[1].max), (ControlKind::Spin, 7, 9));
// Saved again, each is written once and nothing of them is kept twice.
let out = write_xlsx(&wb).unwrap();
let (back, _) = read_xlsx(&out).unwrap();
assert_eq!(back.sheet(0).unwrap().controls.len(), 2);
}
#[test]
fn the_properties_part_wins_over_the_vml() {
let mut wb = book();
if let Some(s) = wb.sheet_mut(0) {
s.comments.clear();
s.controls.truncate(1);
}
let bytes = write_xlsx(&wb).unwrap();
// Rewrite the VML's numbers: the reader still takes the properties part's.
let mut pkg = Package::open(&bytes).unwrap();
let mut parts = Vec::new();
for name in pkg.names() {
let mut data = pkg.read(&name).unwrap().unwrap();
if name.ends_with(".vml") {
data = String::from_utf8(data).unwrap().replace("<x:Max>500</x:Max>", "<x:Max>9</x:Max>").into_bytes();
}
parts.push((name, data));
}
let zipped = super::make_zip(&parts.iter().map(|(n, d)| (n.as_str(), d.as_slice())).collect::<Vec<_>>());
let (back, _) = read_xlsx(&zipped).unwrap();
assert_eq!(back.sheet(0).unwrap().controls[0].max, 500);
}
/// A hostile sheet: more controls than a sheet keeps, properties parts that are not XML or hold
/// numbers no control can take, shape ids that are not numbers, and oversized names, captions and
/// links. Reading must not panic, must keep at most the cap, and every control it keeps must pass
/// the model's rules.
#[test]
fn hostile_form_controls_are_capped_and_repaired() {
let n = 13_000;
let mut controls = String::from("<controls>");
let anchor = r#"<controlPr><anchor><from><xdr:col>1</xdr:col><xdr:colOff>-99999999999</xdr:colOff><xdr:row>99999999999</xdr:row><xdr:rowOff>0</xdr:rowOff></from><to><xdr:col>-4</xdr:col><xdr:colOff>0</xdr:colOff><xdr:row>2</xdr:row><xdr:rowOff>0</xdr:rowOff></to></anchor></controlPr>"#;
for i in 0..n {
let rid = match i % 10 {
0 => "rIdB",
1 => "rIdC",
_ => "rIdA",
};
let _ = std::fmt::Write::write_fmt(
&mut controls,
format_args!(r#"<control shapeId="{}" r:id="{rid}" name="{}">{anchor}</control>"#, 2000 + i, "n".repeat(400)),
);
}
controls
.push_str(r#"<control shapeId="99999999999999999999999" r:id="rIdA"/><control shapeId="x" r:id="rIdA"/><control r:id="rIdA"/></controls>"#);
let rels = r#"<?xml version="1.0"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rIdA" Type="http://schemas.microsoft.com/office/2006/relationships/ctrlProp" Target="../ctrlProps/a.xml"/><Relationship Id="rIdB" Type="http://schemas.microsoft.com/office/2006/relationships/ctrlProp" Target="../ctrlProps/b.xml"/><Relationship Id="rIdC" Type="http://schemas.microsoft.com/office/2006/relationships/ctrlProp" Target="../ctrlProps/missing.xml"/></Relationships>"#;
let link = format!("$B${}", "9".repeat(500));
let prop_a = format!(
r#"<?xml version="1.0"?><formControlPr xmlns="http://schemas.microsoft.com/office/spreadsheetml/2009/9/main" objectType="Scroll" fmlaLink="{link}" min="1e400" max="NaN" inc="-inf" page="18446744073709551616" val="-7"/>"#
);
let body = format!(r#"<sheetData/>{controls}"#)
.replace("<controls>", r#"<controls xmlns:xdr="http://schemas.openxmlformats.org/drawingml/2006/spreadsheetDrawing">"#);
let bytes = minimal(
&body,
&[("xl/worksheets/_rels/sheet1.xml.rels", rels), ("xl/ctrlProps/a.xml", &prop_a), ("xl/ctrlProps/b.xml", "\u{0}\u{1}not xml <<<")],
"",
"",
);
let (wb, _) = read_xlsx(&bytes).unwrap();
let c = &wb.sheet(0).unwrap().controls;
assert_eq!(c.len(), 10_000, "the cap of one sheet");
for x in c {
assert_eq!(x.kind, ControlKind::Slider);
assert!(x.check_numbers().is_ok(), "{x:?}");
assert!(x.value >= x.min && x.value <= x.max, "{x:?}");
assert!(x.name.chars().count() <= 255 && x.link.is_none(), "{x:?}");
assert!(x.anchor.width >= 1.0 && x.anchor.height >= 1.0 && x.anchor.cell.row < gridcraft_core::MAX_ROWS, "{x:?}");
}
// Written back and read again, the kept controls stay valid.
let (back, _) = read_xlsx(&write_xlsx(&wb).unwrap()).unwrap();
assert_eq!(back.sheet(0).unwrap().controls.len(), c.len());
}
/// Each listed control finds its VML shape through an index, so a sheet of many controls reads in
/// linear time (it used to scan every shape for every control).
#[test]
#[allow(clippy::disallowed_methods)] // a native-only timing test: the clock is read off wasm
fn many_listed_controls_read_in_linear_time() {
let mut wb = Workbook::default();
if let Some(sh) = wb.sheet_mut(0) {
for i in 0..10_000u32 {
let a = anchor(&format!("B{}", i + 1), 60.0, 18.0);
sh.controls.push(FormControl { id: i + 1, kind: ControlKind::Spin, anchor: a, ..Default::default() });
}
}
let bytes = write_xlsx(&wb).unwrap();
let t0 = std::time::Instant::now();
let (back, _) = read_xlsx(&bytes).unwrap();
let took = t0.elapsed();
assert_eq!(back.sheet(0).unwrap().controls.len(), 10_000);
// About 0.4 s here; scanning every shape for every control took over 8 s.
assert!(took < std::time::Duration::from_secs(3), "{took:?}");
}
/// The sheet relates each control properties part with the relationship type MS-XLSX defines and
/// Excel writes (in the officeDocument namespace); Excel finds the part by that type.
#[test]
fn the_properties_parts_use_the_ctrl_prop_relationship_type() {
let bytes = write_xlsx(&book()).unwrap();
let mut pkg = Package::open(&bytes).unwrap();
let rels = text(&mut pkg, "xl/worksheets/_rels/sheet1.xml.rels");
let types: Vec<&str> = rels.split("Type=\"").skip(1).filter_map(|r| r.split('"').next()).filter(|t| t.ends_with("/ctrlProp")).collect();
assert_eq!(types.len(), 4, "{rels}");
assert!(types.iter().all(|t| *t == "http://schemas.openxmlformats.org/officeDocument/2006/relationships/ctrlProp"), "{types:?}");
}
/// A sheet with a header and footer drawing and a drawing for its controls: the controls come
/// from the drawing `<legacyDrawing r:id>` names, whichever relationship is listed first.
#[test]
fn controls_come_from_the_legacy_drawing_not_the_header_and_footer_drawing() {
let hf = r##"<xml xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office"><v:shape id="LH" o:spid="_x0000_s2049" type="#_x0000_t75"><v:imagedata o:title="logo"/></v:shape></xml>"##;
let rels = r#"<?xml version="1.0"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/vmlDrawing" Target="../drawings/vmlDrawingHF1.vml"/><Relationship Id="rId9" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/vmlDrawing" Target="../drawings/vmlDrawing1.vml"/></Relationships>"#;
let bytes = minimal(
r#"<sheetData/><legacyDrawing r:id="rId9"/><legacyDrawingHF r:id="rId1"/>"#,
&[("xl/worksheets/_rels/sheet1.xml.rels", rels), ("xl/drawings/vmlDrawingHF1.vml", hf), ("xl/drawings/vmlDrawing1.vml", VML_ONLY)],
"",
"",
);
let (wb, _) = read_xlsx(&bytes).unwrap();
let c = &wb.sheet(0).unwrap().controls;
assert_eq!(c.len(), 3, "{c:?}");
assert_eq!(c[2].text, "Go");
}
/// Every listed control that names the same properties part shares one read of it. Each read is
/// charged to the package's 1 GB budget, so 10,000 reads of a 200 KB part would spend it, drop the
/// controls after that, and fail the parts read after the controls (here the sheet's notes). A
/// hostile file can list that many controls against one part.
#[test]
fn controls_naming_one_properties_part_read_it_once() {
let n = 13_000;
let mut controls = String::from(r#"<controls xmlns:xdr="http://schemas.openxmlformats.org/drawingml/2006/spreadsheetDrawing">"#);
for i in 0..n {
let _ = std::fmt::Write::write_fmt(
&mut controls,
format_args!(
r#"<control shapeId="{}" r:id="rIdA" name="c{i}"><controlPr><anchor><from><xdr:col>1</xdr:col><xdr:colOff>0</xdr:colOff><xdr:row>{i}</xdr:row><xdr:rowOff>0</xdr:rowOff></from><to><xdr:col>2</xdr:col><xdr:colOff>0</xdr:colOff><xdr:row>{}</xdr:row><xdr:rowOff>0</xdr:rowOff></to></anchor></controlPr></control>"#,
2000 + i,
i + 1
),
);
}
controls.push_str("</controls>");
let rels = r#"<?xml version="1.0"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rIdA" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/ctrlProp" Target="../ctrlProps/a.xml"/><Relationship Id="rIdN" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/comments" Target="../comments1.xml"/></Relationships>"#;
// 200 KB of attribute whitespace: read once per control, 10,000 reads would be 2 GB.
let prop_a = format!(
r#"<?xml version="1.0"?><formControlPr xmlns="http://schemas.microsoft.com/office/spreadsheetml/2009/9/main" objectType="Scroll" fmlaLink="$B$1"{}min="0" max="200" inc="5" page="20" val="45"/>"#,
" ".repeat(200 * 1024)
);
let notes = r#"<?xml version="1.0"?><comments xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"><authors><author>Ann</author></authors><commentList><comment ref="A1" authorId="0"><text><t>read after the controls</t></text></comment></commentList></comments>"#;
let bytes = minimal(
&format!("<sheetData/>{controls}"),
&[("xl/worksheets/_rels/sheet1.xml.rels", rels), ("xl/ctrlProps/a.xml", &prop_a), ("xl/comments1.xml", notes)],
"",
"",
);
let (wb, report) = read_xlsx(&bytes).unwrap();
let sh = wb.sheet(0).unwrap();
assert_eq!(sh.controls.len(), 10_000, "the cap of one sheet, every control read from the one part");
assert!(sh.controls.iter().all(|c| {
c.kind == ControlKind::Slider && c.link.as_deref() == Some("$B$1") && (c.min, c.max, c.step, c.page, c.value) == (0, 200, 5, 20, 45)
}));
assert_eq!(sh.comments.len(), 1, "a part read after the controls still reads: {:?}", report.warnings);
}
/// A legacy VML drawing with a button (`1025`, modelled), a check box (`1026`) and an option
/// button (`1027`, with a picture through the drawing's own relationship) that only the drawing
/// lists, plus `note` (a note shape for A1 whose client data carries that `ObjectType` attribute,
/// with its comments part) when given. `listed`: the sheet's
/// `<controls>` lists the button.
fn vml_only_others(listed: bool, button: bool, note: Option<&str>) -> Vec<u8> {
const REL: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships";
let mut rels = format!(r#"<Relationship Id="rId9" Type="{REL}/vmlDrawing" Target="../drawings/vmlDrawing1.vml"/>"#);
if listed {
rels.push_str(&format!(r#"<Relationship Id="rIdA" Type="{REL}/ctrlProp" Target="../ctrlProps/a.xml"/>"#));
}
if note.is_some() {
rels.push_str(&format!(r#"<Relationship Id="rIdN" Type="{REL}/comments" Target="../comments1.xml"/>"#));
}
let rels =
format!(r#"<?xml version="1.0"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">{rels}</Relationships>"#);
let vml_rels = format!(
r#"<?xml version="1.0"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="{REL}/image" Target="../media/image1.png"/></Relationships>"#
);
let prop_a = r#"<?xml version="1.0"?><formControlPr xmlns="http://schemas.microsoft.com/office/spreadsheetml/2009/9/main" objectType="Button" lockText="1"/>"#;
let mut shapes = String::new();
if let Some(attr) = note {
shapes.push_str(&format!(r##"<v:shape id="_x0000_s1028" type="#_x0000_t202"><x:ClientData {attr}><x:Anchor>1, 15, 0, 2, 3, 15, 4, 4</x:Anchor><x:Row>0</x:Row><x:Column>0</x:Column></x:ClientData></v:shape>"##));
}
if button {
shapes.push_str(r##"<v:shape id="_x0000_s1025" type="#_x0000_t201"><v:textbox><div>Go</div></v:textbox><x:ClientData ObjectType="Button"><x:Anchor>2, 0, 3, 0, 4, 0, 5, 0</x:Anchor><x:FmlaMacro>[0]!Go</x:FmlaMacro></x:ClientData></v:shape>"##);
}
shapes.push_str(r##"<v:shape id="_x0000_s1026" type="#_x0000_t201"><v:textbox><div>Paid</div></v:textbox><x:ClientData ObjectType="Checkbox"><x:Anchor>2, 0, 7, 0, 4, 0, 8, 0</x:Anchor><x:FmlaLink>$B$7</x:FmlaLink><x:Checked>1</x:Checked></x:ClientData></v:shape>"##);
shapes.push_str(r##"<v:shape id="_x0000_s1027" type="#_x0000_t201"><v:imagedata o:relid="rId1" o:title=""/><x:ClientData ObjectType="Radio"><x:Anchor>2, 0, 9, 0, 4, 0, 10, 0</x:Anchor><x:FmlaLink>$B$9</x:FmlaLink></x:ClientData></v:shape>"##);
let vml = format!(
r#"<xml xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office" xmlns:x="urn:schemas-microsoft-com:office:excel"><o:shapelayout v:ext="edit"><o:idmap v:ext="edit" data="1"/></o:shapelayout>{shapes}</xml>"#
);
let controls = if listed {
r#"<controls xmlns:xdr="http://schemas.openxmlformats.org/drawingml/2006/spreadsheetDrawing"><control shapeId="1025" r:id="rIdA" name="Button 1"><controlPr><anchor><from><xdr:col>2</xdr:col><xdr:colOff>0</xdr:colOff><xdr:row>3</xdr:row><xdr:rowOff>0</xdr:rowOff></from><to><xdr:col>4</xdr:col><xdr:colOff>0</xdr:colOff><xdr:row>5</xdr:row><xdr:rowOff>0</xdr:rowOff></to></anchor></controlPr></control></controls>"#
} else {
""
};
let notes = r#"<?xml version="1.0"?><comments xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"><authors><author>Ann</author></authors><commentList><comment ref="A1" authorId="0"><text><t>check the totals</t></text></comment></commentList></comments>"#;
let mut parts: Vec<(&str, &str)> = vec![
("xl/worksheets/_rels/sheet1.xml.rels", &rels),
("xl/drawings/vmlDrawing1.vml", &vml),
("xl/drawings/_rels/vmlDrawing1.vml.rels", &vml_rels),
("xl/media/image1.png", "not really a png"),
];
if listed {
parts.push(("xl/ctrlProps/a.xml", prop_a));
}
if note.is_some() {
parts.push(("xl/comments1.xml", notes));
}
// As `minimal` builds it, with content types for the picture and the drawing.
let ct = super::CT.replace(
"</Types>",
r#"<Default Extension="png" ContentType="image/png"/><Default Extension="vml" ContentType="application/vnd.openxmlformats-officedocument.vmlDrawing"/></Types>"#,
);
let base = minimal(&format!(r#"<sheetData/><legacyDrawing r:id="rId9"/>{controls}"#), &parts, "", "");
let mut zip = Package::open(&base).unwrap();
let mut files: Vec<(String, Vec<u8>)> = vec![("[Content_Types].xml".into(), ct.into_bytes())];
for n in zip.names() {
if n != "[Content_Types].xml" {
let data = zip.read(&n).unwrap().unwrap();
files.push((n, data));
}
}
super::make_zip(&files.iter().map(|(n, d)| (n.as_str(), d.as_slice())).collect::<Vec<_>>())
}
/// Checks a saved package keeps the check box and option button shapes once each, in the sheet's
/// one VML drawing, with that drawing's one relationship to its one picture; returns the drawing.
fn kept_vml_only_others(out: &[u8]) -> String {
let mut pkg = Package::open(out).unwrap();
let sheet = text(&mut pkg, "xl/worksheets/sheet1.xml");
assert_eq!(sheet.matches("<legacyDrawing ").count(), 1, "{sheet}");
let rels = text(&mut pkg, "xl/worksheets/_rels/sheet1.xml.rels");
assert_eq!(rels.matches("/vmlDrawing\"").count(), 1, "one VML drawing relationship: {rels}");
let names = pkg.names();
let vmls: Vec<&String> = names.iter().filter(|n| n.ends_with(".vml")).collect();
assert_eq!(vmls.len(), 1, "{names:?}");
let vml_name = vmls[0].clone();
let vml = text(&mut pkg, &vml_name);
assert_eq!(vml.matches("ObjectType=\"Checkbox\"").count(), 1, "the check box is kept once: {vml}");
assert_eq!(vml.matches("ObjectType=\"Radio\"").count(), 1, "the option button is kept once: {vml}");
let (dir, file) = vml_name.rsplit_once('/').unwrap();
let vml_rels = text(&mut pkg, &format!("{dir}/_rels/{file}.rels"));
assert_eq!(vml_rels.matches("<Relationship ").count(), 1, "{vml_rels}");
let media: Vec<&String> = names.iter().filter(|n| n.starts_with("xl/media/")).collect();
assert_eq!(media.len(), 1, "the option button's picture once: {names:?}");
vml
}
/// The sheet's `<controls>` lists only a button, which is modelled; a check box and an option
/// button in the same VML drawing have no worksheet entry. With the button's entry gone from the
/// kept content no `<controls>` element is left, but the drawing still holds the two, so it is
/// kept: the save writes them beside the regenerated button, and they survive a second round trip.
#[test]
fn vml_only_controls_survive_when_the_last_listed_control_is_modelled() {
let bytes = vml_only_others(true, true, None);
let (wb, report) = read_xlsx(&bytes).unwrap();
let sh = wb.sheet(0).unwrap();
assert_eq!(sh.controls.len(), 1, "the button is modelled");
assert_eq!(sh.controls[0].kind, ControlKind::Button);
assert!(sh.preserved.as_ref().is_some_and(|p| p.vml.is_some()), "the drawing is kept: {:?}", report.warnings);
assert!(report.warnings.iter().any(|w| w.contains("form controls are not shown")), "{:?}", report.warnings);
let out = write_xlsx(&wb).unwrap();
let vml = kept_vml_only_others(&out);
assert_eq!(vml.matches("ObjectType=\"Button\"").count(), 1, "the button once, written from the model: {vml}");
let (back, _) = read_xlsx(&out).unwrap();
let sh = back.sheet(0).unwrap();
assert_eq!(sh.controls.len(), 1);
assert_eq!(sh.controls[0].text, "Go");
// Saved again: still once each.
let again = write_xlsx(&back).unwrap();
let vml = kept_vml_only_others(&again);
assert_eq!(vml.matches("ObjectType=\"Button\"").count(), 1, "{vml}");
}
/// A sheet whose only controls are a check box and an option button that only the VML drawing
/// lists (no `<controls>`, no notes, nothing modelled): the drawing is kept on its own.
#[test]
fn a_sheet_whose_only_controls_are_vml_only_and_unmodelled_keeps_them() {
let bytes = vml_only_others(false, false, None);
let (wb, report) = read_xlsx(&bytes).unwrap();
assert!(wb.sheet(0).unwrap().controls.is_empty());
let out = write_xlsx(&wb).unwrap();
let vml = kept_vml_only_others(&out);
assert!(!vml.contains("ObjectType=\"Button\""), "{vml}");
let (back, _) = read_xlsx(&out).unwrap();
let again = write_xlsx(&back).unwrap();
kept_vml_only_others(&again);
assert!(report.warnings.iter().any(|w| w.contains("form controls are not shown")), "{:?}", report.warnings);
}
/// The same with a note in the drawing (one VML drawing for one comments part) and a VML-only
/// button that is modelled: the note and the button are written from the model, the check box and
/// option button stay, all in the one drawing.
#[test]
fn vml_only_controls_beside_a_note_are_kept() {
let bytes = vml_only_others(false, true, Some(r#"ObjectType="Note""#));
let (wb, report) = read_xlsx(&bytes).unwrap();
let sh = wb.sheet(0).unwrap();
assert_eq!((sh.controls.len(), sh.comments.len()), (1, 1), "{:?}", report.warnings);
let out = write_xlsx(&wb).unwrap();
let vml = kept_vml_only_others(&out);
assert_eq!(vml.matches("ObjectType=\"Note\"").count(), 1, "{vml}");
assert_eq!(vml.matches("ObjectType=\"Button\"").count(), 1, "{vml}");
let (back, _) = read_xlsx(&out).unwrap();
let sh = back.sheet(0).unwrap();
assert_eq!((sh.controls.len(), sh.comments.len()), (1, 1));
let vml = kept_vml_only_others(&write_xlsx(&back).unwrap());
assert_eq!(vml.matches("ObjectType=\"Note\"").count(), 1, "{vml}");
}
#[test]
fn a_note_spelled_in_another_case_or_quote_is_not_kept_beside_the_written_one() {
// The note's client data reads ObjectType='note': still a note, so the kept drawing drops it
// and the note GridCraft writes for the comment is the only one.
for attr in ["ObjectType='note'", "ObjectType = \"NOTE\"", "ObjectType=\" Note \""] {
let (wb, report) = read_xlsx(&vml_only_others(false, true, Some(attr))).unwrap();
assert_eq!(wb.sheet(0).unwrap().comments.len(), 1, "{attr}: {:?}", report.warnings);
let out = write_xlsx(&wb).unwrap();
let vml = kept_vml_only_others(&out);
assert_eq!(note_shapes(&vml), 1, "{attr}: {vml}");
let (back, _) = read_xlsx(&out).unwrap();
assert_eq!(back.sheet(0).unwrap().comments.len(), 1, "{attr}");
}
}
/// The note shapes in VML text, counted as the reader tells a note: by each shape's client data
/// `ObjectType`, whatever its spelling.
fn note_shapes(vml: &str) -> usize {
crate::xml::parse(vml.as_bytes()).unwrap().kids("shape").filter(|s| s.child("ClientData").is_some_and(crate::controls::is_note_data)).count()
}
/// The saved package's one VML drawing.
fn only_vml(out: &[u8]) -> String {
let mut pkg = Package::open(out).unwrap();
let names: Vec<String> = pkg.names().into_iter().filter(|n| n.ends_with(".vml")).collect();
assert_eq!(names.len(), 1, "{names:?}");
text(&mut pkg, &names[0])
}
/// A sheet's VML drawing, as the only part beside the sheet's relationships.
fn with_vml(shapes: &str) -> Vec<u8> {
let rels = r#"<?xml version="1.0"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId9" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/vmlDrawing" Target="../drawings/vmlDrawing1.vml"/></Relationships>"#;
let vml = format!(
r#"<xml xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office" xmlns:x="urn:schemas-microsoft-com:office:excel">{shapes}</xml>"#
);
minimal(
r#"<sheetData/><legacyDrawing r:id="rId9"/>"#,
&[("xl/worksheets/_rels/sheet1.xml.rels", rels), ("xl/drawings/vmlDrawing1.vml", &vml)],
"",
"",
)
}
/// Text that reads like a note's `ObjectType` in a check box's caption or in another attribute of
/// an option button doesn't make either a note: both are kept, and a modelled button with that
/// caption keeps it.
#[test]
fn a_control_whose_text_reads_like_a_note_object_type_is_kept() {
let bytes = with_vml(concat!(
r##"<v:shape id="_x0000_s1025" type="#_x0000_t201"><v:textbox><div>ObjectType="Note"</div></v:textbox><x:ClientData ObjectType="Button"><x:Anchor>2, 0, 3, 0, 4, 0, 5, 0</x:Anchor></x:ClientData></v:shape>"##,
r##"<v:shape id="_x0000_s1026" type="#_x0000_t201"><v:textbox><div>ObjectType="Note"</div></v:textbox><x:ClientData ObjectType="Checkbox"><x:Anchor>2, 0, 7, 0, 4, 0, 8, 0</x:Anchor><x:FmlaLink>$B$7</x:FmlaLink></x:ClientData></v:shape>"##,
r##"<v:shape id="_x0000_s1027" type="#_x0000_t201" alt="ObjectType='note'"><x:ClientData ObjectType="Radio"><x:Anchor>2, 0, 9, 0, 4, 0, 10, 0</x:Anchor></x:ClientData></v:shape>"##,
));
let (wb, report) = read_xlsx(&bytes).unwrap();
let sh = wb.sheet(0).unwrap();
assert_eq!(sh.controls.len(), 1, "{:?}", report.warnings);
assert_eq!(sh.controls[0].text, "ObjectType=\"Note\"");
let mut out = write_xlsx(&wb).unwrap();
for _ in 0..2 {
let vml = only_vml(&out);
assert_eq!(vml.matches("ObjectType=\"Checkbox\"").count(), 1, "the check box is kept: {vml}");
assert_eq!(vml.matches("ObjectType=\"Radio\"").count(), 1, "the option button is kept: {vml}");
assert_eq!(vml.matches("ObjectType=\"Button\"").count(), 1, "the button once: {vml}");
assert_eq!(note_shapes(&vml), 0, "{vml}");
let (back, _) = read_xlsx(&out).unwrap();
let sh = back.sheet(0).unwrap();
assert_eq!(sh.controls.len(), 1);
assert_eq!(sh.controls[0].text, "ObjectType=\"Note\"");
out = write_xlsx(&back).unwrap();
}
}
/// Kept VML leaves out the modelled controls by the number the reader gave them: the `id`, else
/// the `o:spid`, wherever either comes in the shape's tag. A button whose `o:spid` comes before a
/// different `id`, and one with a plain numeric `id`, are written once, from the model; a check
/// box whose `o:spid` is a modelled button's number but whose `id` is its own is kept; a button
/// with no number isn't modelled and is kept; notes are dropped whatever their numbers.
#[test]
fn kept_vml_leaves_out_modelled_controls_by_the_readers_shape_number() {
let bytes = with_vml(concat!(
r##"<v:shape o:spid="_x0000_s1030" id="_x0000_s1025" type="#_x0000_t201"><v:textbox><div>A</div></v:textbox><x:ClientData ObjectType="Button"><x:Anchor>2, 0, 1, 0, 4, 0, 2, 0</x:Anchor></x:ClientData></v:shape>"##,
r##"<v:shape id="1026" o:spid="_x0000_s1031" type="#_x0000_t201"><v:textbox><div>B</div></v:textbox><x:ClientData ObjectType="Button"><x:Anchor>2, 0, 3, 0, 4, 0, 4, 0</x:Anchor></x:ClientData></v:shape>"##,
r##"<v:shape o:spid="_x0000_s1025" id="_x0000_s1027" type="#_x0000_t201"><v:textbox><div>Paid</div></v:textbox><x:ClientData ObjectType="Checkbox"><x:Anchor>2, 0, 5, 0, 4, 0, 6, 0</x:Anchor></x:ClientData></v:shape>"##,
r##"<v:shape id="Button 9" type="#_x0000_t201"><v:textbox><div>C</div></v:textbox><x:ClientData ObjectType="Button"><x:Anchor>2, 0, 7, 0, 4, 0, 8, 0</x:Anchor></x:ClientData></v:shape>"##,
r##"<v:shape id="_x0000_s1028" o:spid="_x0000_s1040" type="#_x0000_t202"><x:ClientData ObjectType="Note"><x:Row>0</x:Row><x:Column>0</x:Column></x:ClientData></v:shape>"##,
r##"<v:shape type="#_x0000_t202"><x:ClientData ObjectType="Note"><x:Row>1</x:Row><x:Column>0</x:Column></x:ClientData></v:shape>"##,
));
let (wb, report) = read_xlsx(&bytes).unwrap();
let texts: Vec<&str> = wb.sheet(0).unwrap().controls.iter().map(|c| c.text.as_str()).collect();
assert_eq!(texts, ["A", "B"], "{:?}", report.warnings);
let mut out = write_xlsx(&wb).unwrap();
for _ in 0..2 {
let vml = only_vml(&out);
for caption in [">A</div>", ">B</div>", ">C</div>", ">Paid</div>"] {
assert_eq!(vml.matches(caption).count(), 1, "each control once, kept or written: {caption} {vml}");
}
assert_eq!(vml.matches("ObjectType=\"Checkbox\"").count(), 1, "the check box is kept: {vml}");
assert_eq!(note_shapes(&vml), 0, "{vml}");
let (back, _) = read_xlsx(&out).unwrap();
assert_eq!(back.sheet(0).unwrap().controls.len(), 2);
out = write_xlsx(&back).unwrap();
}
}
+66 -1
View File
@@ -849,6 +849,10 @@ const LOGO: &[u8] = b"\x89PNG\r\n\x1a\n letterhead logo";
const SIGNATURE: &[u8] = b"\x89PNG\r\n\x1a\n footer signature";
const HF_VML: &str = r##"<xml xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office" xmlns:x="urn:schemas-microsoft-com:office:excel"><o:shapelayout v:ext="edit"><o:idmap v:ext="edit" data="1"/></o:shapelayout><v:shapetype id="_x0000_t75" coordsize="21600,21600" o:spt="75" o:preferrelative="t" path="m@4@5l@4@11@9@11@9@5xe" filled="f" stroked="f"/><v:shape id="LH" o:spid="_x0000_s1025" type="#_x0000_t75" style="position:absolute;margin-left:0;margin-top:0;width:120pt;height:40pt;z-index:1"><v:imagedata o:relid="rId1" o:title="logo"/><o:lock v:ext="edit" rotation="t"/></v:shape><v:shape id="CF" o:spid="_x0000_s1026" type="#_x0000_t75" style="position:absolute;margin-left:0;margin-top:0;width:80pt;height:20pt;z-index:2"><v:imagedata o:relid="rId2" o:title="signature"/><o:lock v:ext="edit" rotation="t"/></v:shape></xml>"##;
/// The VML drawing of one note and nothing else. [`VML`] also holds a check box that only the
/// drawing lists, which is kept with the drawing (and its source shape block).
const NOTE_VML: &str = r##"<xml xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office" xmlns:x="urn:schemas-microsoft-com:office:excel"><o:shapelayout v:ext="edit"><o:idmap v:ext="edit" data="1"/></o:shapelayout><v:shape id="_x0000_s1027" type="#_x0000_t202" style="position:absolute"><v:textbox><div>old note<br></div></v:textbox><x:ClientData ObjectType="Note"><x:Row>0</x:Row><x:Column>0</x:Column></x:ClientData></v:shape></xml>"##;
/// #1257: a letterhead in the header and a picture in the footer (`&G`), drawn from a VML part
/// that `<legacyDrawingHF>` names, next to a note whose VML drawing GridCraft rewrites.
fn header_picture_package() -> Vec<u8> {
@@ -875,7 +879,7 @@ fn header_picture_package() -> Vec<u8> {
("xl/worksheets/sheet1.xml", sheet.as_bytes()),
("xl/worksheets/_rels/sheet1.xml.rels", sheet_rels.as_bytes()),
("xl/comments1.xml", COMMENTS.as_bytes()),
("xl/drawings/vmlDrawing1.vml", VML.as_bytes()),
("xl/drawings/vmlDrawing1.vml", NOTE_VML.as_bytes()),
("xl/drawings/vmlDrawing2.vml", HF_VML.as_bytes()),
("xl/drawings/_rels/vmlDrawing2.vml.rels", hf_rels.as_bytes()),
("xl/media/image1.png", LOGO),
@@ -938,3 +942,64 @@ fn notes_take_shape_blocks_the_header_pictures_dont_use() {
}
assert!(notes_ids.iter().all(|b| notes_map.contains(b)), "note ids outside their drawing's blocks");
}
#[test]
fn modelled_form_controls_join_kept_ones_in_one_controls_element() {
use gridcraft_model::{Anchor, ControlKind, FormControl};
let (mut wb, _) = read_xlsx(&rich_package()).unwrap();
assert!(wb.sheet(0).unwrap().controls.is_empty(), "a check box stays with pass-through");
let anchor = Anchor { cell: gridcraft_core::CellRef::new(8, 1), width: 120.0, height: 20.0, ..Default::default() };
let slider = FormControl { id: 900, kind: ControlKind::Slider, anchor, link: Some("$B$9".into()), value: 40, ..Default::default() };
if let Some(sh) = wb.sheet_mut(0) {
sh.controls.push(slider);
}
let out = write_xlsx(&wb).unwrap();
let u = Unpacked::new(&out);
u.assert_valid();
let sheet = sheet_part(&u, "Data");
let sx = u.text(&sheet);
assert_eq!(sx.matches("<controls").count(), 1, "{sx}");
// The kept check box and the slider, each with its own properties part.
let ids: Vec<&str> = sx.split("<control shapeId=\"").skip(1).filter_map(|r| r.split('"').next()).collect();
assert!(ids.contains(&"1026"), "{ids:?}");
let ours = ids.iter().find(|i| **i != "1026").copied().unwrap_or_default();
assert_eq!(u.find(CTRL_PROP.as_bytes()).len(), 1, "the kept properties part is written once");
// One VML drawing holds the kept shapes, the note and the slider, in fresh blocks.
let vml = u.text(&u.target(&sheet, &attr_of(&sx, "legacyDrawing", "r:id")).1);
assert!(vml.contains("_x0000_s1025") && vml.contains("_x0000_s1026"), "{vml}");
assert!(vml.contains(&format!("id=\"_x0000_s{ours}\"")) && vml.contains("ObjectType=\"Scroll\""), "{vml}");
assert!(vml.contains("id=\"_x0000_t201\""), "the form control shape type is defined");
assert_eq!(ours, "2050", "the note is 2049, the slider follows it");
// Read back: the slider is modelled again and the check box is kept, once.
let (wb2, rep) = read_xlsx(&out).unwrap();
assert!(!rep.warnings.iter().any(|w| w.contains("dropped")), "{:?}", rep.warnings);
let data = wb2.sheet(0).unwrap();
assert_eq!(data.controls.len(), 1);
assert_eq!((data.controls[0].kind, data.controls[0].value, data.controls[0].link.as_deref()), (ControlKind::Slider, 40, Some("$B$9")));
let kept = data.preserved.as_deref().unwrap();
let controls = kept.elements.iter().find(|e| e.slot == "controls").map(|e| e.xml.clone()).unwrap_or_default();
assert!(controls.contains("Check Box 1") && !controls.contains(&format!("shapeId=\"{ours}\"")), "{controls}");
let kept_vml = String::from_utf8_lossy(&kept.vml.as_ref().unwrap().data).into_owned();
assert!(!kept_vml.contains("ObjectType=\"Scroll\""), "the modelled slider's shape is not kept");
// A second save writes the same controls again, without duplicates.
let again = Unpacked::new(&write_xlsx(&wb2).unwrap());
let sx2 = again.text(&sheet_part(&again, "Data"));
assert_eq!(sx2.matches("<control shapeId=").count(), sx.matches("<control shapeId=").count(), "{sx2}");
again.assert_valid();
}
#[test]
fn a_sheet_of_modelled_controls_keeps_nothing_for_them() {
use gridcraft_model::{ControlKind, FormControl};
let mut wb = gridcraft_model::Workbook::default();
if let Some(sh) = wb.sheet_mut(0) {
sh.controls.push(FormControl { id: 1, kind: ControlKind::Button, text: "Go".into(), ..Default::default() });
sh.controls.push(FormControl { id: 2, kind: ControlKind::Spin, ..Default::default() });
}
let (back, rep) = read_xlsx(&write_xlsx(&wb).unwrap()).unwrap();
assert!(rep.warnings.is_empty(), "{:?}", rep.warnings);
let sh = back.sheet(0).unwrap();
assert_eq!(sh.controls.len(), 2);
assert!(sh.preserved.is_none(), "{:?}", sh.preserved);
}
+7
View File
@@ -120,6 +120,10 @@ pub struct Out {
pub drawings: u32,
pub tables: u32,
pub comments: u32,
/// VML drawings written, the last VML shape-id block used, and control properties parts.
pub vmls: u32,
pub vml_block: u32,
pub ctrl_props: u32,
/// Authors of threaded comments, in first-use order; written to `xl/persons/person.xml`.
pub persons: Vec<String>,
pub table_id: u32,
@@ -255,6 +259,9 @@ pub fn write_xlsx_named(wb: &Workbook, kind: XlsxKind, book_name: Option<&str>)
drawings: 0,
tables: 0,
comments: 0,
vmls: 0,
vml_block: 0,
ctrl_props: 0,
persons: vec![],
table_id: 0,
dynamic_arrays: false,
+29 -3
View File
@@ -213,8 +213,33 @@ pub fn parse(xml: &[u8]) -> Result<El, IoError> {
parse_streaming(xml, &[], &mut |_| Ok(()))
}
/// Parses a part; complete elements named in `split` (below the root) are handed to `cb` and not
/// kept in the returned tree.
/// The first element named `local_name` (namespace prefix dropped) in `xml`, read from its start tag
/// alone: no children, and nothing after the tag is read, so unbalanced markup around it (a VML
/// caption's unclosed `<br>`) neither hides nor moves it. `None` when there is none, or when the
/// markup before it is malformed.
pub fn first_start(xml: &[u8], local_name: &str) -> Option<El> {
let mut reader = Reader::from_reader(xml);
{
let cfg = reader.config_mut();
cfg.check_end_names = false;
cfg.allow_unmatched_ends = true;
cfg.allow_dangling_amp = true;
}
let mut buf = Vec::new();
loop {
match reader.read_event_into(&mut buf).ok()? {
Event::Start(e) | Event::Empty(e) if local(e.name().as_ref()) == local_name => return Some(start_el(&e)),
Event::Eof => return None,
_ => {}
}
buf.clear();
}
}
/// Parses a part; complete elements named in `split` that are children or grandchildren of the
/// root (`sst/si`, `worksheet/sheetData/row`) are handed to `cb` and not kept in the returned
/// tree. Deeper elements of the same name (the `xdr:row` of a form control's anchor) stay in the
/// tree.
pub fn parse_streaming(xml: &[u8], split: &[&str], cb: &mut dyn FnMut(El) -> Result<(), IoError>) -> Result<El, IoError> {
let data = to_utf8(xml);
let mut reader = Reader::from_reader(data.as_ref());
@@ -245,9 +270,10 @@ pub fn parse_streaming(xml: &[u8], split: &[&str], cb: &mut dyn FnMut(El) -> Res
kept: &mut usize,
kept_at_start: Option<usize>,
) -> Result<(), IoError> {
let shallow = stack.len() <= 2;
match stack.last_mut() {
Some(parent) => {
if split.contains(&el.name.as_str()) {
if shallow && split.contains(&el.name.as_str()) {
cb(el)?;
// The element and everything parsed inside it have left the tree.
if let Some(k) = kept_at_start {
+3 -1
View File
@@ -93,12 +93,14 @@ include `window` or `doc` to run it in that window, as with `engine.execute`; th
them as their own parameters.
`ui.inspect` reports the selected floating object as `selectedObject: {kind, id}` (or `null`),
where `kind` is `chart`, `image`, `shape`, `equation` or `slicer`; the same names go in the `kind` parameter
where `kind` is `chart`, `image`, `shape`, `equation`, `slicer` or `control` (a form button, slider or spin
button); the same names go in the `kind` parameter
of `object.delete`, `object.move`, `object.setAnchorMode` and the `arrange.*` commands. An
equation is a shape too, so its id works under `shape`; a plain shape's id given as `equation`,
a `kind` that is present but not one of these names (`null` included), or an id of no object of
the kind, is an error. `arrange.align` and `arrange.group` check every id in `ids` the same way;
with fewer than two ids they change nothing.
Form controls are worked with `control.setValue` and `control.press`, and listed by `control.list`.
`object.move` and `chart.set` cap `width` and `height` at 10,000 points, or at the object's
current size when a file made it larger: such an object keeps its size when it is moved and
shrinks by what a resize asks, but does not grow further.
+3 -2
View File
@@ -1,6 +1,6 @@
# File-format parity
> **Last reviewed:** 2026-11-01 · **Last updated:** 2026-11-03 · **Change:** minor (a web page's first table opens as a sheet, also when the page is named `.xls`; earlier: `workbookPr filterPrivacy` kept, and honoured on save; earlier: a workbook's R1C1 reference style, `calcPr refMode`, is kept through open and save; earlier: XLSX page margins are written as their decimals, `0.3` rather than `0.30000001192092896`; earlier: Python cells in `.xlsx` as [MS-XLSX] specifies, `_xlfn._xlws.PY(…)` naming code in the Python part, with cached values, and in GridCraft JSON, with limits on open; earlier: equations in xlsx as Office Math with a marker in `descr` so an edit made in Excel reads back, read back only up to the equation source cap, and a brace with a label and a script on its other side keeps both; Symbol font in PDF; earlier: XLSX reads and writes Page Setup's page order, `pageSetup pageOrder`, and PDF export follows it; earlier: chart data tables: native c:dTable import/export; earlier: XLSX workbook-view selection, normalized tab ratios and sheet-edit regressions; fidelity estimates unchanged; earlier: formulas from Google Sheets' XLSX export open as the original formula instead of `IFERROR(__xludf.DUMMYFUNCTION(…),…)` when GridCraft calculates every function in it, and save as it; `file.exportCsv {values: true}` writes raw values for automation; .xls dialog sheets are skipped instead of read as worksheets; `.xls` import reads each sheet's display options from WINDOW2: right-to-left, gridlines, headings, zeros, formulas; XLSX text boxes and shapes keep their paragraphs' right-to-left direction on save; CSV/TSV/TXT can open with every field kept as text via `file.open {csvAsText}`; formulas over Excel's 8,192 characters, and `%` or call chains past the nesting limit, are parse errors instead of stack overflows when a file is opened; XLSX PivotTables read and write `rowGrandTotals`/`colGrandTotals` the way Excel does, so a table saved with only one grand total opens with the right one; HTML export keeps right-to-left; CSV and text import detects likely Windows-1256 Arabic when invalid UTF-8 has Arabic byte runs; other invalid UTF-8 remains Windows-1252; HTML export places pictures and charts, as inline SVG, at their anchors; earlier: `file.exportCsv {values: true}` writes raw values for automation; .xls dialog sheets are skipped instead of read as worksheets; `.xls` import reads each sheet's display options from WINDOW2: right-to-left, gridlines, headings, zeros, formulas; XLSX text boxes and shapes keep their paragraphs' right-to-left direction on save; CSV/TSV/TXT can open with every field kept as text via `file.open {csvAsText}`; formulas over Excel's 8,192 characters, and `%` or call chains past the nesting limit, are parse errors instead of stack overflows when a file is opened; XLSX PivotTables read and write `rowGrandTotals`/`colGrandTotals` the way Excel does, so a table saved with only one grand total opens with the right one; HTML export keeps right-to-left; CSV and text import detects likely Windows-1256 Arabic when invalid UTF-8 has Arabic byte runs; other invalid UTF-8 remains Windows-1252; HTML export places pictures and charts, as inline SVG, at their anchors; CSV and text open detects each column's date order from values that read only one way, warns when every date is ambiguous, and keeps a column with both kinds as text (#2765)) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4
> **Last reviewed:** 2026-11-01 · **Last updated:** 2026-11-03 · **Change:** minor (form controls: buttons, scroll bars and spin buttons read and written; earlier: a web page's first table opens as a sheet, also when the page is named `.xls`; earlier: `workbookPr filterPrivacy` kept, and honoured on save; earlier: a workbook's R1C1 reference style, `calcPr refMode`, is kept through open and save; earlier: XLSX page margins are written as their decimals, `0.3` rather than `0.30000001192092896`; earlier: Python cells in `.xlsx` as [MS-XLSX] specifies, `_xlfn._xlws.PY(…)` naming code in the Python part, with cached values, and in GridCraft JSON, with limits on open; earlier: equations in xlsx as Office Math with a marker in `descr` so an edit made in Excel reads back, read back only up to the equation source cap, and a brace with a label and a script on its other side keeps both; Symbol font in PDF; earlier: XLSX reads and writes Page Setup's page order, `pageSetup pageOrder`, and PDF export follows it; earlier: chart data tables: native c:dTable import/export; earlier: XLSX workbook-view selection, normalized tab ratios and sheet-edit regressions; fidelity estimates unchanged; earlier: formulas from Google Sheets' XLSX export open as the original formula instead of `IFERROR(__xludf.DUMMYFUNCTION(…),…)` when GridCraft calculates every function in it, and save as it; `file.exportCsv {values: true}` writes raw values for automation; .xls dialog sheets are skipped instead of read as worksheets; `.xls` import reads each sheet's display options from WINDOW2: right-to-left, gridlines, headings, zeros, formulas; XLSX text boxes and shapes keep their paragraphs' right-to-left direction on save; CSV/TSV/TXT can open with every field kept as text via `file.open {csvAsText}`; formulas over Excel's 8,192 characters, and `%` or call chains past the nesting limit, are parse errors instead of stack overflows when a file is opened; XLSX PivotTables read and write `rowGrandTotals`/`colGrandTotals` the way Excel does, so a table saved with only one grand total opens with the right one; HTML export keeps right-to-left; CSV and text import detects likely Windows-1256 Arabic when invalid UTF-8 has Arabic byte runs; other invalid UTF-8 remains Windows-1252; HTML export places pictures and charts, as inline SVG, at their anchors; earlier: `file.exportCsv {values: true}` writes raw values for automation; .xls dialog sheets are skipped instead of read as worksheets; `.xls` import reads each sheet's display options from WINDOW2: right-to-left, gridlines, headings, zeros, formulas; XLSX text boxes and shapes keep their paragraphs' right-to-left direction on save; CSV/TSV/TXT can open with every field kept as text via `file.open {csvAsText}`; formulas over Excel's 8,192 characters, and `%` or call chains past the nesting limit, are parse errors instead of stack overflows when a file is opened; XLSX PivotTables read and write `rowGrandTotals`/`colGrandTotals` the way Excel does, so a table saved with only one grand total opens with the right one; HTML export keeps right-to-left; CSV and text import detects likely Windows-1256 Arabic when invalid UTF-8 has Arabic byte runs; other invalid UTF-8 remains Windows-1252; HTML export places pictures and charts, as inline SVG, at their anchors; CSV and text open detects each column's date order from values that read only one way, warns when every date is ambiguous, and keeps a column with both kinds as text (#2765)) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4
Every format Excel opens or saves, with GridCraft's read and write support, fidelity and tests.
Part of [target-app-parity.md](target-app-parity.md). **File formats: ~50% ready, 80–120 h**
@@ -76,7 +76,7 @@ past the parser's limit, and a wrapper inside the text. **Saving writes the real
| Format | Excel | GridCraft read | GridCraft write | Fidelity / notes | Tests |
|---|---|---|---|---|---|
| `.xlsx` Excel Workbook (transitional) | R/W, native | yes | yes | Cells, shared/array/dynamic formulas, shared strings (with their Japanese phonetic runs — ふりがな — kept through open + save: `<rPh>` offsets and readings and `<phoneticPr>` are written back next to the same string; the table holds each text once, so duplicate `<si>` items whose text is the same but whose readings differ keep only the first reading, with a warning on open), styles, themes, merges, CF, validation, tables, comments and notes, hyperlinks, names, freeze panes, outline, print settings, pictures, shapes, equations (Office Math with a plain-text fallback and the LaTeX source; Office Math from other writers read for a common subset), charts (simplified, see [chart-parity.md](chart-parity.md)), sparklines, PivotTables (caches and tables), protection, calc settings, show objects (`workbookPr showObjects`). **Kept unchanged on save (pass-through, `passthrough.rs`):** custom document properties, ribbon customisation and add-in task panes, custom XML, links to external workbooks (never followed or refreshed), data connections, sheet query tables, printer settings (#324), form controls, ActiveX controls, OLE objects and sheet backgrounds (with their VML shapes), and chart sheets (#727: the sheet, drawing, chart, style and colour parts are written back unchanged in their place, and left out when a sheet they plot was deleted or renamed or a worksheet took their name; they can't be edited or created); dropped when their sheet is deleted or their parts point at content GridCraft rewrites. **Not preserved:** slicers and slicer caches, timelines, data model, query tables attached to tables, sheet and workbook `extLst` entries we don't model, chart details; Strict Open XML files keep nothing. Never checked in Excel across a corpus: repair prompts unknown. 100 MB files fail (#175) A picture used on several sheets is written once (#985). | `roundtrip.rs`, `fixtures.rs`, `malformed.rs`, `pivot_tests.rs` |
| `.xlsx` Excel Workbook (transitional) | R/W, native | yes | yes | Cells, shared/array/dynamic formulas, shared strings (with their Japanese phonetic runs — ふりがな — kept through open + save: `<rPh>` offsets and readings and `<phoneticPr>` are written back next to the same string; the table holds each text once, so duplicate `<si>` items whose text is the same but whose readings differ keep only the first reading, with a warning on open), styles, themes, merges, CF, validation, tables, comments and notes, hyperlinks, names, freeze panes, outline, print settings, pictures, shapes, equations (Office Math with a plain-text fallback and the LaTeX source; Office Math from other writers read for a common subset), charts (simplified, see [chart-parity.md](chart-parity.md)), sparklines, PivotTables (caches and tables), form controls (buttons, scroll bars and spin buttons: control properties parts, `<controls>` and VML `x:ClientData`; a VML-only control is read too; a button's command, fixed input range or output cell travels in a hidden defined name and comes back only onto the same button while the file still assigns its macro, or none for a command), protection, calc settings, show objects (`workbookPr showObjects`). **Kept unchanged on save (pass-through, `passthrough.rs`):** custom document properties, ribbon customisation and add-in task panes, custom XML, links to external workbooks (never followed or refreshed), data connections, sheet query tables, printer settings (#324), other form controls (check boxes, list boxes, drop-downs; written in the same `<controls>` element and VML drawing as the modelled ones, also those only the VML drawing lists, with no `<controls>` entry), ActiveX controls, OLE objects and sheet backgrounds (with their VML shapes), and chart sheets (#727: the sheet, drawing, chart, style and colour parts are written back unchanged in their place, and left out when a sheet they plot was deleted or renamed or a worksheet took their name; they can't be edited or created); dropped when their sheet is deleted or their parts point at content GridCraft rewrites. **Not preserved:** slicers and slicer caches, timelines, data model, query tables attached to tables, sheet and workbook `extLst` entries we don't model, chart details; Strict Open XML files keep nothing. Never checked in Excel across a corpus: repair prompts unknown. 100 MB files fail (#175) A picture used on several sheets is written once (#985). | `roundtrip.rs`, `fixtures.rs`, `malformed.rs`, `pivot_tests.rs` |
| `.xlsx` Excel Workbook (transitional) | R/W, native | yes | yes | Cells, shared/array/dynamic formulas, shared strings (with their Japanese phonetic runs — ふりがな — kept through open + save: `<rPh>` offsets and readings and `<phoneticPr>` are written back next to the same string; the table holds each text once, so duplicate `<si>` items whose text is the same but whose readings differ keep only the first reading, with a warning on open), styles, themes, merges, CF, validation, tables, notes (with their shown/hidden state) and threaded comments with replies, resolved state and timestamps (`threadedComments` and `persons` parts, #300), hyperlinks, names, freeze panes, outline, print settings, pictures, shapes, equations (Office Math with a plain-text fallback and the LaTeX source; Office Math from other writers read for a common subset), charts (simplified, see [chart-parity.md](chart-parity.md)), sparklines, PivotTables (caches and tables), protection, calc settings, show objects (`workbookPr showObjects`). **Not preserved:** unmodelled parts and extensions (form controls, slicer caches, timelines, external links, OLE objects, custom XML, data model, query tables, chart details). Never checked in Excel across a corpus: repair prompts unknown. 100 MB files fail (#175) A picture used on several sheets is written once (#985). | `roundtrip.rs`, `fixtures.rs`, `malformed.rs`, `pivot_tests.rs` |
| `.xlsx` Excel Workbook (transitional) | R/W, native | yes | yes | Cells, shared/array/dynamic formulas, shared strings (with their Japanese phonetic runs — ふりがな — kept through open + save: `<rPh>` offsets and readings and `<phoneticPr>` are written back next to the same string; the table holds each text once, so duplicate `<si>` items whose text is the same but whose readings differ keep only the first reading, with a warning on open), styles, themes, merges, CF, validation, tables, comments and notes, hyperlinks, names, freeze panes, outline, print settings, pictures, shapes, equations (Office Math with a plain-text fallback and the LaTeX source; Office Math from other writers read for a common subset), charts (simplified, see [chart-parity.md](chart-parity.md)), sparklines, PivotTables (caches and tables), protection, Allow Edit Ranges (`protectedRanges` with their password hashes, #307), calc settings, show objects (`workbookPr showObjects`). **Not preserved:** unmodelled parts and extensions (form controls, slicer caches, timelines, external links, OLE objects, custom XML, data model, query tables, chart details). Never checked in Excel across a corpus: repair prompts unknown. 100 MB files fail (#175) A picture used on several sheets is written once (#985). | `roundtrip.rs`, `fixtures.rs`, `malformed.rs`, `pivot_tests.rs` |
| `.xlsx` Excel Workbook (transitional) | R/W, native | yes | yes | Cells, shared/array/dynamic formulas, styles, themes (the major/minor `latin`, East Asian `ea` and complex-script `cs` typefaces and the per-script `<a:font script>` lists are kept through a save), merges, CF, validation, tables, comments and notes, hyperlinks, names, freeze panes, outline, print settings, pictures, shapes, equations (Office Math with a plain-text fallback and the LaTeX source; Office Math from other writers read for a common subset), charts (simplified, see [chart-parity.md](chart-parity.md)), sparklines, PivotTables (caches and tables), protection, calc settings. **Kept unchanged on save (pass-through, `passthrough.rs`):** custom document properties, ribbon customisation and add-in task panes, custom XML, links to external workbooks (never followed or refreshed), data connections, sheet query tables, printer settings (#324), form controls, ActiveX controls, OLE objects and sheet backgrounds (with their VML shapes), pictures in headers and footers (`&G`, #1257: their VML drawing and images are written back unchanged; they are not shown, and can't be inserted), and chart sheets (#727: the sheet, drawing, chart, style and colour parts are written back unchanged in their place, and left out when a sheet they plot was deleted or renamed or a worksheet took their name; they can't be edited or created); dropped when their sheet is deleted or their parts point at content GridCraft rewrites. **Not preserved:** slicers and slicer caches, timelines, data model, query tables attached to tables, sheet and workbook `extLst` entries we don't model, chart details; Strict Open XML files keep nothing. Never checked in Excel across a corpus: repair prompts unknown. 100 MB files fail (#175) | `roundtrip.rs`, `fixtures.rs`, `malformed.rs`, `pivot_tests.rs` |
@@ -158,6 +158,7 @@ refresh. Counted under features (Get & Transform, ~10% ready), not here.
| Date | Change | Summary |
|---|---|---|
| 2026-11-02 | minor | XLSX: buttons, scroll bars and spin buttons read and written (control properties parts, `<controls>`, VML `x:ClientData`); other form controls stay with pass-through; a button's command, fixed input or output cell travels in a hidden defined name and comes back only while the file still assigns the button's macro (or none, for a command) |
| 2026-11-03 | minor | SYLK (`.slk`) import: values, R1C1 formulas as A1 (shared formulas too) with cached results, number formats and column widths; import only (#1481) |
| 2026-11-03 | minor | Opening a web page (HTML) reads its first table as a sheet, whatever the file is called: a web app's "Export to Excel" saved as `.xls` no longer opens as lines of markup. Values read as in a CSV (or as text with `csvAsText`), `<th>` cells are bold and centred, `<x:Name>` names the sheet, and a page not named `.htm`/`.html` opens with a warning (#3166) |
| 2026-11-03 | minor | XLSX `<workbookPr filterPrivacy="1"/>` (Remove personal information from file properties on save) is read and written back, and while it is on GridCraft saves no author (`dc:creator`) and no company, as Excel leaves them empty (#3089, file part) |
+42 -7
View File
@@ -1,6 +1,6 @@
# Macros
> **Last updated:** 2026-11-01 · **Change:** minor (a close that waits on the step editor's question runs in the editor's window, and a further close asked meanwhile waits for the answer or is refused; earlier: closing a workbook or quitting asks about the step editor's unsaved draft first; recording macros and the management commands; Record Macro takes a name, shortcut and description; the macro interface: Automate › Macros, the Macros and Record Macro dialogs, the step editor, shortcuts)
> **Last updated:** 2026-11-02 · **Change:** minor (form buttons run macros, `macro.run` takes `output`, and a button action kept in a hidden name comes back on reopen only while the file still agrees with it; earlier: a close that waits on the step editor's question runs in the editor's window, and a further close asked meanwhile waits for the answer or is refused; earlier: closing a workbook or quitting asks about the step editor's unsaved draft first; recording macros and the management commands; Record Macro takes a name, shortcut and description; the macro interface: Automate › Macros, the Macros and Record Macro dialogs, the step editor, shortcuts)
GridCraft macros are typed, declarative lists of commands, not VBA. Every step runs a GridCraft
command (the same ids the ribbon, CLI and MCP use) or one of three bounded control steps. A macro
@@ -98,12 +98,18 @@ refused rather than read as the header.
## Running
`macro.run {name, inputs?, onConflict?, overwrite?, approve?, timeLimit?}`:
`macro.run {name, inputs?, output?, onConflict?, overwrite?, approve?, timeLimit?}` (`output`
replaces the macro's output anchor for this run, as a form button that sends the output elsewhere
does; `macro.check` and the debugger take it too). The policy and the approval go by the macro as
it is stored; the footprint, the conflict rule and the check of each step work on the cells
`output` names, so data there is protected as at the macro's own anchor. An `output` that isn't
reference text of at most 1,024 characters, or that names no cell (a bad reference, a sheet that
doesn't exist, a cell outside the grid), is refused before anything runs:
1. **Policy.** File commands other than Properties, exports and printing (such as Open, Save
and Close), app, window (including New Window, Hide and Unhide), automate and macro commands,
and Undo and Redo, never run in a macro. File › Properties runs, and so do exports and
printing (`file.exportPdf`, `exportCsv`, `exportHtml`, `print`, `printPreview`, `saveBytes`)
Undo and Redo, and pressing a form control (`control.press`), never run in a macro. File ›
Properties runs, and so do exports and printing (`file.exportPdf`, `exportCsv`, `exportHtml`, `print`, `printPreview`, `saveBytes`)
that only produce bytes. Reading or writing a file (`data.getData`, `data.fromTextCsv`,
`insert.picture` or `pageLayout.background` with a `path`, or an export or print with a
`path`) needs the command in the macro's `permissions`. Any `path` other than `null` counts,
@@ -138,9 +144,9 @@ refused rather than read as the header.
do; a step that deletes one of them, or changes the size of a range a Repeat is going over,
stops the run.
`macro.check {name, inputs?, onConflict?, overwrite?, timeLimit?}` reports the bound inputs, the
output anchor, any approval needed and the footprint, and puts back everything the dry run
changed. `ok` says whether `macro.run` with the same parameters would go ahead without a question;
`macro.check {name, inputs?, output?, onConflict?, overwrite?, timeLimit?}` reports the bound
inputs, the output anchor (`output` when given), any approval needed and the footprint, and puts
back everything the dry run changed. `ok` says whether `macro.run` with the same parameters would go ahead without a question;
when it wouldn't, `errors` says why: the run would fail at a step, a Stop step rolls it back, it
would write over data under `ask` or `fail`, shifting can't make room (the check shifts and
checks again, then puts everything back), or the run would go past
@@ -246,6 +252,35 @@ kept but does not run the macro.
{name, to}`. Saving under a name a macro has is refused unless `replace: true` is passed, and
renaming to one is refused; names compare ignoring the case of the letters A to Z. A renamed macro is checked
against every limit again, its size included. Saving, renaming and deleting are undoable.
Renaming a macro also renames it in the form buttons that run it.
## Form buttons
A form button (Insert › Controls › Button, `insert.button`) runs a macro or one command when it is
pressed (`control.press {id, approve?, onConflict?}`). `control.assign {id, macro, input?,
output?}` makes it run a macro, on the selection when it is pressed (`input: "selection"`, the
default) or always on one range (`input: "B2:D9"`), with an optional output cell; `control.assign
{id, command, params}` makes it run a command with fixed parameters. A macro runs through
`macro.run` with its checks; a command goes through the same policy as a macro step and runs, as
a step does, inside the scripts' file sandbox unless it is an approved file command, so a button
in a workbook opened from a file asks before it runs anything. Like a step, a button's command
never opens a web page or a dialog: `link.open` is refused when it is assigned or comes in from
a file, and a command that would open a dialog or a page once it runs (a command given no
parameters opens its dialog) is refused then, with the request dropped and nothing done.
In the window, a press that asks (approve the action, or overwrite data in the macro's way) opens
a prompt whose answer applies only to what it asked about: if the workbook, the sheet, the
button, its action, its macro or the selection changes while the prompt is open (an agent
reassigns the button), the answer runs nothing and the next press asks again.
In xlsx a button carries its macro as Excel writes it (`macro="[0]!Name"`, a macro run on the
selection). A command, a fixed input range or an output cell has no place there and is kept in a
hidden defined name (`_gc_control_actions_<k>`), keyed by the sheet, the button's place, name and
anchor cell. When the file is opened the hidden name is removed and each kept action goes back
onto its button only when the file still agrees with it: a kept macro action needs the file to
assign that macro still, so an assignment removed in another application stays removed and a
button given another macro keeps that one; a kept command needs the file to assign no macro. A
button whose name, anchor cell or place changed, or which another application removed, gets no
kept action.
Macros are saved in the workbook: as a field in GridCraft's JSON format, and in xlsx as hidden
defined names (`_gc_macro_<n>_<k>`, the macro's JSON split into string literals within Excel's
+11 -1
View File
@@ -4,7 +4,7 @@
Each entry is an Excel ribbon or menu command (feature names only). It counts as implemented when GridCraft registers a command with the same id. This measures breadth only; the overall assessment (depth, fidelity, effort) is in [target-app-parity.md](target-app-parity.md).
**Overall: 277 / 292 commands implemented (94.9%).**
**Overall: 281 / 296 commands implemented (94.9%).**
| Tab | Implemented | Total | Parity |
|---|---|---|---|
@@ -18,6 +18,7 @@ Each entry is an Excel ribbon or menu command (feature names only). It counts as
| Review | 15 | 18 | 83% |
| View | 21 | 21 | 100% |
| Automate | 4 | 4 | 100% |
| Developer | 4 | 4 | 100% |
| Table Design | 13 | 13 | 100% |
| Chart Design | 8 | 8 | 100% |
| Format | 3 | 3 | 100% |
@@ -334,6 +335,15 @@ Implemented:
- `automate.recordActions` Record Actions (Scripting Tools)
- `automate.trustScripts` Trust Scripts (Scripting Tools)
## Developer (4 / 4, 100%)
Implemented:
- `insert.button` Button (Form Control) (Controls)
- `insert.spinButton` Spin Button (Form Control) (Controls)
- `insert.slider` Scroll Bar (Form Control) (Controls)
- `control.format` Properties (Controls)
## Table Design (13 / 13, 100%)
Implemented:
+9 -7
View File
@@ -1,6 +1,6 @@
# Target-app parity: GridCraft vs Microsoft Excel
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-11-02 · **Change:** minor (insert objects: equations; earlier: what-if and analysis: Solver Sensitivity report; no score change; earlier: chart resizing no longer listed as missing; no score change; earlier: what-if and analysis: Analysis ToolPak has all 19 tools; earlier: page layout and print: Page Setup's page order; no score change; earlier: charts: data tables with XLSX round-trip and print/PDF; no score change; earlier: what-if and analysis: Solver Options; the Insert Function dialog is named as in Excel; no score change; earlier: Solver Load/Save Model and Reset All; automation: macro interface; catalog count 276 / 292, as `cargo xtask parity` measures; automation: typed macros in the engine; several windows: a native window per workbook window, New Window on the same workbook, Arrange All; no score change) · **Target:** Microsoft Excel (Microsoft 365)
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-11-02 · **Change:** minor (form controls; catalog 281 / 296; earlier: insert objects: equations; earlier: what-if and analysis: Solver Sensitivity report; no score change; earlier: chart resizing no longer listed as missing; no score change; earlier: what-if and analysis: Analysis ToolPak has all 19 tools; earlier: page layout and print: Page Setup's page order; no score change; earlier: charts: data tables with XLSX round-trip and print/PDF; no score change; earlier: what-if and analysis: Solver Options; the Insert Function dialog is named as in Excel; no score change; earlier: Solver Load/Save Model and Reset All; automation: macro interface; catalog count 276 / 292, as `cargo xtask parity` measures; automation: typed macros in the engine; several windows: a native window per workbook window, New Window on the same workbook, Arrange All; no score change) · **Target:** Microsoft Excel (Microsoft 365)
This is the authoritative parity assessment. [`ROADMAP.md`](../ROADMAP.md) summarizes it,
[`gaps.md`](gaps.md) lists every shortfall, and the area checklists hold the detail:
@@ -13,7 +13,7 @@ generated ribbon checklist is [`parity-checklist.md`](parity-checklist.md).
| Number | Value | Kind |
|---|---|---|
| Feature breadth (weighted by use) | **~75%** | estimated from the area table below; anchored on two measurements |
| Ribbon and menu catalog | **277 / 292 (94.9%)** | measured: `cargo xtask parity` (`crates/engine/src/catalog.rs` ∩ registered command ids) |
| Ribbon and menu catalog | **281 / 296 (94.9%)** | measured: `cargo xtask parity` (`crates/engine/src/catalog.rs` ∩ registered command ids) |
| Worksheet functions, by name | **513 / 523 (98.1%)** | measured: registered functions vs Microsoft's published function list (method below) |
| Worksheet functions that do real work | **504 / 523 (96.4%)** | measured: excludes 9 stubs (7 CUBE functions, WEBSERVICE, FILTERXML) |
| **Ready for real work** | **~50%** | estimated; written weights below (51.6 computed) |
@@ -54,11 +54,12 @@ long `.xls` track, so about half parallelizes (≈30–50 wall-clock hours with
COPILOT and PY: **523 functions**. Ours: every `f!(…)` spec in `crates/functions/src/*.rs` plus
`SPECIAL_FUNCTIONS` in `crates/calc/src/eval.rs` (evaluator special forms), minus the test-only
`ADD2`: **505**, all of them Excel names.
- **Ribbon catalog:** `crates/engine/src/catalog.rs` (292 unique ids across 15 tabs) against the
313 command ids registered in `crates/engine/src/cmd/*.rs`, recomputed from source by script and
- **Ribbon catalog:** `crates/engine/src/catalog.rs` (296 unique ids across 16 tabs) against the
321 command ids registered in `crates/engine/src/cmd/*.rs`, recomputed from source by script and
identical to the committed `parity-checklist.md`. The catalog is our own transcription of
Excel's main ribbon tabs; it leaves out most contextual tabs (Picture Format, Shape Format,
Header & Footer, Sparkline, Slicer, Timeline), the Developer and Help tabs and sub-menu items, so
Header & Footer, Sparkline, Slicer, Timeline), most of the Developer tab (its form controls are
in), the Help tab and sub-menu items, so
89% of it is roughly **60–65% of Excel's full command surface** (estimated).
- **Depth and readiness:** source reading (the model types in `crates/model/src/features.rs`,
readers and writers in `crates/xlsx/src`, the evaluator), 478 `#[test]` functions, and the
@@ -91,9 +92,9 @@ performance 8, stability 8, localization 4, hardware 2, platforms 2, ecosystem 2
| Page layout, print, PDF | 5 | ~80% | ~55% | 12–20 | Page setup, print titles, breaks, page order, Page Break Preview, headers/footers, PDF export and printing. No true Page Layout view; the PDF writer uses only the 14 standard fonts without embedding (no CJK or other non-Latin text in PDF); web print only exports (#141, #143) |
| What-if and analysis | 4 | ~50% | ~40% | 22–35 | Goal Seek, Scenario Manager, data tables, Analysis ToolPak (all 19 tools, no charts), Solver (Simplex LP, a local nonlinear method and Evolutionary; Answer, Sensitivity and Limits reports; Load/Save Model, Reset All and Solver Options; no Population report), Forecast Sheet. Missing: Analyze Data |
| Get & Transform, external data, data model | 5 | ~15% | ~10% | 60–100 | Import of CSV/TSV, JSON, HTML. No Power Query editor, no From Web, database or folder sources, no connections or refresh, no relationships or data model, no external workbook links (dropped with a warning) |
| Insert objects | 3 | ~55% | ~45% | 25–40 | Pictures, 8 shape kinds (Excel has ~170), text boxes, own icon set, ink with Ink to Shape, hyperlinks, checkboxes, equations (LaTeX with a live preview, Office Math in xlsx, Symbol font in PDF). Missing: SmartArt, WordArt, signature line, objects, 3-D models, screenshot, pictures in cells (PR #105) |
| Insert objects | 3 | ~55% | ~45% | 25–40 | Pictures, 8 shape kinds (Excel has ~170), text boxes, own icon set, ink with Ink to Shape, hyperlinks, checkboxes, form buttons, scroll bars and spin buttons, equations (LaTeX with a live preview, Office Math in xlsx, Symbol font in PDF). Missing: SmartArt, WordArt, signature line, objects, 3-D models, screenshot, pictures in cells (PR #105) |
| Review, comments, protection, collaboration | 4 | ~55% | ~40% | 35–55 | Threaded comments, notes, spelling (system dictionary), thesaurus, sheet and workbook protection, Allow Edit Ranges with Excel-compatible password hashes (#307). Missing: comment navigation, Show Changes, notes-to-comments conversion, accessibility checker, co-authoring, opening password-encrypted files (refused with a message since #17) |
| Automation | 4 | ~30% | ~20% | 12–20 | Recorded, editable GridCraft scripts (Automate tab). Typed macros in the engine (`macro.*`: recorder, runner with input checks and a footprint dry run, debugger; [macros.md](macros.md)), recorded from Automate › Macros, edited and debugged in a step editor, and run from the Macros dialog or a Ctrl+Shift shortcut. VBA is out of scope by owner decision: an `.xlsm` keeps its VBA project unchanged on save and never runs it. No Office Scripts compatibility |
| Automation | 4 | ~30% | ~20% | 12–20 | Recorded, editable GridCraft scripts (Automate tab). Typed macros in the engine (`macro.*`: recorder, runner with input checks and a footprint dry run, debugger; [macros.md](macros.md)), recorded from Automate › Macros, edited and debugged in a step editor, and run from the Macros dialog, a Ctrl+Shift shortcut or a form button (which can also run one command). VBA is out of scope by owner decision: an `.xlsm` keeps its VBA project unchanged on save and never runs it. No Office Scripts compatibility |
| **Weighted** | 100 | **~75%** | **~59%** | **300–485** | |
## Dimensions
@@ -334,6 +335,7 @@ The inventory carried over from the 2026-10-07 ROADMAP.md, updated for what land
| Date | Change | Summary |
|---|---|---|
| 2026-11-02 | minor | Form controls (buttons, scroll bars, spin buttons) in Insert objects and Automation; catalog 277 / 292 → 281 / 296 (Developer › Controls) |
| 2026-11-02 | minor | Insert objects: equations; catalog count 277 / 292 (no score change) |
| 2026-11-02 | minor | What-if and analysis: Solver Sensitivity report (no score change) |
| 2026-11-02 | minor | Charts move and resize from eight handles like every floating object (#190): the Essentials chart work is polish only (5–8 h), and the chart rows and the interaction discount no longer list resizing as missing; the Mainstream chart row no longer lists trendlines as missing (no score change) |
+4 -2
View File
@@ -1,10 +1,10 @@
# UI parity
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-03 · **Change:** minor (Highlight the active cell's row, a global preference in File › Options › General; earlier: a linked cell's menu edits, opens or removes the link; earlier: Display options for this workbook: sheet tabs and scroll bars; earlier: dragging over row or column headers in point mode extends the reference; earlier: a picture on the system clipboard pastes as a floating picture at the active cell with Cmd/Ctrl+V or Paste (#699); earlier: frozen columns keep their text, gridlines and merged borders whole while the sheet scrolls (#2544); earlier: every Paste Special option has its access key; earlier: the Draw tab's live stroke preview uses the pen's colour and thickness; earlier: copy quotes only cells with a tab or line break, as Excel does; earlier: Ctrl+. Next Corner of Selection; earlier: dragging the selection's border onto data asks before replacing it; earlier: RefEdit reference boxes in dialogs: Goal Seek, Solver and Data Analysis use them, the arrow keys select while a dialog is collapsed and F4 cycles a reference's anchors; earlier: Python cells: Python mode in the formula bar; earlier: Insert › Equation and the equation editor; earlier: every floating object selects, resizes from eight handles and deletes with the keyboard; earlier: Formulas › Error Checking › Trace Error; trace arrows stay through a zoom (#3097); earlier: Page Setup sets the page order, Down, then over or Over, then down, which printing, the print preview, Page Break Preview and Page Layout view follow; earlier: Formulas › Function Library as labelled buttons with category menus; Recently Used and the Insert Function categories; Esc leaves a File-page text field before closing the view; earlier: checkbox cells print as boxes; Ctrl+arrow and Ctrl+Shift+arrow while pointing a formula reference; editing a hyperlink loads its existing fields; AutoSum over a selected row puts its total to the right; ribbon catalog count 276 / 292) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-03 · **Change:** minor (form controls: buttons, sliders and spin buttons on the sheet; earlier: Highlight the active cell's row, a global preference in File › Options › General; earlier: a linked cell's menu edits, opens or removes the link; earlier: Display options for this workbook: sheet tabs and scroll bars; earlier: dragging over row or column headers in point mode extends the reference; earlier: a picture on the system clipboard pastes as a floating picture at the active cell with Cmd/Ctrl+V or Paste (#699); earlier: frozen columns keep their text, gridlines and merged borders whole while the sheet scrolls (#2544); earlier: every Paste Special option has its access key; earlier: the Draw tab's live stroke preview uses the pen's colour and thickness; earlier: copy quotes only cells with a tab or line break, as Excel does; earlier: Ctrl+. Next Corner of Selection; earlier: dragging the selection's border onto data asks before replacing it; earlier: RefEdit reference boxes in dialogs: Goal Seek, Solver and Data Analysis use them, the arrow keys select while a dialog is collapsed and F4 cycles a reference's anchors; earlier: Python cells: Python mode in the formula bar; earlier: Insert › Equation and the equation editor; earlier: every floating object selects, resizes from eight handles and deletes with the keyboard; earlier: Formulas › Error Checking › Trace Error; trace arrows stay through a zoom (#3097); earlier: Page Setup sets the page order, Down, then over or Over, then down, which printing, the print preview, Page Break Preview and Page Layout view follow; earlier: Formulas › Function Library as labelled buttons with category menus; Recently Used and the Insert Function categories; Esc leaves a File-page text field before closing the view; earlier: checkbox cells print as boxes; Ctrl+arrow and Ctrl+Shift+arrow while pointing a formula reference; editing a hyperlink loads its existing fields; AutoSum over a selected row puts its total to the right; ribbon catalog count 276 / 292) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4
Interaction, controls and feel, against Excel. Part of [target-app-parity.md](target-app-parity.md).
**UI/UX fidelity: ~55% ready, 50–80 h** (estimated). Ribbon command coverage is measured
separately in [parity-checklist.md](parity-checklist.md) (277 / 292).
separately in [parity-checklist.md](parity-checklist.md) (281 / 296).
Sources: `crates/ui-egui/src` (ribbon, grid, editor, formula bar, dialogs, panes),
`crates/engine/src/cmd` (shortcuts in command specs), the owner's black-box notes
@@ -73,6 +73,7 @@ this logic, with one undo step.
| Copy as HTML table for other apps | done (#36, 10-10) |
| Context menu on cells | 16 items (Cut, Copy, Paste, Paste Special, Insert, Delete, Clear Contents, Filter by value, Sort, New Comment, New Note, Format Cells, Pick From Drop-down List, Define Name, Link). Insert Copied Cells (Insert Cut Cells for whole rows/columns) while the clipboard holds a copy (#21). Show Details on a PivotTable's value, subtotal or grand total cell (`pivot.showDetails`). Missing: Insert Cut Cells for a cell block, paste-option icons, Quick Analysis, Get Data from Table, Show Changes, Smart Lookup |
| Move and resize charts, pictures and shapes | done (#190, #213): charts, pictures, shapes, equations and slicers select with a frame of eight handles; each handle resizes only its own edges (they never cross), the body moves the object, and the pointer shows a resize or move cursor; Delete or Backspace deletes the selected object and Escape drops the selection; double-click edits a text box, opens Format Chart for a chart. Rotation handle, Shift for proportional resizing, Alt to snap to cells and multi-object selection missing |
| Form controls: buttons, scroll bars (sliders), spin buttons | done (10-31): Insert › Controls (Button, Slider, Spin Button; Excel has them under Developer › Insert) places one by a drag, or at its default size by a click; a click works a control: drag the thumb, page on the track, click or hold an arrow (a drag or a hold is one undo step), press a button, which runs a macro or a command and asks first in a workbook opened from a file (an answer runs only the action it asked about: if an agent changes the button, its macro or the selection while the prompt is open, nothing runs); the control last worked takes the arrows, Page Up and Page Down, Home, End, and Space or Enter (a button gives the keyboard back as soon as the grid takes a key or a right-click; Space on a slider or spin button does what it does with no control focused: it toggles a checkbox cell or starts a cell edit like any other character); Ctrl or Cmd+click or a right-click selects it to move or resize; its menu has Edit Text, Assign Action, Format Control and Delete. Check boxes, option buttons, list boxes and drop-downs (form controls) are kept from files but not shown |
| Context menus on row/column headers, sheet tabs, charts, shapes | headers and tabs done; objects partial |
| Quick Analysis (Ctrl+Q) | missing |
| Smooth scrolling, scrollbars | done; vertical scrollbar added 10-09 (#73); horizontal scrollbar in the tab bar (60/40 split, drag, track click pages) 10-13 (#176, #840); dragging the handle at the scrollbar's left edge gives the tabs more or less of the bar; each workbook keeps its share while it is open (not saved in the file) |
@@ -241,6 +242,7 @@ scrollbar bugs 2–4 · pixel/feel pass against observed Excel 7–12.
| Date | Change | Summary |
|---|---|---|
| 2026-11-02 | minor | Form controls: buttons, sliders (scroll bars) and spin buttons placed, worked, selected and edited on the sheet (Insert › Controls, Assign Action, Format Control); catalog 281 / 296 |
| 2026-11-03 | minor | Highlight the active cell's row: a global preference that tints the whole row, off by default |
| 2026-11-03 | minor | The formula bar's bottom edge is a resize handle: dragging it sets the bar's height in whole lines, up to half the window, and dragging it to one line collapses it; expanding (the chevron, Ctrl+Shift+U) returns to the dragged height, which is kept with the UI preferences. `view.expandFormulaBar {lines}` sets it (#1246) |
| 2026-11-03 | minor | Cell context menu on a cell with a link: Edit Hyperlink… (the link dialog, titled Edit Hyperlink and filled with the link), Open Hyperlink and Remove Hyperlink, as in Excel, instead of Link… (#2120) |