//! Cell entry, selection, undo/redo, clipboard, clear, fill, find & replace. use gridcraft_core::{CellRef, MAX_COLS, MAX_ROWS, RangeRef, Value}; use gridcraft_model::{Cell, Formula, StyleId}; use serde_json::{Value as Json, json}; use super::*; use crate::selection::{current_region, jump}; use crate::{Clipboard, Session}; pub fn specs() -> Vec { 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)}", 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}", 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?}", has_doc, copy), cmd!(noundo "edit.cut", "Cut", ["Home", "Clipboard"], Some("Cmd+X"), "{range?}", has_doc, cut), cmd!( "edit.paste", "Paste", ["Home", "Clipboard"], Some("Cmd+V"), "{at?: \"C3\", text?: \"tab-separated text from the system clipboard\"}", 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|copy|formats|values}: the fill-handle drag", 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("Cmd+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), ] } // ---------------------------------------------------------------- entry /// What typing `input` into a cell produces (constant or formula, plus an automatic number /// format). Errors when the formula can't be parsed. pub(crate) fn input_to_cell(input: &str, old: Option<&Cell>, wb: &mut gridcraft_model::Workbook) -> Result> { let style = old.map(|c| c.style).unwrap_or_default(); if input.is_empty() { let c = Cell { value: Value::Empty, formula: None, style }; return Ok(if c.is_blank() { None } else { Some(c) }); } let fmt_is_text = wb.styles.get(style).num_fmt.as_str() == "@"; if fmt_is_text { return Ok(Some(Cell { value: Value::text(input), formula: None, style })); } let is_formula = input.starts_with('=') || (input.len() > 1 && (input.starts_with('+') || input.starts_with('-')) && gridcraft_core::parse::parse_number_text(input).is_none() && input.chars().nth(1).is_some_and(|c| c.is_ascii_alphabetic() || c == '(')); if is_formula { let body = input.strip_prefix('=').unwrap_or(input); // Excel closes missing parentheses for you. let mut text = body.to_string(); let mut parsed = gridcraft_formula::parse(&text); for _ in 0..8 { if parsed.is_ok() { break; } text.push(')'); parsed = gridcraft_formula::parse(&text); } let expr = parsed.map_err(|e| EngineError::Other(format!("There's a problem with this formula: {e}")))?; let mut cell = Cell::formula(Formula::from_expr(expr)); cell.style = style; // Formulas whose result is a date/time get a format from functions like TODAY(). if wb.styles.get(style).num_fmt.as_str() == "General" { let f = cell.formula.as_ref().map(|f| f.text.to_ascii_uppercase()).unwrap_or_default(); let auto = if f.starts_with("TODAY(") || f.starts_with("DATE(") || f.starts_with("EDATE(") || f.starts_with("EOMONTH(") || f.starts_with("WORKDAY(") { Some("m/d/yyyy") } else if f.starts_with("NOW(") { Some("m/d/yyyy h:mm") } else if f.starts_with("TIME(") { Some("h:mm AM/PM") } else { None }; if let Some(code) = auto { cell.style = wb.styles.derive(style, |s| s.num_fmt = gridcraft_model::NumFmt::new(code)); } } return Ok(Some(cell)); } let parsed = gridcraft_core::parse::parse_input(input, wb.date_system); let mut style = style; if let Some(code) = parsed.format { let cur = wb.styles.get(style).num_fmt.as_str().to_string(); if cur == "General" { style = wb.styles.derive(style, |s| s.num_fmt = gridcraft_model::NumFmt::new(code)); } } // Wrap text automatically when the input has line breaks (Alt+Enter). if input.contains('\n') && !wb.styles.get(style).align.wrap { style = wb.styles.derive(style, |s| s.align.wrap = true); } Ok(Some(Cell { value: parsed.value, formula: None, style })) } fn cell_set(s: &mut Session, p: &Json) -> Result { let sheet = target_sheet(s, p)?; let at = match cell_param(p, "cell") { Some(c) => c, None => s.doc()?.selection.active, }; let input = str_param(p, "input").or_else(|| str_param(p, "value")).map(str::to_string).or_else(|| p.get("value").map(json_to_input)).unwrap_or_default(); let array = bool_param(p, "array").unwrap_or(false); let protected = s.doc()?.wb.sheet(sheet).is_some_and(|sh| sh.is_protected() && s.doc().is_ok_and(|d| d.wb.styles.get(sh.style_id(at)).protection.locked)); if protected { return Err(EngineError::Other("The cell or chart you're trying to change is on a protected sheet.".into())); } edit(s, |cx| { if array { // Legacy Ctrl+Shift+Enter array formula over the selection. let range = cx.sel.current(); let old = cx.wb.sheet(sheet).and_then(|sh| sh.cell(range.start)).cloned(); let mut cell = input_to_cell(&input, old.as_ref(), &mut cx.wb)?.unwrap_or_default(); if let Some(f) = cell.formula.as_mut() { std::sync::Arc::make_mut(f).array = Some(range); } let sh = cx.sheet_mut(sheet)?; sh.cells.set(range.start, cell); cx.touch(sheet, range.start); return Ok(Json::Null); } let old = cx.wb.sheet(sheet).and_then(|sh| sh.cell(at)).cloned(); let cell = input_to_cell(&input, old.as_ref(), &mut cx.wb)?; let sh = cx.sheet_mut(sheet)?; match cell { Some(c) => sh.cells.set(at, c), None => { sh.cells.remove(at); } } cx.touch(sheet, at); Ok(Json::Null) })?; let v = s.doc()?.wb.sheet(sheet).map(|sh| sh.value(at)).unwrap_or_default(); Ok(json!({"cell": at.a1(), "value": v.display()})) } pub(crate) fn json_to_input(v: &Json) -> String { match v { Json::Null => String::new(), Json::Bool(b) => { if *b { "TRUE".into() } else { "FALSE".into() } } Json::Number(n) => n.to_string(), Json::String(s) => s.clone(), other => other.to_string(), } } fn range_set_values(s: &mut Session, p: &Json) -> Result { let sheet = target_sheet(s, p)?; let start = target_range(s, p)?.start; let Some(rows) = p.get("values").and_then(Json::as_array) else { return Err(bad("range.setValues", "missing `values` (array of rows)")) }; if rows.len() > MAX_ROWS as usize { return Err(bad("range.setValues", "too many rows")); } let mut count = 0; edit(s, |cx| { for (ri, row) in rows.iter().enumerate() { let cols: Vec<&Json> = match row.as_array() { Some(a) => a.iter().collect(), None => vec![row], }; for (ci, v) in cols.iter().enumerate() { let Some(at) = start.offset(ri as i64, ci as i64) else { continue }; let input = json_to_input(v); let old = cx.wb.sheet(sheet).and_then(|sh| sh.cell(at)).cloned(); let cell = match v { Json::Number(n) => Some(Cell { value: Value::number(n.as_f64().unwrap_or(0.0)), formula: None, style: old.as_ref().map(|c| c.style).unwrap_or_default(), }), Json::Bool(b) => Some(Cell { value: Value::Bool(*b), formula: None, style: old.as_ref().map(|c| c.style).unwrap_or_default() }), _ => input_to_cell(&input, old.as_ref(), &mut cx.wb)?, }; let sh = cx.sheet_mut(sheet)?; match cell { Some(c) => sh.cells.set(at, c), None => { sh.cells.remove(at); } } cx.touch(sheet, at); count += 1; } } Ok(()) })?; Ok(json!({"cells": count})) } fn range_fill(s: &mut Session, p: &Json) -> Result { let sheet = target_sheet(s, p)?; let ranges = target_ranges(s, p)?; let input = str_param(p, "input").unwrap_or("").to_string(); let origin = s.doc()?.selection.active; edit(s, |cx| { let old = cx.wb.sheet(sheet).and_then(|sh| sh.cell(origin)).cloned(); let base = input_to_cell(&input, old.as_ref(), &mut cx.wb)?; for r in &ranges { if r.count() > 2_000_000 { return Err(bad("range.fill", "range too large")); } for at in r.iter() { let mut cell = base.clone().unwrap_or_default(); if let Some(f) = &cell.formula && let Some(e) = f.expr() { let shifted = gridcraft_formula::adjust::shift_relative(e, at.row as i64 - origin.row as i64, at.col as i64 - origin.col as i64); cell.formula = Some(std::sync::Arc::new(Formula::from_expr(shifted))); } cell.style = cx.wb.sheet(sheet).and_then(|sh| sh.cell(at)).map(|c| c.style).unwrap_or(cell.style); cx.sheet_mut(sheet)?.cells.set(at, cell); cx.touch(sheet, at); } } Ok(Json::Null) }) } // ---------------------------------------------------------------- selection fn selection_set(s: &mut Session, p: &Json) -> Result { if p.get("sheet").is_some() { let i = target_sheet(s, p)?; s.doc_mut()?.wb_switch_sheet(i); } else if let Some(r) = str_param(p, "range").or_else(|| str_param(p, "cell")) && let (Some(_), _) = split_sheet(r) { let i = target_sheet(s, p)?; s.doc_mut()?.wb_switch_sheet(i); } let ranges = target_ranges(s, p)?; let d = s.doc_mut()?; let active = cell_param(p, "active").or_else(|| ranges.last().map(|r| r.start)).unwrap_or_default(); let mut sel = crate::Selection { active, anchor: ranges.last().map(|r| r.start).unwrap_or(active), ranges }; if let Some(sh) = d.wb.active() { sel.expand_merges(sh); } d.selection = sel; Ok(json!({"selection": d.selection.a1(), "active": d.selection.active.a1()})) } impl crate::DocState { /// Switches the active sheet, remembering each sheet's selection. pub fn wb_switch_sheet(&mut self, i: usize) { if i >= self.wb.sheets.len() || i == self.wb.active_sheet { return; } let cur = self.wb.active_sheet; if self.sheet_selections.len() < self.wb.sheets.len() { self.sheet_selections.resize(self.wb.sheets.len(), crate::Selection::default()); } if let Some(slot) = self.sheet_selections.get_mut(cur) { *slot = self.selection.clone(); } // The active sheet is view state: change it without making the workbook dirty. let dirty = self.is_dirty(); let wb = std::sync::Arc::make_mut(&mut self.wb); wb.active_sheet = i; if !dirty { self.saved = self.wb.clone(); } self.selection = self.sheet_selections.get(i).cloned().unwrap_or_default(); } } fn selection_move(s: &mut Session, p: &Json) -> Result { let dr = p.get("dr").and_then(Json::as_i64).unwrap_or(0); let dc = p.get("dc").and_then(Json::as_i64).unwrap_or(0); let extend = bool_param(p, "extend").unwrap_or(false); let jmp = bool_param(p, "jump").unwrap_or(false); let d = s.doc_mut()?; let Some(sheet) = d.wb.active() else { return Err(EngineError::NoDocument) }; let sel = &mut d.selection; // Extension moves the far corner, not the active cell. let from = if extend { let r = sel.current(); let far_row = if sel.anchor.row == r.start.row { r.end.row } else { r.start.row }; let far_col = if sel.anchor.col == r.start.col { r.end.col } else { r.start.col }; CellRef::new(far_row, far_col) } else { sel.active }; // Skip hidden rows/columns and step over merged areas. let mut to = if jmp { jump(sheet, from, dr.signum(), dc.signum()) } else { let mut t = from.offset_clamped(dr, dc); let mut guard = 0; while (sheet.is_row_hidden(t.row) || sheet.is_col_hidden(t.col)) && guard < 100_000 { let n = t.offset_clamped(dr.signum(), dc.signum()); if n == t { break; } t = n; guard += 1; } if !extend && let Some(m) = sheet.merge_at(from) { // Leaving a merged cell moves from its edge. if dr > 0 { t = CellRef::new((m.end.row + dr as u32).min(MAX_ROWS - 1), from.col); } else if dc > 0 { t = CellRef::new(from.row, (m.end.col + dc as u32).min(MAX_COLS - 1)); } } t }; if let Some(m) = sheet.merge_at(to) && !extend { to = m.start; } if extend { sel.extend_to(to); sel.expand_merges(sheet); } else { *sel = crate::Selection::at(to); if let Some(m) = sheet.merge_at(to) { sel.ranges = vec![m]; } } Ok(json!({"selection": sel.a1(), "active": sel.active.a1()})) } fn selection_next(s: &mut Session, p: &Json) -> Result { let fwd = bool_param(p, "forward").unwrap_or(true); let by_row = bool_param(p, "byRow").unwrap_or(false); let d = s.doc_mut()?; d.selection.cycle(fwd, by_row); Ok(json!({"active": d.selection.active.a1()})) } fn select_all(s: &mut Session, _: &Json) -> Result { let d = s.doc_mut()?; let Some(sheet) = d.wb.active() else { return Err(EngineError::NoDocument) }; let region = current_region(sheet, d.selection.active); let all = RangeRef::all(); let r = if region.is_single() || d.selection.current() == region { all } else { region }; let active = d.selection.active; d.selection = crate::Selection { active, anchor: r.start, ranges: vec![r] }; Ok(json!({"selection": d.selection.a1()})) } fn select_region(s: &mut Session, _: &Json) -> Result { let d = s.doc_mut()?; let Some(sheet) = d.wb.active() else { return Err(EngineError::NoDocument) }; let r = current_region(sheet, d.selection.active); let active = d.selection.active; d.selection = crate::Selection { active, anchor: r.start, ranges: vec![r] }; Ok(json!({"selection": d.selection.a1()})) } fn select_row(s: &mut Session, _: &Json) -> Result { let d = s.doc_mut()?; let r = d.selection.current(); let active = d.selection.active; d.selection = crate::Selection { active, anchor: CellRef::new(r.start.row, 0), ranges: vec![RangeRef::rows(r.start.row, r.end.row)] }; Ok(json!({"selection": d.selection.a1()})) } fn select_col(s: &mut Session, _: &Json) -> Result { let d = s.doc_mut()?; let r = d.selection.current(); let active = d.selection.active; d.selection = crate::Selection { active, anchor: CellRef::new(0, r.start.col), ranges: vec![RangeRef::cols(r.start.col, r.end.col)] }; Ok(json!({"selection": d.selection.a1()})) } fn go_to(s: &mut Session, p: &Json) -> Result { let Some(reference) = str_param(p, "reference").or_else(|| str_param(p, "range")) else { return Err(bad("edit.goTo", "missing `reference`")) }; // A defined name or table? let d = s.doc()?; let resolved = if RangeRef::parse(split_sheet(reference).1).is_none() { let sheet = d.wb.active_sheet; if let Some(n) = d.wb.name(reference, sheet) { n.formula.clone() } else if let Some((si, ti)) = d.wb.table(reference) { let t = d.wb.sheet(si).and_then(|sh| sh.tables.get(ti)).map(|t| t.range.a1()).unwrap_or_default(); format!("'{}'!{}", d.wb.sheet(si).map(|s| s.name.clone()).unwrap_or_default(), t) } else { return Err(EngineError::Other("Reference isn't valid.".into())); } } else { reference.to_string() }; let resolved = resolved.replace('$', ""); selection_set(s, &json!({"range": resolved})) } fn go_to_special(s: &mut Session, p: &Json) -> Result { let kind = str_param(p, "kind").unwrap_or("blanks"); let d = s.doc()?; let Some(sheet) = d.wb.active() else { return Err(EngineError::NoDocument) }; let scope = if d.selection.is_single_cell() { sheet.used_range().unwrap_or(RangeRef::cell(d.selection.active)) } else { d.selection.bounds() }; if kind == "lastCell" { let c = sheet.used_range().map(|r| r.end).unwrap_or_default(); return selection_set(s, &json!({"cell": c.a1()})); } if kind == "currentRegion" { return select_region(s, &json!({})); } let mut hits: Vec = Vec::new(); if scope.count() > 4_000_000 { return Err(bad("edit.goToSpecial", "range too large")); } for c in scope.iter() { let cell = sheet.cell(c); let v = sheet.value(c); let ok = match kind { "blanks" => v.is_empty(), "constants" => cell.is_some_and(|x| x.formula.is_none() && !x.value.is_empty()), "formulas" => cell.is_some_and(|x| x.formula.is_some()), "comments" | "notes" => sheet.comments.contains_key(&c), "errors" => v.is_error(), "numbers" => v.is_number(), "text" => v.is_text(), "visible" => !sheet.is_row_hidden(c.row) && !sheet.is_col_hidden(c.col), "conditionalFormats" => sheet.cond_formats.iter().any(|cf| cf.ranges.iter().any(|r| r.contains(c))), "dataValidation" => sheet.validations.iter().any(|dv| dv.ranges.iter().any(|r| r.contains(c))), _ => return Err(bad("edit.goToSpecial", format!("unknown kind `{kind}`"))), }; if ok { hits.push(c); } } if hits.is_empty() { return Err(EngineError::Other("No cells were found.".into())); } let ranges = coalesce(&hits); let d = s.doc_mut()?; d.selection = crate::Selection { active: hits[0], anchor: hits[0], ranges }; Ok(json!({"selection": d.selection.a1(), "count": hits.len()})) } /// Merges cells into vertical runs per column (a compact multi-area selection). fn coalesce(cells: &[CellRef]) -> Vec { let mut sorted = cells.to_vec(); sorted.sort_by_key(|c| (c.col, c.row)); let mut out: Vec = 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 { let steps = u32_param(p, "steps").unwrap_or(1).max(1); let d = s.doc_mut()?; let mut label = String::new(); for _ in 0..steps { let Some(e) = d.undo.pop() else { break }; let cur = crate::HistoryEntry { label: e.label.clone(), wb: d.wb.clone(), selection: d.selection.clone() }; d.redo.push(cur); label = e.label.clone(); d.wb = e.wb; d.selection = e.selection; } d.calc.rebuild(&d.wb); d.revision += 1; Ok(json!({"undone": label})) } fn redo(s: &mut Session, p: &Json) -> Result { let steps = u32_param(p, "steps").unwrap_or(1).max(1); let d = s.doc_mut()?; let mut label = String::new(); for _ in 0..steps { let Some(e) = d.redo.pop() else { break }; let cur = crate::HistoryEntry { label: e.label.clone(), wb: d.wb.clone(), selection: d.selection.clone() }; d.undo.push(cur); label = e.label.clone(); d.wb = e.wb; d.selection = e.selection; } d.calc.rebuild(&d.wb); d.revision += 1; Ok(json!({"redone": label})) } // ---------------------------------------------------------------- clipboard fn copy_impl(s: &mut Session, p: &Json, cut: bool) -> Result { let sheet = target_sheet(s, p)?; let ranges = target_ranges(s, p)?; if ranges.len() > 1 { return Err(EngineError::Other("This action won't work on multiple selections.".into())); } let range = ranges.first().copied().unwrap_or_default(); let d = s.doc()?; let Some(sh) = d.wb.sheet(sheet) else { return Err(EngineError::NoDocument) }; let range = if range.is_full_cols() || range.is_full_rows() { let used = sh.used_range().unwrap_or(RangeRef::cell(range.start)); range.intersection(&used.union(&RangeRef::cell(range.start))).unwrap_or(range) } else { range }; let text = crate::display::range_text(&d.wb, sheet, range); let clip = Clipboard { wb: d.wb.clone(), sheet, range, cut, doc_uid: d.uid, text: text.clone() }; s.clipboard = Some(clip); Ok(json!({"range": range.a1(), "text": text})) } fn copy(s: &mut Session, p: &Json) -> Result { copy_impl(s, p, false) } fn cut(s: &mut Session, p: &Json) -> Result { copy_impl(s, p, true) } fn clear_clipboard(s: &mut Session, _: &Json) -> Result { s.clipboard = None; s.format_painter = None; ok() } #[derive(Clone, Copy, PartialEq, Eq)] enum What { All, Formulas, Values, Formats, Comments, Validation, AllExceptBorders, ColumnWidths, FormulasAndNumberFormats, ValuesAndNumberFormats, } fn paste(s: &mut Session, p: &Json) -> Result { // External text (from the system clipboard) when it differs from ours. if let Some(text) = str_param(p, "text") && s.clipboard.as_ref().is_none_or(|c| c.text.trim_end_matches(['\r', '\n']) != text.trim_end_matches(['\r', '\n'])) { return paste_text(s, p, text); } if s.clipboard.is_none() { return Err(EngineError::Other("Nothing to paste.".into())); } paste_special(s, &json!({"what": "all", "at": p.get("at").cloned().unwrap_or(Json::Null)})) } fn paste_text(s: &mut Session, p: &Json, text: &str) -> Result { let sheet = target_sheet(s, p)?; let at = cell_param(p, "at").unwrap_or(s.doc()?.selection.current().start); let rows: Vec> = parse_tsv(text); let h = rows.len(); let w = rows.iter().map(Vec::len).max().unwrap_or(0); edit(s, |cx| { for (ri, row) in rows.iter().enumerate() { for (ci, v) in row.iter().enumerate() { let Some(c) = at.offset(ri as i64, ci as i64) else { continue }; let old = cx.wb.sheet(sheet).and_then(|sh| sh.cell(c)).cloned(); let cell = input_to_cell(v, old.as_ref(), &mut cx.wb).unwrap_or_else(|_| Some(Cell::value(Value::text(v.as_str())))); let sh = cx.sheet_mut(sheet)?; match cell { Some(cell) => sh.cells.set(c, cell), None => { sh.cells.remove(c); } } cx.touch(sheet, c); } } if h > 0 && w > 0 { let end = at.offset_clamped(h as i64 - 1, w as i64 - 1); *cx.sel = crate::Selection { active: at, anchor: at, ranges: vec![RangeRef::new(at, end)] }; } Ok(()) })?; Ok(json!({"rows": h, "cols": w})) } /// Tab-separated text with Excel-style quoting. pub(crate) fn parse_tsv(text: &str) -> Vec> { let mut rows = Vec::new(); let mut row = Vec::new(); let mut field = String::new(); let mut chars = text.chars().peekable(); let mut quoted = false; let mut at_start = true; while let Some(c) = chars.next() { if quoted { if c == '"' { if chars.peek() == Some(&'"') { field.push('"'); chars.next(); } else { quoted = false; } } else { field.push(c); } continue; } match c { '"' if at_start => { quoted = true; at_start = false; } '\t' => { row.push(std::mem::take(&mut field)); at_start = true; } '\r' => {} '\n' => { row.push(std::mem::take(&mut field)); rows.push(std::mem::take(&mut row)); at_start = true; } _ => { field.push(c); at_start = false; } } } if !field.is_empty() || !row.is_empty() { row.push(field); rows.push(row); } rows } fn paste_special(s: &mut Session, p: &Json) -> Result { let Some(clip) = s.clipboard.clone() else { return Err(EngineError::Other("Nothing to paste.".into())) }; let what = match str_param(p, "what").unwrap_or("all") { "all" => What::All, "formulas" => What::Formulas, "values" => What::Values, "formats" => What::Formats, "comments" | "notes" => What::Comments, "validation" => What::Validation, "allExceptBorders" => What::AllExceptBorders, "columnWidths" => What::ColumnWidths, "formulasAndNumberFormats" => What::FormulasAndNumberFormats, "valuesAndNumberFormats" => What::ValuesAndNumberFormats, other => return Err(bad("edit.pasteSpecial", format!("unknown `what`: {other}"))), }; let op = str_param(p, "operation").unwrap_or("none").to_string(); let skip_blanks = bool_param(p, "skipBlanks").unwrap_or(false); let transpose = bool_param(p, "transpose").unwrap_or(false); let link = bool_param(p, "link").unwrap_or(false); let dest_sheet = target_sheet(s, p)?; let sel = s.doc()?.selection.current(); let at = cell_param(p, "at").unwrap_or(sel.start); let src = clip.range; let (h, w) = if transpose { (src.width(), src.height()) } else { (src.height(), src.width()) }; // Tile the copy over a larger destination that is a multiple of its size. let tile = sel.height() % h == 0 && sel.width() % w == 0 && !sel.is_single() && cell_param(p, "at").is_none() && !sel.is_full_cols() && !sel.is_full_rows() && sel.count() <= 4_000_000; let (tiles_r, tiles_c) = if tile { (sel.height() / h, sel.width() / w) } else { (1, 1) }; let same_doc = s.doc()?.uid == clip.doc_uid; let cut_move = clip.cut && same_doc && what == What::All; let src_wb = clip.wb.clone(); let Some(src_sheet) = src_wb.sheet(clip.sheet) else { return Err(EngineError::Other("The copied sheet no longer exists.".into())) }; let src_sheet_name = src_sheet.name.clone(); edit(s, |cx| { // Styles from another workbook must be re-interned. let mut style_map = std::collections::HashMap::new(); let mut map_style = |id: StyleId, wb: &mut gridcraft_model::Workbook| -> StyleId { if same_doc { return id; } *style_map.entry(id).or_insert_with(|| wb.styles.intern(src_wb.styles.get(id).clone())) }; let mut moved_formulas = Vec::new(); if cut_move { // Remove the source first (contents and formats), and fix references to it. let taken = cx.sheet_mut(clip.sheet)?.cells.take_range(src); for (c, _) in &taken { cx.touch(clip.sheet, *c); } moved_formulas = taken; } for tr in 0..tiles_r { for tc in 0..tiles_c { for r in 0..src.height() { for c in 0..src.width() { let sc = CellRef::new(src.start.row + r, src.start.col + c); let (dr, dc) = if transpose { (c, r) } else { (r, c) }; let Some(dest) = at.offset((tr * h + dr) as i64, (tc * w + dc) as i64) else { continue }; let src_cell = if cut_move { moved_formulas.iter().find(|(k, _)| *k == sc).map(|(_, v)| v.clone()) } else { src_sheet.cell(sc).cloned() }; let src_val = src_sheet.value(sc); if skip_blanks && src_val.is_empty() && src_cell.as_ref().is_none_or(|x| x.formula.is_none()) { continue; } let old = cx.wb.sheet(dest_sheet).and_then(|sh| sh.cell(dest)).cloned().unwrap_or_default(); let mut new = old.clone(); let shift = |cell: &Cell| -> Option> { let f = cell.formula.as_ref()?; let e = f.expr()?; if cut_move { return Some(std::sync::Arc::new(Formula::from_expr(e))); } let moved = gridcraft_formula::adjust::shift_relative(e, dest.row as i64 - sc.row as i64, dest.col as i64 - sc.col as i64); Some(std::sync::Arc::new(Formula::from_expr(moved))) }; if link { let sheet_prefix = if dest_sheet == clip.sheet && same_doc { String::new() } else { format!("{}!", gridcraft_formula::quote_sheet(&src_sheet_name)) }; new.formula = Some(std::sync::Arc::new(Formula::new(&format!("={sheet_prefix}{}", sc.a1())))); new.value = Value::Empty; } else { let sc_cell = src_cell.clone().unwrap_or_default(); match what { What::All | What::AllExceptBorders => { new.formula = shift(&sc_cell); new.value = if new.formula.is_some() { Value::Empty } else { sc_cell.value.clone() }; let st = map_style(sc_cell.style, &mut cx.wb); new.style = if what == What::AllExceptBorders { let borders = cx.wb.styles.get(old.style).borders; cx.wb.styles.derive(st, |s| s.borders = borders) } else { st }; } What::Formulas | What::FormulasAndNumberFormats => { new.formula = shift(&sc_cell); new.value = if new.formula.is_some() { Value::Empty } else { sc_cell.value.clone() }; if what == What::FormulasAndNumberFormats { let nf = src_wb.styles.get(sc_cell.style).num_fmt.clone(); new.style = cx.wb.styles.derive(old.style, |s| s.num_fmt = nf); } } What::Values | What::ValuesAndNumberFormats => { new.formula = None; new.value = src_val.clone(); if what == What::ValuesAndNumberFormats { let nf = src_wb.styles.get(sc_cell.style).num_fmt.clone(); new.style = cx.wb.styles.derive(old.style, |s| s.num_fmt = nf); } } What::Formats => new.style = map_style(sc_cell.style, &mut cx.wb), What::Comments | What::Validation | What::ColumnWidths => {} } // Operations combine numbers with what is already there. if op != "none" && matches!(what, What::All | What::Values | What::ValuesAndNumberFormats | What::Formulas) && new.formula.is_none() { let a = old.value.clone(); let b = new.value.clone(); if let (Ok(x), Ok(y)) = (a.to_number(), b.to_number()) && (a.is_number() || a.is_empty()) && (b.is_number() || b.is_empty()) { new.value = match op.as_str() { "add" => Value::number(x + y), "subtract" => Value::number(x - y), "multiply" => Value::number(x * y), "divide" => { if y == 0.0 { Value::Error(gridcraft_core::CellError::Div0) } else { Value::number(x / y) } } _ => b, }; } } } match what { What::Comments => { if let Some(cm) = src_sheet.comments.get(&sc).cloned() { cx.sheet_mut(dest_sheet)?.comments.insert(dest, cm); } } What::ColumnWidths => { if let Some(info) = src_sheet.cols.get(&sc.col).copied() { cx.sheet_mut(dest_sheet)?.cols.insert(dest.col, info); } } What::All => { if let Some(cm) = src_sheet.comments.get(&sc).cloned() { cx.sheet_mut(dest_sheet)?.comments.insert(dest, cm); } cx.sheet_mut(dest_sheet)?.cells.set(dest, new); } _ => cx.sheet_mut(dest_sheet)?.cells.set(dest, new), } cx.touch(dest_sheet, dest); } } } } // Merged areas inside the copied block travel with it. if matches!(what, What::All | What::Formats) && !transpose { let merges: Vec = src_sheet.merges.iter().filter(|m| src.contains_range(m)).copied().collect(); for m in merges { let dr = at.row as i64 - src.start.row as i64; let dc = at.col as i64 - src.start.col as i64; if let (Some(a), Some(b)) = (m.start.offset(dr, dc), m.end.offset(dr, dc)) { let nm = RangeRef::new(a, b); let sh = cx.sheet_mut(dest_sheet)?; sh.merges.retain(|x| !x.intersects(&nm)); sh.merges.push(nm); } } } if cut_move { // References elsewhere to the moved block now point to its new place. let dest_name = cx.wb.sheet(dest_sheet).map(|s| s.name.clone()).unwrap_or_default(); if dest_sheet == clip.sheet { let e = gridcraft_formula::adjust::Edit::Move { from: src, to_row: at.row, to_col: at.col }; rewrite_all_formulas(&mut cx.wb, &src_sheet_name, &e); } let _ = dest_name; cx.structural = true; } let end = at.offset_clamped((tiles_r * h) as i64 - 1, (tiles_c * w) as i64 - 1); *cx.sel = crate::Selection { active: at, anchor: at, ranges: vec![RangeRef::new(at, end)] }; Ok(()) })?; if cut_move { s.clipboard = None; } Ok(json!({"pasted": s.doc()?.selection.a1()})) } /// Applies a reference adjustment to every formula in the workbook (and names, CF, DV). pub(crate) fn rewrite_all_formulas(wb: &mut gridcraft_model::Workbook, target: &str, e: &gridcraft_formula::adjust::Edit) { for si in 0..wb.sheets.len() { let host = wb.sheets.get(si).map(|s| s.name.clone()).unwrap_or_default(); let keys: Vec<(CellRef, std::sync::Arc)> = wb.sheets.get(si).map(|s| s.cells.iter().filter_map(|(c, cell)| cell.formula.clone().map(|f| (c, f))).collect()).unwrap_or_default(); if keys.is_empty() { continue; } let Some(sheet) = wb.sheet_mut(si) else { continue }; for (c, f) in keys { let Some(expr) = f.expr() else { continue }; let new = gridcraft_formula::adjust::adjust(expr, &host, target, e); let text = gridcraft_formula::print(&new); if text != f.text && let Some(cell) = sheet.cells.get_mut(c) { let mut nf = Formula::from_expr(new); nf.array = f.array; cell.formula = Some(std::sync::Arc::new(nf)); } } } for n in wb.names.iter_mut() { if let Ok(expr) = gridcraft_formula::parse(&n.formula) { // Names are workbook-level: unqualified refs don't occur; qualified ones adjust. let new = gridcraft_formula::adjust::adjust(expr, "\u{0}", target, e); n.formula = gridcraft_formula::print(&new); } } } // ---------------------------------------------------------------- clear fn clear(s: &mut Session, p: &Json, what: &str) -> Result { let sheet = target_sheet(s, p)?; let ranges = target_ranges(s, p)?; 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(); let sh = cx.sheet_mut(sheet)?; for (c, mut cell) in cells { match what { "all" => { sh.cells.remove(c); } "contents" => { cell.value = Value::Empty; cell.formula = None; sh.cells.set(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") { sh.merges.retain(|m| !r.contains_range(m)); } for c in r.iter().take(1_000_000) { cx.changed.push((sheet, c)); } } Ok(Json::Null) }) } // ---------------------------------------------------------------- fill fn fill_dir(s: &mut Session, p: &Json, dr: i64, dc: i64) -> Result { let sheet = target_sheet(s, p)?; let mut r = target_range(s, p)?; // Whole rows or columns fill only where the sheet has content (nothing on an empty sheet). if r.is_full_cols() || r.is_full_rows() { match s.doc()?.wb.sheet(sheet).and_then(|sh| sh.used_range()).and_then(|u| r.intersection(&u)) { Some(u) => r = u, None => return Ok(Json::Null), } } if r.count() > 5_000_000 { return Err(bad("edit.fill", "range too large")); } // With one row selected, Fill Down copies from the row above (Excel behaviour). if dr == 1 && r.height() == 1 { r.start.row = r.start.row.saturating_sub(1); } if dc == 1 && r.width() == 1 { r.start.col = r.start.col.saturating_sub(1); } let src = if dr == 1 { RangeRef::new(r.start, CellRef::new(r.start.row, r.end.col)) } else if dr == -1 { RangeRef::new(CellRef::new(r.end.row, r.start.col), r.end) } else if dc == 1 { RangeRef::new(r.start, CellRef::new(r.end.row, r.start.col)) } else { RangeRef::new(CellRef::new(r.start.row, r.end.col), r.end) }; edit(s, |cx| { crate::fill::fill(cx, sheet, src, r, crate::fill::FillMode::Copy)?; Ok(Json::Null) }) } fn auto_fill(s: &mut Session, p: &Json) -> Result { let sheet = target_sheet(s, p)?; let src = str_param(p, "source").and_then(RangeRef::parse).map_or_else(|| target_range(s, p), Ok)?; let Some(target) = str_param(p, "target").and_then(RangeRef::parse) else { return Err(bad("edit.autoFill", "missing `target`")) }; let mode = match str_param(p, "mode").unwrap_or("series") { "copy" => crate::fill::FillMode::Copy, "formats" => crate::fill::FillMode::Formats, "values" => crate::fill::FillMode::ValuesOnly, _ => crate::fill::FillMode::Series, }; if target.count() > 5_000_000 { return Err(bad("edit.autoFill", "target too large")); } edit(s, |cx| { crate::fill::fill(cx, sheet, src, target, mode)?; *cx.sel = crate::Selection { active: src.start, anchor: src.start, ranges: vec![target.union(&src)] }; Ok(Json::Null) }) } fn fill_series(s: &mut Session, p: &Json) -> Result { let sheet = target_sheet(s, p)?; let r = target_range(s, p)?; let rows = str_param(p, "direction").map(|d| d == "rows").unwrap_or(r.width() > r.height()); let kind = str_param(p, "type").unwrap_or("linear").to_string(); let step = f64_param(p, "step").unwrap_or(1.0); let stop = f64_param(p, "stop"); let unit = str_param(p, "dateUnit").unwrap_or("day").to_string(); edit(s, |cx| { crate::fill::series(cx, sheet, r, rows, &kind, step, stop, &unit)?; Ok(Json::Null) }) } fn flash_fill(s: &mut Session, p: &Json) -> Result { let sheet = target_sheet(s, p)?; let r = target_range(s, p)?; let n = edit(s, |cx| crate::fill::flash_fill(cx, sheet, r.start))?; if n == 0 { return Err(EngineError::Other( "We looked at all the data next to your selection and didn't see a pattern for filling in values for you.".into(), )); } Ok(json!({"filled": n})) } // ---------------------------------------------------------------- find & replace fn matches(hay: &str, what: &str, case: bool, whole: bool) -> bool { let (h, w) = if case { (hay.to_string(), what.to_string()) } else { (hay.to_lowercase(), what.to_lowercase()) }; let has_wild = w.contains(['*', '?']); if has_wild { let pat = if whole { w } else { format!("*{w}*") }; return wildcard(&h, &pat); } if whole { h == w } else { h.contains(&w) } } /// `*`/`?` wildcard match with `~` escapes. pub(crate) fn wildcard(text: &str, pat: &str) -> bool { let t: Vec = text.chars().collect(); let mut p: Vec<(char, bool)> = Vec::new(); let mut it = pat.chars(); while let Some(c) = it.next() { if c == '~' { if let Some(n) = it.next() { p.push((n, true)); } } else { p.push((c, false)); } } let (mut ti, mut pi) = (0usize, 0usize); let (mut star, mut mark) = (None, 0usize); while ti < t.len() { if pi < p.len() && (p[pi] == ('?', false) || (p[pi].0 == t[ti] && p[pi] != ('*', false))) { ti += 1; pi += 1; } else if pi < p.len() && p[pi] == ('*', false) { star = Some(pi); mark = ti; pi += 1; } else if let Some(sp) = star { pi = sp + 1; mark += 1; ti = mark; } else { return false; } } while pi < p.len() && p[pi] == ('*', false) { pi += 1; } pi == p.len() } fn find(s: &mut Session, p: &Json) -> Result { let what = str_param(p, "what").unwrap_or("").to_string(); if what.is_empty() { return Err(bad("edit.find", "missing `what`")); } let case = bool_param(p, "matchCase").unwrap_or(false); let whole = bool_param(p, "wholeCell").unwrap_or(false); let values = str_param(p, "lookIn") == Some("values"); let by_cols = bool_param(p, "byColumns").unwrap_or(false); let all = bool_param(p, "all").unwrap_or(false); let workbook = str_param(p, "within") == Some("workbook"); s.last_find = Some(p.clone()); let d = s.doc()?; let sheets: Vec = if workbook { (0..d.wb.sheets.len()).collect() } else { vec![d.wb.active_sheet] }; let mut hits: Vec<(usize, CellRef, String)> = Vec::new(); for si in sheets { let Some(sh) = d.wb.sheet(si) else { continue }; let mut cells: Vec<(CellRef, String)> = sh .cells .iter() .filter_map(|(c, cell)| { let text = if values || cell.formula.is_none() { crate::display::cell_text(&d.wb, sh, c) } else { cell.input_text() }; matches(&text, &what, case, whole).then_some((c, text)) }) .collect(); if by_cols { cells.sort_by_key(|(c, _)| (c.col, c.row)); } hits.extend(cells.into_iter().map(|(c, t)| (si, c, t))); } if hits.is_empty() { return Err(EngineError::Other(format!("We couldn't find what you were looking for: {what}"))); } if all { let list: Vec = hits.iter().map(|(si, c, t)| json!({"sheet": d.wb.sheet(*si).map(|s| s.name.clone()), "cell": c.a1(), "value": t})).collect(); return Ok(json!({"count": list.len(), "results": list})); } // Next hit after the active cell. let cur = (d.wb.active_sheet, d.selection.active); let key = |si: usize, c: CellRef| if by_cols { (si, c.col, c.row) } else { (si, c.row, c.col) }; let next = hits.iter().find(|(si, c, _)| key(*si, *c) > key(cur.0, cur.1)).or_else(|| hits.first()).cloned(); let Some((si, c, t)) = next else { return Err(EngineError::Other("not found".into())) }; let dd = s.doc_mut()?; dd.wb_switch_sheet(si); dd.selection = crate::Selection::at(c); Ok(json!({"cell": c.a1(), "sheet": si, "value": t, "count": hits.len()})) } fn replace(s: &mut Session, p: &Json) -> Result { let what = str_param(p, "what").unwrap_or("").to_string(); let with = str_param(p, "with").unwrap_or("").to_string(); if what.is_empty() { return Err(bad("edit.replace", "missing `what`")); } let case = bool_param(p, "matchCase").unwrap_or(false); let whole = bool_param(p, "wholeCell").unwrap_or(false); let all = bool_param(p, "all").unwrap_or(true); let workbook = str_param(p, "within") == Some("workbook"); let active = s.doc()?.selection.active; let n = edit(s, |cx| { let sheets: Vec = if workbook { (0..cx.wb.sheets.len()).collect() } else { vec![cx.wb.active_sheet] }; let mut n = 0; for si in sheets { let cells: Vec<(CellRef, String)> = cx.wb.sheet(si).map(|sh| sh.cells.iter().map(|(c, cell)| (c, cell.input_text())).collect()).unwrap_or_default(); for (c, text) in cells { if !all && c != active { continue; } if !matches(&text, &what, case, whole) { continue; } let new = if whole { with.clone() } else if case { text.replace(&what, &with) } else { replace_ci(&text, &what, &with) }; let old = cx.wb.sheet(si).and_then(|sh| sh.cell(c)).cloned(); let cell = input_to_cell(&new, old.as_ref(), &mut cx.wb).unwrap_or_else(|_| Some(Cell::value(Value::text(new.as_str())))); let sh = cx.sheet_mut(si)?; match cell { Some(cell) => sh.cells.set(c, cell), None => { sh.cells.remove(c); } } cx.touch(si, c); n += 1; } } Ok(n) })?; Ok(json!({"replaced": n})) } fn replace_ci(text: &str, what: &str, with: &str) -> String { let lower = text.to_lowercase(); let w = what.to_lowercase(); if lower.len() != text.len() || w.is_empty() { return text.replace(what, with); } let mut out = String::new(); let mut i = 0; while let Some(pos) = lower.get(i..).and_then(|s| s.find(&w)) { out.push_str(text.get(i..i + pos).unwrap_or("")); out.push_str(with); i += pos + w.len(); } out.push_str(text.get(i..).unwrap_or("")); out } // ---------------------------------------------------------------- format painter fn format_painter(s: &mut Session, p: &Json) -> Result { 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 }; 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 { s.ui_requests.push(crate::UiRequest::EditCell(None)); ok() } #[cfg(test)] mod tests { use super::*; #[test] fn wildcards() { assert!(wildcard("hello", "h*o")); assert!(wildcard("hello", "h?llo")); assert!(!wildcard("hello", "h?lo")); assert!(wildcard("a*b", "a~*b")); assert!(!wildcard("axb", "a~*b")); assert!(wildcard("", "*")); } #[test] fn tsv() { assert_eq!(parse_tsv("a\tb\n1\t2\n"), vec![vec!["a", "b"], vec!["1", "2"]]); assert_eq!(parse_tsv("\"x\ty\"\tz"), vec![vec!["x\ty", "z"]]); assert_eq!(parse_tsv("\"he said \"\"hi\"\"\""), vec![vec!["he said \"hi\""]]); } #[test] fn replace_case_insensitive() { assert_eq!(replace_ci("Apple apple APPLE", "apple", "pear"), "pear pear pear"); } }