Merge pull request #112 from nihilist-narwhal/fix-shifted-cell-references

Insert/Delete Cells: adjust references like Excel
This commit is contained in:
Brandon Thomas 2026-10-10 11:57:07 -04:00
commit e92a67bbba
4 changed files with 145 additions and 31 deletions

View File

@ -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<RangeRef> {
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<CellRef> {
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<Json> {
"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<Json> {
} 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<Json> {
"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<Json> {
} 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<Json> {
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<RangeRef> {
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);
}
}

View File

@ -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();

View File

@ -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<Reference> {
};
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;

View File

@ -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 };