Skip to main content

ravenrs_sheet/engine/cmd/
edit.rs

1//! Cell entry, selection, undo/redo, clipboard, clear, fill, find & replace.
2
3use 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
170/// Merges cells into vertical runs per column (a compact multi-area selection).
171fn 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
192// ---------------------------------------------------------------- history
193
194fn 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        // Redo goes back to the undone edit's cells, not wherever the user clicked before undoing (#1855).
203        let cur_selection = e.selection.clone();
204        label = e.label.clone();
205        d.wb = e.wb;
206        d.selection = e.selection;
207        // Step by step: a rename and a new sheet undone together match up only one step apart.
208        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
238// ---------------------------------------------------------------- clear
239
240/// The rows of `range` an applied filter hides (rows hidden by hand are not among them).
241fn 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
245/// The Clear commands (Delete is Clear Contents). As in Excel, the rows an applied filter hides
246/// are left as they are; rows hidden by hand are cleared.
247fn 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    // Delete on checkboxes works in two passes (as in Excel): checked boxes are unchecked first,
251    // and only when every box in the selection is unchecked are the boxes removed.
252    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    // Notes and links aren't cell contents, so the cell comparison that guards a protected sheet
266    // doesn't see them go: a locked cell's are refused here, stored cell or not.
267    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            // Cells are only stored inside the used range. Notes and links are kept apart from
287            // them, so they are cleared over the whole range: one from a file can sit on a cell
288            // with nothing stored in it.
289            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            // Report every stored cell we touch, even beyond a million coordinates in a sparse
296            // selection. Removed formula anchors must also reach calc so their spills disappear.
297            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            // A removed note or link changes its cell even with nothing stored there.
316            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                    // An empty merge can block a spill without storing any cells in this range.
359                    // As in Unmerge, retry blocked formula anchors that could reach the freed area.
360                    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
375// ---------------------------------------------------------------- format painter
376
377fn 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            // Whole rows or columns take a line format, as the ribbon's formatting commands give
387            // them, instead of a style on each of their cells (a column alone has a million). The
388            // line format comes from the first cell of the source pattern across the line; cells
389            // already stored follow the pattern. Row heights and column widths stay as they are.
390            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                    // The source cell for position `k` along the line.
399                    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                        // A row format outranks a column format: the column's cells in formatted
423                        // rows need a style of their own.
424                        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;