Files
gridcraft/crates/engine/src/cmd/edit.rs
T

403 lines
20 KiB
Rust

//! Cell entry, selection, undo/redo, clipboard, clear, fill, find & replace.
use gridcraft_core::{CellRef, MAX_ROWS, RangeRef, Value};
use gridcraft_model::{Cell, Formula, StyleId};
use serde_json::{Value as Json, json};
use super::{
CommandSpec, EngineError, Result, bad, bool_param, can_redo, can_undo, cell_param, cell_param_on, cmd, edit, f64_param, has_clipboard, has_doc,
locked_cell_error, ok, split_sheet, str_param, target_range, target_ranges, target_sheet, u32_param,
};
use crate::selection::{current_region, jump};
use crate::{Clipboard, Session};
mod cell;
mod clipboard;
mod fill;
mod find;
mod go_to;
mod paste_special;
mod selection;
use cell::{cell_set, range_fill, range_set_values};
pub(crate) use cell::{check_arg_counts, input_to_cell, json_to_input, parse_typed_formula};
#[cfg(test)]
pub(crate) use clipboard::parse_tsv;
use clipboard::{clear_clipboard, copy, cut, paste};
use fill::{auto_fill, fill_dir, fill_series, flash_fill};
#[cfg(test)]
use find::replace_ci;
pub(crate) use find::wildcard;
use find::{find, replace};
pub(super) use go_to::go_to;
pub use go_to::go_to_names;
use go_to::go_to_special;
#[cfg(test)]
use go_to::{GO_TO_MAX_AREAS, GO_TO_RECENT, reference_parts};
use paste_special::paste_special;
pub(crate) use paste_special::rewrite_all_formulas;
use selection::{select_all, select_col, select_region, select_row, selection_move, selection_next, selection_set};
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!("cell.set", "Enter Cell", [], None, "{cell?: \"B2\", input: \"text, number or =formula\", sheet?, array?: bool}", has_doc, cell_set),
cmd!(
"range.setValues",
"Set Values",
[],
None,
"{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)}",
has_doc,
range_set_values
),
cmd!(
"range.fill",
"Fill Range With Input",
[],
Some("Ctrl+Enter"),
"{range?, input}: enters the same input in every selected cell (relative formulas adjust)",
has_doc,
range_fill
),
cmd!(noundo "selection.set", "Select", [], None, "{range: \"A1:B2,D4\" | cell, active?: \"A1\", sheet?}", has_doc, selection_set),
cmd!(noundo "selection.move", "Move Selection", [], None, "{dr, dc, extend?: bool, jump?: bool (Ctrl+arrow), page?: bool}", has_doc, selection_move),
cmd!(noundo "selection.next", "Next Cell in Selection", [], None, "{forward?: true, byRow?: false} (Enter/Tab inside a selection)", has_doc, selection_next),
cmd!(noundo "edit.selectAll", "Select All", ["Edit"], Some("Cmd+A"), "{} (current region first, then the whole sheet)", has_doc, select_all),
cmd!(noundo "selection.currentRegion", "Select Current Region", [], Some("Ctrl+Shift+8"), "{}", has_doc, select_region),
cmd!(noundo "selection.row", "Select Entire Row", [], Some("Shift+Space"), "{}", has_doc, select_row),
cmd!(noundo "selection.column", "Select Entire Column", [], Some("Ctrl+Space"), "{}", has_doc, select_col),
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),
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),
cmd!(noundo "edit.undo", "Undo", ["Edit"], Some("Cmd+Z"), "{steps?: 1}", can_undo, undo),
cmd!(noundo "edit.redo", "Redo", ["Edit"], Some("Cmd+Y"), "{steps?: 1}", can_redo, redo),
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),
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),
cmd!(
"edit.paste",
"Paste",
["Home", "Clipboard"],
Some("Cmd+V"),
"{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\"}",
has_doc,
paste
),
cmd!(
"edit.pasteSpecial",
"Paste Special…",
["Home", "Clipboard"],
Some("Ctrl+Cmd+V"),
"{what: all|formulas|values|formats|comments|validation|allExceptBorders|columnWidths|formulasAndNumberFormats|valuesAndNumberFormats, operation?: none|add|subtract|multiply|divide, skipBlanks?, transpose?, link?}",
has_clipboard,
paste_special
),
cmd!(noundo "edit.clearClipboard", "Cancel Copy", [], Some("Escape"), "{}", has_doc, clear_clipboard),
cmd!("edit.clearAll", "Clear All", ["Home", "Editing", "Clear"], None, "{range?}", has_doc, |s, p| clear(s, p, "all")),
cmd!("edit.clearFormats", "Clear Formats", ["Home", "Editing", "Clear"], None, "{range?}", has_doc, |s, p| clear(s, p, "formats")),
cmd!("edit.clearContents", "Clear Contents", ["Home", "Editing", "Clear"], Some("Delete"), "{range?}", has_doc, |s, p| clear(
s, p, "contents"
)),
cmd!("edit.clearComments", "Clear Comments and Notes", ["Home", "Editing", "Clear"], None, "{range?}", has_doc, |s, p| clear(
s, p, "comments"
)),
cmd!("edit.clearHyperlinks", "Clear Hyperlinks", ["Home", "Editing", "Clear"], None, "{range?}", has_doc, |s, p| clear(s, p, "hyperlinks")),
cmd!("edit.fillDown", "Fill Down", ["Home", "Editing", "Fill"], Some("Cmd+D"), "{range?}", has_doc, |s, p| fill_dir(s, p, 1, 0)),
cmd!("edit.fillRight", "Fill Right", ["Home", "Editing", "Fill"], Some("Cmd+R"), "{range?}", has_doc, |s, p| fill_dir(s, p, 0, 1)),
cmd!("edit.fillUp", "Fill Up", ["Home", "Editing", "Fill"], None, "{range?}", has_doc, |s, p| fill_dir(s, p, -1, 0)),
cmd!("edit.fillLeft", "Fill Left", ["Home", "Editing", "Fill"], None, "{range?}", has_doc, |s, p| fill_dir(s, p, 0, -1)),
cmd!(
"edit.autoFill",
"AutoFill",
[],
None,
"{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)",
has_doc,
auto_fill
),
cmd!(
"edit.fillSeries",
"Series…",
["Home", "Editing", "Fill"],
None,
"{range?, direction?: columns|rows, type?: linear|growth|date|autofill, step?: 1, stop?, dateUnit?: day|weekday|month|year}",
has_doc,
fill_series
),
cmd!("edit.flashFill", "Flash Fill", ["Home", "Editing", "Fill"], Some("Ctrl+E"), "{range?}", has_doc, flash_fill),
cmd!(noundo "edit.find", "Find…", ["Home", "Editing", "Find & Select"], Some("Cmd+F"), "{what, matchCase?, wholeCell?, lookIn?: formulas|values, byColumns?, all?: bool, within?: sheet|workbook}", has_doc, find),
cmd!(
"edit.replace",
"Replace…",
["Home", "Editing", "Find & Select"],
Some("Ctrl+H"),
"{what, with, matchCase?, wholeCell?, all?: true, within?: sheet|workbook}",
has_doc,
replace
),
cmd!(
"edit.formatPainter",
"Format Painter",
["Home", "Clipboard"],
None,
"{sticky?: bool} — first call picks up the selection's formats; with {apply: \"C3:D4\"} pastes them",
has_doc,
format_painter
),
cmd!(noundo "edit.beginEdit", "Edit Cell", [], Some("F2"), "{}", has_doc, begin_edit),
]
}
/// Merges cells into vertical runs per column (a compact multi-area selection).
fn coalesce(cells: &[CellRef]) -> Vec<RangeRef> {
let mut sorted = cells.to_vec();
sorted.sort_by_key(|c| (c.col, c.row));
let mut out: Vec<RangeRef> = Vec::new();
for c in sorted {
if let Some(last) = out.last_mut()
&& last.start.col == c.col
&& last.end.col == c.col
&& last.end.row + 1 == c.row
{
last.end.row = c.row;
continue;
}
out.push(RangeRef::cell(c));
}
if out.len() > 2000 {
out.truncate(2000);
}
out
}
// ---------------------------------------------------------------- history
fn undo(s: &mut Session, p: &Json) -> Result<Json> {
let steps = u32_param(p, "steps").unwrap_or(1).max(1);
let d = s.doc_mut()?;
let mut label = String::new();
let now = d.wb.clone();
for _ in 0..steps {
let Some(e) = d.undo.pop() else { break };
let cur_wb = d.wb.clone();
// Redo goes back to the undone edit's cells, not wherever the user clicked before undoing (#1855).
let cur_selection = e.selection.clone();
label = e.label.clone();
d.wb = e.wb;
d.selection = e.selection;
crate::push_history(&mut d.redo, label.clone(), cur_wb, &d.wb, cur_selection);
}
super::extra::keep_watch_list(&now, &mut d.wb);
d.calc.rebuild(&d.wb);
d.revision += 1;
Ok(json!({"undone": label}))
}
fn redo(s: &mut Session, p: &Json) -> Result<Json> {
let steps = u32_param(p, "steps").unwrap_or(1).max(1);
let d = s.doc_mut()?;
let mut label = String::new();
let now = d.wb.clone();
for _ in 0..steps {
let Some(e) = d.redo.pop() else { break };
let cur_wb = d.wb.clone();
let cur_selection = d.selection.clone();
label = e.label.clone();
d.wb = e.wb;
d.selection = e.selection;
crate::push_history(&mut d.undo, label.clone(), cur_wb, &d.wb, cur_selection);
}
super::extra::keep_watch_list(&now, &mut d.wb);
d.calc.rebuild(&d.wb);
d.revision += 1;
Ok(json!({"redone": label}))
}
// ---------------------------------------------------------------- clear
fn clear(s: &mut Session, p: &Json, what: &str) -> Result<Json> {
let sheet = target_sheet(s, p)?;
let ranges = target_ranges(s, p)?;
// Delete on checkboxes works in two passes (as in Excel): checked boxes are unchecked first,
// and only when every box in the selection is unchecked are the boxes removed.
let uncheck_only = what == "contents" && {
let d = s.doc()?;
d.wb.sheet(sheet).is_some_and(|sh| {
ranges.iter().any(|r| {
r.intersection(&sh.used_range().unwrap_or(RangeRef::cell(r.start))).is_some_and(|r| {
sh.cells.iter_range(r).any(|(c, x)| x.value == Value::Bool(true) && super::extra::is_checkbox_cell(&d.wb, sh, c))
})
})
})
};
edit(s, |cx| {
for r in &ranges {
let r = match cx.wb.sheet(sheet).and_then(|sh| sh.used_range()) {
Some(u) => match r.intersection(&u) {
Some(x) => x,
None => continue,
},
None => continue,
};
let cells: Vec<(CellRef, Cell)> =
cx.wb.sheet(sheet).map(|sh| sh.cells.iter_range(r).map(|(c, x)| (c, x.clone())).collect()).unwrap_or_default();
// Report every stored cell we touch, even beyond a million coordinates in a sparse
// selection. Removed formula anchors must also reach calc so their spills disappear.
cx.changed.extend(cells.iter().map(|(c, _)| (sheet, *c)));
let mut cells = cells;
if what == "contents" {
for (_, cell) in cells.iter_mut() {
let checkbox = cx.wb.styles.get(cell.style).num_fmt.as_str() == "checkbox";
cell.formula = None;
if checkbox && uncheck_only {
cell.value = Value::Bool(false);
} else {
cell.value = Value::Empty;
if checkbox {
cell.style = cx.wb.styles.derive(cell.style, |st| st.num_fmt = gridcraft_model::NumFmt::new("General"));
}
}
}
}
let sh = cx.sheet_mut(sheet)?;
for (c, mut cell) in cells {
match what {
"all" => {
sh.remove_cell(c);
}
"contents" => {
sh.set_cell(c, cell);
}
"formats" => {
cell.style = StyleId::DEFAULT;
sh.cells.set(c, cell);
}
_ => {}
}
}
if matches!(what, "all" | "comments") {
sh.comments.retain(|c, _| !r.contains(*c));
}
if matches!(what, "all" | "hyperlinks") {
sh.hyperlinks.retain(|c, _| !r.contains(*c));
}
if matches!(what, "all" | "formats") {
let mut unmerged = false;
sh.merges.retain(|m| {
let keep = !r.contains_range(m);
unmerged |= !keep;
keep
});
if unmerged {
// An empty merge can block a spill without storing any cells in this range.
// As in Unmerge, retry blocked formula anchors that could reach the freed area.
let reach = RangeRef::new(CellRef::new(0, 0), r.end);
let blocked: Vec<_> = sh
.cells
.iter_range(reach)
.filter(|(_, cell)| cell.formula.is_some() && cell.value == Value::Error(gridcraft_core::CellError::Spill))
.map(|(c, _)| (sheet, c))
.collect();
cx.changed.extend(blocked);
}
}
}
Ok(Json::Null)
})
}
// ---------------------------------------------------------------- format painter
fn format_painter(s: &mut Session, p: &Json) -> Result<Json> {
if let Some(dest) = str_param(p, "apply").and_then(RangeRef::parse) {
let Some((wb, sheet, src, sticky)) = s.format_painter.clone() else { return Err(EngineError::Other("Pick up a format first.".into())) };
let dest_sheet = s.doc()?.wb.active_sheet();
let same = std::sync::Arc::ptr_eq(&wb, &s.doc()?.wb) || s.doc()?.wb.styles == wb.styles;
edit(s, |cx| {
let Some(src_sheet) = wb.sheet(sheet) else { return Ok(()) };
let (h, w) = (src.height(), src.width());
let dest = if dest.is_single() { RangeRef::new(dest.start, dest.start.offset_clamped(h as i64 - 1, w as i64 - 1)) } else { dest };
// Whole rows or columns take a line format, as the ribbon's formatting commands give
// them, instead of a style on each of their cells (a column alone has a million). The
// line format comes from the first cell of the source pattern across the line; cells
// already stored follow the pattern. Row heights and column widths stay as they are.
let rows = dest.is_full_rows() && !dest.is_full_cols();
let cols = dest.is_full_cols() && !dest.is_full_rows();
let src_full = (rows && src.is_full_rows()) || (cols && src.is_full_cols());
if rows || cols {
let (first, src_first, n) = if rows { (dest.start.row, src.start.row, h) } else { (dest.start.col, src.start.col, w) };
let lines = if rows { dest.start.row..=dest.end.row } else { dest.start.col..=dest.end.col };
for line in lines {
let sl = src_first + (line - first) % n;
// The source cell for position `k` along the line.
let at = |k: u32| if rows { CellRef::new(sl, src.start.col + k % w) } else { CellRef::new(src.start.row + k % h, sl) };
let line_style = if src_full {
if rows { src_sheet.rows.get(&sl) } else { src_sheet.cols.get(&sl) }.and_then(|i| i.style)
} else {
Some(src_sheet.style_id(at(0)))
};
let line_style = line_style.map(|st| if same { st } else { cx.wb.styles.intern(wb.styles.get(st).clone()) });
let (dest_line, src_line) = if rows {
(RangeRef::rows(line, line), RangeRef::rows(sl, sl))
} else {
(RangeRef::cols(line, line), RangeRef::cols(sl, sl))
};
let mut cells: Vec<CellRef> =
cx.wb.sheet(dest_sheet).map(|sh| sh.cells.iter_range(dest_line).map(|(c, _)| c).collect()).unwrap_or_default();
if src_full {
cells.extend(
src_sheet
.cells
.iter_range(src_line)
.map(|(c, _)| if rows { CellRef::new(line, c.col) } else { CellRef::new(c.row, line) }),
);
}
if cols {
// A row format outranks a column format: the column's cells in formatted
// rows need a style of their own.
cells.extend(
cx.wb
.sheet(dest_sheet)
.into_iter()
.flat_map(|sh| sh.rows.iter().filter(|(_, i)| i.style.is_some()).map(|(r, _)| CellRef::new(*r, line))),
);
}
let sh = cx.sheet_mut(dest_sheet)?;
(if rows { &mut sh.rows } else { &mut sh.cols }).entry(line).or_default().style = line_style;
for c in cells {
let st = src_sheet.style_id(at(if rows { c.col } else { c.row }));
let st = if same { st } else { cx.wb.styles.intern(wb.styles.get(st).clone()) };
cx.sheet_mut(dest_sheet)?.set_style(c, st);
cx.touch(dest_sheet, c);
}
}
*cx.sel = crate::Selection::range(dest);
return Ok(());
}
for c in dest.iter().take(2_000_000) {
let sc = CellRef::new(src.start.row + (c.row - dest.start.row) % h, src.start.col + (c.col - dest.start.col) % w);
let st = src_sheet.style_id(sc);
let st = if same { st } else { cx.wb.styles.intern(wb.styles.get(st).clone()) };
cx.sheet_mut(dest_sheet)?.set_style(c, st);
cx.touch(dest_sheet, c);
}
*cx.sel = crate::Selection::range(dest);
Ok(())
})?;
if !sticky {
s.format_painter = None;
}
return ok();
}
let sticky = bool_param(p, "sticky").unwrap_or(false);
let d = s.doc()?;
s.format_painter = Some((d.wb.clone(), d.wb.active_sheet(), d.selection.current(), sticky));
ok()
}
fn begin_edit(s: &mut Session, _: &Json) -> Result<Json> {
s.ui_requests.push(crate::UiRequest::EditCell(None));
ok()
}
#[cfg(test)]
mod tests;