1pub 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 pub menu: &'static [&'static str],
46 pub shortcut: Option<&'static str>,
48 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 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 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
106pub fn is_secret_param(key: &str) -> bool {
108 key == "password" || key.ends_with("Password")
109}
110
111pub 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}
137fn 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 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#[doc(hidden)]
207pub fn register_test_command(id: &'static str, run: Run) {
208 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#[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
249pub(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
267pub(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
282pub(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
294pub(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
308pub(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
317pub(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
338pub(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
345pub(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
362pub struct Ctx<'a> {
366 pub wb: Workbook,
367 pub changed: Vec<Key>,
368 pub structural: bool,
369 pub sel: &'a mut Selection,
370 pub fit_rows: Vec<(usize, u32)>,
372 pub protection_checked: bool,
375 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 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 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
460fn 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 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 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
516fn 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
527pub(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
547fn 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}
571pub(crate) const TOUCH_LOG_LIMIT: usize = 1_000_000;
574
575pub(crate) const PROTECTED: &str = "The cell or chart you're trying to change is on a protected sheet.";
577
578fn 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
606pub(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
618pub(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 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
638fn 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
668fn 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
686fn 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 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
718pub 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 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 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 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 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 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}