Skip to main content

ravenrs_sheet/engine/cmd/
sheet.rs

1//! Worksheets and structure: insert/delete rows, columns and cells; sheet tabs.
2
3use std::sync::Arc;
4
5use crate::base::{CellRef, MAX_COLS, MAX_ROWS, RangeRef};
6use crate::formula::adjust::{Axis, Edit};
7use crate::model::{Anchor, AnchorMode, Sheet, Visibility};
8use serde_json::{Value as Json, json};
9
10use super::{
11    CommandSpec, Ctx, EngineError, PROTECTED, Result, Session, bad, bool_param, cell_param_on, cmd, edit, has_clipboard, has_doc, ok, parse_lines,
12    str_param, target_range, target_sheet, u32_param,
13};
14
15mod tabs;
16
17use tabs::{activate, delete_sheet, hide_sheet, insert_sheet, move_sheet, rename_sheet, step_sheet, tab_color, unhide_sheet};
18
19pub fn specs() -> Vec<CommandSpec> {
20    vec![
21        cmd!(
22            "home.insertRows",
23            "Insert Sheet Rows",
24            ["Home", "Cells", "Insert"],
25            Some("Ctrl+Shift+="),
26            "{rows?: \"3:5\", count?}",
27            has_doc,
28            |s, p| insert_lines(s, p, Axis::Rows)
29        ),
30        cmd!("home.insertColumns", "Insert Sheet Columns", ["Home", "Cells", "Insert"], None, "{cols?: \"B:C\"}", has_doc, |s, p| insert_lines(
31            s,
32            p,
33            Axis::Cols
34        )),
35        cmd!("home.deleteRows", "Delete Sheet Rows", ["Home", "Cells", "Delete"], Some("Cmd+-"), "{rows?}", has_doc, |s, p| delete_lines(
36            s,
37            p,
38            Axis::Rows
39        )),
40        cmd!("home.deleteColumns", "Delete Sheet Columns", ["Home", "Cells", "Delete"], None, "{cols?}", has_doc, |s, p| delete_lines(
41            s,
42            p,
43            Axis::Cols
44        )),
45        cmd!("sheet.moveColumns", "Move Columns", [], None, "{cols?: \"B:C\", before: zero-based column index}", has_doc, move_columns),
46        cmd!("home.insertCells", "Insert Cells…", ["Home", "Cells", "Insert"], None, "{range?, shift: right|down|row|column}", has_doc, insert_cells),
47        cmd!(
48            "home.insertCopiedCells",
49            "Insert Copied Cells",
50            ["Home", "Cells", "Insert"],
51            None,
52            "{at?: \"B3\", shift?: down|right}: inserts the copied cells at `at` (the active cell) and pastes them there. Whole copied rows or columns insert whole rows or columns; other blocks shift the cells at `at` down (default) or right. A cut of whole rows or columns moves them (Insert Cut Cells)",
53            has_clipboard,
54            insert_copied_cells
55        ),
56        cmd!("home.deleteCells", "Delete Cells…", ["Home", "Cells", "Delete"], None, "{range?, shift: left|up|row|column}", has_doc, delete_cells),
57        cmd!("home.insertSheet", "Insert Sheet", ["Home", "Cells", "Insert"], Some("Shift+F11"), "{name?, before?: index}", has_doc, insert_sheet),
58        cmd!("home.deleteSheet", "Delete Sheet", ["Home", "Cells", "Delete"], None, "{sheet?}", has_doc, delete_sheet),
59        cmd!("sheet.rename", "Rename Sheet", ["Home", "Cells", "Format"], None, "{sheet?, name}", has_doc, rename_sheet),
60        cmd!("sheet.move", "Move or Copy Sheet…", ["Home", "Cells", "Format"], None, "{sheet?, to: index, copy?: bool}", has_doc, move_sheet),
61        cmd!("sheet.tabColor", "Tab Color", ["Home", "Cells", "Format"], None, "{sheet?, color: \"#FF0000\"|\"none\"}", has_doc, tab_color),
62        cmd!("sheet.hide", "Hide Sheet", ["Home", "Cells", "Format", "Hide & Unhide"], None, "{sheet?}", has_doc, hide_sheet),
63        cmd!("sheet.unhide", "Unhide Sheet…", ["Home", "Cells", "Format", "Hide & Unhide"], None, "{sheet}", has_doc, unhide_sheet),
64        cmd!(noundo "sheet.activate", "Activate Sheet", [], None, "{sheet: index | name}", has_doc, activate),
65        cmd!(noundo "sheet.next", "Next Sheet", [], Some("Ctrl+PageDown"), "{}", has_doc, |s, _| step_sheet(s, 1)),
66        cmd!(noundo "sheet.previous", "Previous Sheet", [], Some("Ctrl+PageUp"), "{}", has_doc, |s, _| step_sheet(s, -1)),
67    ]
68}
69
70fn lines(s: &Session, p: &Json, axis: Axis) -> Result<(u32, u32)> {
71    let key = if axis == Axis::Rows { "rows" } else { "cols" };
72    let r = match str_param(p, key).or_else(|| str_param(p, "range")) {
73        Some(t) => parse_lines(t).ok_or_else(|| bad(key, format!("not a range: {t}")))?,
74        None => s.doc()?.selection.current(),
75    };
76    let (a, b) = if axis == Axis::Rows { (r.start.row, r.end.row) } else { (r.start.col, r.end.col) };
77    let count = u32_param(p, "count").unwrap_or(b - a + 1).max(1);
78    Ok((a, count))
79}
80
81/// Bottom-right line of an anchor (the `to` marker of a two-cell anchor) and the offset of that
82/// corner inside it, in points. Measured against `sh`'s current geometry.
83fn anchor_corner(sh: &Sheet, a: &Anchor) -> (u32, u32, f32, f32) {
84    let x2 = sh.col_left(a.cell.col) + a.dx as f64 + a.width.max(0.0) as f64;
85    let y2 = sh.row_top(a.cell.row) + a.dy as f64 + a.height.max(0.0) as f64;
86    let tc = sh.col_at(x2);
87    let tr = sh.row_at(y2);
88    (tc, tr, (x2 - sh.col_left(tc)) as f32, (y2 - sh.row_top(tr)) as f32)
89}
90
91/// Repositions one floating object's anchor for a structural insert/delete, honouring its
92/// [`AnchorMode`]: absolute objects ("Don't move or size with cells") stay put; "move but don't
93/// size" follows its cell and keeps its size; "move and size" is re-fitted to the lines it lands
94/// on. `corner` is the pre-edit bottom-right line/offset (from [`anchor_corner`]); a line deleted
95/// out from under an edge collapses that edge onto `at`. `sh` is the post-edit grid, used to
96/// measure the re-fitted size.
97fn shift_anchor(sh: &Sheet, a: Anchor, corner: (u32, u32, f32, f32), axis: Axis, at: u32, map_line: &impl Fn(u32) -> Option<u32>) -> Anchor {
98    if a.mode == AnchorMode::Absolute {
99        return a;
100    }
101    let main = if axis == Axis::Rows { a.cell.row } else { a.cell.col };
102    // A deleted anchor line collapses onto the line that takes its place.
103    let nmain = map_line(main).unwrap_or(at);
104    let (nr, nc) = if axis == Axis::Rows { (nmain, a.cell.col) } else { (a.cell.row, nmain) };
105    if a.mode == AnchorMode::MoveOnly {
106        return Anchor { cell: CellRef::new(nr, nc), ..a };
107    }
108    // Move and size: re-fit to the lines the object lands on. A far edge whose line was deleted
109    // collapses onto the near cell, shrinking the object.
110    let (tc, tr, tdx, tdy) = corner;
111    let far = if axis == Axis::Rows { tr } else { tc };
112    let nfar = map_line(far).unwrap_or(nmain);
113    let (nbr, nbc) = if axis == Axis::Rows { (nfar, tc) } else { (tr, nfar) };
114    let x1 = sh.col_left(nc) + a.dx as f64;
115    let y1 = sh.row_top(nr) + a.dy as f64;
116    let xn = sh.col_left(nbc) + tdx as f64;
117    let yn = sh.row_top(nbr) + tdy as f64;
118    Anchor { cell: CellRef::new(nr, nc), dx: a.dx, dy: a.dy, width: (xn - x1).max(0.0) as f32, height: (yn - y1).max(0.0) as f32, mode: a.mode }
119}
120
121/// Shifts everything that has a position on the sheet for an insert/delete.
122fn shift_sheet_features(sh: &mut Sheet, axis: Axis, at: u32, count: u32, insert: bool) {
123    let map_line = |v: u32| -> Option<u32> {
124        if insert {
125            if v >= at { v.checked_add(count) } else { Some(v) }
126        } else if v < at {
127            Some(v)
128        } else if v >= at + count {
129            Some(v - count)
130        } else {
131            None
132        }
133    };
134    let e = if insert { Edit::Insert { axis, at, count } } else { Edit::Delete { axis, at, count } };
135    let map_range = |r: RangeRef| map_range(r, &e);
136    let map_cell = |c: CellRef| -> Option<CellRef> {
137        match axis {
138            Axis::Rows => map_line(c.row).map(|r| CellRef::new(r, c.col)),
139            Axis::Cols => map_line(c.col).map(|x| CellRef::new(c.row, x)),
140        }
141    };
142    // Floating objects: snapshot their bottom-right corners against the pre-shift geometry, so a
143    // re-fit after the shift measures against the grid the object actually lands on.
144    let obj_corners: Vec<(u32, u32, f32, f32)> = sh.object_anchors().map(|a| anchor_corner(sh, a)).collect();
145    // Line info.
146    let lines = if axis == Axis::Rows { &mut sh.rows } else { &mut sh.cols };
147    let old = std::mem::take(lines);
148    for (k, v) in old {
149        if let Some(n) = map_line(k)
150            && n < if axis == Axis::Rows { MAX_ROWS } else { MAX_COLS }
151        {
152            lines.insert(n, v);
153        }
154    }
155    sh.merges = sh.merges.iter().filter_map(|m| map_range(*m)).filter(|m| !m.is_single()).collect();
156    for cf in sh.cond_formats.iter_mut() {
157        cf.ranges = cf.ranges.iter().filter_map(|r| map_range(*r)).collect();
158    }
159    for er in sh.edit_ranges.iter_mut() {
160        er.ranges = er.ranges.iter().filter_map(|r| map_range(*r)).collect();
161    }
162    sh.edit_ranges.retain(|er| !er.ranges.is_empty());
163    sh.cond_formats.retain(|cf| !cf.ranges.is_empty());
164    for dv in sh.validations.iter_mut() {
165        dv.ranges = dv.ranges.iter().filter_map(|r| map_range(*r)).collect();
166    }
167    sh.validations.retain(|dv| !dv.ranges.is_empty());
168    for t in sh.tables.iter_mut() {
169        if let Some(r) = map_range(t.range) {
170            if axis == Axis::Cols {
171                shift_table_columns(t, at, count, insert);
172            }
173            t.range = r;
174        } else {
175            t.range = RangeRef::default();
176        }
177    }
178    sh.tables.retain(|t| t.range != RangeRef::default() && !t.columns.is_empty());
179    if let Some(af) = sh.autofilter.as_mut() {
180        match map_range(af.range) {
181            Some(r) => af.range = r,
182            None => sh.autofilter = None,
183        }
184    }
185    sh.cell_pictures =
186        std::mem::take(&mut sh.cell_pictures).into_iter().filter_map(|(c, v)| map_cell(c).filter(CellRef::is_valid).map(|c| (c, v))).collect();
187    sh.comments = std::mem::take(&mut sh.comments).into_iter().filter_map(|(c, v)| map_cell(c).map(|c| (c, v))).collect();
188    sh.hyperlinks = std::mem::take(&mut sh.hyperlinks).into_iter().filter_map(|(c, v)| map_cell(c).map(|c| (c, v))).collect();
189    // Floating objects follow their anchor lines per their [`AnchorMode`]. New anchors are
190    // computed against the post-shift grid first (the re-fit needs its sizes), then written back
191    // in the same `object_anchors` order the corners were snapshotted in.
192    let new_anchors: Vec<Anchor> =
193        sh.object_anchors().zip(&obj_corners).map(|(a, &corner)| shift_anchor(sh, *a, corner, axis, at, &map_line)).collect();
194    for (a, n) in sh.object_anchors_mut().zip(new_anchors) {
195        *a = n;
196    }
197    sh.sparklines.retain_mut(|sp| match map_cell(sp.cell) {
198        Some(c) => {
199            sp.cell = c;
200            true
201        }
202        None => false,
203    });
204    if let Some(pa) = sh.print.print_area {
205        sh.print.print_area = map_range(pa);
206    }
207    // PivotTables follow their report cells. A deleted anchor line collapses onto the line that
208    // takes its place; a report deleted entirely has no last area until the next refresh.
209    for pt in sh.pivots.iter_mut() {
210        pt.anchor = map_cell(pt.anchor).unwrap_or_else(|| match axis {
211            Axis::Rows => CellRef::new(at, pt.anchor.col),
212            Axis::Cols => CellRef::new(pt.anchor.row, at),
213        });
214        pt.last_range = pt.last_range.and_then(map_range);
215    }
216}
217
218/// Rewrites every formula reference (chart series included), sparkline source and PivotTable
219/// source for a structural edit on `target`.
220pub(super) fn rewrite(cx: &mut Ctx, target: &str, e: &Edit) -> Result<()> {
221    cx.edits.push((target.to_string(), e.clone()));
222    let wb = &mut cx.wb;
223    super::edit::rewrite_all_formulas(wb, target, e)?;
224    for si in 0..wb.sheets.len() {
225        let host = wb.sheets.get(si).map(|s| s.name.clone()).unwrap_or_default();
226        let Some(sh) = wb.sheet_mut(si) else { continue };
227        let fix = |text: &str| -> String {
228            match crate::formula::parse(text) {
229                Ok(expr) => crate::formula::print(&crate::formula::adjust::adjust(expr, &host, target, e)),
230                Err(_) => text.to_string(),
231            }
232        };
233        for sp in sh.sparklines.iter_mut() {
234            sp.source = fix(&sp.source);
235        }
236        // PivotTable sources (a range, table or defined name) are only rewritten when the edit
237        // moves them, so table and name sources keep their text.
238        for pt in sh.pivots.iter_mut() {
239            let body = pt.source.trim().trim_start_matches('=');
240            if let Ok(expr) = crate::formula::parse(body) {
241                let before = crate::formula::print(&expr);
242                let after = crate::formula::print(&crate::formula::adjust::adjust(expr, &host, target, e));
243                if after != before {
244                    pt.source = after;
245                }
246            }
247        }
248    }
249    Ok(())
250}
251
252/// Inserting or deleting cells moves the copied block away from the stored source range, so, like
253/// Excel, it cancels copy/cut mode for this workbook (a later paste would take the wrong cells).
254fn cancel_copy_mode(s: &mut Session) {
255    let uid = s.doc().map(|d| d.uid).ok();
256    if s.clipboard.as_ref().is_some_and(|c| Some(c.doc_uid) == uid) {
257        s.clipboard = None;
258    }
259}
260
261fn inherit_line_format(sh: &mut Sheet, axis: Axis, at: u32, count: u32) {
262    if at == 0 {
263        return;
264    }
265    let prev = if axis == Axis::Rows { sh.rows.get(&(at - 1)).copied() } else { sh.cols.get(&(at - 1)).copied() };
266    if let Some(info) = prev {
267        for i in at..at + count {
268            let map = if axis == Axis::Rows { &mut sh.rows } else { &mut sh.cols };
269            map.insert(i, crate::model::LineInfo { hidden: false, outline: 0, collapsed: false, ..info });
270        }
271    }
272}
273
274fn check_push_off_sheet(s: &Session, sheet: usize, axis: Axis, at: u32, count: u32, limit: u32) -> Result<()> {
275    if let Some(u) = s.doc()?.wb.sheet(sheet).and_then(|sh| sh.used_range()) {
276        let last = if axis == Axis::Rows { u.end.row } else { u.end.col };
277        if last >= at && last as u64 + count as u64 >= limit as u64 {
278            return Err(EngineError::Other("To prevent possible loss of data, GridCraft cannot shift nonblank cells off of the worksheet.".into()));
279        }
280    }
281    Ok(())
282}
283
284fn insert_lines(s: &mut Session, p: &Json, axis: Axis) -> Result<Json> {
285    let (at, count) = lines(s, p, axis)?;
286    let limit = if axis == Axis::Rows { MAX_ROWS } else { MAX_COLS };
287    if at.checked_add(count).is_none_or(|end| end > limit) {
288        let cmd = if axis == Axis::Rows { "home.insertRows" } else { "home.insertColumns" };
289        return Err(bad(cmd, "the inserted lines would extend past the end of the worksheet"));
290    }
291    check_lines_protection(s, p, axis, at, count, true)?;
292    let sheet = target_sheet(s, p)?;
293    check_push_off_sheet(s, sheet, axis, at, count, limit)?;
294    let out = edit(s, |cx| {
295        let name = cx.wb.sheet(sheet).map(|s| s.name.clone()).unwrap_or_default();
296        cx.protection_checked = true;
297        {
298            let sh = cx.sheet_mut(sheet)?;
299            match axis {
300                Axis::Rows => sh.cells.shift_rows(at, count as i64),
301                Axis::Cols => sh.cells.shift_cols(at, count as i64),
302            }
303            shift_sheet_features(sh, axis, at, count, true);
304            // New lines take the format of the line before (Excel's "Format Same As Above").
305            inherit_line_format(sh, axis, at, count);
306        }
307        rewrite(cx, &name, &Edit::Insert { axis, at, count })?;
308        cx.structural = true;
309        Ok(Json::Null)
310    })?;
311    cancel_copy_mode(s);
312    Ok(out)
313}
314
315/// The areas a line delete/insert targets as `(first line, count)`, merged and sorted from the
316/// last line to the first so deleting one never shifts the next. A comma-separated list
317/// (`"3:3,7:8"`) or a multi-area selection yields several; `count` forces the single-area form.
318fn delete_areas(s: &Session, p: &Json, axis: Axis) -> Result<Vec<(u32, u32)>> {
319    let key = if axis == Axis::Rows { "rows" } else { "cols" };
320    let spans: Vec<(u32, u32)> = if u32_param(p, "count").is_some() {
321        let (at, count) = lines(s, p, axis)?;
322        let limit = if axis == Axis::Rows { MAX_ROWS } else { MAX_COLS };
323        // Deleting past the last line only removes up to the end of the worksheet.
324        let count = count.min(limit.saturating_sub(at)).max(1);
325        vec![(at, at.saturating_add(count - 1))]
326    } else if let Some(t) = str_param(p, key).or_else(|| str_param(p, "range")) {
327        t.split(',')
328            .map(|part| {
329                let r = parse_lines(part).ok_or_else(|| bad(key, format!("not a range: {part}")))?;
330                Ok(if axis == Axis::Rows { (r.start.row, r.end.row) } else { (r.start.col, r.end.col) })
331            })
332            .collect::<Result<_>>()?
333    } else {
334        s.doc()?.selection.ranges.iter().map(|r| if axis == Axis::Rows { (r.start.row, r.end.row) } else { (r.start.col, r.end.col) }).collect()
335    };
336    let mut spans: Vec<(u32, u32)> = spans.into_iter().map(|(a, b)| (a.min(b), a.max(b))).collect();
337    spans.sort_unstable();
338    let mut merged: Vec<(u32, u32)> = Vec::new();
339    for (a, b) in spans {
340        match merged.last_mut() {
341            Some(last) if a <= last.1.saturating_add(1) => last.1 = last.1.max(b),
342            _ => merged.push((a, b)),
343        }
344    }
345    Ok(merged.into_iter().rev().map(|(a, b)| (a, b - a + 1)).collect())
346}
347
348fn delete_lines(s: &mut Session, p: &Json, axis: Axis) -> Result<Json> {
349    let areas = delete_areas(s, p, axis)?;
350    for &(at, count) in &areas {
351        check_lines_protection(s, p, axis, at, count, false)?;
352    }
353    let sheet = target_sheet(s, p)?;
354    let out = edit(s, |cx| {
355        let name = cx.wb.sheet(sheet).map(|s| s.name.clone()).unwrap_or_default();
356        cx.protection_checked = true;
357        // Last area first, so the earlier ones keep their positions.
358        for (at, count) in areas {
359            {
360                let sh = cx.sheet_mut(sheet)?;
361                let block = match axis {
362                    Axis::Rows => RangeRef::rows(at, (at + count - 1).min(MAX_ROWS - 1)),
363                    Axis::Cols => RangeRef::cols(at, (at + count - 1).min(MAX_COLS - 1)),
364                };
365                sh.take_cells(block);
366                match axis {
367                    Axis::Rows => sh.cells.shift_rows(at + count, -(count as i64)),
368                    Axis::Cols => sh.cells.shift_cols(at + count, -(count as i64)),
369                }
370                shift_sheet_features(sh, axis, at, count, false);
371            }
372            rewrite(cx, &name, &Edit::Delete { axis, at, count })?;
373        }
374        cx.structural = true;
375        Ok(Json::Null)
376    })?;
377    cancel_copy_mode(s);
378    Ok(out)
379}
380
381/// Reorders a contiguous block of whole columns without replacing the destination columns.
382/// `before` is a boundary in the original sheet: moving B:C before E produces A,D,B,C,E.
383fn move_columns(s: &mut Session, p: &Json) -> Result<Json> {
384    let (from, count) = lines(s, p, Axis::Cols)?;
385    let before = u32_param(p, "before").ok_or_else(|| bad("sheet.moveColumns", "missing `before`"))?;
386    let end =
387        from.checked_add(count).filter(|end| *end <= MAX_COLS).ok_or_else(|| bad("sheet.moveColumns", "source columns are outside the worksheet"))?;
388    if before >= MAX_COLS || before.checked_add(count).is_none_or(|end| end > MAX_COLS) {
389        return Err(bad("sheet.moveColumns", "destination columns are outside the worksheet"));
390    }
391    if (from..=end).contains(&before) {
392        return Ok(json!({"cols": RangeRef::cols(from, end.saturating_sub(1)).a1()}));
393    }
394    let sheet = target_sheet(s, p)?;
395    if let Some(u) = s.doc()?.wb.sheet(sheet).and_then(|sh| sh.used_range())
396        && u.end.col >= before
397        && u.end.col as u64 + count as u64 >= MAX_COLS as u64
398    {
399        return Err(EngineError::Other("To prevent possible loss of data, columns cannot be shifted off of the worksheet.".into()));
400    }
401    // The checks must authorize the sheet this command modifies, not whatever sheet is active:
402    // both get the index `target_sheet` resolved (from `sheet`, or a `Sheet!` range or cell).
403    check_lines_protection(s, &json!({"cols": RangeRef::cols(before, before).a1(), "sheet": sheet}), Axis::Cols, before, count, true)?;
404    check_lines_protection(s, &json!({"cols": RangeRef::cols(from, end - 1).a1(), "sheet": sheet}), Axis::Cols, from, count, false)?;
405
406    let moved_from = if before <= from { from + count } else { from };
407    if moved_from.checked_add(count).is_none_or(|end| end > MAX_COLS) {
408        return Err(bad("sheet.moveColumns", "the move is too close to the worksheet boundary"));
409    }
410    let destination = before;
411    let delete_at = moved_from;
412    let final_start = if before > from { before - count } else { before };
413    let active_row = s.doc()?.selection.active.row;
414    let source = RangeRef::cols(moved_from, moved_from + count - 1);
415    let out = edit(s, |cx| {
416        let name = cx.wb.sheet(sheet).map(|sh| sh.name.clone()).unwrap_or_default();
417        cx.protection_checked = true;
418
419        {
420            let sh = cx.sheet_mut(sheet)?;
421            sh.cells.shift_cols(before, count as i64);
422            shift_sheet_features(sh, Axis::Cols, before, count, true);
423        }
424        rewrite(cx, &name, &Edit::Insert { axis: Axis::Cols, at: before, count })?;
425
426        let mut touched = Vec::new();
427        {
428            let sh = cx.sheet_mut(sheet)?;
429            let pictures: Vec<_> =
430                sh.cell_pictures.iter().filter(|(cell, _)| source.contains(**cell)).map(|(cell, picture)| (*cell, picture.clone())).collect();
431            let cells = sh.take_cells(source);
432            let moved_lines: Vec<_> = (0..count).filter_map(|offset| sh.cols.remove(&(moved_from + offset)).map(|info| (offset, info))).collect();
433            let movement = Edit::Move { from: source, to_row: 0, to_col: destination };
434            shift_cell_features(sh, &movement);
435            for object in sh
436                .charts
437                .iter_mut()
438                .map(|o| &mut o.anchor)
439                .chain(sh.images.iter_mut().map(|o| &mut o.anchor))
440                .chain(sh.shapes.iter_mut().map(|o| &mut o.anchor))
441            {
442                if object.mode != AnchorMode::Absolute && (moved_from..moved_from + count).contains(&object.cell.col) {
443                    object.cell.col = destination + (object.cell.col - moved_from);
444                }
445            }
446            for (cell, value) in cells {
447                let dest = CellRef::new(cell.row, destination + (cell.col - moved_from));
448                sh.cells.set(dest, value);
449                touched.extend([cell, dest]);
450            }
451            for (cell, picture) in pictures {
452                let dest = CellRef::new(cell.row, destination + (cell.col - moved_from));
453                sh.cell_pictures.insert(dest, picture);
454            }
455            for (offset, info) in moved_lines {
456                sh.cols.insert(destination + offset, info);
457            }
458        }
459        for cell in touched {
460            cx.touch(sheet, cell);
461        }
462        rewrite(cx, &name, &Edit::Move { from: source, to_row: 0, to_col: destination })?;
463
464        {
465            let sh = cx.sheet_mut(sheet)?;
466            sh.cells.shift_cols(delete_at + count, -(count as i64));
467            shift_sheet_features(sh, Axis::Cols, delete_at, count, false);
468        }
469        rewrite(cx, &name, &Edit::Delete { axis: Axis::Cols, at: delete_at, count })?;
470        *cx.sel = crate::engine::Selection {
471            active: CellRef::new(active_row, final_start),
472            anchor: CellRef::new(active_row, final_start),
473            ranges: vec![RangeRef::cols(final_start, final_start + count - 1)],
474        };
475        cx.structural = true;
476        Ok(json!({"cols": RangeRef::cols(final_start, final_start + count - 1).a1()}))
477    })?;
478    cancel_copy_mode(s);
479    Ok(out)
480}
481
482fn insert_cells(s: &mut Session, p: &Json) -> Result<Json> {
483    let shift = str_param(p, "shift").unwrap_or("down");
484    let r = target_range(s, p)?;
485    match shift {
486        "row" | "entireRow" => return insert_lines(s, &json!({"rows": format!("{}:{}", r.start.row + 1, r.end.row + 1)}), Axis::Rows),
487        "column" | "entireColumn" => return insert_lines(s, &json!({"cols": RangeRef::cols(r.start.col, r.end.col).a1()}), Axis::Cols),
488        _ => {}
489    }
490    let e = Edit::InsertCells { axis: if shift == "right" { Axis::Cols } else { Axis::Rows }, range: r };
491    let sheet = target_sheet(s, p)?;
492    if let Some(sh) = s.doc()?.wb.sheet(sheet) {
493        check_table_shift(sh, &e)?;
494        // Refuse to push data off the sheet: cells in the last `width`/`height` positions would be dropped.
495        let doomed = if shift == "right" {
496            RangeRef::new(CellRef::new(r.start.row, r.start.col.max(MAX_COLS.saturating_sub(r.width()))), CellRef::new(r.end.row, MAX_COLS - 1))
497        } else {
498            RangeRef::new(CellRef::new(r.start.row.max(MAX_ROWS.saturating_sub(r.height())), r.start.col), CellRef::new(MAX_ROWS - 1, r.end.col))
499        };
500        if sh.cells.iter_range(doomed).any(|(_, x)| x.has_content()) {
501            return Err(EngineError::Other("To prevent possible loss of data, GridCraft cannot shift nonblank cells off of the worksheet.".into()));
502        }
503    }
504    let right = shift == "right";
505    let out = edit(s, |cx| {
506        let sh = cx.sheet_mut(sheet)?;
507        if right {
508            sh.cells.shift_cols_in_rows(r.start.row, r.end.row, r.start.col, r.width() as i64);
509        } else {
510            sh.cells.shift_rows_in_cols(r.start.col, r.end.col, r.start.row, r.height() as i64);
511        }
512        shift_cell_features(sh, &e);
513        let name = cx.wb.sheet(sheet).map(|s| s.name.clone()).unwrap_or_default();
514        rewrite(cx, &name, &e)?;
515        cx.structural = true;
516        Ok(Json::Null)
517    })?;
518    cancel_copy_mode(s);
519    Ok(out)
520}
521
522fn delete_cells(s: &mut Session, p: &Json) -> Result<Json> {
523    let shift = str_param(p, "shift").unwrap_or("up");
524    let r = target_range(s, p)?;
525    match shift {
526        "row" | "entireRow" => return delete_lines(s, &json!({"rows": format!("{}:{}", r.start.row + 1, r.end.row + 1)}), Axis::Rows),
527        "column" | "entireColumn" => return delete_lines(s, &json!({"cols": RangeRef::cols(r.start.col, r.end.col).a1()}), Axis::Cols),
528        _ => {}
529    }
530    let e = Edit::DeleteCells { axis: if shift == "left" { Axis::Cols } else { Axis::Rows }, range: r };
531    let sheet = target_sheet(s, p)?;
532    if let Some(sh) = s.doc()?.wb.sheet(sheet) {
533        check_table_shift(sh, &e)?;
534    }
535    let left = shift == "left";
536    let out = edit(s, |cx| {
537        let name = cx.wb.sheet(sheet).map(|s| s.name.clone()).unwrap_or_default();
538        {
539            let sh = cx.sheet_mut(sheet)?;
540            sh.take_cells(r);
541            if left {
542                sh.cells.shift_cols_in_rows(r.start.row, r.end.row, r.end.col + 1, -(r.width() as i64));
543            } else {
544                sh.cells.shift_rows_in_cols(r.start.col, r.end.col, r.end.row + 1, -(r.height() as i64));
545            }
546            shift_cell_features(sh, &e);
547        }
548        // References into the deleted cells become #REF!; the rest move.
549        rewrite(cx, &name, &e)?;
550        cx.structural = true;
551        Ok(Json::Null)
552    })?;
553    cancel_copy_mode(s);
554    Ok(out)
555}
556
557/// Insert Copied Cells / Insert Cut Cells: makes room at the destination, then pastes (or, for a
558/// cut, moves) the clipboard there, as one undo step.
559fn insert_copied_cells(s: &mut Session, p: &Json) -> Result<Json> {
560    let Some(clip) = s.clipboard.clone() else { return Err(EngineError::Other("Nothing to paste.".into())) };
561    let sheet = target_sheet(s, p)?;
562    let d = s.doc()?;
563    let at = cell_param_on(s, p, "at", sheet)?.unwrap_or(d.selection.current().start);
564    let shift = str_param(p, "shift").unwrap_or("down");
565    if shift != "down" && shift != "right" {
566        return Err(bad("home.insertCopiedCells", format!("unknown `shift`: {shift} (down or right)")));
567    }
568    let sel = clip.selected;
569    let rows = sel.is_full_rows();
570    let cols = sel.is_full_cols();
571    if rows && cols {
572        return Err(EngineError::Other("The copy area is the whole sheet; there is no room to insert it.".into()));
573    }
574    let same_doc = d.uid == clip.doc_uid;
575    let cut = clip.cut && same_doc;
576    if cut && !rows && !cols {
577        return Err(EngineError::Other("Insert Cut Cells works on whole rows or columns; cut whole rows or columns, or use Paste.".into()));
578    }
579    // A moved block can't be inserted inside itself.
580    if cut && clip.sheet == sheet {
581        let inside = if rows { at.row > sel.start.row && at.row <= sel.end.row } else { at.col > sel.start.col && at.col <= sel.end.col };
582        if inside {
583            return Err(EngineError::Other("The cut rows or columns can't be inserted inside themselves.".into()));
584        }
585    }
586    let revision = d.revision;
587    let before = (d.wb.clone(), d.selection.clone());
588    // Inside an undo group the nested steps add no undo entries of their own.
589    let nested_entries = !s.in_group(d.uid);
590    let result = insert_copied_steps(s, &clip, sheet, at, shift, cut);
591    let d = s.doc_mut()?;
592    // The nested steps collapse into this command's single undo step.
593    if nested_entries {
594        for _ in 0..d.revision.saturating_sub(revision) {
595            d.undo.pop();
596        }
597    }
598    match result {
599        Ok((final_range, out)) => {
600            d.selection = crate::engine::Selection { active: final_range.start, anchor: final_range.start, ranges: vec![final_range] };
601            // Like inserting cells, this ends copy mode (the copied block may have moved).
602            cancel_copy_mode(s);
603            Ok(out)
604        }
605        Err(e) => {
606            d.wb = before.0;
607            d.selection = before.1;
608            d.calc.rebuild(&d.wb);
609            s.clipboard = Some(clip);
610            Err(e)
611        }
612    }
613}
614
615fn insert_copied_steps(
616    s: &mut Session,
617    clip: &crate::engine::Clipboard,
618    sheet: usize,
619    at: CellRef,
620    shift: &str,
621    cut: bool,
622) -> Result<(RangeRef, Json)> {
623    let sel = clip.selected;
624    let rows = sel.is_full_rows();
625    let cols = sel.is_full_cols();
626    // Where the copied block's top-left lands, relative to the (untrimmed) copied lines.
627    let (inserted, paste_at) = if rows {
628        let h = sel.height();
629        let start = at.row;
630        let r = format!("{}:{}", start as u64 + 1, start as u64 + h as u64);
631        s.execute("home.insertRows", json!({"rows": r, "sheet": sheet}))?;
632        let dr = clip.range.start.row.saturating_sub(sel.start.row);
633        (RangeRef::rows(start, start.saturating_add(h - 1).min(MAX_ROWS - 1)), CellRef::new(start.saturating_add(dr), clip.range.start.col))
634    } else if cols {
635        let w = sel.width();
636        let start = at.col;
637        let end = start.saturating_add(w - 1).min(MAX_COLS - 1);
638        s.execute("home.insertColumns", json!({"cols": RangeRef::cols(start, end).a1(), "sheet": sheet}))?;
639        let dc = clip.range.start.col.saturating_sub(sel.start.col);
640        (RangeRef::cols(start, end), CellRef::new(clip.range.start.row, start.saturating_add(dc)))
641    } else {
642        let end = at
643            .offset(clip.range.height() as i64 - 1, clip.range.width() as i64 - 1)
644            .ok_or_else(|| EngineError::Other("The copied cells don't fit on the sheet at this position.".into()))?;
645        let block = RangeRef::new(at, end);
646        s.execute("home.insertCells", json!({"range": block.a1(), "shift": shift, "sheet": sheet}))?;
647        (block, at)
648    };
649    // Inserting ended copy mode; bring the clipboard back for the paste. A cut reads the live
650    // workbook, so it follows the source if the insert pushed it along.
651    let mut clip = clip.clone();
652    if cut {
653        let pushed = clip.sheet == sheet && if rows { sel.start.row >= at.row } else { sel.start.col >= at.col };
654        if pushed {
655            let (dr, dc) = if rows { (inserted.height() as i64, 0) } else { (0, inserted.width() as i64) };
656            clip.range = RangeRef::new(clip.range.start.offset_clamped(dr, dc), clip.range.end.offset_clamped(dr, dc));
657            clip.selected = RangeRef::new(sel.start.offset_clamped(dr, dc), sel.end.offset_clamped(dr, dc));
658        }
659        clip.wb = s.doc()?.wb.clone();
660    }
661    let source_lines = clip.selected;
662    s.clipboard = Some(clip.clone());
663    let out = s.execute("edit.pasteSpecial", json!({"what": "all", "at": paste_at.a1(), "sheet": sheet}))?;
664    if !cut {
665        return Ok((inserted, out));
666    }
667    // Close the gap the moved lines left behind.
668    let (removed, before_dest) = if rows {
669        s.execute(
670            "home.deleteRows",
671            json!({"rows": format!("{}:{}", source_lines.start.row as u64 + 1, source_lines.end.row as u64 + 1), "sheet": clip.sheet}),
672        )?;
673        (source_lines.height(), clip.sheet == sheet && source_lines.start.row < inserted.start.row)
674    } else {
675        s.execute("home.deleteColumns", json!({"cols": RangeRef::cols(source_lines.start.col, source_lines.end.col).a1(), "sheet": clip.sheet}))?;
676        (source_lines.width(), clip.sheet == sheet && source_lines.start.col < inserted.start.col)
677    };
678    let moved = if before_dest {
679        let (dr, dc) = if rows { (-(removed as i64), 0) } else { (0, -(removed as i64)) };
680        RangeRef::new(inserted.start.offset_clamped(dr, dc), inserted.end.offset_clamped(dr, dc))
681    } else {
682        inserted
683    };
684    Ok((moved, out))
685}
686
687/// Sheet protection for inserting or deleting rows/columns: the protection must allow it, and
688/// deleted lines must not hold locked cells.
689fn check_lines_protection(s: &Session, p: &Json, axis: Axis, at: u32, count: u32, insert: bool) -> Result<()> {
690    let sheet = target_sheet(s, p)?;
691    let wb = &s.doc()?.wb;
692    let Some(sh) = wb.sheet(sheet) else { return Ok(()) };
693    let Some(pr) = &sh.protection else { return Ok(()) };
694    let allowed = match (axis, insert) {
695        (Axis::Rows, true) => pr.insert_rows,
696        (Axis::Rows, false) => pr.delete_rows,
697        (Axis::Cols, true) => pr.insert_columns,
698        (Axis::Cols, false) => pr.delete_columns,
699    };
700    let locked = |st: crate::model::StyleId| wb.styles.get(st).protection.locked;
701    let refuse = || EngineError::Other(PROTECTED.into());
702    if !allowed {
703        return Err(refuse());
704    }
705    if insert {
706        return Ok(());
707    }
708    let end = at.saturating_add(count - 1).min(if axis == Axis::Rows { MAX_ROWS - 1 } else { MAX_COLS - 1 });
709    let infos = if axis == Axis::Rows { &sh.rows } else { &sh.cols };
710    if (at..=end).any(|i| locked(infos.get(&i).and_then(|x| x.style).unwrap_or_default())) {
711        return Err(refuse());
712    }
713    let block = if axis == Axis::Rows { RangeRef::rows(at, end) } else { RangeRef::cols(at, end) };
714    if sh.cells.iter_range(block).any(|(_, cell)| locked(cell.style)) {
715        return Err(refuse());
716    }
717    Ok(())
718}
719
720/// Where a range of the edited sheet goes after a structural edit (`None` = deleted).
721pub(crate) fn map_range(r: RangeRef, e: &Edit) -> Option<RangeRef> {
722    let expr = crate::formula::Expr::Ref(crate::formula::Reference {
723        sheet: crate::formula::SheetSel::Current,
724        kind: crate::formula::RefKind::Range(
725            crate::formula::Anchor { row: r.start.row, col: r.start.col, row_abs: false, col_abs: false },
726            crate::formula::Anchor { row: r.end.row, col: r.end.col, row_abs: false, col_abs: false },
727        ),
728    });
729    match crate::formula::adjust::adjust(expr, "S", "S", e) {
730        crate::formula::Expr::Ref(r) => Some(r.range()),
731        _ => None,
732    }
733}
734
735/// Shifts what Insert/Delete Cells moves along with the cells: merged areas, conditional
736/// format, validation, table, filter and print-area ranges, notes, links and sparklines.
737pub(super) fn shift_cell_features(sh: &mut Sheet, e: &Edit) {
738    let map_cell = |c: CellRef| map_range(RangeRef::cell(c), e).map(|r| r.start);
739    sh.merges = sh.merges.iter().filter_map(|m| map_range(*m, e)).filter(|m| !m.is_single()).collect();
740    for cf in sh.cond_formats.iter_mut() {
741        cf.ranges = cf.ranges.iter().filter_map(|r| map_range(*r, e)).collect();
742    }
743    for er in sh.edit_ranges.iter_mut() {
744        er.ranges = er.ranges.iter().filter_map(|r| map_range(*r, e)).collect();
745    }
746    sh.edit_ranges.retain(|er| !er.ranges.is_empty());
747    sh.cond_formats.retain(|cf| !cf.ranges.is_empty());
748    for dv in sh.validations.iter_mut() {
749        dv.ranges = dv.ranges.iter().filter_map(|r| map_range(*r, e)).collect();
750    }
751    sh.validations.retain(|dv| !dv.ranges.is_empty());
752    for t in sh.tables.iter_mut() {
753        // Cells inserted or deleted across a table's full height add or remove table columns.
754        if let Edit::InsertCells { axis: Axis::Cols, range } | Edit::DeleteCells { axis: Axis::Cols, range } = *e
755            && range.start.row <= t.range.start.row
756            && range.end.row >= t.range.end.row
757        {
758            shift_table_columns(t, range.start.col, range.width(), matches!(e, Edit::InsertCells { .. }));
759        }
760        t.range = map_range(t.range, e).unwrap_or_default();
761    }
762    sh.tables.retain(|t| t.range != RangeRef::default() && !t.columns.is_empty());
763    if let Some(af) = sh.autofilter.as_mut() {
764        match map_range(af.range, e) {
765            Some(r) => af.range = r,
766            None => sh.autofilter = None,
767        }
768    }
769    sh.cell_pictures =
770        std::mem::take(&mut sh.cell_pictures).into_iter().filter_map(|(c, v)| map_cell(c).filter(CellRef::is_valid).map(|c| (c, v))).collect();
771    sh.comments = std::mem::take(&mut sh.comments).into_iter().filter_map(|(c, v)| map_cell(c).map(|c| (c, v))).collect();
772    sh.hyperlinks = std::mem::take(&mut sh.hyperlinks).into_iter().filter_map(|(c, v)| map_cell(c).map(|c| (c, v))).collect();
773    for sp in sh.sparklines.iter_mut() {
774        if let Some(c) = map_cell(sp.cell) {
775            sp.cell = c;
776        }
777    }
778    if let Some(pa) = sh.print.print_area {
779        sh.print.print_area = map_range(pa, e);
780    }
781}
782
783/// Columns inserted inside a table become new table columns (with names no other column has);
784/// deleted ones leave it.
785fn shift_table_columns(t: &mut crate::model::Table, at: u32, count: u32, insert: bool) {
786    if insert {
787        if at > t.range.start.col && at <= t.range.end.col {
788            let idx = (at - t.range.start.col) as usize;
789            let mut n = t.columns.len();
790            for k in 0..count as usize {
791                let name = loop {
792                    n += 1;
793                    let name = format!("Column{n}");
794                    if !t.columns.iter().any(|c| c.name.eq_ignore_ascii_case(&name)) {
795                        break name;
796                    }
797                };
798                let col =
799                    crate::model::TableColumn { name, totals: Default::default(), totals_label: None, formula: None, unique_name: None, xml: None };
800                t.columns.insert((idx + k).min(t.columns.len()), col);
801            }
802        }
803        return;
804    }
805    let lo = at.max(t.range.start.col);
806    let hi = at.saturating_add(count).min(t.range.end.col + 1);
807    if hi > lo {
808        let a = (lo - t.range.start.col) as usize;
809        let b = (hi - t.range.start.col) as usize;
810        if b <= t.columns.len() {
811            t.columns.drain(a..b);
812        }
813    }
814}
815
816/// Like Excel, Insert/Delete Cells never shifts part of a table: cells shifted up or down must
817/// hold whole table columns, cells shifted left or right whole table rows, and deleting cells
818/// never takes a table's header row without the whole table.
819pub(crate) fn check_table_shift(sh: &Sheet, e: &Edit) -> Result<()> {
820    let (axis, r, delete) = match *e {
821        Edit::InsertCells { axis, range } => (axis, range, false),
822        Edit::DeleteCells { axis, range } => (axis, range, true),
823        _ => return Ok(()),
824    };
825    for t in &sh.tables {
826        let tr = t.range;
827        let ok = match axis {
828            Axis::Rows => {
829                let moves = tr.end.row >= r.start.row && tr.start.col <= r.end.col && tr.end.col >= r.start.col;
830                let header_alone = delete && t.header_row && r.start.row <= tr.start.row && r.end.row >= tr.start.row && r.end.row < tr.end.row;
831                !moves || (r.start.col <= tr.start.col && r.end.col >= tr.end.col && !header_alone)
832            }
833            Axis::Cols => {
834                let moves = tr.end.col >= r.start.col && tr.start.row <= r.end.row && tr.end.row >= r.start.row;
835                !moves || (r.start.row <= tr.start.row && r.end.row >= tr.end.row)
836            }
837        };
838        if !ok {
839            return Err(EngineError::Other("This operation isn't allowed: it would shift cells in a table on your worksheet.".into()));
840        }
841    }
842    Ok(())
843}