mirror of
https://github.com/storytold/gridcraft.git
synced 2026-10-24 10:46:50 -06:00
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
599 lines
24 KiB
Rust
599 lines
24 KiB
Rust
//! The command registry. Ids follow Excel's ribbon (`home.bold`, `insert.chart`,
|
||
//! `data.sortAscending`) plus primitive editing commands (`cell.set`, `selection.set`).
|
||
|
||
pub mod analysis;
|
||
pub mod data;
|
||
pub mod draw;
|
||
pub mod edit;
|
||
pub mod extra;
|
||
pub mod file;
|
||
pub mod forecast;
|
||
pub mod format;
|
||
pub mod formulas;
|
||
pub mod insert;
|
||
pub mod inspect;
|
||
pub mod pivot;
|
||
pub mod print;
|
||
pub mod review;
|
||
pub mod sheet;
|
||
pub mod slicer;
|
||
pub mod solver;
|
||
pub mod spelling;
|
||
pub mod stash;
|
||
pub mod view;
|
||
pub mod whatif;
|
||
|
||
use gridcraft_calc::Key;
|
||
use gridcraft_core::{CellRef, RangeRef};
|
||
use gridcraft_model::Workbook;
|
||
use serde::Serialize;
|
||
use serde_json::Value as Json;
|
||
|
||
use crate::{DocState, EngineError, Result, Selection, Session};
|
||
|
||
pub type Run = fn(&mut Session, &Json) -> Result<Json>;
|
||
pub type Enabled = fn(&Session) -> std::result::Result<(), String>;
|
||
|
||
pub struct CommandSpec {
|
||
pub id: &'static str,
|
||
pub label: &'static str,
|
||
/// Ribbon/menu placement, e.g. `["Home", "Font"]`. Empty = not in menus.
|
||
pub menu: &'static [&'static str],
|
||
pub shortcut: Option<&'static str>,
|
||
pub params: &'static str,
|
||
pub enabled: Enabled,
|
||
pub run: Run,
|
||
pub journal: bool,
|
||
pub undoable: bool,
|
||
}
|
||
|
||
#[derive(Clone, Debug, Serialize)]
|
||
pub struct CommandInfo {
|
||
pub id: &'static str,
|
||
pub label: &'static str,
|
||
pub menu: Vec<&'static str>,
|
||
pub shortcut: Option<&'static str>,
|
||
pub params: &'static str,
|
||
pub enabled: bool,
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
pub disabled_reason: Option<String>,
|
||
}
|
||
|
||
impl CommandSpec {
|
||
pub fn info(&self, s: &Session) -> CommandInfo {
|
||
let e = (self.enabled)(s);
|
||
CommandInfo {
|
||
id: self.id,
|
||
label: self.label,
|
||
menu: self.menu.to_vec(),
|
||
shortcut: self.shortcut,
|
||
params: self.params,
|
||
enabled: e.is_ok(),
|
||
disabled_reason: e.err(),
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Params that hold a secret: `password`, or a name ending in `Password`.
|
||
pub fn is_secret_param(key: &str) -> bool {
|
||
key == "password" || key.ends_with("Password")
|
||
}
|
||
|
||
/// The params as the journal records them: secret params are left out, so a password never
|
||
/// reaches the journal dialog, `engine.journal` or a recorded script (saved in the workbook).
|
||
/// Replaying such an entry runs the command without that param.
|
||
pub fn journal_params(params: &Json) -> Json {
|
||
match params {
|
||
Json::Object(o) => Json::Object(o.iter().filter(|(k, _)| !is_secret_param(k)).map(|(k, v)| (k.clone(), v.clone())).collect()),
|
||
other => other.clone(),
|
||
}
|
||
}
|
||
|
||
pub fn always(_: &Session) -> std::result::Result<(), String> {
|
||
Ok(())
|
||
}
|
||
pub fn has_doc(s: &Session) -> std::result::Result<(), String> {
|
||
s.active().map(|_| ()).ok_or_else(|| "no workbook open".into())
|
||
}
|
||
pub fn can_undo(s: &Session) -> std::result::Result<(), String> {
|
||
has_doc(s)?;
|
||
not_grouping(s)?;
|
||
if s.active().is_some_and(|d| !d.undo.is_empty()) { Ok(()) } else { Err("nothing to undo".into()) }
|
||
}
|
||
pub fn can_redo(s: &Session) -> std::result::Result<(), String> {
|
||
has_doc(s)?;
|
||
not_grouping(s)?;
|
||
if s.active().is_some_and(|d| !d.redo.is_empty()) { Ok(()) } else { Err("nothing to redo".into()) }
|
||
}
|
||
/// Undo and Redo wait while an undo group is open on the active document (an action that spans
|
||
/// several calls is still running).
|
||
fn not_grouping(s: &Session) -> std::result::Result<(), String> {
|
||
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(()) }
|
||
}
|
||
pub fn has_clipboard(s: &Session) -> std::result::Result<(), String> {
|
||
has_doc(s)?;
|
||
if s.clipboard.is_some() { Ok(()) } else { Err("the clipboard is empty".into()) }
|
||
}
|
||
|
||
macro_rules! cmd {
|
||
($id:literal, $label:literal, [$($m:literal),*], $sc:expr, $params:literal, $en:expr, $run:expr) => {
|
||
$crate::cmd::CommandSpec { id: $id, label: $label, menu: &[$($m),*], shortcut: $sc, params: $params, enabled: $en, run: $run, journal: true, undoable: true }
|
||
};
|
||
(query $id:literal, $label:literal, [$($m:literal),*], $sc:expr, $params:literal, $en:expr, $run:expr) => {
|
||
$crate::cmd::CommandSpec { id: $id, label: $label, menu: &[$($m),*], shortcut: $sc, params: $params, enabled: $en, run: $run, journal: false, undoable: false }
|
||
};
|
||
(noundo $id:literal, $label:literal, [$($m:literal),*], $sc:expr, $params:literal, $en:expr, $run:expr) => {
|
||
$crate::cmd::CommandSpec { id: $id, label: $label, menu: &[$($m),*], shortcut: $sc, params: $params, enabled: $en, run: $run, journal: true, undoable: false }
|
||
};
|
||
}
|
||
pub(crate) use cmd;
|
||
|
||
pub fn command_specs() -> &'static [CommandSpec] {
|
||
static SPECS: std::sync::OnceLock<Vec<CommandSpec>> = std::sync::OnceLock::new();
|
||
SPECS.get_or_init(|| {
|
||
let mut v = Vec::new();
|
||
v.extend(file::specs());
|
||
v.extend(edit::specs());
|
||
v.extend(format::specs());
|
||
v.extend(sheet::specs());
|
||
v.extend(insert::specs());
|
||
v.extend(data::specs());
|
||
v.extend(formulas::specs());
|
||
v.extend(review::specs());
|
||
v.extend(view::specs());
|
||
v.extend(inspect::specs());
|
||
v.extend(extra::specs());
|
||
v.extend(draw::specs());
|
||
v.extend(spelling::specs());
|
||
v.extend(solver::specs());
|
||
v.extend(pivot::specs());
|
||
v.extend(slicer::specs());
|
||
v.extend(whatif::specs());
|
||
v.extend(forecast::specs());
|
||
v.extend(analysis::specs());
|
||
v.extend(print::specs());
|
||
v
|
||
})
|
||
}
|
||
|
||
pub fn find_command(id: &str) -> Option<&'static CommandSpec> {
|
||
if let Some(spec) = command_specs().iter().find(|c| c.id == id) {
|
||
return Some(spec);
|
||
}
|
||
// Commands registered by UI integration tests (pending keytips go live
|
||
// the moment their command lands, with no table edit).
|
||
test_commands().iter().find(|c| c.id == id).copied()
|
||
}
|
||
|
||
static TEST_COMMANDS: std::sync::Mutex<Vec<&CommandSpec>> = std::sync::Mutex::new(Vec::new());
|
||
|
||
/// Test-only: register an extra command so UI integration tests can prove a pending
|
||
/// keytip becomes live the moment its command lands (see crates/ui-egui/tests/keytips.rs).
|
||
/// Not part of the public API; harmless no-op for production callers who never call it.
|
||
#[doc(hidden)]
|
||
pub fn register_test_command(id: &'static str, run: Run) {
|
||
// Leaked intentionally: test commands live for the whole process.
|
||
let spec: &'static CommandSpec = Box::leak(Box::new(CommandSpec {
|
||
id,
|
||
label: id,
|
||
menu: &[],
|
||
shortcut: None,
|
||
params: "",
|
||
enabled: |_| Ok(()),
|
||
run,
|
||
journal: true,
|
||
undoable: false,
|
||
}));
|
||
test_commands().push(spec);
|
||
}
|
||
|
||
/// Test-only: an undoable command, to exercise how `execute` records its step.
|
||
#[cfg(test)]
|
||
pub(crate) fn register_undoable_test_command(id: &'static str, run: Run) {
|
||
let spec: &'static CommandSpec = Box::leak(Box::new(CommandSpec {
|
||
id,
|
||
label: id,
|
||
menu: &[],
|
||
shortcut: None,
|
||
params: "",
|
||
enabled: |_| Ok(()),
|
||
run,
|
||
journal: false,
|
||
undoable: true,
|
||
}));
|
||
test_commands().push(spec);
|
||
}
|
||
|
||
fn test_commands() -> std::sync::MutexGuard<'static, Vec<&'static CommandSpec>> {
|
||
#[allow(clippy::unwrap_used)] // a poisoned test registry has no meaningful recovery
|
||
match TEST_COMMANDS.lock() {
|
||
Ok(guard) => guard,
|
||
Err(poisoned) => poisoned.into_inner(),
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------- param helpers
|
||
|
||
pub(crate) fn bad(cmd: &str, msg: impl Into<String>) -> EngineError {
|
||
EngineError::BadParams { cmd: cmd.into(), msg: msg.into() }
|
||
}
|
||
pub(crate) fn str_param<'a>(p: &'a Json, key: &str) -> Option<&'a str> {
|
||
p.get(key).and_then(Json::as_str)
|
||
}
|
||
pub(crate) fn f64_param(p: &Json, key: &str) -> Option<f64> {
|
||
p.get(key).and_then(Json::as_f64)
|
||
}
|
||
pub(crate) fn bool_param(p: &Json, key: &str) -> Option<bool> {
|
||
p.get(key).and_then(Json::as_bool)
|
||
}
|
||
pub(crate) fn u32_param(p: &Json, key: &str) -> Option<u32> {
|
||
p.get(key).and_then(Json::as_u64).map(|v| v.min(u32::MAX as u64) as u32)
|
||
}
|
||
|
||
/// An object anchor mode from Excel's wording or the model's: `"moveAndSize"`/`"twoCell"`,
|
||
/// `"moveOnly"`/`"oneCell"`, `"absolute"`/`"don't move or size"` (case-insensitive).
|
||
pub(crate) fn anchor_mode_param(p: &Json) -> Option<gridcraft_model::AnchorMode> {
|
||
parse_anchor_mode(str_param(p, "mode").or_else(|| str_param(p, "anchorMode"))?)
|
||
}
|
||
|
||
pub(crate) fn parse_anchor_mode(s: &str) -> Option<gridcraft_model::AnchorMode> {
|
||
match s.to_ascii_lowercase().replace([' ', '-', '_', '\''], "").as_str() {
|
||
"moveandsize" | "moveandsizeandcells" | "twocell" => Some(gridcraft_model::AnchorMode::MoveAndSize),
|
||
"moveonly" | "movebutdontsize" | "movebutdontsizewithcells" | "onecell" => Some(gridcraft_model::AnchorMode::MoveOnly),
|
||
"absolute" | "dontmoveorsize" | "dontmoveorsizewithcells" | "none" => Some(gridcraft_model::AnchorMode::Absolute),
|
||
_ => None,
|
||
}
|
||
}
|
||
|
||
/// The wire name of an anchor mode (matches the `mode` param of [`anchor_mode_param`]).
|
||
pub(crate) fn anchor_mode_name(m: gridcraft_model::AnchorMode) -> &'static str {
|
||
match m {
|
||
gridcraft_model::AnchorMode::MoveAndSize => "moveAndSize",
|
||
gridcraft_model::AnchorMode::MoveOnly => "moveOnly",
|
||
gridcraft_model::AnchorMode::Absolute => "absolute",
|
||
}
|
||
}
|
||
pub(crate) fn ok() -> Result<Json> {
|
||
Ok(Json::Null)
|
||
}
|
||
|
||
/// A range parameter (`"A1:B2"`, `"Sheet2!A1"`, `"B:B"`), defaulting to the current selection's
|
||
/// areas.
|
||
pub(crate) fn target_ranges(s: &Session, p: &Json) -> Result<Vec<RangeRef>> {
|
||
if let Some(r) = str_param(p, "range").or_else(|| str_param(p, "cell")) {
|
||
let (_, body) = split_sheet(r);
|
||
let mut out = Vec::new();
|
||
for part in body.split(',') {
|
||
out.push(RangeRef::parse(part).ok_or_else(|| bad("range", format!("not a range: `{part}`")))?);
|
||
}
|
||
return Ok(out);
|
||
}
|
||
Ok(s.doc()?.selection.ranges.clone())
|
||
}
|
||
|
||
/// A `rows`/`cols` parameter: `3:5` and `B:C`, or a single `3` or `B`.
|
||
pub(crate) fn parse_lines(t: &str) -> Option<RangeRef> {
|
||
RangeRef::parse(t).or_else(|| if t.contains(':') { None } else { RangeRef::parse(&format!("{t}:{t}")) })
|
||
}
|
||
|
||
pub(crate) fn target_range(s: &Session, p: &Json) -> Result<RangeRef> {
|
||
Ok(target_ranges(s, p)?.first().copied().unwrap_or_default())
|
||
}
|
||
|
||
/// The sheet a call names (`sheet` param or a `Sheet!` prefix on `range`), else the active one.
|
||
pub(crate) fn target_sheet(s: &Session, p: &Json) -> Result<usize> {
|
||
let d = s.doc()?;
|
||
if let Some(v) = p.get("sheet") {
|
||
if let Some(i) = v.as_u64() {
|
||
return if (i as usize) < d.wb.sheets.len() { Ok(i as usize) } else { Err(bad("sheet", "no such sheet")) };
|
||
}
|
||
if let Some(n) = v.as_str() {
|
||
return d.wb.sheet_index(n).ok_or_else(|| bad("sheet", format!("no sheet named `{n}`")));
|
||
}
|
||
}
|
||
if let Some(r) = str_param(p, "range").or_else(|| str_param(p, "cell"))
|
||
&& let (Some(sh), _) = split_sheet(r)
|
||
{
|
||
return d.wb.sheet_index(&sh).ok_or_else(|| bad("range", format!("no sheet named `{sh}`")));
|
||
}
|
||
Ok(d.wb.active_sheet)
|
||
}
|
||
|
||
pub(crate) use gridcraft_core::split_sheet;
|
||
|
||
/// A single-cell parameter. Absent or null is `Ok(None)` (callers default it); a value that is not
|
||
/// a cell reference, or that names a sheet other than the active one, is an error rather than a
|
||
/// silent fallback to some other cell.
|
||
pub(crate) fn cell_param(s: &Session, p: &Json, key: &str) -> Result<Option<CellRef>> {
|
||
cell_param_on(s, p, key, s.doc()?.wb.active_sheet)
|
||
}
|
||
|
||
/// Like [`cell_param`], for a command that operates on `sheet` (a `Sheet!` prefix must name it).
|
||
pub(crate) fn cell_param_on(s: &Session, p: &Json, key: &str, sheet: usize) -> Result<Option<CellRef>> {
|
||
let t = match p.get(key) {
|
||
None | Some(Json::Null) => return Ok(None),
|
||
Some(Json::String(t)) => t,
|
||
Some(v) => return Err(bad("cell", format!("invalid cell reference `{v}` for `{key}`"))),
|
||
};
|
||
let (sh, body) = split_sheet(t);
|
||
let c = CellRef::parse(body).ok_or_else(|| bad("cell", format!("invalid cell reference `{t}` for `{key}`")))?;
|
||
if let Some(name) = sh
|
||
&& s.doc()?.wb.sheet_index(&name) != Some(sheet)
|
||
{
|
||
return Err(bad("cell", format!("`{t}` for `{key}` names a sheet other than the one this command works on")));
|
||
}
|
||
Ok(Some(c))
|
||
}
|
||
|
||
/// Applies an edit to a copy of the active workbook, then recalculates and commits it.
|
||
/// `changed` collects cells whose content changed; set `structural` for row/column/sheet edits
|
||
/// (full recalc).
|
||
pub struct Ctx<'a> {
|
||
pub wb: Workbook,
|
||
pub changed: Vec<Key>,
|
||
pub structural: bool,
|
||
pub sel: &'a mut Selection,
|
||
/// Rows whose automatic height should be recomputed (sheet, row).
|
||
pub fit_rows: Vec<(usize, u32)>,
|
||
/// The command applied sheet protection itself (row/column inserts and deletes the
|
||
/// protection allows), so cells it shifts aren't refused as edits of locked cells.
|
||
pub protection_checked: bool,
|
||
}
|
||
|
||
pub(crate) fn edit<R>(s: &mut Session, f: impl FnOnce(&mut Ctx) -> Result<R>) -> Result<R> {
|
||
let d = s.doc_mut()?;
|
||
commit(d, |cx| {
|
||
let r = f(cx)?;
|
||
crate::tables::sync_headers(cx);
|
||
Ok(r)
|
||
})
|
||
}
|
||
|
||
pub(crate) fn commit<R>(d: &mut DocState, f: impl FnOnce(&mut Ctx) -> Result<R>) -> Result<R> {
|
||
// `GRIDCRAFT_PROFILE` timing. Read the clock only when profiling, and never on wasm: there
|
||
// `Instant::now` traps (`RuntimeError: unreachable`), which broke every edit in the web app.
|
||
#[allow(clippy::disallowed_methods)]
|
||
let t0 = if cfg!(target_arch = "wasm32") { None } else { std::env::var_os("GRIDCRAFT_PROFILE").map(|_| std::time::Instant::now()) };
|
||
let mut sel = d.selection.clone();
|
||
let mut ctx = Ctx { wb: (*d.wb).clone(), changed: Vec::new(), structural: false, sel: &mut sel, fit_rows: Vec::new(), protection_checked: false };
|
||
let r = f(&mut ctx)?;
|
||
let Ctx { mut wb, changed, structural, mut fit_rows, protection_checked, .. } = ctx;
|
||
if !protection_checked {
|
||
check_protection(&d.wb, &wb, &d.unlocked_ranges)?;
|
||
}
|
||
if !structural {
|
||
check_arrays(&d.wb, &wb)?;
|
||
}
|
||
if changed.len() <= 200_000 {
|
||
fit_rows.extend(changed.iter().map(|(s, c)| (*s, c.row)));
|
||
}
|
||
if let Some(t0) = t0 {
|
||
eprintln!("commit: edit {:?}, {} changed", t0.elapsed(), changed.len());
|
||
}
|
||
if structural {
|
||
d.calc.recalc_all(&mut wb);
|
||
} else if !changed.is_empty() {
|
||
d.calc.cells_changed(&mut wb, &changed);
|
||
}
|
||
if let Some(t0) = t0 {
|
||
eprintln!("commit: + recalc {:?}", t0.elapsed());
|
||
}
|
||
fit_rows.sort_unstable();
|
||
fit_rows.dedup();
|
||
for (si, row) in fit_rows {
|
||
auto_row_height(&mut wb, si, row);
|
||
}
|
||
if let Some(t0) = t0 {
|
||
eprintln!("commit: + rows {:?}", t0.elapsed());
|
||
}
|
||
d.wb = std::sync::Arc::new(wb);
|
||
d.selection = sel;
|
||
Ok(r)
|
||
}
|
||
|
||
/// Excel's message for an edit of a locked cell on a protected sheet.
|
||
pub(crate) const PROTECTED: &str = "The cell or chart you're trying to change is on a protected sheet.";
|
||
|
||
/// Refuses an edit that changed what a locked cell on a protected sheet holds (its value or
|
||
/// formula), whichever command made it. Sheets are matched by name, so moving, renaming or
|
||
/// deleting a sheet isn't an edit of its cells.
|
||
fn check_protection(old: &Workbook, new: &Workbook, unlocked: &[(String, String)]) -> Result<()> {
|
||
for sh in &old.sheets {
|
||
if !sh.is_protected() {
|
||
continue;
|
||
}
|
||
let Some(after) = new.sheet_index(&sh.name).and_then(|i| new.sheets.get(i)) else { continue };
|
||
if std::sync::Arc::ptr_eq(sh, after) || !after.is_protected() {
|
||
continue;
|
||
}
|
||
for c in sh.cells.diff(&after.cells) {
|
||
if !same_content(sh.cell(c), after.cell(c))
|
||
&& old.styles.get(sh.style_id(c)).protection.locked
|
||
&& let Some(m) = locked_cell_error(sh, c, unlocked)
|
||
{
|
||
return Err(EngineError::Other(m));
|
||
}
|
||
}
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
/// Refuses an action a protected sheet does not allow, by the tick box for it in Review ▸ Protect
|
||
/// Sheet. `what` names the action in the message. An unprotected sheet allows everything.
|
||
///
|
||
/// Cell edits are guarded separately, by whether the cell is locked (`locked_cell_error`): these
|
||
/// permissions are the ones that are not about one cell's contents.
|
||
pub(crate) fn refuse_unless_allowed(s: &Session, sheet: usize, what: &str, pick: fn(&gridcraft_model::SheetProtection) -> bool) -> Result<()> {
|
||
if s.doc()?.wb.sheet(sheet).is_some_and(|sh| sh.protection.as_ref().is_some_and(|pr| !pick(pr))) {
|
||
return Err(EngineError::Other(format!("{what} isn't allowed on this protected sheet (Review › Unprotect Sheet).")));
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
/// Whether a locked cell on a protected sheet stays locked: `None` when it's in an Allow Edit
|
||
/// Range without a password or one unlocked in this session, else the message to show.
|
||
pub(crate) fn locked_cell_error(sh: &gridcraft_model::Sheet, c: CellRef, unlocked: &[(String, String)]) -> Option<String> {
|
||
let mut needs_password = None;
|
||
for r in sh.edit_ranges.iter().filter(|r| r.contains(c)) {
|
||
if r.password.is_none() || unlocked.iter().any(|(s, t)| *s == sh.name && *t == r.title) {
|
||
return None;
|
||
}
|
||
needs_password.get_or_insert(r.title.as_str());
|
||
}
|
||
Some(match needs_password {
|
||
Some(t) => format!("The range \"{t}\" needs its password before it can be changed (Review › Allow Edit Ranges)."),
|
||
None => PROTECTED.into(),
|
||
})
|
||
}
|
||
|
||
/// Refuses an edit that put something into a cell of a multi-cell legacy array other than its
|
||
/// formula cell, while the array stays (Excel: "You can't change part of an array."). The array
|
||
/// would show its value there while the file kept the new content. Cells shifted by row and
|
||
/// column edits aren't edits of their content, so structural commits aren't checked.
|
||
fn check_arrays(old: &Workbook, new: &Workbook) -> Result<()> {
|
||
for sh in &old.sheets {
|
||
if sh.spill.is_empty() {
|
||
continue;
|
||
}
|
||
let Some(after) = new.sheet_index(&sh.name).and_then(|i| new.sheets.get(i)) else { continue };
|
||
if std::sync::Arc::ptr_eq(sh, after) {
|
||
continue;
|
||
}
|
||
for c in sh.cells.diff(&after.cells) {
|
||
let filled = after.cell(c).is_some_and(|x| x.formula.is_some() || !x.value.is_empty());
|
||
if !filled || !sh.spill.contains_key(&c) || same_content(sh.cell(c), after.cell(c)) {
|
||
continue;
|
||
}
|
||
let in_array = sh.spill_ranges.keys().any(|&anchor| {
|
||
anchor != c
|
||
&& 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))
|
||
});
|
||
if in_array {
|
||
return Err(EngineError::Other("You can't change part of an array.".into()));
|
||
}
|
||
}
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
/// Two cells hold the same value or formula (formats aside).
|
||
fn same_content(a: Option<&gridcraft_model::Cell>, b: Option<&gridcraft_model::Cell>) -> bool {
|
||
let empty = |c: Option<&gridcraft_model::Cell>| c.is_none_or(|c| c.formula.is_none() && c.value.is_empty());
|
||
match (a, b) {
|
||
(Some(a), Some(b)) => match (&a.formula, &b.formula) {
|
||
(Some(x), Some(y)) => x.text == y.text && x.array == y.array,
|
||
(None, None) => a.value == b.value,
|
||
_ => false,
|
||
},
|
||
_ => empty(a) && empty(b),
|
||
}
|
||
}
|
||
|
||
/// Lines a single text line needs when word-wrapped into `width` points. The grid wraps whole
|
||
/// words (egui's LayoutJob), so dividing the total width by the column width undercounts: each
|
||
/// drawn line ends early at the last word that fits. A word wider than the column breaks
|
||
/// mid-word, the way egui does. (#1969)
|
||
fn wrapped_line_count(line: &str, width: f32, size: f32, bold: bool) -> f32 {
|
||
let word_w = |w: &str| crate::cmd::format::approx_text_width(w, size, bold);
|
||
let space_w = word_w(" ");
|
||
let mut lines = 1.0f32;
|
||
let mut used = 0.0f32;
|
||
for word in line.split_whitespace() {
|
||
let w = word_w(word);
|
||
if w > width {
|
||
// wider than the column: egui fills the current line, then breaks it across lines
|
||
if used > 0.0 {
|
||
lines += 1.0;
|
||
}
|
||
let chunks = (w / width).ceil();
|
||
lines += chunks - 1.0;
|
||
used = w - (chunks - 1.0) * width;
|
||
continue;
|
||
}
|
||
let gap = if used > 0.0 { space_w } else { 0.0 };
|
||
if used + gap + w > width {
|
||
lines += 1.0;
|
||
used = w;
|
||
} else {
|
||
used += gap + w;
|
||
}
|
||
}
|
||
lines
|
||
}
|
||
|
||
/// Recomputes an automatic row height from its content: the largest font, and wrapped text.
|
||
/// Rows with a user-set height keep it.
|
||
///
|
||
/// Line breaks in a cell's text count only when the cell wraps, as in Excel: with Wrap Text off
|
||
/// a multi-line value shows on one line and AutoFit gives a one-line row.
|
||
pub fn auto_row_height(wb: &mut Workbook, si: usize, row: u32) {
|
||
let Some(sh) = wb.sheet(si) else { return };
|
||
if sh.rows.get(&row).is_some_and(|i| i.custom) {
|
||
return;
|
||
}
|
||
let default = sh.default_row_height;
|
||
// Fast path: rows of default-size, unwrapped content keep the default height.
|
||
let plain = sh.cells.row(row, 0, gridcraft_core::MAX_COLS - 1).all(|(_, cell)| {
|
||
let st = wb.styles.get(cell.style);
|
||
st.font.size <= gridcraft_model::DEFAULT_FONT_SIZE && !st.align.wrap
|
||
});
|
||
if plain {
|
||
if sh.rows.get(&row).is_some_and(|i| i.size.is_some())
|
||
&& let Some(shm) = wb.sheet_mut(si)
|
||
&& let Some(e) = shm.rows.get_mut(&row)
|
||
{
|
||
{
|
||
e.size = None;
|
||
if *e == gridcraft_model::LineInfo::default() {
|
||
shm.rows.remove(&row);
|
||
}
|
||
}
|
||
}
|
||
return;
|
||
}
|
||
let mut need: f32 = default;
|
||
for (c, cell) in sh.cells.row(row, 0, gridcraft_core::MAX_COLS - 1) {
|
||
// Merged cells never auto-fit their rows, like Excel (column autofit skips them too).
|
||
if sh.merge_at(CellRef::new(row, c)).is_some_and(|m| !m.is_single()) {
|
||
continue;
|
||
}
|
||
let st = wb.styles.get(cell.style);
|
||
let size = st.font.size;
|
||
// Scaled from an 11pt row's height, not the sheet's default: a sheet whose default
|
||
// already fits a bigger font (21pt rows for 16pt text) would otherwise scale it twice.
|
||
let line = (size * 1.8).round().max(gridcraft_model::DEFAULT_ROW_HEIGHT * size / gridcraft_model::DEFAULT_FONT_SIZE);
|
||
let mut lines = 1.0f32;
|
||
if !cell.value.is_empty() || cell.formula.is_some() {
|
||
// Only a wrapping cell is laid out over several lines: each explicit line break
|
||
// starts one, and each line folds at the column width.
|
||
if st.align.wrap {
|
||
let text = crate::display::cell_text(wb, sh, CellRef::new(row, c));
|
||
let w = sh.col_width(c).max(1.0) - 6.0;
|
||
lines = text.lines().map(|l| wrapped_line_count(l, w.max(1.0), size, st.font.bold)).sum::<f32>().max(1.0);
|
||
}
|
||
} else if st.font.size <= gridcraft_model::DEFAULT_FONT_SIZE {
|
||
continue;
|
||
}
|
||
let h = if lines > 1.0 { (size * 96.0 / 72.0 * 1.22 * lines + 5.0).ceil() } else { line };
|
||
need = need.max(h.min(546.0));
|
||
}
|
||
let Some(shm) = wb.sheet_mut(si) else { return };
|
||
let e = shm.rows.entry(row).or_default();
|
||
e.size = if (need - default).abs() < 0.5 { None } else { Some(need) };
|
||
if *e == gridcraft_model::LineInfo::default() {
|
||
shm.rows.remove(&row);
|
||
}
|
||
}
|
||
|
||
impl Ctx<'_> {
|
||
pub fn sheet_index(&self) -> usize {
|
||
self.wb.active_sheet
|
||
}
|
||
pub fn sheet_mut(&mut self, i: usize) -> Result<&mut gridcraft_model::Sheet> {
|
||
self.wb.sheet_mut(i).ok_or_else(|| EngineError::Other("no such sheet".into()))
|
||
}
|
||
pub fn touch(&mut self, sheet: usize, c: CellRef) {
|
||
self.changed.push((sheet, c));
|
||
}
|
||
}
|