From 68fac6d861a8fec3c58f13cfa39f02d6ae711b86 Mon Sep 17 00:00:00 2001 From: nihilist-narwhal <240798405+nihilist-narwhal@users.noreply.github.com> Date: Fri, 9 Oct 2026 22:49:24 -0500 Subject: [PATCH] Insert/Delete Cells: adjust references like Excel Delete Cells built the references' edit and then discarded it, so references into the deleted cells didn't become #REF!, and Insert Cells treated the shift as a move of everything below, so =SUM(A1:A5) didn't grow to A1:A6 when A3 was inserted. Two new structural edits, InsertCells and DeleteCells, adjust a reference like inserting/deleting rows (columns) when it lies within the shifted columns (rows), and leave it as it is otherwise, as Excel does. Formulas, defined names, chart series, conditional-format, validation, table and print-area ranges, notes, links and sparklines follow. --- crates/engine/src/cmd/sheet.rs | 90 ++++++++++++++++++++++------------ crates/engine/src/tests.rs | 47 ++++++++++++++++++ crates/formula/src/adjust.rs | 21 ++++++++ crates/formula/src/tests.rs | 18 +++++++ 4 files changed, 145 insertions(+), 31 deletions(-) diff --git a/crates/engine/src/cmd/sheet.rs b/crates/engine/src/cmd/sheet.rs index 837f080..9ba3672 100644 --- a/crates/engine/src/cmd/sheet.rs +++ b/crates/engine/src/cmd/sheet.rs @@ -74,20 +74,8 @@ fn shift_sheet_features(sh: &mut Sheet, axis: Axis, at: u32, count: u32, insert: None } }; - let map_range = |r: RangeRef| -> Option { - let e = if insert { Edit::Insert { axis, at, count } } else { Edit::Delete { axis, at, count } }; - let expr = gridcraft_formula::Expr::Ref(gridcraft_formula::Reference { - sheet: gridcraft_formula::SheetSel::Current, - kind: gridcraft_formula::RefKind::Range( - gridcraft_formula::Anchor { row: r.start.row, col: r.start.col, row_abs: false, col_abs: false }, - gridcraft_formula::Anchor { row: r.end.row, col: r.end.col, row_abs: false, col_abs: false }, - ), - }); - match gridcraft_formula::adjust::adjust(expr, "S", "S", &e) { - gridcraft_formula::Expr::Ref(r) => Some(r.range()), - _ => None, - } - }; + let e = if insert { Edit::Insert { axis, at, count } } else { Edit::Delete { axis, at, count } }; + let map_range = |r: RangeRef| map_range(r, &e); let map_cell = |c: CellRef| -> Option { match axis { Axis::Rows => map_line(c.row).map(|r| CellRef::new(r, c.col)), @@ -265,6 +253,7 @@ fn insert_cells(s: &mut Session, p: &Json) -> Result { "column" | "entireColumn" => return insert_lines(s, &json!({"cols": RangeRef::cols(r.start.col, r.end.col).a1()}), Axis::Cols), _ => {} } + let e = Edit::InsertCells { axis: if shift == "right" { Axis::Cols } else { Axis::Rows }, range: r }; let sheet = target_sheet(s, p)?; let right = shift == "right"; edit(s, |cx| { @@ -274,15 +263,9 @@ fn insert_cells(s: &mut Session, p: &Json) -> Result { } else { sh.cells.shift_rows_in_cols(r.start.col, r.end.col, r.start.row, r.height() as i64); } - // Formulas referring to cells that moved: approximate with a whole-sheet move edit. - let moved = if right { - RangeRef::new(r.start, CellRef::new(r.end.row, MAX_COLS - 1 - r.width())) - } else { - RangeRef::new(r.start, CellRef::new(MAX_ROWS - 1 - r.height(), r.end.col)) - }; + shift_cell_features(sh, &e); let name = cx.wb.sheet(sheet).map(|s| s.name.clone()).unwrap_or_default(); - let to = if right { (r.start.row, r.start.col + r.width()) } else { (r.start.row + r.height(), r.start.col) }; - super::edit::rewrite_all_formulas(&mut cx.wb, &name, &Edit::Move { from: moved, to_row: to.0, to_col: to.1 }); + rewrite(&mut cx.wb, &name, &e); cx.structural = true; Ok(Json::Null) }) @@ -296,6 +279,7 @@ fn delete_cells(s: &mut Session, p: &Json) -> Result { "column" | "entireColumn" => return delete_lines(s, &json!({"cols": RangeRef::cols(r.start.col, r.end.col).a1()}), Axis::Cols), _ => {} } + let e = Edit::DeleteCells { axis: if shift == "left" { Axis::Cols } else { Axis::Rows }, range: r }; let sheet = target_sheet(s, p)?; let left = shift == "left"; edit(s, |cx| { @@ -308,16 +292,10 @@ fn delete_cells(s: &mut Session, p: &Json) -> Result { } else { sh.cells.shift_rows_in_cols(r.start.col, r.end.col, r.end.row + 1, -(r.height() as i64)); } + shift_cell_features(sh, &e); } - // References into the deleted block become #REF!; the rest move. - let deleted = Edit::Move { from: r, to_row: r.start.row, to_col: r.start.col }; - let _ = deleted; - let moved_from = if left { - RangeRef::new(CellRef::new(r.start.row, r.end.col + 1), CellRef::new(r.end.row, MAX_COLS - 1)) - } else { - RangeRef::new(CellRef::new(r.end.row + 1, r.start.col), CellRef::new(MAX_ROWS - 1, r.end.col)) - }; - super::edit::rewrite_all_formulas(&mut cx.wb, &name, &Edit::Move { from: moved_from, to_row: r.start.row, to_col: r.start.col }); + // References into the deleted cells become #REF!; the rest move. + rewrite(&mut cx.wb, &name, &e); cx.structural = true; Ok(Json::Null) }) @@ -538,3 +516,53 @@ fn step_sheet(s: &mut Session, dir: i64) -> Result { s.doc_mut()?.wb_switch_sheet(i as usize); ok() } + +/// Where a range of the edited sheet goes after a structural edit (`None` = deleted). +fn map_range(r: RangeRef, e: &Edit) -> Option { + let expr = gridcraft_formula::Expr::Ref(gridcraft_formula::Reference { + sheet: gridcraft_formula::SheetSel::Current, + kind: gridcraft_formula::RefKind::Range( + gridcraft_formula::Anchor { row: r.start.row, col: r.start.col, row_abs: false, col_abs: false }, + gridcraft_formula::Anchor { row: r.end.row, col: r.end.col, row_abs: false, col_abs: false }, + ), + }); + match gridcraft_formula::adjust::adjust(expr, "S", "S", e) { + gridcraft_formula::Expr::Ref(r) => Some(r.range()), + _ => None, + } +} + +/// Shifts what Insert/Delete Cells moves along with the cells: merged areas, conditional +/// format, validation, table, filter and print-area ranges, notes, links and sparklines. +fn shift_cell_features(sh: &mut Sheet, e: &Edit) { + let map_cell = |c: CellRef| map_range(RangeRef::cell(c), e).map(|r| r.start); + sh.merges = sh.merges.iter().filter_map(|m| map_range(*m, e)).filter(|m| !m.is_single()).collect(); + for cf in sh.cond_formats.iter_mut() { + cf.ranges = cf.ranges.iter().filter_map(|r| map_range(*r, e)).collect(); + } + sh.cond_formats.retain(|cf| !cf.ranges.is_empty()); + for dv in sh.validations.iter_mut() { + dv.ranges = dv.ranges.iter().filter_map(|r| map_range(*r, e)).collect(); + } + sh.validations.retain(|dv| !dv.ranges.is_empty()); + for t in sh.tables.iter_mut() { + t.range = map_range(t.range, e).unwrap_or_default(); + } + sh.tables.retain(|t| t.range != RangeRef::default()); + if let Some(af) = sh.autofilter.as_mut() { + match map_range(af.range, e) { + Some(r) => af.range = r, + None => sh.autofilter = None, + } + } + sh.comments = std::mem::take(&mut sh.comments).into_iter().filter_map(|(c, v)| map_cell(c).map(|c| (c, v))).collect(); + sh.hyperlinks = std::mem::take(&mut sh.hyperlinks).into_iter().filter_map(|(c, v)| map_cell(c).map(|c| (c, v))).collect(); + for sp in sh.sparklines.iter_mut() { + if let Some(c) = map_cell(sp.cell) { + sp.cell = c; + } + } + if let Some(pa) = sh.print.print_area { + sh.print.print_area = map_range(pa, e); + } +} diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index f3e36cd..0faa773 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -84,6 +84,53 @@ fn fill_series() { assert_eq!(v(&s, "C2"), Value::from("Item 10")); } +#[test] +fn insert_delete_cells_adjust() { + let formula = |s: &mut Session, a: &str| s.execute("cell.get", json!({"cell": a})).unwrap()["formula"].clone(); + let mut s = s(); + s.execute("range.setValues", json!({"range": "A1", "values": [[1], [2], [3], [4], [5]]})).unwrap(); + s.execute("cell.set", json!({"cell": "C1", "input": "=SUM(A1:A5)"})).unwrap(); + s.execute("cell.set", json!({"cell": "C2", "input": "=A4*10"})).unwrap(); + s.execute("cell.set", json!({"cell": "C3", "input": "=SUM(A1:B5)"})).unwrap(); + s.execute("formulas.defineName", json!({"name": "Fourth", "refersTo": "=Sheet1!$A$4"})).unwrap(); + s.execute( + "home.conditionalFormat", + json!({"range": "A4:A5", "rule": {"type": "cellIs", "operator": "greater", "value": "3", "preset": "redText"}}), + ) + .unwrap(); + s.execute("data.validation", json!({"range": "A5", "type": "whole", "operator": "greater", "formula1": "0"})).unwrap(); + s.execute("insert.chart", json!({"range": "A1:A5", "type": "line"})).unwrap(); + // Insert A3 shifting down: ranges across the insertion grow, references below move. + s.execute("home.insertCells", json!({"range": "A3", "shift": "down"})).unwrap(); + assert_eq!(formula(&mut s, "C1"), "=SUM(A1:A6)"); + assert_eq!(formula(&mut s, "C2"), "=A5*10"); + assert_eq!(formula(&mut s, "C3"), "=SUM(A1:B5)"); + assert_eq!(v(&s, "C2"), Value::Number(40.0)); + let sh = s.doc().unwrap().wb.active().unwrap().clone(); + assert_eq!(s.doc().unwrap().wb.names[0].formula, "Sheet1!$A$5"); + assert_eq!(sh.cond_formats[0].ranges[0].a1(), "A5:A6"); + assert_eq!(sh.validations[0].ranges[0].a1(), "A6"); + assert_eq!(sh.charts[0].series[0].values, "Sheet1!$A$1:$A$6"); + // Delete A5 (the 4) shifting up: references to it become #REF!, those below move up. + s.execute("home.deleteCells", json!({"range": "A5", "shift": "up"})).unwrap(); + assert_eq!(formula(&mut s, "C1"), "=SUM(A1:A5)"); + assert_eq!(formula(&mut s, "C2"), "=#REF!*10"); + assert_eq!(s.doc().unwrap().wb.names[0].formula, "#REF!"); + assert_eq!(v(&s, "C1"), Value::Number(11.0)); + let sh = s.doc().unwrap().wb.active().unwrap().clone(); + assert_eq!(sh.cond_formats[0].ranges[0].a1(), "A5"); + assert_eq!(sh.validations[0].ranges[0].a1(), "A5"); + assert_eq!(sh.charts[0].series[0].values, "Sheet1!$A$1:$A$5"); + // Shifting right and left. + s.execute("range.setValues", json!({"range": "E1", "values": [[1, 2, 3]]})).unwrap(); + s.execute("cell.set", json!({"cell": "E3", "input": "=G1+SUM(E1:G1)"})).unwrap(); + s.execute("home.insertCells", json!({"range": "F1", "shift": "right"})).unwrap(); + assert_eq!(formula(&mut s, "E3"), "=H1+SUM(E1:H1)"); + s.execute("home.deleteCells", json!({"range": "E1:F1", "shift": "left"})).unwrap(); + assert_eq!(formula(&mut s, "E3"), "=F1+SUM(E1:F1)"); + assert_eq!(v(&s, "E3"), Value::Number(8.0)); +} + #[test] fn sort_and_filter() { let mut s = s(); diff --git a/crates/formula/src/adjust.rs b/crates/formula/src/adjust.rs index 734ff5c..ae082a6 100644 --- a/crates/formula/src/adjust.rs +++ b/crates/formula/src/adjust.rs @@ -56,6 +56,12 @@ pub enum Edit { Delete { axis: Axis, at: u32, count: u32 }, /// Cut-paste of a block: references into `from` now point into the block at `to` (top-left). Move { from: RangeRef, to_row: u32, to_col: u32 }, + /// Insert Cells shifting down (`Rows`) or right (`Cols`): the cells of `range` and those + /// below (right of) it move by its height (width), within its columns (rows) only. + InsertCells { axis: Axis, range: RangeRef }, + /// Delete Cells shifting up (`Rows`) or left (`Cols`): `range` goes and the cells below + /// (right of) it move back by its height (width), within its columns (rows) only. + DeleteCells { axis: Axis, range: RangeRef }, } /// Adjusts a formula living on sheet `host` after `edit` on sheet `target`. @@ -158,6 +164,21 @@ fn adjust_ref(r: &Reference, edit: &Edit) -> Option { }; Some(Reference { sheet: r.sheet.clone(), kind }) } + Edit::InsertCells { axis, range } | Edit::DeleteCells { axis, range } => { + // Like inserting/deleting lines, for references that lie within the shifted + // columns (rows); Excel leaves references reaching outside them as they are. + let span = r.range(); + let (inside, at, count) = match axis { + Axis::Rows => (range.start.col <= span.start.col && span.end.col <= range.end.col, range.start.row, range.height()), + Axis::Cols => (range.start.row <= span.start.row && span.end.row <= range.end.row, range.start.col, range.width()), + }; + if !inside { + return Some(r.clone()); + } + let axis = *axis; + let lines = if matches!(edit, Edit::InsertCells { .. }) { Edit::Insert { axis, at, count } } else { Edit::Delete { axis, at, count } }; + adjust_ref(r, &lines) + } Edit::Move { from, to_row, to_col } => { let dr = *to_row as i64 - from.start.row as i64; let dc = *to_col as i64 - from.start.col as i64; diff --git a/crates/formula/src/tests.rs b/crates/formula/src/tests.rs index ff84a5b..e6c0b59 100644 --- a/crates/formula/src/tests.rs +++ b/crates/formula/src/tests.rs @@ -120,6 +120,24 @@ fn insert_delete() { assert_eq!(print(&adjust(e, "S", "S", &del)), "SUM(#REF!)"); } +#[test] +fn insert_delete_cells() { + let range = |a: &str| gridcraft_core::RangeRef::parse(a).unwrap(); + let adj = |f: &str, e: &Edit| print(&adjust(parse(f).unwrap(), "S", "S", e)); + // Insert A3:A4, shifting down: column A moves, other columns and wider ranges don't. + let ins = Edit::InsertCells { axis: Axis::Rows, range: range("A3:A4") }; + assert_eq!(adj("SUM(A1:A5)+A2+A3+$A$9+B3+SUM(A1:B5)+SUM(A:A)", &ins), "SUM(A1:A7)+A2+A5+$A$11+B3+SUM(A1:B5)+SUM(A:A)"); + // Insert B2 shifting right. + let ins = Edit::InsertCells { axis: Axis::Cols, range: range("B2") }; + assert_eq!(adj("A2+B2+C2+B3+SUM(A2:C2)", &ins), "A2+C2+D2+B3+SUM(A2:D2)"); + // Delete A3:A4 shifting up: references into it become #REF!, those below move up. + let del = Edit::DeleteCells { axis: Axis::Rows, range: range("A3:A4") }; + assert_eq!(adj("A2+A3+A4+A5+B5+SUM(A1:A10)+SUM(A3:A4)+SUM(A1:B10)", &del), "A2+#REF!+#REF!+A3+B5+SUM(A1:A8)+SUM(#REF!)+SUM(A1:B10)"); + // Delete B1:C2 shifting left. + let del = Edit::DeleteCells { axis: Axis::Cols, range: range("B1:C2") }; + assert_eq!(adj("A1+B1+D1+D2+D3+SUM(A1:E1)", &del), "A1+#REF!+B1+B2+D3+SUM(A1:C1)"); +} + #[test] fn moves_and_renames() { let mv = Edit::Move { from: gridcraft_core::RangeRef::parse("A1:B2").unwrap(), to_row: 9, to_col: 3 };