From a0d991e903e5953b17e52c3ec5abe02ff1908b25 Mon Sep 17 00:00:00 2001 From: prxnvt <53838599+prxnvt@users.noreply.github.com> Date: Sun, 11 Oct 2026 22:33:25 -0400 Subject: [PATCH] Form controls: model and commands for buttons, sliders and spin buttons A sheet holds form controls as floating objects of a new kind (ObjKind::Control): buttons that run a macro or one command, and sliders and spin buttons that hold a whole number from 0 to 30000 and write it to a linked cell. They take part in everything other objects do: ids from the workbook sequence, object.move, object.delete and object.setAnchorMode, the Selection Pane and Bring Forward / Send Backward, anchors that follow row and column edits, fresh ids on a copied sheet, and links that follow inserts, deletes, cut-moves, sheet renames and sheet deletes like chart series. Commands (cmd/controls.rs): insert.button, insert.slider, insert.spinButton, control.format, control.assign, control.setValue, control.press and the control.list query. Each numeric rule (0 <= min < max <= 30000, 1 <= step <= max - min, page >= step for sliders) is refused with its own message, and values are clamped and snapped to the step. control.setValue writes the linked cell through cell.set inside one undo group, so dependents recalculate and one Undo restores both; every commit moves linked controls to what their cells hold. control.press runs a button's macro through macro.run, or its command through the macro policy with the document's trust, so a button in a workbook opened from a file asks before it runs anything. Like a macro step, the command runs inside the scripts' file sandbox unless it is an approved file command. Macros never press controls, and macro.rename keeps buttons pointing at the renamed macro. macro.run, macro.check and the debugger take `output`, which replaces the macro's output anchor for that run (a button that sends the output elsewhere). The policy and the approval go by the stored macro; the footprint, the conflict rule and the per-step check work on the cells `output` names. A value that isn't reference text of at most 1,024 characters, or names no cell, is refused before anything runs. --- crates/engine/src/cmd.rs | 3 + crates/engine/src/cmd/controls.rs | 532 ++++++++++++++++++++ crates/engine/src/cmd/edit/paste_special.rs | 8 +- crates/engine/src/cmd/extra.rs | 6 +- crates/engine/src/cmd/extra/objects.rs | 12 + crates/engine/src/cmd/insert.rs | 8 +- crates/engine/src/cmd/macros.rs | 22 +- crates/engine/src/cmd/sheet/tabs.rs | 1 + crates/engine/src/macros/policy.rs | 2 + crates/engine/src/macros/run.rs | 34 +- crates/engine/src/tests.rs | 1 + crates/engine/src/tests/controls.rs | 317 ++++++++++++ crates/engine/src/tests/macros.rs | 55 ++ crates/engine/src/tests/objects.rs | 34 +- crates/model/src/features.rs | 169 ++++++- crates/model/src/sheet.rs | 22 +- crates/model/src/workbook.rs | 7 + crates/ui-egui/src/objects.rs | 2 +- docs/macros.md | 26 +- 19 files changed, 1220 insertions(+), 41 deletions(-) create mode 100644 crates/engine/src/cmd/controls.rs create mode 100644 crates/engine/src/tests/controls.rs diff --git a/crates/engine/src/cmd.rs b/crates/engine/src/cmd.rs index be1a28f4d..5d2690ace 100644 --- a/crates/engine/src/cmd.rs +++ b/crates/engine/src/cmd.rs @@ -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; @@ -138,6 +139,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(data::specs()); v.extend(formulas::specs()); v.extend(review::specs()); @@ -401,6 +403,7 @@ pub(crate) fn commit(d: &mut DocState, f: impl FnOnce(&mut Ctx) -> Result) } 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)); } diff --git a/crates/engine/src/cmd/controls.rs b/crates/engine/src/cmd/controls.rs new file mode 100644 index 000000000..77193e091 --- /dev/null +++ b/crates/engine/src/cmd/controls.rs @@ -0,0 +1,532 @@ +//! 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, 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, str_param, u32_param, +}; +use crate::macros::policy::{Policy, macro_policy}; + +pub fn specs() -> Vec { + 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 { + 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> { + 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::().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> { + 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>> { + 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 { + 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 { + 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 { + 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 { + 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). +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| if c.formula.is_none() { c.value.as_f64() } else { None }); + 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. +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 { + 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()); + let out = control_json(&c); + 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(out) + }) +} + +/// A button action from `macro` (with `input` and `output`) or `command` (with `params`). +fn action_param(s: &Session, cmd: &str, p: &Json) -> Result> { + 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}`"))); + } + 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) = macro_policy(id, ¶ms, 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 { + 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 { + 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::().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 { + 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, ¶ms) + } + Some(ControlAction::Command { id, params }) => { + let d = s.doc()?; + let (doc, trust) = (d.uid, d.trust); + let policy = macro_policy(&id, ¶ms, 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, ¶ms)); + if !s.macro_approvals.contains(&key) { + if bool_param(p, "approve") != Some(true) { + return Err(EngineError::Approve(format!("Run {id} from '{name}'? It {why}."))); + } + 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| s.execute(&id, params.clone())) + } + } +} + +/// 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; + } + } + } + } +} diff --git a/crates/engine/src/cmd/edit/paste_special.rs b/crates/engine/src/cmd/edit/paste_special.rs index c9e296ba5..80c37618e 100644 --- a/crates/engine/src/cmd/edit/paste_special.rs +++ b/crates/engine/src/cmd/edit/paste_special.rs @@ -359,14 +359,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) { @@ -383,5 +384,8 @@ 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); + } } } diff --git a/crates/engine/src/cmd/extra.rs b/crates/engine/src/cmd/extra.rs index 237b32860..8dfda7601 100644 --- a/crates/engine/src/cmd/extra.rs +++ b/crates/engine/src/cmd/extra.rs @@ -97,7 +97,7 @@ pub fn specs() -> Vec { "Bring Forward", ["Page Layout", "Arrange"], None, - "{kind: chart|image|shape|slicer, id, toFront?: bool}", + "{kind: chart|image|shape|slicer|control, id, toFront?: bool}", has_doc, |s, p| arrange(s, p, true) ), @@ -106,7 +106,7 @@ pub fn specs() -> Vec { "Send Backward", ["Page Layout", "Arrange"], None, - "{kind: chart|image|shape|slicer, id, toBack?: bool}", + "{kind: chart|image|shape|slicer|control, id, toBack?: bool}", has_doc, |s, p| arrange(s, p, false) ), @@ -116,7 +116,7 @@ pub fn specs() -> Vec { "Align", ["Page Layout", "Arrange"], None, - "{kind: chart|image|shape|slicer, ids: [..], align: left|center|right|top|middle|bottom}", + "{kind: chart|image|shape|slicer|control, ids: [..], align: left|center|right|top|middle|bottom}", has_doc, align ), diff --git a/crates/engine/src/cmd/extra/objects.rs b/crates/engine/src/cmd/extra/objects.rs index 03e8d07b2..67286f268 100644 --- a/crates/engine/src/cmd/extra/objects.rs +++ b/crates/engine/src/cmd/extra/objects.rs @@ -67,6 +67,11 @@ pub(super) fn arrange(s: &mut Session, p: &Json, forward: bool) -> Result reorder(&mut sh.slicers, i, forward, extreme); } } + "control" => { + if let Some(i) = sh.controls.iter().position(|c| c.id == id) { + reorder(&mut sh.controls, i, forward, extreme); + } + } _ => { if let Some(i) = sh.shapes.iter().position(|c| c.id == id) { reorder(&mut sh.shapes, i, forward, extreme); @@ -95,6 +100,12 @@ pub(super) fn selection_pane(s: &mut Session, _: &Json) -> Result { 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)) } @@ -103,6 +114,7 @@ fn anchors_of<'a>(sh: &'a mut Sheet, kind: &str) -> Vec<(u32, &'a mut Anchor)> { "chart" => sh.charts.iter_mut().map(|c| (c.id, &mut c.anchor)).collect(), "image" => sh.images.iter_mut().map(|c| (c.id, &mut c.anchor)).collect(), "slicer" => sh.slicers.iter_mut().map(|c| (c.id, &mut c.anchor)).collect(), + "control" => sh.controls.iter_mut().map(|c| (c.id, &mut c.anchor)).collect(), _ => sh.shapes.iter_mut().map(|c| (c.id, &mut c.anchor)).collect(), } } diff --git a/crates/engine/src/cmd/insert.rs b/crates/engine/src/cmd/insert.rs index 669dc6a62..0806f2183 100644 --- a/crates/engine/src/cmd/insert.rs +++ b/crates/engine/src/cmd/insert.rs @@ -146,13 +146,13 @@ pub fn specs() -> Vec { &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, id}", has_doc, delete_object), + cmd!("object.delete", "Delete Object", [], None, "{kind: chart|image|shape|slicer|control, id}", has_doc, delete_object), cmd!( "object.move", "Move Object", [], None, - "{kind: chart|image|shape|slicer, id, at?: \"C3\", dx?, dy?, width?, height?}", + "{kind: chart|image|shape|slicer|control, id, at?: \"C3\", dx?, dy?, width?, height?}", has_doc, move_object ), @@ -161,7 +161,7 @@ pub fn specs() -> Vec { "Object Properties", [], None, - "{kind: chart|image|shape|slicer, id, mode: moveAndSize|moveOnly|absolute}", + "{kind: chart|image|shape|slicer|control, id, mode: moveAndSize|moveOnly|absolute}", has_doc, set_anchor_mode ), @@ -1180,7 +1180,7 @@ fn shape_set_text(s: &mut Session, p: &Json) -> Result { /// 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 or slicer"))?; + let kind = str_param(p, "kind").and_then(ObjKind::parse).ok_or_else(|| bad(cmd, "kind must be chart, image, shape, slicer or control"))?; let d = s.doc()?; let sheet = d.wb.active_sheet(); let o = ObjRef::new(kind, id); diff --git a/crates/engine/src/cmd/macros.rs b/crates/engine/src/cmd/macros.rs index 396e0e28f..37f400f05 100644 --- a/crates/engine/src/cmd/macros.rs +++ b/crates/engine/src/cmd/macros.rs @@ -28,7 +28,7 @@ pub fn specs() -> Vec { "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 { "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 { "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 { 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})) diff --git a/crates/engine/src/cmd/sheet/tabs.rs b/crates/engine/src/cmd/sheet/tabs.rs index 2f87294bb..8d8340aab 100644 --- a/crates/engine/src/cmd/sheet/tabs.rs +++ b/crates/engine/src/cmd/sheet/tabs.rs @@ -175,6 +175,7 @@ pub(super) fn move_sheet(s: &mut Session, p: &Json) -> Result { 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 = cx diff --git a/crates/engine/src/macros/policy.rs b/crates/engine/src/macros/policy.rs index a84097986..e04ea65d4 100644 --- a/crates/engine/src/macros/policy.rs +++ b/crates/engine/src/macros/policy.rs @@ -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}"); diff --git a/crates/engine/src/macros/run.rs b/crates/engine/src/macros/run.rs index fcb6768f3..89e0cddca 100644 --- a/crates/engine/src/macros/run.rs +++ b/crates/engine/src/macros/run.rs @@ -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}; @@ -1272,7 +1272,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)) } @@ -1380,6 +1385,7 @@ pub fn begin(s: &mut Session, name: &str, p: &Json) -> Result { 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); @@ -1480,9 +1486,21 @@ pub fn run(s: &mut Session, name: &str, p: &Json) -> Result { }) } +/// `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::>().join(", "); @@ -1515,6 +1533,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()); } @@ -1591,7 +1615,7 @@ pub fn check(s: &mut Session, name: &str, p: &Json) -> Result { // 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()), }; diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 49c09dee5..c4c7d3d99 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -5202,6 +5202,7 @@ fn a_script_cannot_switch_windows() { assert_eq!(v(&s, "A1"), Value::number(2.0)); } +mod controls; mod macros; mod objects; mod seams; diff --git a/crates/engine/src/tests/controls.rs b/crates/engine/src/tests/controls.rs new file mode 100644 index 000000000..08f2e550a --- /dev/null +++ b/crates/engine/src/tests/controls.rs @@ -0,0 +1,317 @@ +//! 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) -> 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 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. + s.execute("macro.save", json!({"name": "Fill", "steps": [{"command": "cell.set", "params": {"cell": "A1", "input": "x"}}]})).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 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 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")); +} diff --git a/crates/engine/src/tests/macros.rs b/crates/engine/src/tests/macros.rs index aa17080fa..fe983f97e 100644 --- a/crates/engine/src/tests/macros.rs +++ b/crates/engine/src/tests/macros.rs @@ -2434,3 +2434,58 @@ 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)); +} diff --git a/crates/engine/src/tests/objects.rs b/crates/engine/src/tests/objects.rs index 7082b9054..3d420be16 100644 --- a/crates/engine/src/tests/objects.rs +++ b/crates/engine/src/tests/objects.rs @@ -19,9 +19,9 @@ fn id_of(v: &serde_json::Value, key: &str) -> u32 { v[key].as_u64().unwrap() as u32 } -/// A table with one object of each kind on the first sheet: a chart, a picture, a shape and a -/// slicer, in `ObjKind::ALL` order. -fn every_kind(s: &mut Session) -> [ObjRef; 4] { +/// A table with one object of each kind on the first sheet: a chart, a picture, a shape, a +/// slicer and a form control, in `ObjKind::ALL` order. +fn every_kind(s: &mut Session) -> [ObjRef; 5] { 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"); @@ -29,7 +29,14 @@ fn every_kind(s: &mut Session) -> [ObjRef; 4] { let shape = id_of(&s.execute("insert.shape", json!({"kind": "ellipse", "at": "H18"})).unwrap(), "shape"); s.execute("selection.set", json!({"cell": "A2"})).unwrap(); let slicer = id_of(&s.execute("insert.slicer", json!({"column": "Region", "at": "L2"})).unwrap(), "slicer"); - [ObjRef::new(ObjKind::Chart, chart), ObjRef::new(ObjKind::Image, image), ObjRef::new(ObjKind::Shape, shape), ObjRef::new(ObjKind::Slicer, slicer)] + 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::Control, control), + ] } #[test] @@ -58,9 +65,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 slicer back. + // One undo step per delete brings the control back. s.execute("edit.undo", json!({})).unwrap(); - assert!(sheet(&s, 0).has_object(objs[3])); + assert!(sheet(&s, 0).has_object(objs[4])); } #[test] @@ -74,9 +81,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[4].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)); @@ -112,17 +121,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. + // Anchors: chart D2, picture D18, shape H18, slicer L2, 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"]); + assert_eq!(cells(&s), ["D2", "D18", "H18", "L2", "P18"]); s.execute("home.insertRows", json!({"rows": "10:11"})).unwrap(); - assert_eq!(cells(&s), ["D2", "D20", "H18", "L2"], "rows inserted above move the objects below them; an absolute shape stays"); + assert_eq!(cells(&s), ["D2", "D20", "H18", "L2", "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"]); + assert_eq!(cells(&s), ["E2", "E20", "H18", "M2", "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"]); + assert_eq!(cells(&s), ["D2", "D18", "H18", "L2", "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)); @@ -177,6 +186,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 = wb.sheets.iter().flat_map(|sh| ids_of(sh)).collect(); diff --git a/crates/model/src/features.rs b/crates/model/src/features.rs index 0d6cc53ff..0c7f880d7 100644 --- a/crates/model/src/features.rs +++ b/crates/model/src/features.rs @@ -604,6 +604,171 @@ pub struct Shape { pub format: ShapeFormat, } +/// 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, + }, + /// 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, + /// 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, + /// 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. #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] @@ -613,10 +778,11 @@ pub enum ObjKind { Image, Shape, Slicer, + Control, } impl ObjKind { - pub const ALL: [ObjKind; 4] = [ObjKind::Chart, ObjKind::Image, ObjKind::Shape, ObjKind::Slicer]; + pub const ALL: [ObjKind; 5] = [ObjKind::Chart, ObjKind::Image, ObjKind::Shape, ObjKind::Slicer, ObjKind::Control]; /// The name used in command parameters. pub fn name(self) -> &'static str { @@ -625,6 +791,7 @@ impl ObjKind { ObjKind::Image => "image", ObjKind::Shape => "shape", ObjKind::Slicer => "slicer", + ObjKind::Control => "control", } } diff --git a/crates/model/src/sheet.rs b/crates/model/src/sheet.rs index d442a5340..70b504174 100644 --- a/crates/model/src/sheet.rs +++ b/crates/model/src/sheet.rs @@ -8,8 +8,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, SheetProtection, - Slicer, Sparkline, Table, Validation, + Anchor, AutoFilter, Chart, Comment, CondFormat, FormControl, Hyperlink, Image, LineInfo, ObjKind, ObjRef, PivotTable, PrintSettings, Shape, + SheetProtection, Slicer, Sparkline, Table, Validation, }; use crate::store::CellStore; use crate::style::{Color, StyleId}; @@ -111,6 +111,10 @@ pub struct Sheet { /// On-sheet slicer widgets (filter a table column by clicking value tiles). #[serde(default)] pub slicers: Vec, + /// 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, /// PivotTables whose output lives on this sheet. #[serde(default)] pub pivots: Vec, @@ -182,6 +186,7 @@ impl Sheet { shapes: vec![], sparklines: vec![], slicers: vec![], + controls: vec![], pivots: vec![], protection: None, edit_ranges: vec![], @@ -212,6 +217,8 @@ impl Sheet { 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; }; @@ -230,6 +237,7 @@ impl Sheet { ObjKind::Image => self.images.iter().find(|c| c.id == o.id).map(|c| c.anchor), 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::Control => self.controls.iter().find(|c| c.id == o.id).map(|c| c.anchor), } } @@ -239,6 +247,7 @@ impl Sheet { ObjKind::Image => self.images.iter_mut().find(|c| c.id == o.id).map(|c| &mut c.anchor), 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::Control => self.controls.iter_mut().find(|c| c.id == o.id).map(|c| &mut c.anchor), } } @@ -254,12 +263,13 @@ impl Sheet { ObjKind::Image => take(&mut self.images, |c| c.id == o.id), ObjKind::Shape => take(&mut self.shapes, |c| c.id == o.id), ObjKind::Slicer => take(&mut self.slicers, |c| c.id == o.id), + 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 { self.charts .iter() @@ -267,6 +277,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 { @@ -276,6 +287,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 { diff --git a/crates/model/src/workbook.rs b/crates/model/src/workbook.rs index 12feee628..dca068155 100644 --- a/crates/model/src/workbook.rs +++ b/crates/model/src/workbook.rs @@ -432,6 +432,9 @@ impl Workbook { for c in &s.slicers { m = m.max(c.id); } + for c in &s.controls { + m = m.max(c.id); + } } m.saturating_add(1) } @@ -493,6 +496,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)); } diff --git a/crates/ui-egui/src/objects.rs b/crates/ui-egui/src/objects.rs index 004f07d09..e6afa30bd 100644 --- a/crates/ui-egui/src/objects.rs +++ b/crates/ui-egui/src/objects.rs @@ -124,7 +124,7 @@ pub fn open_action(sh: &Sheet, o: ObjRef) -> OpenAction { match o.kind { ObjKind::Chart => OpenAction::FormatChart(o.id), ObjKind::Shape if sh.shapes.iter().any(|s| s.id == o.id && s.kind == ShapeKind::TextBox) => OpenAction::EditText(o.id), - ObjKind::Shape | ObjKind::Image | ObjKind::Slicer => OpenAction::Select, + ObjKind::Shape | ObjKind::Image | ObjKind::Slicer | ObjKind::Control => OpenAction::Select, } } diff --git a/docs/macros.md b/docs/macros.md index 3335b5ecc..9a4b32859 100644 --- a/docs/macros.md +++ b/docs/macros.md @@ -1,6 +1,6 @@ # Macros -> **Last updated:** 2026-10-25 · **Change:** minor (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-10-31 · **Change:** minor (form buttons run macros; `macro.run` takes `output`) 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, @@ -246,6 +252,18 @@ 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. Macros are saved in the workbook: as a field in GridCraft's JSON format, and in xlsx as hidden defined names (`_gc_macro__`, the macro's JSON split into string literals within Excel's