diff --git a/ROADMAP.md b/ROADMAP.md index e8e1c06..ffc306c 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -132,6 +132,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | Date | What landed | |---|---| +| 2026-10-12 | Deleting several Ctrl-selected rows or columns removes them all in one undo step (#758) | | 2026-10-12 | File › Print selects a system printer, copies, duplex and grayscale, submits PDF jobs through craft-print on a worker and shows spooler errors; browser printing and PDF export remain available | | 2026-10-11 | Command-sequence fuzzing: random command sequences checked against undo/redo, XLSX round-trip and full recalculation; fixes for the table, legacy-array, spill, AutoSum, Watch Window and sheet-copy bugs it found | | 2026-10-11 | Password-protected workbooks open: GridCraft asks for the password and decrypts Agile and Standard encryption (#11) | @@ -178,6 +179,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | Progress log: multi-area row/column delete (#758) | | 2026-10-12 | minor | Desktop File > Print now discovers printers and submits PDF jobs through pinned craft-print with printer choice, copies, duplex and grayscale; browser printing and PDF export remain available. Remaining print parity includes page ranges and driver-specific tray/options UI; physical printer fidelity needs hardware validation. | | 2026-10-11 | minor | Password-encrypted workbooks open (#11); progress log row | | 2026-10-11 | minor | Forecast Sheet (#322); progress log row | diff --git a/crates/engine/src/cmd/sheet.rs b/crates/engine/src/cmd/sheet.rs index f05b77d..1e35d39 100644 --- a/crates/engine/src/cmd/sheet.rs +++ b/crates/engine/src/cmd/sheet.rs @@ -288,27 +288,62 @@ fn insert_lines(s: &mut Session, p: &Json, axis: Axis) -> Result { Ok(out) } +/// The areas a line delete/insert targets as `(first line, count)`, merged and sorted from the +/// last line to the first so deleting one never shifts the next. A comma-separated list +/// (`"3:3,7:8"`) or a multi-area selection yields several; `count` forces the single-area form. +fn delete_areas(s: &Session, p: &Json, axis: Axis) -> Result> { + let key = if axis == Axis::Rows { "rows" } else { "cols" }; + let spans: Vec<(u32, u32)> = if u32_param(p, "count").is_some() { + let (at, count) = lines(s, p, axis)?; + vec![(at, at.saturating_add(count - 1))] + } else if let Some(t) = str_param(p, key).or_else(|| str_param(p, "range")) { + t.split(',') + .map(|part| { + let r = RangeRef::parse(part).ok_or_else(|| bad(key, format!("not a range: {part}")))?; + Ok(if axis == Axis::Rows { (r.start.row, r.end.row) } else { (r.start.col, r.end.col) }) + }) + .collect::>()? + } else { + 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() + }; + let mut spans: Vec<(u32, u32)> = spans.into_iter().map(|(a, b)| (a.min(b), a.max(b))).collect(); + spans.sort_unstable(); + let mut merged: Vec<(u32, u32)> = Vec::new(); + for (a, b) in spans { + match merged.last_mut() { + Some(last) if a <= last.1.saturating_add(1) => last.1 = last.1.max(b), + _ => merged.push((a, b)), + } + } + Ok(merged.into_iter().rev().map(|(a, b)| (a, b - a + 1)).collect()) +} + fn delete_lines(s: &mut Session, p: &Json, axis: Axis) -> Result { - let (at, count) = lines(s, p, axis)?; - check_lines_protection(s, p, axis, at, count, false)?; + let areas = delete_areas(s, p, axis)?; + for &(at, count) in &areas { + check_lines_protection(s, p, axis, at, count, false)?; + } let sheet = target_sheet(s, p)?; let out = edit(s, |cx| { let name = cx.wb.sheet(sheet).map(|s| s.name.clone()).unwrap_or_default(); cx.protection_checked = true; - { - let sh = cx.sheet_mut(sheet)?; - let block = match axis { - Axis::Rows => RangeRef::rows(at, (at + count - 1).min(MAX_ROWS - 1)), - Axis::Cols => RangeRef::cols(at, (at + count - 1).min(MAX_COLS - 1)), - }; - sh.take_cells(block); - match axis { - Axis::Rows => sh.cells.shift_rows(at + count, -(count as i64)), - Axis::Cols => sh.cells.shift_cols(at + count, -(count as i64)), + // Last area first, so the earlier ones keep their positions. + for (at, count) in areas { + { + let sh = cx.sheet_mut(sheet)?; + let block = match axis { + Axis::Rows => RangeRef::rows(at, (at + count - 1).min(MAX_ROWS - 1)), + Axis::Cols => RangeRef::cols(at, (at + count - 1).min(MAX_COLS - 1)), + }; + sh.take_cells(block); + match axis { + Axis::Rows => sh.cells.shift_rows(at + count, -(count as i64)), + Axis::Cols => sh.cells.shift_cols(at + count, -(count as i64)), + } + shift_sheet_features(sh, axis, at, count, false); } - shift_sheet_features(sh, axis, at, count, false); + rewrite(&mut cx.wb, &name, &Edit::Delete { axis, at, count }); } - rewrite(&mut cx.wb, &name, &Edit::Delete { axis, at, count }); cx.structural = true; Ok(Json::Null) })?; diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 33fdc11..668c963 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -560,6 +560,28 @@ fn insert_delete_rows_adjust() { assert_eq!(v(&s, "A1"), Value::Number(10.0)); } +#[test] +fn deleting_a_multi_area_row_selection_removes_every_area_and_undoes_once() { + let mut s = s(); + s.execute("range.setValues", json!({"range": "A1", "values": [[1], [2], [3], [4], [5], [6]]})).unwrap(); + s.execute("cell.set", json!({"cell": "B1", "input": "=A6"})).unwrap(); + // Ctrl-select rows 2, 4 and 5:6 (A1 refs are 1-based). + let sel = &mut s.doc_mut().unwrap().selection; + sel.ranges = vec![gridcraft_core::RangeRef::rows(1, 1), gridcraft_core::RangeRef::rows(3, 3), gridcraft_core::RangeRef::rows(4, 5)]; + sel.active = CellRef::new(4, 0); + s.doc_mut().unwrap().undo.clear(); + s.execute("home.deleteRows", json!({})).unwrap(); + assert_eq!((v(&s, "A1"), v(&s, "A2"), v(&s, "A3")), (Value::Number(1.0), Value::Number(3.0), Value::Empty)); + assert_eq!(s.execute("cell.get", json!({"cell": "B1"})).unwrap()["formula"], "=#REF!"); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!((1..=6).map(|r| v(&s, &format!("A{r}"))).collect::>(), (1..=6).map(|n| Value::Number(n as f64)).collect::>()); + assert!(s.doc().unwrap().undo.is_empty(), "one undo step restored all rows"); + // The explicit list form behaves the same, for columns too. + s.execute("range.setValues", json!({"range": "A1", "values": [[1, 2, 3, 4]]})).unwrap(); + s.execute("home.deleteColumns", json!({"cols": "A:A,C:C"})).unwrap(); + assert_eq!((v(&s, "A1"), v(&s, "B1"), v(&s, "C1")), (Value::Number(2.0), Value::Number(4.0), Value::Empty)); +} + #[test] fn move_columns_reorders_without_overwriting_and_undoes_once() { let mut s = s();