Skip to main content

ravenrs_sheet/engine/
cmd.rs

1//! The command registry. Ids follow Excel's ribbon (`home.bold`, `insert.chart`,
2//! `data.sortAscending`) plus primitive editing commands (`cell.set`, `selection.set`).
3
4pub mod analysis;
5pub mod controls;
6pub mod data;
7pub mod draw;
8pub mod edit;
9pub mod equation;
10pub mod extra;
11pub mod file;
12pub mod forecast;
13pub mod format;
14pub mod formulas;
15pub mod insert;
16pub mod inspect;
17pub mod macros;
18pub mod pivot;
19pub mod print;
20pub mod python;
21pub mod review;
22pub mod sheet;
23pub mod slicer;
24pub mod solver;
25pub mod spelling;
26pub mod stash;
27pub mod view;
28pub mod whatif;
29
30use crate::base::{CellRef, RangeRef};
31use crate::calc::Key;
32use crate::model::Workbook;
33use serde::Serialize;
34use serde_json::Value as Json;
35
36use crate::engine::{DocState, EngineError, Result, Selection, Session};
37
38pub type Run = fn(&mut Session, &Json) -> Result<Json>;
39pub type Enabled = fn(&Session) -> std::result::Result<(), String>;
40
41pub struct CommandSpec {
42    pub id: &'static str,
43    pub label: &'static str,
44    /// Ribbon/menu placement, e.g. `["Home", "Font"]`. Empty = not in menus.
45    pub menu: &'static [&'static str],
46    /// Portable notation: `Cmd` is ⌘ on macOS and Ctrl elsewhere.
47    pub shortcut: Option<&'static str>,
48    /// macOS override when its key differs. Equal to `shortcut` marks a Mac-only shortcut
49    /// (Align Left/Center): other platforms show none. See [`CommandInfo::shortcut_for_platform`].
50    pub shortcut_mac: Option<&'static str>,
51    pub params: &'static str,
52    pub enabled: Enabled,
53    pub run: Run,
54    pub journal: bool,
55    pub undoable: bool,
56}
57
58#[derive(Clone, Debug, Serialize)]
59pub struct CommandInfo {
60    pub id: &'static str,
61    pub label: &'static str,
62    pub menu: Vec<&'static str>,
63    pub shortcut: Option<&'static str>,
64    pub shortcut_mac: Option<&'static str>,
65    pub params: &'static str,
66    pub enabled: bool,
67    #[serde(skip_serializing_if = "Option::is_none")]
68    pub disabled_reason: Option<String>,
69}
70
71impl CommandInfo {
72    /// Format the portable shortcut notation for the runtime operating system.
73    pub fn shortcut_for_platform(&self, mac: bool) -> Option<String> {
74        let keys = if mac {
75            self.shortcut_mac.or(self.shortcut)
76        } else if self.shortcut_mac.is_some() && self.shortcut_mac == self.shortcut {
77            // An identical Mac override marks a Mac-only display shortcut.
78            // The portable field remains unchanged for legacy CLI consumers.
79            None
80        } else {
81            self.shortcut
82        }?;
83        if mac {
84            return Some(keys.to_owned());
85        }
86        Some(keys.split('+').map(|part| if part == "Cmd" { "Ctrl" } else { part }).collect::<Vec<_>>().join("+"))
87    }
88}
89
90impl CommandSpec {
91    pub fn info(&self, s: &Session) -> CommandInfo {
92        let e = (self.enabled)(s);
93        CommandInfo {
94            id: self.id,
95            label: self.label,
96            menu: self.menu.to_vec(),
97            shortcut: self.shortcut,
98            shortcut_mac: self.shortcut_mac,
99            params: self.params,
100            enabled: e.is_ok(),
101            disabled_reason: e.err(),
102        }
103    }
104}
105
106/// Params that hold a secret: `password`, or a name ending in `Password`.
107pub fn is_secret_param(key: &str) -> bool {
108    key == "password" || key.ends_with("Password")
109}
110
111/// The params as the journal records them: secret params are left out, so a password never
112/// reaches the journal dialog, `engine.journal` or a recorded script (saved in the workbook).
113/// Replaying such an entry runs the command without that param.
114pub fn journal_params(params: &Json) -> Json {
115    match params {
116        Json::Object(o) => Json::Object(o.iter().filter(|(k, _)| !is_secret_param(k)).map(|(k, v)| (k.clone(), v.clone())).collect()),
117        other => other.clone(),
118    }
119}
120
121pub fn always(_: &Session) -> std::result::Result<(), String> {
122    Ok(())
123}
124pub fn has_doc(s: &Session) -> std::result::Result<(), String> {
125    s.active().map(|_| ()).ok_or_else(|| "no workbook open".into())
126}
127pub fn can_undo(s: &Session) -> std::result::Result<(), String> {
128    has_doc(s)?;
129    not_grouping(s)?;
130    if s.active().is_some_and(|d| !d.undo.is_empty()) { Ok(()) } else { Err("nothing to undo".into()) }
131}
132pub fn can_redo(s: &Session) -> std::result::Result<(), String> {
133    has_doc(s)?;
134    not_grouping(s)?;
135    if s.active().is_some_and(|d| !d.redo.is_empty()) { Ok(()) } else { Err("nothing to redo".into()) }
136}
137/// Undo and Redo wait while an undo group is open on the active document (an action that spans
138/// several calls is still running).
139fn not_grouping(s: &Session) -> std::result::Result<(), String> {
140    if s.active().is_some_and(|d| s.in_group(d.uid)) { Err("finish or stop the running action first (automate.stopAction)".into()) } else { Ok(()) }
141}
142pub fn has_clipboard(s: &Session) -> std::result::Result<(), String> {
143    has_doc(s)?;
144    if s.clipboard.is_some() { Ok(()) } else { Err("the clipboard is empty".into()) }
145}
146
147macro_rules! cmd {
148    ($id:literal, $label:literal, [$($m:literal),*], $sc:expr, $params:literal, $en:expr, $run:expr) => {
149        $crate::engine::cmd::CommandSpec { id: $id, label: $label, menu: &[$($m),*], shortcut: $sc, shortcut_mac: None, params: $params, enabled: $en, run: $run, journal: true, undoable: true }
150    };
151    (query $id:literal, $label:literal, [$($m:literal),*], $sc:expr, $params:literal, $en:expr, $run:expr) => {
152        $crate::engine::cmd::CommandSpec { id: $id, label: $label, menu: &[$($m),*], shortcut: $sc, shortcut_mac: None, params: $params, enabled: $en, run: $run, journal: false, undoable: false }
153    };
154    (noundo $id:literal, $label:literal, [$($m:literal),*], $sc:expr, $params:literal, $en:expr, $run:expr) => {
155        $crate::engine::cmd::CommandSpec { id: $id, label: $label, menu: &[$($m),*], shortcut: $sc, shortcut_mac: None, params: $params, enabled: $en, run: $run, journal: true, undoable: false }
156    };
157}
158pub(crate) use cmd;
159
160pub fn command_specs() -> &'static [CommandSpec] {
161    static SPECS: std::sync::OnceLock<Vec<CommandSpec>> = std::sync::OnceLock::new();
162    SPECS.get_or_init(|| {
163        let mut v = Vec::new();
164        v.extend(file::specs());
165        v.extend(edit::specs());
166        v.extend(format::specs());
167        v.extend(sheet::specs());
168        v.extend(insert::specs());
169        v.extend(controls::specs());
170        v.extend(equation::specs());
171        v.extend(data::specs());
172        v.extend(formulas::specs());
173        v.extend(review::specs());
174        v.extend(view::specs());
175        v.extend(inspect::specs());
176        v.extend(extra::specs());
177        v.extend(macros::specs());
178        v.extend(draw::specs());
179        v.extend(spelling::specs());
180        v.extend(solver::specs());
181        v.extend(pivot::specs());
182        v.extend(python::specs());
183        v.extend(slicer::specs());
184        v.extend(whatif::specs());
185        v.extend(forecast::specs());
186        v.extend(analysis::specs());
187        v.extend(print::specs());
188        v
189    })
190}
191
192pub fn find_command(id: &str) -> Option<&'static CommandSpec> {
193    if let Some(spec) = command_specs().iter().find(|c| c.id == id) {
194        return Some(spec);
195    }
196    // Commands registered by UI integration tests (pending keytips go live
197    // the moment their command lands, with no table edit).
198    test_commands().iter().find(|c| c.id == id).copied()
199}
200
201static TEST_COMMANDS: std::sync::Mutex<Vec<&CommandSpec>> = std::sync::Mutex::new(Vec::new());
202
203/// Test-only: register an extra command so UI integration tests can prove a pending
204/// keytip becomes live the moment its command lands (see crates/ui-egui/tests/keytips.rs).
205/// Not part of the public API; harmless no-op for production callers who never call it.
206#[doc(hidden)]
207pub fn register_test_command(id: &'static str, run: Run) {
208    // Leaked intentionally: test commands live for the whole process.
209    let spec: &'static CommandSpec = Box::leak(Box::new(CommandSpec {
210        id,
211        label: id,
212        menu: &[],
213        shortcut: None,
214        shortcut_mac: None,
215        params: "",
216        enabled: |_| Ok(()),
217        run,
218        journal: true,
219        undoable: false,
220    }));
221    test_commands().push(spec);
222}
223
224/// Test-only: an undoable command, to exercise how `execute` records its step.
225#[cfg(test)]
226pub(crate) fn register_undoable_test_command(id: &'static str, run: Run) {
227    let spec: &'static CommandSpec = Box::leak(Box::new(CommandSpec {
228        id,
229        label: id,
230        menu: &[],
231        shortcut: None,
232        shortcut_mac: None,
233        params: "",
234        enabled: |_| Ok(()),
235        run,
236        journal: false,
237        undoable: true,
238    }));
239    test_commands().push(spec);
240}
241
242fn test_commands() -> std::sync::MutexGuard<'static, Vec<&'static CommandSpec>> {
243    match TEST_COMMANDS.lock() {
244        Ok(guard) => guard,
245        Err(poisoned) => poisoned.into_inner(),
246    }
247}
248
249// ---------------------------------------------------------------- param helpers
250
251pub(crate) fn bad(cmd: &str, msg: impl Into<String>) -> EngineError {
252    EngineError::BadParams { cmd: cmd.into(), msg: msg.into() }
253}
254pub(crate) fn str_param<'a>(p: &'a Json, key: &str) -> Option<&'a str> {
255    p.get(key).and_then(Json::as_str)
256}
257pub(crate) fn f64_param(p: &Json, key: &str) -> Option<f64> {
258    p.get(key).and_then(Json::as_f64)
259}
260pub(crate) fn bool_param(p: &Json, key: &str) -> Option<bool> {
261    p.get(key).and_then(Json::as_bool)
262}
263pub(crate) fn u32_param(p: &Json, key: &str) -> Option<u32> {
264    p.get(key).and_then(Json::as_u64).map(|v| v.min(u32::MAX as u64) as u32)
265}
266
267/// An object anchor mode from Excel's wording or the model's: `"moveAndSize"`/`"twoCell"`,
268/// `"moveOnly"`/`"oneCell"`, `"absolute"`/`"don't move or size"` (case-insensitive).
269pub(crate) fn anchor_mode_param(p: &Json) -> Option<crate::model::AnchorMode> {
270    parse_anchor_mode(str_param(p, "mode").or_else(|| str_param(p, "anchorMode"))?)
271}
272
273pub(crate) fn parse_anchor_mode(s: &str) -> Option<crate::model::AnchorMode> {
274    match s.to_ascii_lowercase().replace([' ', '-', '_', '\''], "").as_str() {
275        "moveandsize" | "moveandsizeandcells" | "twocell" => Some(crate::model::AnchorMode::MoveAndSize),
276        "moveonly" | "movebutdontsize" | "movebutdontsizewithcells" | "onecell" => Some(crate::model::AnchorMode::MoveOnly),
277        "absolute" | "dontmoveorsize" | "dontmoveorsizewithcells" | "none" => Some(crate::model::AnchorMode::Absolute),
278        _ => None,
279    }
280}
281
282/// The wire name of an anchor mode (matches the `mode` param of [`anchor_mode_param`]).
283pub(crate) fn anchor_mode_name(m: crate::model::AnchorMode) -> &'static str {
284    match m {
285        crate::model::AnchorMode::MoveAndSize => "moveAndSize",
286        crate::model::AnchorMode::MoveOnly => "moveOnly",
287        crate::model::AnchorMode::Absolute => "absolute",
288    }
289}
290pub(crate) fn ok() -> Result<Json> {
291    Ok(Json::Null)
292}
293
294/// A range parameter (`"A1:B2"`, `"Sheet2!A1"`, `"B:B"`), defaulting to the current selection's
295/// areas.
296pub(crate) fn target_ranges(s: &Session, p: &Json) -> Result<Vec<RangeRef>> {
297    if let Some(r) = str_param(p, "range").or_else(|| str_param(p, "cell")) {
298        let (_, body) = split_sheet(r);
299        let mut out = Vec::new();
300        for part in body.split(',') {
301            out.push(RangeRef::parse(part).ok_or_else(|| bad("range", format!("not a range: `{part}`")))?);
302        }
303        return Ok(out);
304    }
305    Ok(s.doc()?.selection.ranges.clone())
306}
307
308/// A `rows`/`cols` parameter: `3:5` and `B:C`, or a single `3` or `B`.
309pub(crate) fn parse_lines(t: &str) -> Option<RangeRef> {
310    RangeRef::parse(t).or_else(|| if t.contains(':') { None } else { RangeRef::parse(&format!("{t}:{t}")) })
311}
312
313pub(crate) fn target_range(s: &Session, p: &Json) -> Result<RangeRef> {
314    Ok(target_ranges(s, p)?.first().copied().unwrap_or_default())
315}
316
317/// The sheet a call names (`sheet` param or a `Sheet!` prefix on `range`), else the active one.
318pub(crate) fn target_sheet(s: &Session, p: &Json) -> Result<usize> {
319    let d = s.doc()?;
320    if let Some(v) = p.get("sheet") {
321        if let Some(i) = v.as_u64() {
322            return if (i as usize) < d.wb.sheets.len() { Ok(i as usize) } else { Err(bad("sheet", "no such sheet")) };
323        }
324        if let Some(n) = v.as_str() {
325            return d.wb.sheet_index(n).ok_or_else(|| bad("sheet", format!("no sheet named `{n}`")));
326        }
327    }
328    if let Some(r) = str_param(p, "range").or_else(|| str_param(p, "cell"))
329        && let (Some(sh), _) = split_sheet(r)
330    {
331        return d.wb.sheet_index(&sh).ok_or_else(|| bad("range", format!("no sheet named `{sh}`")));
332    }
333    Ok(d.wb.active_sheet())
334}
335
336pub(crate) use crate::base::split_sheet;
337
338/// A single-cell parameter. Absent or null is `Ok(None)` (callers default it); a value that is not
339/// a cell reference, or that names a sheet other than the active one, is an error rather than a
340/// silent fallback to some other cell.
341pub(crate) fn cell_param(s: &Session, p: &Json, key: &str) -> Result<Option<CellRef>> {
342    cell_param_on(s, p, key, s.doc()?.wb.active_sheet())
343}
344
345/// Like [`cell_param`], for a command that operates on `sheet` (a `Sheet!` prefix must name it).
346pub(crate) fn cell_param_on(s: &Session, p: &Json, key: &str, sheet: usize) -> Result<Option<CellRef>> {
347    let t = match p.get(key) {
348        None | Some(Json::Null) => return Ok(None),
349        Some(Json::String(t)) => t,
350        Some(v) => return Err(bad("cell", format!("invalid cell reference `{v}` for `{key}`"))),
351    };
352    let (sh, body) = split_sheet(t);
353    let c = CellRef::parse(body).ok_or_else(|| bad("cell", format!("invalid cell reference `{t}` for `{key}`")))?;
354    if let Some(name) = sh
355        && s.doc()?.wb.sheet_index(&name) != Some(sheet)
356    {
357        return Err(bad("cell", format!("`{t}` for `{key}` names a sheet other than the one this command works on")));
358    }
359    Ok(Some(c))
360}
361
362/// Applies an edit to a copy of the active workbook, then recalculates and commits it.
363/// `changed` collects cells whose content changed; set `structural` for row/column/sheet edits
364/// (full recalc).
365pub struct Ctx<'a> {
366    pub wb: Workbook,
367    pub changed: Vec<Key>,
368    pub structural: bool,
369    pub sel: &'a mut Selection,
370    /// Rows whose automatic height should be recomputed (sheet, row).
371    pub fit_rows: Vec<(usize, u32)>,
372    /// The command applied sheet protection itself (row/column inserts and deletes the
373    /// protection allows), so cells it shifts aren't refused as edits of locked cells.
374    pub protection_checked: bool,
375    /// The structural edits the command made, in order (the sheet's name, the edit), as
376    /// references were rewritten for them.
377    pub edits: Vec<(String, crate::formula::adjust::Edit)>,
378}
379
380pub(crate) fn edit<R>(s: &mut Session, f: impl FnOnce(&mut Ctx) -> Result<R>) -> Result<R> {
381    let d = s.doc_mut()?;
382    commit(d, |cx| {
383        let r = f(cx)?;
384        crate::engine::tables::sync_headers(cx);
385        Ok(r)
386    })
387}
388
389fn wall_clock() -> std::time::Duration {
390    std::time::SystemTime::UNIX_EPOCH.elapsed().unwrap_or_default()
391}
392
393pub(crate) fn commit<R>(d: &mut DocState, f: impl FnOnce(&mut Ctx) -> Result<R>) -> Result<R> {
394    // `GRIDCRAFT_PROFILE` timing, never on wasm where the clock traps. `elapsed` on the epoch
395    // reads the wall clock without the (disallowed) `now`.
396    let t0 = if cfg!(target_arch = "wasm32") { None } else { std::env::var_os("GRIDCRAFT_PROFILE").map(|_| wall_clock()) };
397    let since = |t0: std::time::Duration| wall_clock().saturating_sub(t0);
398    let mut sel = d.selection.clone();
399    let mut ctx = Ctx {
400        wb: (*d.wb).clone(),
401        changed: Vec::new(),
402        structural: false,
403        sel: &mut sel,
404        fit_rows: Vec::new(),
405        protection_checked: false,
406        edits: Vec::new(),
407    };
408    let r = f(&mut ctx)?;
409    let Ctx { mut wb, mut changed, structural, mut fit_rows, protection_checked, edits, .. } = ctx;
410    // Equations whose source changed (or that arrived without a layout, as from a paste) are
411    // laid out here, so painting only ever reads the cache.
412    wb.refresh_equations();
413    if !protection_checked {
414        check_protection(&d.wb, &wb, &d.unlocked_ranges)?;
415    }
416    if !structural {
417        check_arrays(&d.wb, &wb)?;
418    }
419    if wb.calc.precision_as_displayed {
420        round_edited_as_displayed(&d.wb, &mut wb, &mut changed, structural);
421    }
422    if let Some(log) = d.touched.as_mut() {
423        for (si, c) in changed.iter().take(TOUCH_LOG_LIMIT.saturating_sub(log.len())) {
424            if let Some(sh) = wb.sheet(*si) {
425                log.push((sh.name.clone(), *c));
426            }
427        }
428    }
429    if let Some(log) = d.edits.as_mut() {
430        log.extend(edits);
431    }
432    if changed.len() <= 200_000 {
433        fit_rows.extend(changed.iter().map(|(s, c)| (*s, c.row)));
434    }
435    if let Some(t0) = t0 {
436        eprintln!("commit: edit {:?}, {} changed", since(t0), changed.len());
437    }
438    if structural {
439        d.calc.recalc_all(&mut wb);
440    } else if !changed.is_empty() {
441        d.calc.cells_changed(&mut wb, &changed);
442    }
443    controls::sync_linked_values(&mut wb);
444    if let Some(t0) = t0 {
445        eprintln!("commit: + recalc {:?}", since(t0));
446    }
447    fit_rows.sort_unstable();
448    fit_rows.dedup();
449    for (si, row) in fit_rows {
450        auto_row_height(&mut wb, si, row);
451    }
452    if let Some(t0) = t0 {
453        eprintln!("commit: + rows {:?}", since(t0));
454    }
455    d.wb = std::sync::Arc::new(wb);
456    d.selection = sel;
457    Ok(r)
458}
459
460/// "Set precision as displayed": a number typed, pasted or reformatted is stored as its format
461/// shows it (Excel rounds a constant when it is entered and when its cell's format changes,
462/// observed through COM). Only cells the edit touched are visited, so this costs nothing for
463/// the rest of the workbook; formula cells whose format changed are recalculated, and the
464/// recalculation rounds them. A structural edit recalculates everything anyway, so it only
465/// rounds its constants (a sheet the edit created, for one, holds written numbers).
466fn round_edited_as_displayed(old: &Workbook, wb: &mut Workbook, changed: &mut Vec<crate::calc::Key>, structural: bool) {
467    let mut prec = crate::calc::DisplayedPrecision::new();
468    for si in 0..wb.sheets.len() {
469        let Some(after) = wb.sheets.get(si) else { continue };
470        // Sheets are matched by name. A sheet the edit created has no earlier version, so all of
471        // its cells are new.
472        let before = old.sheet_index(&after.name).and_then(|i| old.sheets.get(i));
473        if before.is_some_and(|b| std::sync::Arc::ptr_eq(b, after)) {
474            continue;
475        }
476        let diffs: Vec<CellRef> = match before {
477            Some(before) => before.cells.diff(&after.cells),
478            None => after.cells.iter().map(|(c, _)| c).collect(),
479        };
480        let mut anchors = std::collections::HashSet::new();
481        for &c in &diffs {
482            if !structural && after.cell(c).is_some_and(|cell| cell.formula.is_some()) {
483                changed.push((si, c));
484                anchors.insert(c);
485            }
486        }
487        let updates = displayed_updates(&mut prec, wb, si, diffs.iter().copied());
488        // A spill destination is no stored cell: reformatting it (or its row or column) leaves
489        // no change in the store, so its anchor is recalculated to round the new element.
490        if let (Some(before), false) = (before, structural) {
491            for (&anchor, range) in &after.spill_ranges {
492                if !anchors.contains(&anchor)
493                    && (diffs.iter().any(|d| range.contains(*d))
494                        || line_styles_differ(&before.rows, &after.rows, range.start.row, range.end.row)
495                        || line_styles_differ(&before.cols, &after.cols, range.start.col, range.end.col))
496                {
497                    changed.push((si, anchor));
498                }
499            }
500        }
501        if updates.is_empty() {
502            continue;
503        }
504        let Some(sh) = wb.sheet_mut(si) else { continue };
505        for (c, r) in updates {
506            if let Some(cell) = sh.cells.get_mut(c) {
507                cell.value = crate::base::Value::Number(r);
508                if !structural {
509                    changed.push((si, c));
510                }
511            }
512        }
513    }
514}
515
516/// Whether any row or column style in `lo..=hi` differs between two sheets' line maps.
517fn line_styles_differ(
518    before: &std::collections::BTreeMap<u32, crate::model::LineInfo>,
519    after: &std::collections::BTreeMap<u32, crate::model::LineInfo>,
520    lo: u32,
521    hi: u32,
522) -> bool {
523    let style = |m: &std::collections::BTreeMap<u32, crate::model::LineInfo>, k: u32| m.get(&k).and_then(|l| l.style);
524    after.range(lo..=hi).any(|(k, l)| l.style != style(before, *k)) || before.range(lo..=hi).any(|(k, l)| l.style != style(after, *k))
525}
526
527/// Turning "Set precision as displayed" on rounds every number constant in the workbook to its
528/// format, permanently (Excel warns "Data will permanently lose accuracy"). Formulas are rounded
529/// by the recalculation that follows.
530pub(crate) fn round_all_constants_as_displayed(wb: &mut Workbook) {
531    let mut prec = crate::calc::DisplayedPrecision::new();
532    for si in 0..wb.sheets.len() {
533        let Some(sh) = wb.sheets.get(si) else { continue };
534        let updates = displayed_updates(&mut prec, wb, si, sh.cells.iter().map(|(c, _)| c));
535        if updates.is_empty() {
536            continue;
537        }
538        let Some(sh) = wb.sheet_mut(si) else { continue };
539        for (c, r) in updates {
540            if let Some(cell) = sh.cells.get_mut(c) {
541                cell.value = crate::base::Value::Number(r);
542            }
543        }
544    }
545}
546
547/// The constants among `cells` on sheet `si` whose value the option changes, with their rounded
548/// values. A locked constant on a protected sheet keeps its value: protection keeps its content,
549/// and rounding it by its format would change the content that the sheet protects.
550fn displayed_updates(
551    prec: &mut crate::calc::DisplayedPrecision,
552    wb: &Workbook,
553    si: usize,
554    cells: impl Iterator<Item = CellRef>,
555) -> Vec<(CellRef, f64)> {
556    let Some(sh) = wb.sheet(si) else { return Vec::new() };
557    cells
558        .filter(|&c| !(sh.is_protected() && wb.styles.get(sh.style_id(c)).protection.locked))
559        .filter_map(|c| {
560            let cell = sh.cell(c)?;
561            match cell.value {
562                crate::base::Value::Number(n) if cell.formula.is_none() => {
563                    let r = prec.round(wb, si, c, n);
564                    (r.to_bits() != n.to_bits()).then_some((c, r))
565                }
566                _ => None,
567            }
568        })
569        .collect()
570}
571/// The most cells a dry run's touch log keeps; a footprint whose log is full may be missing writes
572/// that kept a cell's value, so a shift is refused then.
573pub(crate) const TOUCH_LOG_LIMIT: usize = 1_000_000;
574
575/// Excel's message for an edit of a locked cell on a protected sheet.
576pub(crate) const PROTECTED: &str = "The cell or chart you're trying to change is on a protected sheet.";
577
578/// Refuses an edit that changed what a locked cell on a protected sheet holds (its value or
579/// formula), or that unlocked it, whichever command made it. Sheets are matched by name, so
580/// moving, renaming or deleting a sheet isn't an edit of its cells. A sort or duplicate removal
581/// that moves an unlocked cell with equal content onto a locked one changes no content but would
582/// leave the cell editable, so the effective lock (row and column formats included) is compared
583/// as well.
584fn check_protection(old: &Workbook, new: &Workbook, unlocked: &[(String, String)]) -> Result<()> {
585    for sh in &old.sheets {
586        if !sh.is_protected() {
587            continue;
588        }
589        let Some(after) = new.sheet_index(&sh.name).and_then(|i| new.sheets.get(i)) else { continue };
590        if std::sync::Arc::ptr_eq(sh, after) || !after.is_protected() {
591            continue;
592        }
593        for c in sh.cells.diff(&after.cells) {
594            let unlocked_now = !new.styles.get(after.style_id(c)).protection.locked;
595            if (unlocked_now || !same_content(sh.cell(c), after.cell(c)))
596                && old.styles.get(sh.style_id(c)).protection.locked
597                && let Some(m) = locked_cell_error(sh, c, unlocked)
598            {
599                return Err(EngineError::Other(m));
600            }
601        }
602    }
603    Ok(())
604}
605
606/// Refuses an action a protected sheet does not allow, by the tick box for it in Review ▸ Protect
607/// Sheet. `what` names the action in the message. An unprotected sheet allows everything.
608///
609/// Cell edits are guarded separately, by whether the cell is locked (`locked_cell_error`): these
610/// permissions are the ones that are not about one cell's contents.
611pub(crate) fn refuse_unless_allowed(s: &Session, sheet: usize, what: &str, pick: fn(&crate::model::SheetProtection) -> bool) -> Result<()> {
612    if s.doc()?.wb.sheet(sheet).is_some_and(|sh| sh.protection.as_ref().is_some_and(|pr| !pick(pr))) {
613        return Err(EngineError::Other(format!("{what} isn't allowed on this protected sheet (Review › Unprotect Sheet).")));
614    }
615    Ok(())
616}
617
618/// Whether a locked cell on a protected sheet stays locked: `None` when it's in an Allow Edit
619/// Range without a password or one unlocked in this session, else the message to show.
620pub(crate) fn locked_cell_error(sh: &crate::model::Sheet, c: CellRef, unlocked: &[(String, String)]) -> Option<String> {
621    let mut needs_password = None;
622    for r in sh.edit_ranges.iter().filter(|r| r.contains(c)) {
623        // A range limited to Windows users or groups (a security descriptor) can't be checked
624        // here, so without a password it stays locked rather than opening to every editor.
625        if (r.password.is_none() && r.security_descriptor.is_none()) || unlocked.iter().any(|(s, t)| *s == sh.name && *t == r.title) {
626            return None;
627        }
628        if r.password.is_some() {
629            needs_password.get_or_insert(r.title.as_str());
630        }
631    }
632    Some(match needs_password {
633        Some(t) => format!("The range \"{t}\" needs its password before it can be changed (Review › Allow Edit Ranges)."),
634        None => PROTECTED.into(),
635    })
636}
637
638/// Refuses an edit that put something into a cell of a multi-cell legacy array other than its
639/// formula cell, while the array stays (Excel: "You can't change part of an array."). The array
640/// would show its value there while the file kept the new content. Cells shifted by row and
641/// column edits aren't edits of their content, so structural commits aren't checked.
642fn check_arrays(old: &Workbook, new: &Workbook) -> Result<()> {
643    for sh in &old.sheets {
644        if sh.spill.is_empty() {
645            continue;
646        }
647        let Some(after) = new.sheet_index(&sh.name).and_then(|i| new.sheets.get(i)) else { continue };
648        if std::sync::Arc::ptr_eq(sh, after) {
649            continue;
650        }
651        for c in sh.cells.diff(&after.cells) {
652            let filled = after.cell(c).is_some_and(crate::model::Cell::has_content);
653            if !filled || !sh.spill.contains_key(&c) || same_content(sh.cell(c), after.cell(c)) {
654                continue;
655            }
656            let in_array = sh.spill_ranges.keys().any(|&anchor| {
657                anchor != c
658                    && after.cell(anchor).and_then(|x| x.formula.as_ref()).and_then(|f| f.array).is_some_and(|a| a.start == anchor && a.contains(c))
659            });
660            if in_array {
661                return Err(EngineError::Other("You can't change part of an array.".into()));
662            }
663        }
664    }
665    Ok(())
666}
667
668/// Two cells hold the same value, formula or Python code (formats aside; a Python cell's result
669/// is not something the person edits).
670fn same_content(a: Option<&crate::model::Cell>, b: Option<&crate::model::Cell>) -> bool {
671    let empty = |c: Option<&crate::model::Cell>| c.is_none_or(|c| !c.has_content());
672    match (a, b) {
673        (Some(a), Some(b)) => match (&a.formula, &b.formula) {
674            (Some(x), Some(y)) => x.text == y.text && x.array == y.array,
675            (None, None) => match (&a.py, &b.py) {
676                (Some(x), Some(y)) => x.code == y.code && x.object == y.object,
677                (None, None) => a.value == b.value,
678                _ => false,
679            },
680            _ => false,
681        },
682        _ => empty(a) && empty(b),
683    }
684}
685
686/// Lines a single text line needs when word-wrapped into `width` points. The grid wraps whole
687/// words (egui's LayoutJob), so dividing the total width by the column width undercounts: each
688/// drawn line ends early at the last word that fits. A word wider than the column breaks
689/// mid-word, the way egui does. (#1969)
690fn wrapped_line_count(line: &str, width: f32, size: f32, bold: bool) -> f32 {
691    let word_w = |w: &str| crate::engine::cmd::format::approx_text_width(w, size, bold);
692    let space_w = word_w(" ");
693    let mut lines = 1.0f32;
694    let mut used = 0.0f32;
695    for word in line.split_whitespace() {
696        let w = word_w(word);
697        if w > width {
698            // wider than the column: egui fills the current line, then breaks it across lines
699            if used > 0.0 {
700                lines += 1.0;
701            }
702            let chunks = (w / width).ceil();
703            lines += chunks - 1.0;
704            used = w - (chunks - 1.0) * width;
705            continue;
706        }
707        let gap = if used > 0.0 { space_w } else { 0.0 };
708        if used + gap + w > width {
709            lines += 1.0;
710            used = w;
711        } else {
712            used += gap + w;
713        }
714    }
715    lines
716}
717
718/// Recomputes an automatic row height from its content: the largest font, and wrapped text.
719/// Rows with a user-set height keep it.
720///
721/// Line breaks in a cell's text count only when the cell wraps, as in Excel: with Wrap Text off
722/// a multi-line value shows on one line and AutoFit gives a one-line row.
723pub fn auto_row_height(wb: &mut Workbook, si: usize, row: u32) {
724    let Some(sh) = wb.sheet(si) else { return };
725    if sh.rows.get(&row).is_some_and(|i| i.custom) {
726        return;
727    }
728    let default = sh.default_row_height;
729    // Fast path: rows of default-size, unwrapped content keep the default height.
730    let plain = sh.cells.row(row, 0, crate::base::MAX_COLS - 1).all(|(_, cell)| {
731        let st = wb.styles.get(cell.style);
732        st.font.size <= crate::model::DEFAULT_FONT_SIZE && !st.align.wrap
733    });
734    if plain {
735        if sh.rows.get(&row).is_some_and(|i| i.size.is_some())
736            && let Some(shm) = wb.sheet_mut(si)
737            && let Some(e) = shm.rows.get_mut(&row)
738        {
739            {
740                e.size = None;
741                if *e == crate::model::LineInfo::default() {
742                    shm.rows.remove(&row);
743                }
744            }
745        }
746        return;
747    }
748    let mut need: f32 = default;
749    // The cell loop must not rescan every merge per cell: the merges touching this row are
750    // picked out once, and per cell the candidates are re-checked (normally none or a couple;
751    // a sheet with many merges and many saved columns would otherwise scan all of them per
752    // cell, rows × merges × columns).
753    let row_merges: Vec<crate::base::RangeRef> =
754        sh.merges.iter().copied().filter(|m| !m.is_single() && m.start.row <= row && row <= m.end.row).collect();
755    for (c, cell) in sh.cells.row(row, 0, crate::base::MAX_COLS - 1) {
756        // Merged cells never auto-fit their rows, like Excel (column autofit skips them too).
757        if row_merges.iter().any(|m| m.contains(CellRef::new(row, c))) {
758            continue;
759        }
760        let st = wb.styles.get(cell.style);
761        let size = st.font.size;
762        // Scaled from an 11pt row's height, not the sheet's default: a sheet whose default
763        // already fits a bigger font (21pt rows for 16pt text) would otherwise scale it twice.
764        let line = (size * 1.8).round().max(crate::model::DEFAULT_ROW_HEIGHT * size / crate::model::DEFAULT_FONT_SIZE);
765        let mut lines = 1.0f32;
766        if !cell.value.is_empty() || cell.formula.is_some() {
767            // Only a wrapping cell is laid out over several lines: each explicit line break
768            // starts one, and each line folds at the column width.
769            if st.align.wrap {
770                let text = crate::engine::display::cell_text(wb, sh, CellRef::new(row, c));
771                let w = sh.col_width(c).max(1.0) - 6.0;
772                lines = text.lines().map(|l| wrapped_line_count(l, w.max(1.0), size, st.font.bold)).sum::<f32>().max(1.0);
773            }
774        } else if st.font.size <= crate::model::DEFAULT_FONT_SIZE {
775            continue;
776        }
777        let h = if lines > 1.0 { (size * 96.0 / 72.0 * 1.22 * lines + 5.0).ceil() } else { line };
778        need = need.max(h.min(546.0));
779    }
780    let Some(shm) = wb.sheet_mut(si) else { return };
781    let e = shm.rows.entry(row).or_default();
782    e.size = if (need - default).abs() < 0.5 { None } else { Some(need) };
783    if *e == crate::model::LineInfo::default() {
784        shm.rows.remove(&row);
785    }
786}
787
788impl Ctx<'_> {
789    pub fn sheet_index(&self) -> usize {
790        self.wb.active_sheet()
791    }
792    pub fn sheet_mut(&mut self, i: usize) -> Result<&mut crate::model::Sheet> {
793        self.wb.sheet_mut(i).ok_or_else(|| EngineError::Other("no such sheet".into()))
794    }
795    pub fn touch(&mut self, sheet: usize, c: CellRef) {
796        self.changed.push((sheet, c));
797    }
798}