1use crate::base::{CellRef, MAX_ROWS, RangeRef, Value};
4use crate::model::{Cell, Formula, StyleId};
5use serde_json::{Value as Json, json};
6
7use super::{
8 CommandSpec, EngineError, Result, bad, bool_param, can_redo, can_undo, cell_param, cell_param_on, cmd, edit, f64_param, has_clipboard, has_doc,
9 locked_cell_error, ok, split_sheet, str_param, target_range, target_ranges, target_sheet, u32_param,
10};
11use crate::engine::selection::{current_region, jump};
12use crate::engine::{Clipboard, Session};
13
14mod cell;
15mod clipboard;
16mod fill;
17mod find;
18mod go_to;
19mod paste_special;
20mod selection;
21
22use cell::{cell_set, range_fill, range_set_values};
23pub(crate) use cell::{check_arg_counts, input_to_cell, is_formula_input, json_to_input, parse_typed_formula};
24#[cfg(test)]
25pub(crate) use clipboard::parse_tsv;
26use clipboard::{clear_clipboard, copy, cut, paste, range_move};
27use fill::{auto_fill, fill_dir, fill_series, flash_fill};
28#[cfg(test)]
29use find::replace_ci;
30pub(crate) use find::wildcard;
31use find::{find, replace};
32pub(super) use go_to::go_to;
33pub use go_to::go_to_names;
34use go_to::go_to_special;
35#[cfg(test)]
36use go_to::{GO_TO_MAX_AREAS, GO_TO_RECENT, reference_parts};
37use paste_special::paste_special;
38pub(crate) use paste_special::{rewrite_all_formulas, rewrite_cell_links, rewrite_python_refs};
39use selection::{select_all, select_col, select_region, select_row, selection_move, selection_next, selection_next_corner, selection_set};
40
41pub fn specs() -> Vec<CommandSpec> {
42 vec![
43 cmd!("cell.set", "Enter Cell", [], None, "{cell?: \"B2\", input: \"text, number or =formula\", sheet?, array?: bool}", has_doc, cell_set),
44 cmd!(
45 "range.setValues",
46 "Set Values",
47 [],
48 None,
49 "{range?: \"A1\", values: [[...]] (rows of numbers/strings/bools/null; strings starting with = are formulas), parse?: bool (default true: a string goes through typed input, so \"05.04.2026\" becomes a date and \"2.500\" a number, by en-US rules; false stores every other string as the text it is, so a dd.mm.yyyy date or a thousands separator survives, a leading ' is kept and an empty string clears the cell)} → {cells, convertedCount, converted: [{cell, input, stored}] (up to 20 of the strings typed input read as a number, date, time, boolean or error, so a script can notice)}",
50 has_doc,
51 range_set_values
52 ),
53 cmd!(
54 "range.fill",
55 "Fill Range With Input",
56 [],
57 Some("Ctrl+Enter"),
58 "{range?, input}: enters the same input in every selected cell (relative formulas adjust)",
59 has_doc,
60 range_fill
61 ),
62 cmd!(noundo "selection.set", "Select", [], None, "{range: \"A1:B2,D4\" | cell, active?: \"A1\", sheet?}", has_doc, selection_set),
63 cmd!(noundo "selection.move", "Move Selection", [], None, "{dr, dc, extend?: bool, jump?: bool (Ctrl+arrow), page?: bool}", has_doc, selection_move),
64 cmd!(noundo "selection.next", "Next Cell in Selection", [], None, "{forward?: true, byRow?: false} (Enter/Tab inside a selection)", has_doc, selection_next),
65 cmd!(
66 noundo "selection.nextCorner",
67 "Next Corner of Selection",
68 [],
69 Some("Ctrl+."),
70 "{} (the active cell moves clockwise to the next corner of the selected area, which stays selected)",
71 has_doc,
72 selection_next_corner
73 ),
74 cmd!(noundo "edit.selectAll", "Select All", ["Edit"], Some("Cmd+A"), "{} (current region first, then the whole sheet)", has_doc, select_all),
75 cmd!(noundo "selection.currentRegion", "Select Current Region", [], Some("Ctrl+Shift+8"), "{}", has_doc, select_region),
76 cmd!(noundo "selection.row", "Select Entire Row", [], Some("Shift+Space"), "{}", has_doc, select_row),
77 cmd!(noundo "selection.column", "Select Entire Column", [], Some("Ctrl+Space"), "{}", has_doc, select_col),
78 cmd!(noundo "edit.goTo", "Go To…", ["Home", "Editing", "Find & Select"], Some("Ctrl+G"), "{reference: \"B5\" | \"Sheet2!A1:C3\" | name | \"Sheet2!Name\" | \"B14:C22,F19:G30\" | \"Price, Type\", record?: bool} (record: keep a reference without names among the recent ones) or {list: true} → {names, recent}", has_doc, go_to),
79 cmd!(noundo "edit.goToSpecial", "Go To Special…", ["Home", "Editing", "Find & Select"], None, "{kind: blanks|constants|formulas|comments|lastCell|currentRegion|visible|conditionalFormats|dataValidation|errors|numbers|text}", has_doc, go_to_special),
80 cmd!(noundo "edit.undo", "Undo", ["Edit"], Some("Cmd+Z"), "{steps?: 1}", can_undo, undo),
81 cmd!(noundo "edit.redo", "Redo", ["Edit"], Some("Cmd+Y"), "{steps?: 1}", can_redo, redo),
82 cmd!(noundo "edit.copy", "Copy", ["Home", "Clipboard"], Some("Cmd+C"), "{range?, html?: bool} → {range, text, html?} (html: a styled HTML table, only when requested and small enough)", has_doc, copy),
83 cmd!(noundo "edit.cut", "Cut", ["Home", "Clipboard"], Some("Cmd+X"), "{range?, html?: bool} → {range, text, html?} (html: a styled HTML table, only when requested and small enough)", has_doc, cut),
84 cmd!(
85 "edit.paste",
86 "Paste",
87 ["Home", "Clipboard"],
88 Some("Cmd+V"),
89 "{at?: \"C3\", text?: \"tab-separated text from the system clipboard\", html?: \"the clipboard's HTML flavour; cells the source recorded as numbers (x:num, sdval) stay numbers\"}",
90 has_doc,
91 paste
92 ),
93 cmd!(
94 "range.move",
95 "Drag and Drop",
96 [],
97 None,
98 "{range?, to: \"C3\", overwrite?: bool} (drag the selection's border: a cut and paste that asks before replacing data unless overwrite)",
99 has_doc,
100 range_move
101 ),
102 CommandSpec {
103 shortcut_mac: Some("Ctrl+Cmd+V"),
104 ..cmd!(
105 "edit.pasteSpecial",
106 "Paste Special…",
107 ["Home", "Clipboard"],
108 Some("Ctrl+Alt+V"),
109 "{what: all|formulas|values|formats|comments|validation|allExceptBorders|columnWidths|formulasAndNumberFormats|valuesAndNumberFormats, operation?: none|add|subtract|multiply|divide, skipBlanks?, transpose?, link?}",
110 has_clipboard,
111 paste_special
112 )
113 },
114 cmd!(noundo "edit.clearClipboard", "Cancel Copy", [], Some("Escape"), "{}", has_doc, clear_clipboard),
115 cmd!("edit.clearAll", "Clear All", ["Home", "Editing", "Clear"], None, "{range?}", has_doc, |s, p| clear(s, p, "all")),
116 cmd!("edit.clearFormats", "Clear Formats", ["Home", "Editing", "Clear"], None, "{range?}", has_doc, |s, p| clear(s, p, "formats")),
117 cmd!("edit.clearContents", "Clear Contents", ["Home", "Editing", "Clear"], Some("Delete"), "{range?}", has_doc, |s, p| clear(
118 s, p, "contents"
119 )),
120 cmd!("edit.clearComments", "Clear Comments and Notes", ["Home", "Editing", "Clear"], None, "{range?}", has_doc, |s, p| clear(
121 s, p, "comments"
122 )),
123 cmd!("edit.clearHyperlinks", "Clear Hyperlinks", ["Home", "Editing", "Clear"], None, "{range?}", has_doc, |s, p| clear(s, p, "hyperlinks")),
124 cmd!("edit.fillDown", "Fill Down", ["Home", "Editing", "Fill"], Some("Cmd+D"), "{range?}", has_doc, |s, p| fill_dir(s, p, 1, 0)),
125 cmd!("edit.fillRight", "Fill Right", ["Home", "Editing", "Fill"], Some("Cmd+R"), "{range?}", has_doc, |s, p| fill_dir(s, p, 0, 1)),
126 cmd!("edit.fillUp", "Fill Up", ["Home", "Editing", "Fill"], None, "{range?}", has_doc, |s, p| fill_dir(s, p, -1, 0)),
127 cmd!("edit.fillLeft", "Fill Left", ["Home", "Editing", "Fill"], None, "{range?}", has_doc, |s, p| fill_dir(s, p, 0, -1)),
128 cmd!(
129 "edit.autoFill",
130 "AutoFill",
131 [],
132 None,
133 "{source: \"A1:A2\", target: \"A1:A10\", mode?: series|alternate|copy|formats|values|extend|days|weekdays|months|years}: the fill-handle drag (alternate = Ctrl+drag: a lone number counts up, any other series copies; values = Fill Without Formatting: the series, targets keep their formats; extend = Fill Series: the series, and a lone number counts up; days, weekdays, months, years = Fill Days, Fill Weekdays, Fill Months, Fill Years: dates step by one unit, or by the difference between the first two dates in that unit, and weekdays skip Saturday and Sunday)",
134 has_doc,
135 auto_fill
136 ),
137 cmd!(
138 "edit.fillSeries",
139 "Series…",
140 ["Home", "Editing", "Fill"],
141 None,
142 "{range?, direction?: columns|rows, type?: linear|growth|date|autofill, step?: 1, stop?, dateUnit?: day|weekday|month|year}",
143 has_doc,
144 fill_series
145 ),
146 cmd!("edit.flashFill", "Flash Fill", ["Home", "Editing", "Fill"], Some("Ctrl+E"), "{range?}", has_doc, flash_fill),
147 cmd!(noundo "edit.find", "Find…", ["Home", "Editing", "Find & Select"], Some("Cmd+F"), "{what, matchCase?, wholeCell?, lookIn?: formulas|values, byColumns?, all?: bool, within?: sheet|workbook|selection} (selection: only the selected cells; Find Next keeps them selected and moves the active cell)", has_doc, find),
148 cmd!(
149 "edit.replace",
150 "Replace…",
151 ["Home", "Editing", "Find & Select"],
152 Some("Ctrl+H"),
153 "{what, with, matchCase?, wholeCell?, all?: true, within?: sheet|workbook|selection} → {replaced, skipped?} (in a Python cell the code is edited; skipped counts Python cells whose code would no longer fit)",
154 has_doc,
155 replace
156 ),
157 cmd!(
158 "edit.formatPainter",
159 "Format Painter",
160 ["Home", "Clipboard"],
161 None,
162 "{sticky?: bool} — first call picks up the selection's formats; with {apply: \"C3:D4\"} pastes them",
163 has_doc,
164 format_painter
165 ),
166 cmd!(noundo "edit.beginEdit", "Edit Cell", [], Some("F2"), "{}", has_doc, begin_edit),
167 ]
168}
169
170fn coalesce(cells: &[CellRef]) -> Vec<RangeRef> {
172 let mut sorted = cells.to_vec();
173 sorted.sort_by_key(|c| (c.col, c.row));
174 let mut out: Vec<RangeRef> = Vec::new();
175 for c in sorted {
176 if let Some(last) = out.last_mut()
177 && last.start.col == c.col
178 && last.end.col == c.col
179 && last.end.row + 1 == c.row
180 {
181 last.end.row = c.row;
182 continue;
183 }
184 out.push(RangeRef::cell(c));
185 }
186 if out.len() > 2000 {
187 out.truncate(2000);
188 }
189 out
190}
191
192fn undo(s: &mut Session, p: &Json) -> Result<Json> {
195 let steps = u32_param(p, "steps").unwrap_or(1).max(1);
196 let d = s.doc_mut()?;
197 let mut label = String::new();
198 let now = d.wb.clone();
199 for _ in 0..steps {
200 let Some(e) = d.undo.pop() else { break };
201 let cur_wb = d.wb.clone();
202 let cur_selection = e.selection.clone();
204 label = e.label.clone();
205 d.wb = e.wb;
206 d.selection = e.selection;
207 super::extra::keep_view_modes(d, &cur_wb);
209 crate::engine::push_history(&mut d.redo, label.clone(), cur_wb, &d.wb, cur_selection);
210 }
211 super::extra::keep_watch_list(&now, &mut d.wb);
212 d.calc.rebuild(&d.wb);
213 d.revision += 1;
214 Ok(json!({"undone": label}))
215}
216
217fn redo(s: &mut Session, p: &Json) -> Result<Json> {
218 let steps = u32_param(p, "steps").unwrap_or(1).max(1);
219 let d = s.doc_mut()?;
220 let mut label = String::new();
221 let now = d.wb.clone();
222 for _ in 0..steps {
223 let Some(e) = d.redo.pop() else { break };
224 let cur_wb = d.wb.clone();
225 let cur_selection = d.selection.clone();
226 label = e.label.clone();
227 d.wb = e.wb;
228 d.selection = e.selection;
229 super::extra::keep_view_modes(d, &cur_wb);
230 crate::engine::push_history(&mut d.undo, label.clone(), cur_wb, &d.wb, cur_selection);
231 }
232 super::extra::keep_watch_list(&now, &mut d.wb);
233 d.calc.rebuild(&d.wb);
234 d.revision += 1;
235 Ok(json!({"redone": label}))
236}
237
238fn filtered_rows(sh: &crate::model::Sheet, range: RangeRef) -> std::collections::BTreeSet<u32> {
242 sh.rows.range(range.start.row..=range.end.row).filter(|(r, info)| info.hidden && sh.is_row_filtered(**r)).map(|(r, _)| *r).collect()
243}
244
245fn clear(s: &mut Session, p: &Json, what: &str) -> Result<Json> {
248 let sheet = target_sheet(s, p)?;
249 let ranges = target_ranges(s, p)?;
250 let uncheck_only = what == "contents" && {
253 let d = s.doc()?;
254 d.wb.sheet(sheet).is_some_and(|sh| {
255 ranges.iter().any(|r| {
256 r.intersection(&sh.used_range().unwrap_or(RangeRef::cell(r.start))).is_some_and(|r| {
257 let skip = filtered_rows(sh, r);
258 sh.cells
259 .iter_range(r)
260 .any(|(c, x)| !skip.contains(&c.row) && x.value == Value::Bool(true) && super::extra::is_checkbox_cell(&d.wb, sh, c))
261 })
262 })
263 })
264 };
265 if matches!(what, "all" | "comments" | "hyperlinks") {
268 let d = s.doc()?;
269 if let Some(sh) = d.wb.sheet(sheet).filter(|sh| sh.is_protected()) {
270 for &whole in &ranges {
271 let skip = filtered_rows(sh, whole);
272 let notes = matches!(what, "all" | "comments").then(|| sh.comments.keys()).into_iter().flatten();
273 let links = matches!(what, "all" | "hyperlinks").then(|| sh.hyperlinks.keys()).into_iter().flatten();
274 for &c in notes.chain(links).filter(|c| whole.contains(**c) && !skip.contains(&c.row)) {
275 if d.wb.styles.get(sh.style_id(c)).protection.locked
276 && let Some(m) = locked_cell_error(sh, c, &d.unlocked_ranges)
277 {
278 return Err(EngineError::Other(m));
279 }
280 }
281 }
282 }
283 }
284 edit(s, |cx| {
285 for &whole in &ranges {
286 let used = cx.wb.sheet(sheet).and_then(|sh| sh.used_range()).and_then(|u| whole.intersection(&u));
290 let skip = cx.wb.sheet(sheet).map(|sh| filtered_rows(sh, whole)).unwrap_or_default();
291 let cells: Vec<(CellRef, Cell)> = match (cx.wb.sheet(sheet), used) {
292 (Some(sh), Some(r)) => sh.cells.iter_range(r).filter(|(c, _)| !skip.contains(&c.row)).map(|(c, x)| (c, x.clone())).collect(),
293 _ => Vec::new(),
294 };
295 cx.changed.extend(cells.iter().map(|(c, _)| (sheet, *c)));
298 let mut cells = cells;
299 if what == "contents" {
300 for (_, cell) in cells.iter_mut() {
301 let checkbox = cx.wb.styles.get(cell.style).num_fmt.as_str() == "checkbox";
302 cell.formula = None;
303 cell.py = None;
304 if checkbox && uncheck_only {
305 cell.value = Value::Bool(false);
306 } else {
307 cell.value = Value::Empty;
308 if checkbox {
309 cell.style = cx.wb.styles.derive(cell.style, |st| st.num_fmt = crate::model::NumFmt::new("General"));
310 }
311 }
312 }
313 }
314 let cleared = |c: CellRef| whole.contains(c) && !skip.contains(&c.row);
315 let metadata: Vec<CellRef> = cx
317 .wb
318 .sheet(sheet)
319 .map(|sh| {
320 let notes = matches!(what, "all" | "comments").then(|| sh.comments.keys()).into_iter().flatten();
321 let links = matches!(what, "all" | "hyperlinks").then(|| sh.hyperlinks.keys()).into_iter().flatten();
322 notes.chain(links).copied().filter(|c| cleared(*c)).collect()
323 })
324 .unwrap_or_default();
325 cx.changed.extend(metadata.into_iter().map(|c| (sheet, c)));
326 let sh = cx.sheet_mut(sheet)?;
327 for (c, mut cell) in cells {
328 match what {
329 "all" => {
330 sh.remove_cell(c);
331 }
332 "contents" => {
333 sh.set_cell(c, cell);
334 }
335 "formats" => {
336 cell.style = StyleId::DEFAULT;
337 sh.cells.set(c, cell);
338 }
339 _ => {}
340 }
341 }
342 if matches!(what, "all" | "comments") {
343 sh.comments.retain(|c, _| !cleared(*c));
344 }
345 if matches!(what, "all" | "hyperlinks") {
346 sh.hyperlinks.retain(|c, _| !cleared(*c));
347 }
348 if let Some(r) = used
349 && matches!(what, "all" | "formats")
350 {
351 let mut unmerged = false;
352 sh.merges.retain(|m| {
353 let keep = !r.contains_range(m) || skip.range(m.start.row..=m.end.row).next().is_some();
354 unmerged |= !keep;
355 keep
356 });
357 if unmerged {
358 let reach = RangeRef::new(CellRef::new(0, 0), r.end);
361 let blocked: Vec<_> = sh
362 .cells
363 .iter_range(reach)
364 .filter(|(_, cell)| cell.formula.is_some() && cell.value == Value::Error(crate::base::CellError::Spill))
365 .map(|(c, _)| (sheet, c))
366 .collect();
367 cx.changed.extend(blocked);
368 }
369 }
370 }
371 Ok(Json::Null)
372 })
373}
374
375fn format_painter(s: &mut Session, p: &Json) -> Result<Json> {
378 if let Some(dest) = str_param(p, "apply").and_then(RangeRef::parse) {
379 let Some((wb, sheet, src, sticky)) = s.format_painter.clone() else { return Err(EngineError::Other("Pick up a format first.".into())) };
380 let dest_sheet = s.doc()?.wb.active_sheet();
381 let same = std::sync::Arc::ptr_eq(&wb, &s.doc()?.wb) || s.doc()?.wb.styles == wb.styles;
382 edit(s, |cx| {
383 let Some(src_sheet) = wb.sheet(sheet) else { return Ok(()) };
384 let (h, w) = (src.height(), src.width());
385 let dest = if dest.is_single() { RangeRef::new(dest.start, dest.start.offset_clamped(h as i64 - 1, w as i64 - 1)) } else { dest };
386 let rows = dest.is_full_rows() && !dest.is_full_cols();
391 let cols = dest.is_full_cols() && !dest.is_full_rows();
392 let src_full = (rows && src.is_full_rows()) || (cols && src.is_full_cols());
393 if rows || cols {
394 let (first, src_first, n) = if rows { (dest.start.row, src.start.row, h) } else { (dest.start.col, src.start.col, w) };
395 let lines = if rows { dest.start.row..=dest.end.row } else { dest.start.col..=dest.end.col };
396 for line in lines {
397 let sl = src_first + (line - first) % n;
398 let at = |k: u32| if rows { CellRef::new(sl, src.start.col + k % w) } else { CellRef::new(src.start.row + k % h, sl) };
400 let line_style = if src_full {
401 if rows { src_sheet.rows.get(&sl) } else { src_sheet.cols.get(&sl) }.and_then(|i| i.style)
402 } else {
403 Some(src_sheet.style_id(at(0)))
404 };
405 let line_style = line_style.map(|st| if same { st } else { cx.wb.styles.intern(wb.styles.get(st).clone()) });
406 let (dest_line, src_line) = if rows {
407 (RangeRef::rows(line, line), RangeRef::rows(sl, sl))
408 } else {
409 (RangeRef::cols(line, line), RangeRef::cols(sl, sl))
410 };
411 let mut cells: Vec<CellRef> =
412 cx.wb.sheet(dest_sheet).map(|sh| sh.cells.iter_range(dest_line).map(|(c, _)| c).collect()).unwrap_or_default();
413 if src_full {
414 cells.extend(
415 src_sheet
416 .cells
417 .iter_range(src_line)
418 .map(|(c, _)| if rows { CellRef::new(line, c.col) } else { CellRef::new(c.row, line) }),
419 );
420 }
421 if cols {
422 cells.extend(
425 cx.wb
426 .sheet(dest_sheet)
427 .into_iter()
428 .flat_map(|sh| sh.rows.iter().filter(|(_, i)| i.style.is_some()).map(|(r, _)| CellRef::new(*r, line))),
429 );
430 }
431 let sh = cx.sheet_mut(dest_sheet)?;
432 (if rows { &mut sh.rows } else { &mut sh.cols }).entry(line).or_default().style = line_style;
433 for c in cells {
434 let st = src_sheet.style_id(at(if rows { c.col } else { c.row }));
435 let st = if same { st } else { cx.wb.styles.intern(wb.styles.get(st).clone()) };
436 cx.sheet_mut(dest_sheet)?.set_style(c, st);
437 cx.touch(dest_sheet, c);
438 }
439 }
440 *cx.sel = crate::engine::Selection::range(dest);
441 return Ok(());
442 }
443 for c in dest.iter().take(2_000_000) {
444 let sc = CellRef::new(src.start.row + (c.row - dest.start.row) % h, src.start.col + (c.col - dest.start.col) % w);
445 let st = src_sheet.style_id(sc);
446 let st = if same { st } else { cx.wb.styles.intern(wb.styles.get(st).clone()) };
447 cx.sheet_mut(dest_sheet)?.set_style(c, st);
448 cx.touch(dest_sheet, c);
449 }
450 *cx.sel = crate::engine::Selection::range(dest);
451 Ok(())
452 })?;
453 if !sticky {
454 s.format_painter = None;
455 }
456 return ok();
457 }
458 let sticky = bool_param(p, "sticky").unwrap_or(false);
459 let d = s.doc()?;
460 s.format_painter = Some((d.wb.clone(), d.wb.active_sheet(), d.selection.current(), sticky));
461 ok()
462}
463
464fn begin_edit(s: &mut Session, _: &Json) -> Result<Json> {
465 s.ui_requests.push(crate::engine::UiRequest::EditCell(None));
466 ok()
467}
468
469#[cfg(test)]
470mod tests;