Skip to main content

ravenrs_sheet/engine/cmd/
controls.rs

1//! Form controls (Insert › Controls): buttons that run a macro or a command, and sliders and spin
2//! buttons that hold a whole number from 0 to 30000 and write it to a linked cell.
3//!
4//! A slider or spin button's value lives on the control and in its linked cell. Setting it goes
5//! through `cell.set`, so the cell's dependents recalculate and Undo takes both back as one step;
6//! editing the linked cell moves the control (see [`sync_linked_values`]). Pressing a button runs
7//! its action through the macro policy with the document's trust, so a button from a file someone
8//! else made asks before it runs anything.
9
10use crate::base::CellRef;
11use crate::model::{Anchor, AnchorMode, CONTROL_MAX, ControlAction, ControlKind, FormControl, MAX_MACRO_NAME, MacroInput, ObjKind, Sheet, Workbook};
12use serde_json::{Value as Json, json};
13
14use super::{
15    CommandSpec, EngineError, PROTECTED, Result, Session, anchor_mode_param, bad, bool_param, cell_param, cmd, edit, f64_param, find_command,
16    has_doc, split_sheet, stash, str_param, u32_param,
17};
18use crate::engine::macros::policy::{Policy, macro_policy};
19
20pub fn specs() -> Vec<CommandSpec> {
21    vec![
22        cmd!(
23            "insert.button",
24            "Button",
25            ["Insert", "Controls"],
26            None,
27            "{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}",
28            has_doc,
29            insert_button
30        ),
31        cmd!(
32            "insert.slider",
33            "Slider",
34            ["Insert", "Controls"],
35            None,
36            "{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}",
37            has_doc,
38            |s, p| insert_number_control(s, p, ControlKind::Slider)
39        ),
40        cmd!(
41            "insert.spinButton",
42            "Spin Button",
43            ["Insert", "Controls"],
44            None,
45            "{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}",
46            has_doc,
47            |s, p| insert_number_control(s, p, ControlKind::Spin)
48        ),
49        cmd!(
50            "control.format",
51            "Format Control",
52            [],
53            None,
54            "{id, text?, name?, link?: \"$B$1\" | \"\" (no link), min?, max?, step?, page?, value?, horizontal?, print?, locked?} → the control (as control.list)",
55            has_doc,
56            format
57        ),
58        cmd!(
59            "control.assign",
60            "Assign Action",
61            [],
62            None,
63            "{id, macro?: name, input?: \"selection\" | \"B2:D9\", output?: \"F2\" | command?: id, params?: {..} | clear?: true} sets what a button runs → the control",
64            has_doc,
65            assign
66        ),
67        cmd!(
68            "control.setValue",
69            "Change Control Value",
70            [],
71            None,
72            "{id, value} clamps the value to min..max, snaps it to the step, and writes it to the linked cell → {value}",
73            has_doc,
74            set_value
75        ),
76        cmd!(
77            "control.press",
78            "Press Button",
79            [],
80            None,
81            "{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",
82            has_doc,
83            press
84        ),
85        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),
86    ]
87}
88
89/// The longest caption or name a control takes.
90const MAX_TEXT: usize = 1_024;
91
92fn control_json(c: &FormControl) -> Json {
93    let mut o = json!({
94        "id": c.id,
95        "kind": match c.kind { ControlKind::Button => "button", ControlKind::Slider => "slider", ControlKind::Spin => "spinButton" },
96        "name": c.display_name(),
97        "at": c.anchor.cell.a1(),
98        "width": c.anchor.width,
99        "height": c.anchor.height,
100        "print": c.print,
101        "locked": c.locked,
102    });
103    if c.kind == ControlKind::Button {
104        o["text"] = json!(c.text);
105        if let Some(a) = &c.action {
106            o["action"] = serde_json::to_value(a).unwrap_or(Json::Null);
107        }
108    } else {
109        o["value"] = json!(c.value);
110        o["min"] = json!(c.min);
111        o["max"] = json!(c.max);
112        o["step"] = json!(c.step);
113        if c.kind == ControlKind::Slider {
114            o["page"] = json!(c.page);
115            o["horizontal"] = json!(c.horizontal);
116        }
117    }
118    if let Some(l) = &c.link {
119        o["link"] = json!(l);
120    }
121    o
122}
123
124fn list(s: &mut Session, _: &Json) -> Result<Json> {
125    let sh = s.doc()?.wb.active().ok_or(EngineError::NoDocument)?;
126    Ok(Json::Array(sh.controls.iter().map(control_json).collect()))
127}
128
129/// A whole number from 0 to 30000 named `key`, if given. Numbers in text (`"12"`) count; anything
130/// else is refused with a message that names the parameter.
131fn whole(cmd: &str, p: &Json, key: &str) -> Result<Option<u32>> {
132    let v = match p.get(key) {
133        None | Some(Json::Null) => return Ok(None),
134        Some(Json::Number(n)) => n.as_f64(),
135        Some(Json::String(t)) => t.trim().parse::<f64>().ok(),
136        Some(_) => None,
137    };
138    match v {
139        Some(x) if x.is_finite() && x.fract() == 0.0 && (0.0..=CONTROL_MAX as f64).contains(&x) => Ok(Some(x as u32)),
140        _ => Err(bad(cmd, format!("`{key}` must be a whole number from 0 to {CONTROL_MAX}"))),
141    }
142}
143
144fn text_param(cmd: &str, p: &Json, key: &str) -> Result<Option<String>> {
145    match p.get(key) {
146        None | Some(Json::Null) => Ok(None),
147        Some(Json::String(t)) if t.chars().count() <= MAX_TEXT => Ok(Some(t.clone())),
148        Some(Json::String(_)) => Err(bad(cmd, format!("`{key}` is longer than {MAX_TEXT} characters"))),
149        Some(_) => Err(bad(cmd, format!("`{key}` must be text"))),
150    }
151}
152
153/// The cell a link names, from the control's sheet `host`: `(sheet index, cell)`. `None` when it
154/// doesn't name one cell of an existing sheet (a link into a deleted sheet reads `#REF!`).
155pub fn link_target(wb: &Workbook, host: usize, link: &str) -> Option<(usize, CellRef)> {
156    let text = link.trim().trim_start_matches('=');
157    let (sheet, body) = split_sheet(text);
158    let si = match sheet {
159        Some(n) => wb.sheet_index(&n)?,
160        None => host,
161    };
162    wb.sheet(si)?;
163    Some((si, CellRef::parse(body.trim())?))
164}
165
166/// A link parameter in its stored form: `$B$2` on the host sheet, `'My Sheet'!$B$2` elsewhere.
167/// An empty text removes the link.
168fn link_param(cmd: &str, wb: &Workbook, host: usize, p: &Json) -> Result<Option<Option<String>>> {
169    let text = match p.get("link") {
170        None => return Ok(None),
171        Some(Json::Null) => return Ok(Some(None)),
172        Some(Json::String(t)) => t.trim(),
173        Some(_) => return Err(bad(cmd, "`link` must be a cell reference such as $B$1")),
174    };
175    if text.is_empty() {
176        return Ok(Some(None));
177    }
178    let (si, c) = link_target(wb, host, text)
179        .ok_or_else(|| bad(cmd, format!("`{text}` isn't a cell of this workbook; the link must be one cell, such as $B$1")))?;
180    let abs = format!("${}${}", crate::base::col_to_letters(c.col), c.row as u64 + 1);
181    Ok(Some(Some(if si == host {
182        abs
183    } else {
184        let name = wb.sheet(si).map(|sh| sh.name.clone()).unwrap_or_default();
185        format!("{}!{abs}", crate::formula::quote_sheet(&name))
186    })))
187}
188
189/// The anchor of a new control: `at` or `cell` (default the active cell), with its size.
190fn new_anchor(s: &Session, p: &Json, size: (f32, f32)) -> Result<Anchor> {
191    let cell = match cell_param(s, p, "at")? {
192        Some(c) => c,
193        None => cell_param(s, p, "cell")?.unwrap_or(s.doc()?.selection.active),
194    };
195    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);
196    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);
197    Ok(Anchor {
198        cell,
199        dx: off("dx"),
200        dy: off("dy"),
201        width: dim("width", size.0),
202        height: dim("height", size.1),
203        mode: anchor_mode_param(p).unwrap_or(AnchorMode::MoveOnly),
204    })
205}
206
207fn insert_button(s: &mut Session, p: &Json) -> Result<Json> {
208    const CMD: &str = "insert.button";
209    let anchor = new_anchor(s, p, (96.0, 28.0))?;
210    let action = action_param(s, CMD, p)?;
211    let d = s.doc()?;
212    let id = d.wb.next_object_id();
213    let text = text_param(CMD, p, "text")?.unwrap_or_else(|| format!("Button {id}"));
214    let sheet = d.wb.active_sheet();
215    let control = FormControl { id, kind: ControlKind::Button, anchor, text, action, ..FormControl::default() };
216    edit(s, |cx| {
217        cx.sheet_mut(sheet)?.controls.push(control);
218        Ok(json!({"control": id}))
219    })
220}
221
222fn insert_number_control(s: &mut Session, p: &Json, kind: ControlKind) -> Result<Json> {
223    let cmd = if kind == ControlKind::Slider { "insert.slider" } else { "insert.spinButton" };
224    let horizontal = bool_param(p, "horizontal").unwrap_or(true);
225    let size = match kind {
226        ControlKind::Slider if horizontal => (144.0, 18.0),
227        ControlKind::Slider => (18.0, 144.0),
228        _ => (18.0, 36.0),
229    };
230    let anchor = new_anchor(s, p, size)?;
231    let d = s.doc()?;
232    let sheet = d.wb.active_sheet();
233    let id = d.wb.next_object_id();
234    let mut c = FormControl { id, kind, anchor, horizontal, ..FormControl::default() };
235    numbers(cmd, p, &mut c)?;
236    if let Some(link) = link_param(cmd, &d.wb, sheet, p)? {
237        c.link = link;
238    }
239    let given = whole(cmd, p, "value")?;
240    // Without a value, a control starts at the number its linked cell holds (or its minimum).
241    let start = given.map(f64::from).or_else(|| linked_number(&d.wb, sheet, &c)).unwrap_or(c.min as f64);
242    c.value = c.snap(start);
243    let value = c.value;
244    let link = c.link.clone();
245    s.grouped(&format!("Insert {}", kind.label()), |s| {
246        edit(s, |cx| {
247            cx.sheet_mut(sheet)?.controls.push(c);
248            Ok(())
249        })?;
250        if given.is_some() {
251            write_link(s, sheet, link.as_deref(), value)?;
252        }
253        Ok(json!({"control": id}))
254    })
255}
256
257/// Applies `min`, `max`, `step`, `page` and `value` from `p` to `c` and checks the result.
258fn numbers(cmd: &str, p: &Json, c: &mut FormControl) -> Result<()> {
259    for (key, slot) in [("min", &mut c.min), ("max", &mut c.max), ("step", &mut c.step), ("page", &mut c.page)] {
260        if let Some(v) = whole(cmd, p, key)? {
261            *slot = v;
262        }
263    }
264    // A new slider's page follows a larger step unless it was given.
265    if p.get("page").is_none() && c.page < c.step {
266        c.page = c.step;
267    }
268    c.check_numbers().map_err(|e| bad(cmd, e))
269}
270
271/// The number the control's linked cell holds, if it holds one.
272fn linked_number(wb: &Workbook, host: usize, c: &FormControl) -> Option<f64> {
273    let (si, cell) = link_target(wb, host, c.link.as_deref()?)?;
274    wb.sheet(si)?.cell(cell)?.value.as_f64()
275}
276
277/// Writes `value` to the linked cell through `cell.set` (nothing without a link, or when the cell
278/// already shows `value`, formula or not: Format Control's OK sends the value back every time, and
279/// a write that changes nothing must not turn a formula into a constant or fail on a locked cell
280/// of a protected sheet; a real change replaces a formula with the constant, as in Excel).
281fn write_link(s: &mut Session, host: usize, link: Option<&str>, value: u32) -> Result<()> {
282    let Some(link) = link else { return Ok(()) };
283    let Some((si, cell)) = link_target(&s.doc()?.wb, host, link) else { return Ok(()) };
284    let holds = s.doc()?.wb.sheet(si).and_then(|sh| sh.cell(cell)).and_then(|c| c.value.as_f64());
285    if holds == Some(value as f64) {
286        return Ok(());
287    }
288    s.execute("cell.set", json!({"sheet": si, "cell": cell.a1(), "input": value.to_string()}))?;
289    Ok(())
290}
291
292/// The control `id` names on the active sheet: (sheet, its index in `controls`, a copy).
293fn find(s: &Session, cmd: &str, p: &Json) -> Result<(usize, usize, FormControl)> {
294    let id = u32_param(p, "id").ok_or_else(|| bad(cmd, "missing `id` (a control id from control.list)"))?;
295    let d = s.doc()?;
296    let sheet = d.wb.active_sheet();
297    let sh = d.wb.sheet(sheet).ok_or(EngineError::NoDocument)?;
298    let i = sh.controls.iter().position(|c| c.id == id).ok_or_else(|| bad(cmd, format!("there is no control {id} on this sheet")))?;
299    let c = sh.controls.get(i).cloned().ok_or_else(|| bad(cmd, "no such control"))?;
300    Ok((sheet, i, c))
301}
302
303/// Drawing objects on a protected sheet can't be changed while they are locked.
304/// For the object commands (`object.move`, `object.delete`, `object.setAnchorMode`, `arrange.*`,
305/// `arrange.align`): refuses to change form controls `ids` on `sheet` when one is locked and the
306/// sheet is protected, as `control.format` and `control.assign` do. Other kinds pass.
307pub(crate) fn check_objects_unlocked(s: &Session, sheet: usize, kind: ObjKind, ids: &[u32]) -> Result<()> {
308    if kind != ObjKind::Control {
309        return Ok(());
310    }
311    let Some(sh) = s.doc()?.wb.sheet(sheet) else { return Ok(()) };
312    if sh.is_protected() && sh.controls.iter().any(|c| c.locked && ids.contains(&c.id)) {
313        return Err(EngineError::Other(PROTECTED.into()));
314    }
315    Ok(())
316}
317
318fn check_unlocked(s: &Session, sheet: usize, c: &FormControl) -> Result<()> {
319    if c.locked && s.doc()?.wb.sheet(sheet).is_some_and(Sheet::is_protected) {
320        return Err(EngineError::Other(PROTECTED.into()));
321    }
322    Ok(())
323}
324
325fn format(s: &mut Session, p: &Json) -> Result<Json> {
326    const CMD: &str = "control.format";
327    let (sheet, i, mut c) = find(s, CMD, p)?;
328    check_unlocked(s, sheet, &c)?;
329    if let Some(t) = text_param(CMD, p, "text")? {
330        c.text = t;
331    }
332    if let Some(n) = text_param(CMD, p, "name")? {
333        c.name = n.trim().to_string();
334    }
335    for (key, slot) in [("print", &mut c.print), ("locked", &mut c.locked), ("horizontal", &mut c.horizontal)] {
336        if let Some(v) = bool_param(p, key) {
337            *slot = v;
338        }
339    }
340    let mut given = None;
341    if c.kind != ControlKind::Button {
342        for (key, slot) in [("min", &mut c.min), ("max", &mut c.max), ("step", &mut c.step), ("page", &mut c.page)] {
343            if let Some(v) = whole(CMD, p, key)? {
344                *slot = v;
345            }
346        }
347        c.check_numbers().map_err(|e| bad(CMD, e))?;
348        given = whole(CMD, p, "value")?;
349    }
350    if let Some(link) = link_param(CMD, &s.doc()?.wb, sheet, p)? {
351        c.link = link;
352    }
353    // New bounds pull the value in; a given value is snapped to them.
354    c.value = c.snap(given.map_or(c.value as f64, f64::from));
355    let number = c.kind != ControlKind::Button;
356    let write = number && (given.is_some() || p.get("min").is_some() || p.get("max").is_some() || p.get("step").is_some());
357    let (value, link) = (c.value, c.link.clone());
358    s.grouped("Format Control", |s| {
359        edit(s, |cx| {
360            if let Some(slot) = cx.sheet_mut(sheet)?.controls.get_mut(i) {
361                *slot = c;
362            }
363            Ok(())
364        })?;
365        if write {
366            write_link(s, sheet, link.as_deref(), value)?;
367        }
368        Ok(())
369    })?;
370    // The reply is the control as committed: a new link to a cell holding another number has
371    // moved its value there.
372    let (_, _, c) = find(s, CMD, p)?;
373    Ok(control_json(&c))
374}
375
376/// Commands a button never runs, beyond the macro policy's lists: a button has no way to show
377/// what its command would open, so a web page is refused when the action is assigned or comes in
378/// from a file. A command that turns out to open a page or a dialog when it runs is refused
379/// then, by [`press`].
380const BUTTON_DENIED: &[(&str, &str)] = &[("link.open", "buttons don't open web pages")];
381
382/// The macro policy for a button's command, with the button's own refusals first.
383fn button_policy(id: &str, params: &Json, trust: crate::engine::Trust) -> Policy {
384    match BUTTON_DENIED.iter().find(|(d, _)| *d == id) {
385        Some((_, why)) => Policy::Deny((*why).into()),
386        None => macro_policy(id, params, trust),
387    }
388}
389
390/// Drops the UI requests a button's command queued from `queued` on, keeping messages for the
391/// person, and says why the press is refused when one would open a dialog or a web page, which
392/// buttons don't do (`name` is the button's). A cell edit the command asked for is dropped too.
393pub(crate) fn take_requests(s: &mut Session, queued: usize, name: &str) -> Option<String> {
394    use crate::engine::UiRequest;
395    let mut blocked = None;
396    let asked: Vec<UiRequest> = s.ui_requests.drain(queued.min(s.ui_requests.len())..).collect();
397    for q in asked {
398        match q {
399            UiRequest::Dialog(d, _) => blocked = Some(format!("'{name}' would open the {d} dialog; give its command the parameters it needs.")),
400            UiRequest::OpenUrl(_) => blocked = Some(format!("'{name}' would open a web page, which buttons don't do.")),
401            UiRequest::EditCell(_) | UiRequest::InsertName(_) => {}
402            m @ UiRequest::Message(_) => s.ui_requests.push(m),
403        }
404    }
405    blocked
406}
407
408/// The stored name of the workbook's macro `name` when it exists and declares no inputs: a fixed
409/// input range would make every press fail (`macro.run` refuses inputs it doesn't take), so such
410/// an assignment is refused when it is made and dropped to the selection when a file brings one
411/// in. A macro the workbook doesn't have (yet) is not checked: the assignment is kept as given.
412fn takes_no_inputs(wb: &Workbook, name: &str) -> Option<String> {
413    crate::engine::macros::find(wb, name).and_then(|i| wb.macros.get(i)).filter(|m| m.inputs.is_empty()).map(|m| m.name.clone())
414}
415
416/// A button action from `macro` (with `input` and `output`) or `command` (with `params`).
417fn action_param(s: &Session, cmd: &str, p: &Json) -> Result<Option<ControlAction>> {
418    if let Some(name) = str_param(p, "macro").map(str::trim).filter(|n| !n.is_empty()) {
419        if name.chars().count() > MAX_MACRO_NAME {
420            return Err(bad(cmd, "`macro` is not a macro name"));
421        }
422        let input = match str_param(p, "input").map(str::trim) {
423            None | Some("") => MacroInput::Selection,
424            Some(t) if t.eq_ignore_ascii_case("selection") => MacroInput::Selection,
425            Some(t) => {
426                let (_, body) = split_sheet(t);
427                if crate::base::RangeRef::parse(body).is_none() {
428                    return Err(bad(cmd, format!("`input` must be \"selection\" or a range such as B2:D9, not `{t}`")));
429                }
430                if let Some(stored) = takes_no_inputs(&s.doc()?.wb, name) {
431                    return Err(bad(cmd, format!("macro '{stored}' takes no inputs, so `input` must be \"selection\"")));
432                }
433                MacroInput::Fixed(t.to_string())
434            }
435        };
436        let output = str_param(p, "output").map(str::trim).filter(|o| !o.is_empty()).map(str::to_string);
437        if let Some(o) = &output
438            && crate::base::RangeRef::parse(split_sheet(o).1).is_none()
439        {
440            return Err(bad(cmd, format!("`output` must be a cell such as F2, not `{o}`")));
441        }
442        return Ok(Some(ControlAction::Macro { name: name.to_string(), input, output }));
443    }
444    if let Some(id) = str_param(p, "command").map(str::trim).filter(|n| !n.is_empty()) {
445        if find_command(id).is_none() {
446            return Err(bad(cmd, format!("there is no command `{id}`")));
447        }
448        let params = p.get("params").cloned().unwrap_or_else(|| json!({}));
449        if !params.is_object() {
450            return Err(bad(cmd, "`params` must be an object"));
451        }
452        let trust = s.doc()?.trust;
453        if let Policy::Deny(why) = button_policy(id, &params, trust) {
454            return Err(EngineError::Other(format!("A button can't run {id}: {why}.")));
455        }
456        return Ok(Some(ControlAction::Command { id: id.to_string(), params }));
457    }
458    Ok(None)
459}
460
461fn assign(s: &mut Session, p: &Json) -> Result<Json> {
462    const CMD: &str = "control.assign";
463    let (sheet, i, mut c) = find(s, CMD, p)?;
464    check_unlocked(s, sheet, &c)?;
465    if c.kind != ControlKind::Button {
466        return Err(bad(CMD, "only buttons run actions; a slider or spin button writes its value to its linked cell"));
467    }
468    let action = action_param(s, CMD, p)?;
469    if action.is_none() && bool_param(p, "clear") != Some(true) {
470        return Err(bad(CMD, "pass `macro` or `command` (or `clear: true` to remove the action)"));
471    }
472    c.action = action;
473    let out = control_json(&c);
474    edit(s, |cx| {
475        if let Some(slot) = cx.sheet_mut(sheet)?.controls.get_mut(i) {
476            *slot = c;
477        }
478        Ok(out)
479    })
480}
481
482fn set_value(s: &mut Session, p: &Json) -> Result<Json> {
483    const CMD: &str = "control.setValue";
484    let (sheet, i, c) = find(s, CMD, p)?;
485    if c.kind == ControlKind::Button {
486        return Err(bad(CMD, "a button has no value; use control.press"));
487    }
488    let v = match p.get("value") {
489        Some(Json::Number(n)) => n.as_f64(),
490        Some(Json::String(t)) => t.trim().parse::<f64>().ok(),
491        _ => None,
492    }
493    .filter(|v| v.is_finite())
494    .ok_or_else(|| bad(CMD, "`value` must be a number"))?;
495    let value = c.snap(v);
496    let label = format!("Change {}", c.kind.label());
497    s.grouped(&label, |s| {
498        if c.value != value {
499            edit(s, |cx| {
500                if let Some(slot) = cx.sheet_mut(sheet)?.controls.get_mut(i) {
501                    slot.value = value;
502                }
503                Ok(())
504            })?;
505        }
506        write_link(s, sheet, c.link.as_deref(), value)?;
507        Ok(json!({"value": value}))
508    })
509}
510
511fn press(s: &mut Session, p: &Json) -> Result<Json> {
512    const CMD: &str = "control.press";
513    let (_, _, c) = find(s, CMD, p)?;
514    if c.kind != ControlKind::Button {
515        return Err(bad(CMD, "only buttons can be pressed; use control.setValue for a slider or spin button"));
516    }
517    let name = c.display_name();
518    match c.action {
519        None => Err(EngineError::Other(format!("'{name}' has no action. Right-click it and choose Assign Action."))),
520        Some(ControlAction::Macro { name: mac, input, output }) => {
521            let mut params = json!({"name": mac});
522            if let MacroInput::Fixed(r) = input {
523                params["inputs"] = json!(r);
524            }
525            if let Some(o) = output {
526                params["output"] = json!(o);
527            }
528            for key in ["approve", "onConflict", "overwrite", "timeLimit"] {
529                if let Some(v) = p.get(key) {
530                    params[key] = v.clone();
531                }
532            }
533            // The macro runner gates every step with the document's trust.
534            crate::engine::macros::run::run(s, &mac, &params)
535        }
536        Some(ControlAction::Command { id, params }) => {
537            let d = s.doc()?;
538            let (doc, trust) = (d.uid, d.trust);
539            let policy = button_policy(&id, &params, trust);
540            let file_command = matches!(policy, Policy::NeedsPermission(_));
541            let why = match policy {
542                Policy::Allow => None,
543                Policy::Ask(why) | Policy::NeedsPermission(why) => Some(why),
544                Policy::Deny(why) => return Err(EngineError::Other(format!("'{name}' can't run {id}: {why}."))),
545            };
546            if let Some(why) = why {
547                let key = (doc, approval_key(&id, &params));
548                if !s.macro_approvals.contains(&key) {
549                    if bool_param(p, "approve") != Some(true) {
550                        return Err(EngineError::Approve(format!("Run {id} from '{name}'? It {why}{}.", path_note(&params))));
551                    }
552                    s.macro_approvals.insert(key);
553                }
554            }
555            // As a macro step: only an approved file command reaches the file system, so a
556            // command that reads or writes a file under another parameter name can't.
557            let _sandbox = (!file_command).then(crate::engine::io::ScriptSandbox::enter);
558            s.grouped(&name, |s| {
559                let queued = s.ui_requests.len();
560                let r = s.execute(&id, params.clone());
561                // As a macro step: a command that would open a dialog or a web page is refused
562                // and its group rolled back; messages for the person stay queued.
563                let blocked = take_requests(s, queued, &name);
564                let r = r?;
565                match blocked {
566                    Some(why) => Err(EngineError::Other(why)),
567                    None => Ok(r),
568                }
569            })
570        }
571    }
572}
573
574/// The file a button's command names in its `path`, as ` (path)` for its approval question, so
575/// the person sees what it reads or writes before saying yes. Empty without a `path`; a long one
576/// is shortened in the middle.
577fn path_note(params: &Json) -> String {
578    let text = match params.get("path") {
579        None | Some(Json::Null) => return String::new(),
580        Some(Json::String(t)) => t.clone(),
581        Some(other) => other.to_string(),
582    };
583    let text: String = text.chars().map(|c| if c.is_control() { ' ' } else { c }).collect();
584    let n = text.chars().count();
585    let shown = if n > 200 {
586        let head: String = text.chars().take(100).collect();
587        let tail: String = text.chars().skip(n - 99).collect();
588        format!("{head}…{tail}")
589    } else {
590        text
591    };
592    format!(" ({shown})")
593}
594
595/// The approval key of a button's command: its id and parameters, with a prefix that keeps it
596/// apart from a macro's fingerprint (canonical JSON, which starts with `{`).
597fn approval_key(id: &str, params: &Json) -> String {
598    format!("control-command\u{0}{id}\u{0}{params}")
599}
600
601/// After an edit: each slider and spin button whose linked cell holds a number takes that number
602/// (clamped and snapped), so editing the cell moves the control. Sheets without such a control
603/// are left untouched.
604pub(crate) fn sync_linked_values(wb: &mut Workbook) {
605    for si in 0..wb.sheets.len() {
606        let updates: Vec<(usize, u32)> = match wb.sheet(si) {
607            Some(sh) if sh.controls.iter().any(|c| c.link.is_some()) => sh
608                .controls
609                .iter()
610                .enumerate()
611                .filter(|(_, c)| c.kind != ControlKind::Button)
612                .filter_map(|(i, c)| linked_number(wb, si, c).map(|n| (i, c.snap(n))).filter(|(_, v)| *v != c.value))
613                .collect(),
614            _ => continue,
615        };
616        if updates.is_empty() {
617            continue;
618        }
619        if let Some(sh) = wb.sheet_mut(si) {
620            for (i, v) in updates {
621                if let Some(c) = sh.controls.get_mut(i) {
622                    c.value = v;
623                }
624            }
625        }
626    }
627}
628
629// ---------------------------------------------------------------- xlsx
630
631/// The hidden-data key of the button actions xlsx has no place for.
632pub(crate) const ACTIONS_KEY: &str = "control_actions";
633
634/// A button's action that xlsx carries itself: a macro on the selection (`macro="[0]!Name"`).
635fn plain(a: &ControlAction) -> bool {
636    matches!(a, ControlAction::Macro { input: MacroInput::Selection, output: None, .. })
637}
638
639/// A copy of `wb` for xlsx with the button actions xlsx can't express (commands, fixed inputs,
640/// output cells) in a hidden name, keyed by sheet name and the control's place on the sheet, with
641/// the button's name and anchor cell to check it is still the same button when the file comes
642/// back. `None` when there is nothing to store.
643pub(crate) fn with_action_names(wb: &Workbook) -> Result<Option<Workbook>> {
644    let mut items = Vec::new();
645    for sh in &wb.sheets {
646        for (i, c) in sh.controls.iter().enumerate() {
647            if let Some(a) = c.action.as_ref().filter(|a| !plain(a)) {
648                items.push(json!({"sheet": sh.name, "index": i, "name": c.display_name(), "at": c.anchor.cell.a1(), "action": a}));
649            }
650        }
651    }
652    let stale = stash::get(wb, ACTIONS_KEY).is_some();
653    if items.is_empty() && !stale {
654        return Ok(None);
655    }
656    let mut out = wb.clone();
657    stash::remove(&mut out, ACTIONS_KEY);
658    if !items.is_empty() {
659        stash::set_json(&mut out, ACTIONS_KEY, &Json::Array(items))?;
660    }
661    Ok(Some(out))
662}
663
664/// After a file is opened: gives buttons back the actions kept in the hidden name, and removes
665/// it. Entries that don't parse, or don't match a button, are dropped: an entry must carry the
666/// button's name and anchor cell, the button at the stored place must have that name and anchor
667/// cell (another application may have added, removed or reordered controls), and it must not
668/// have been given another macro since (a stored macro action comes back only on a button that
669/// still runs that macro, a stored command only on one the file gives no macro). A stored fixed
670/// input for a macro that takes no inputs becomes the selection (see [`takes_no_inputs`]).
671pub(crate) fn load_action_names(wb: &mut Workbook) {
672    let Some(items) = stash::get_json(wb, ACTIONS_KEY) else { return };
673    stash::remove(wb, ACTIONS_KEY);
674    for item in items.as_array().into_iter().flatten().take(100_000) {
675        let (Some(sheet), Some(index)) = (item.get("sheet").and_then(Json::as_str), item.get("index").and_then(Json::as_u64)) else { continue };
676        let Ok(action) = serde_json::from_value::<ControlAction>(item.get("action").cloned().unwrap_or(Json::Null)) else { continue };
677        let valid = match &action {
678            ControlAction::Macro { name, .. } => !name.trim().is_empty() && name.chars().count() <= MAX_MACRO_NAME,
679            ControlAction::Command { id, params } => !id.is_empty() && id.len() <= 200 && params.is_object(),
680        };
681        let Some(si) = wb.sheet_index(sheet) else { continue };
682        let Ok(index) = usize::try_from(index) else { continue };
683        let text = |k: &str| item.get(k).and_then(Json::as_str);
684        let same = |c: &FormControl| {
685            c.kind == ControlKind::Button
686                && text("name").is_some_and(|n| n == c.display_name())
687                && text("at").is_some_and(|a| a == c.anchor.cell.a1())
688                && match (&c.action, &action) {
689                    (None, ControlAction::Command { .. }) => true,
690                    (Some(ControlAction::Macro { name: now, .. }), ControlAction::Macro { name: kept, .. }) => now.eq_ignore_ascii_case(kept),
691                    _ => false,
692                }
693        };
694        if !valid || !wb.sheet(si).is_some_and(|sh| sh.controls.get(index).is_some_and(same)) {
695            continue;
696        }
697        let action = match action {
698            // The macro takes no inputs now: the stored range would fail every press, so the
699            // button runs the macro on the selection, as the file itself assigns it.
700            ControlAction::Macro { name, input: MacroInput::Fixed(_), output } if takes_no_inputs(wb, &name).is_some() => {
701                ControlAction::Macro { name, input: MacroInput::Selection, output }
702            }
703            other => other,
704        };
705        if let Some(c) = wb.sheet_mut(si).and_then(|sh| sh.controls.get_mut(index)) {
706            c.action = Some(action);
707        }
708    }
709}