From 5361e2c08a4c31f52beaf7ad0920ff0adce08993 Mon Sep 17 00:00:00 2001 From: Hector Villarreal Date: Mon, 2 Nov 2026 23:43:02 -0600 Subject: [PATCH 1/3] #3136: ask before a drag and drop replaces data Dragging the selection's border onto cells that hold data replaced them without asking, so dragging column B onto column A silently destroyed A. Excel asks "There's already data here. Do you want to replace it?" first (its "Alert before overwriting cells" option, on by default). The drop now runs a new `range.move {range?, to, overwrite?}` command: a cut and paste that returns a confirm question when the destination holds data other than the moved cells; the grid shows it with OK and Cancel, and OK runs it again with `overwrite: true`. A refused move leaves the sheet and the clipboard as they were. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/edit.rs | 11 +++++++++- crates/engine/src/cmd/edit/clipboard.rs | 29 ++++++++++++++++++++++++- crates/engine/src/cmd/edit/tests.rs | 28 ++++++++++++++++++++++++ crates/ui-egui/src/grid/interact.rs | 4 ++-- crates/ui-egui/tests/it/drag_cursor.rs | 27 +++++++++++++++++++++++ docs/ui-parity.md | 5 +++-- 6 files changed, 98 insertions(+), 6 deletions(-) diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 2aa9d8fe3..2dfe2dbfb 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -23,7 +23,7 @@ use cell::{cell_set, range_fill, range_set_values}; pub(crate) use cell::{check_arg_counts, input_to_cell, is_formula_input, json_to_input, parse_typed_formula}; #[cfg(test)] pub(crate) use clipboard::parse_tsv; -use clipboard::{clear_clipboard, copy, cut, paste}; +use clipboard::{clear_clipboard, copy, cut, paste, range_move}; use fill::{auto_fill, fill_dir, fill_series, flash_fill}; #[cfg(test)] use find::replace_ci; @@ -81,6 +81,15 @@ pub fn specs() -> Vec { has_doc, paste ), + cmd!( + "range.move", + "Drag and Drop", + [], + None, + "{range?, to: \"C3\", overwrite?: bool} (drag the selection's border: a cut and paste that asks before replacing data unless overwrite)", + has_doc, + range_move + ), CommandSpec { shortcut_mac: Some("Ctrl+Cmd+V"), ..cmd!( diff --git a/crates/engine/src/cmd/edit/clipboard.rs b/crates/engine/src/cmd/edit/clipboard.rs index 3a1cd0739..d416d7b12 100644 --- a/crates/engine/src/cmd/edit/clipboard.rs +++ b/crates/engine/src/cmd/edit/clipboard.rs @@ -1,7 +1,8 @@ use super::cell::input_to_cell; use super::paste_special::paste_special; use super::{ - Cell, Clipboard, EngineError, Json, RangeRef, Result, Session, Value, cell_param_on, edit, json, ok, str_param, target_ranges, target_sheet, + Cell, Clipboard, EngineError, Json, RangeRef, Result, Session, Value, bad, bool_param, cell_param_on, edit, json, ok, str_param, target_ranges, + target_sheet, }; // ---------------------------------------------------------------- clipboard @@ -163,6 +164,32 @@ pub(super) fn paste(s: &mut Session, p: &Json) -> Result { paste_special(s, &json!({"what": "all", "at": p.get("at").cloned().unwrap_or(Json::Null)})) } +/// Drag and drop (dragging the selection's border): moves `range` so its top-left cell lands on +/// `to`, as Cut and Paste would. Like Excel, it asks before replacing cells that hold data (other +/// than the moved cells themselves); run it again with `overwrite: true` to replace them. A +/// refused or failed move leaves the clipboard as it was. +pub(super) fn range_move(s: &mut Session, p: &Json) -> Result { + let sheet = target_sheet(s, p)?; + let to = cell_param_on(s, p, "to", sheet)?.ok_or_else(|| bad("range.move", "missing `to`"))?; + let kept = s.clipboard.take(); + let moved = cut(s, p).and_then(|_| { + let src = s.clipboard.as_ref().map(|c| c.range).unwrap_or_default(); + let dest = to.offset(i64::from(src.height()) - 1, i64::from(src.width()) - 1).map(|end| RangeRef::new(to, end)); + if bool_param(p, "overwrite") != Some(true) + && let Some(dest) = dest + && let Some(sh) = s.doc()?.wb.sheet(sheet) + && sh.cells.iter_range(dest).any(|(c, cell)| !src.contains(c) && cell.has_content()) + { + return Err(EngineError::Confirm("There's already data here. Do you want to replace it?".into())); + } + paste(s, &json!({"at": to.a1()})) + }); + if moved.is_err() { + s.clipboard = kept; + } + moved +} + fn paste_text(s: &mut Session, p: &Json, text: &str) -> Result { let sheet = target_sheet(s, p)?; let at = cell_param_on(s, p, "at", sheet)?.unwrap_or(s.doc()?.selection.current().start); diff --git a/crates/engine/src/cmd/edit/tests.rs b/crates/engine/src/cmd/edit/tests.rs index 3f3a2fb6d..232a9955a 100644 --- a/crates/engine/src/cmd/edit/tests.rs +++ b/crates/engine/src/cmd/edit/tests.rs @@ -353,6 +353,34 @@ fn cut_paste_that_overflows_the_grid_moves_nothing() { assert_eq!(get(&mut s, "XFD2")["value"], json!(2.0)); } +#[test] +fn drag_and_drop_asks_before_replacing_data() { + // #3136: dragging column B's border onto column A replaced A without asking. + let mut s = Session::new(); + s.new_workbook(); + s.execute("range.setValues", json!({"range": "A1:B3", "values": [["Apple", "Kiwi"], ["Pear", "Plum"], ["Fig", "Lime"]]})).unwrap(); + s.execute("edit.copy", json!({"range": "D9"})).unwrap(); + let text = |s: &mut Session, a: &str| s.execute("cell.get", json!({"cell": a})).unwrap()["input"].clone(); + let r = s.execute("range.move", json!({"range": "B:B", "to": "A1"})); + let Err(EngineError::Confirm(q)) = r else { panic!("{r:?}") }; + assert_eq!(q, "There's already data here. Do you want to replace it?"); + // Nothing moved, and the copy made before is still on the clipboard. + assert_eq!(text(&mut s, "A1"), json!("Apple")); + assert_eq!(text(&mut s, "B1"), json!("Kiwi")); + assert!(s.clipboard.as_ref().is_some_and(|c| !c.cut && c.range.a1() == "D9")); + // Confirmed, column B replaces column A. + s.execute("range.move", json!({"range": "B:B", "to": "A1", "overwrite": true})).unwrap(); + assert_eq!(text(&mut s, "A1"), json!("Kiwi")); + assert_eq!(text(&mut s, "A3"), json!("Lime")); + assert_eq!(text(&mut s, "B1"), json!("")); + // Onto empty cells, or overlapping only the moved cells themselves, nothing is asked. + s.execute("range.move", json!({"range": "A1:A3", "to": "A2"})).unwrap(); + assert_eq!(text(&mut s, "A4"), json!("Lime")); + s.execute("range.move", json!({"range": "A2:A4", "to": "C2"})).unwrap(); + assert_eq!(text(&mut s, "C2"), json!("Kiwi")); + assert!(s.execute("range.move", json!({"range": "C2"})).is_err(), "`to` is required"); +} + #[test] fn replace_case_insensitive() { assert_eq!(replace_ci("Apple apple APPLE", "apple", "pear"), "pear pear pear"); diff --git a/crates/ui-egui/src/grid/interact.rs b/crates/ui-egui/src/grid/interact.rs index 5de25fd53..87f5d582b 100644 --- a/crates/ui-egui/src/grid/interact.rs +++ b/crates/ui-egui/src/grid/interact.rs @@ -719,9 +719,9 @@ fn handle_release(app: &mut SheetApp, input: &InteractionInput<'_>) { } Drag::Move { to: Some(to), .. } => { let src = sel.current(); + // Dropping on cells that hold data asks first, as in Excel. if to != src.start { - let _ = app.session.run("edit.cut", json!({"range": src.a1()})); - let _ = app.run("edit.paste", json!({"at": to.a1()})); + crate::macro_ui::call(app, "GridCraft", "range.move", json!({"range": src.a1(), "to": to.a1()})); } } Drag::MoveCols { source, before: Some(before) } => { diff --git a/crates/ui-egui/tests/it/drag_cursor.rs b/crates/ui-egui/tests/it/drag_cursor.rs index 1b2134564..667b552bb 100644 --- a/crates/ui-egui/tests/it/drag_cursor.rs +++ b/crates/ui-egui/tests/it/drag_cursor.rs @@ -172,6 +172,33 @@ fn moving_a_selection_keeps_move_and_still_moves_cells() { assert_eq!(sh.value(CellRef::parse("E5").unwrap()), Value::Number(2.0)); } +#[test] +fn moving_a_selection_onto_data_asks_before_replacing_it() { + // #3136: the drop replaced the cells there without asking. + let mut h = harness(); + h.state_mut().session.execute("selection.set", json!({"range": "A2:B2"})).unwrap(); + h.run_steps(2); + let r = selection_rect(&h); + press(&mut h, pos2(r.left() + 10.0, r.top() + 1.0)); + let target = cell(&h, "A1").center(); + move_with_cursor(&mut h, target, CursorIcon::Move); + button(&mut h, target, false); + h.run_steps(1); + let q = h.state().macros.question.clone().expect("a question before replacing A1:B1"); + assert_eq!(q.text, "There's already data here. Do you want to replace it?"); + let sh = h.state().session.active().unwrap().wb.active().unwrap(); + assert_eq!(sh.value(CellRef::parse("A1").unwrap()), Value::text("A")); + assert_eq!(sh.value(CellRef::parse("A2").unwrap()), Value::Number(1.0)); + // OK replaces them. + h.key_press(egui::Key::Enter); + h.run_steps(2); + assert!(h.state().macros.question.is_none()); + let sh = h.state().session.active().unwrap().wb.active().unwrap(); + assert_eq!(sh.value(CellRef::parse("A1").unwrap()), Value::Number(1.0)); + assert_eq!(sh.value(CellRef::parse("B1").unwrap()), Value::Number(2.0)); + assert_eq!(sh.value(CellRef::parse("A2").unwrap()), Value::Empty); +} + #[test] fn resizing_headers_keeps_the_axis_cursor_when_leaving_the_header() { for cols in [true, false] { diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 125182d27..6a365749a 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-02 · **Change:** minor (RefEdit reference boxes in dialogs: Goal Seek, Solver and Data Analysis use them, the arrow keys select while a dialog is collapsed and F4 cycles a reference's anchors; earlier: Python cells: Python mode in the formula bar; earlier: Insert › Equation and the equation editor; earlier: every floating object selects, resizes from eight handles and deletes with the keyboard; earlier: Formulas › Error Checking › Trace Error; trace arrows stay through a zoom (#3097); earlier: Page Setup sets the page order, Down, then over or Over, then down, which printing, the print preview, Page Break Preview and Page Layout view follow; earlier: Formulas › Function Library as labelled buttons with category menus; Recently Used and the Insert Function categories; Esc leaves a File-page text field before closing the view; earlier: checkbox cells print as boxes; Ctrl+arrow and Ctrl+Shift+arrow while pointing a formula reference; editing a hyperlink loads its existing fields; AutoSum over a selected row puts its total to the right; ribbon catalog count 276 / 292) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-02 · **Change:** minor (dragging the selection's border onto data asks before replacing it; earlier: RefEdit reference boxes in dialogs: Goal Seek, Solver and Data Analysis use them, the arrow keys select while a dialog is collapsed and F4 cycles a reference's anchors; earlier: Python cells: Python mode in the formula bar; earlier: Insert › Equation and the equation editor; earlier: every floating object selects, resizes from eight handles and deletes with the keyboard; earlier: Formulas › Error Checking › Trace Error; trace arrows stay through a zoom (#3097); earlier: Page Setup sets the page order, Down, then over or Over, then down, which printing, the print preview, Page Break Preview and Page Layout view follow; earlier: Formulas › Function Library as labelled buttons with category menus; Recently Used and the Insert Function categories; Esc leaves a File-page text field before closing the view; earlier: checkbox cells print as boxes; Ctrl+arrow and Ctrl+Shift+arrow while pointing a formula reference; editing a hyperlink loads its existing fields; AutoSum over a selected row puts its total to the right; ribbon catalog count 276 / 292) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 Interaction, controls and feel, against Excel. Part of [target-app-parity.md](target-app-parity.md). **UI/UX fidelity: ~55% ready, 50–80 h** (estimated). Ribbon command coverage is measured @@ -55,7 +55,7 @@ this logic, with one undo step. | Cursor while dragging cells, headers, freeze handles and objects | stable for the active drag (#936); Draw tools keep their crosshair; header arrows and a custom cell cursor remain unimplemented | | Selection outline glides to a new selection (Excel: "Provide feedback with animation") | done (10-11): 0.12 s, decelerating, with the fill and fill handle; View ▸ Animations (`view.animations`) turns it off. The timing is an estimate, not yet measured against Excel; the system's reduced-motion setting isn't followed yet | | Fill handle: drag to fill series, double-click to fill down, right-drag menu | fill done (Ctrl+drag swaps copy and series; month patterns use the workbook's 1900 or 1904 date system); right-drag menu partial: Copy Cells, Fill Series, Fill Formatting Only, Fill Without Formatting, and from a date Fill Days, Fill Weekdays, Fill Months and Fill Years, then Series…; a left-drag leaves an Auto Fill Options button with the same fill choices (gone once the workbook changes or the selection moves); Linear/Growth Trend and Flash Fill missing | -| Drag-move and drag-copy cells (border drag, Ctrl/Option, Shift to insert) | done (M3) | +| Drag-move and drag-copy cells (border drag, Ctrl/Option, Shift to insert) | border drag moves (M3) and asks "There's already data here. Do you want to replace it?" before replacing cells that hold data (`range.move`, #3136); Ctrl/Option to copy and Shift to insert are not wired to the border drag yet (reorder whole columns by their headers instead) | | In-cell editing, Enter/Tab movement, Alt/Option+Enter line breaks | done | | Point mode (click/drag/arrow references), reference colouring, F4 | done; Command/Ctrl-click adds separate arguments with the edit's locale separator, and Shift-click extends the current reference from a fixed anchor on the same sheet. Pointer-event regressions cover repeated/reversed selections, manual separators, sheet switches and keyboard continuation. | | Number-format colors (`[Green]`, `[Color1]`–`[Color56]`) | Rendered in grid and PDF from shared RGB mappings (#1452) | @@ -237,6 +237,7 @@ scrollbar bugs 2–4 · pixel/feel pass against observed Excel 7–12. | Date | Change | Summary | |---|---|---| +| 2026-11-02 | minor | Dragging the selection's border onto cells that hold data asks "There's already data here. Do you want to replace it?" (OK replaces, Cancel leaves the sheet and clipboard as they were) through the new `range.move` command (#3136); the row no longer claims Ctrl/Option copy and Shift insert for the border drag, which are not wired yet | | 2026-11-02 | minor | RefEdit reference boxes in dialogs: pick a range on the grid, collapse the dialog while selecting; Goal Seek, Solver (with the Model Range of Load/Save) and Data Analysis use them; while collapsed the arrow keys move the selection and Shift+arrow extends it, F4 cycles a reference's anchors, a click elsewhere keeps the box picking, a dialog opened over a picking one puts the selection back, OK stays on the sheet its command showed, and scripted cancel and confirm end a pick like the buttons; the dialog's sheet is followed through the recorded undo steps, so a sheet deleted and recreated under its name is not taken for it and an undo of the deletion brings it back; a box the dialog stops showing is no longer picked into | | 2026-11-02 | minor | Python mode edits a cell's code in the formula bar's code editor (Ctrl+Alt+Shift+P, `=PY` then Tab, editing a Python cell), with the PY badge beside fx; its keys apply in input order, also several in one frame, and Ctrl+Enter commits through `python.set` (empty code clears the cell), keeping the code and showing the command's message when the code is refused | | 2026-11-02 | minor | Insert › Symbols: Equation split button (access keys N E), a gallery of examples, and an equation editor with quick-insert buttons, the error underlined at its position and a live preview; double-clicking an equation edits it | From 6874e0b6892cd622a2a3c525198291435b958b4f Mon Sep 17 00:00:00 2001 From: Hector Villarreal Date: Tue, 3 Nov 2026 00:13:27 -0600 Subject: [PATCH 2/3] #3136: range.move pastes on the range's sheet and asks before replacing spilled values Addresses CodeRabbit review on #3216: - "Pass the resolved sheet to the paste step": a sheet-qualified move (`Two!B1:B2` with Sheet1 active) was checked on Two but pasted on the active sheet, replacing its data unasked. range.move now calls paste_special with the range's sheet. - "Check spilled values in the drop destination": cells showing a dynamic array's spilled values hold no cell of their own, so the drop replaced them without the prompt. They now count as data, as in Excel (ISBLANK of a spill cell is FALSE, COUNTA counts it). Tests: drag_and_drop_on_another_sheet_stays_on_that_sheet, drag_and_drop_asks_before_replacing_spilled_values. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/edit/clipboard.rs | 10 ++++--- crates/engine/src/cmd/edit/tests.rs | 36 +++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/cmd/edit/clipboard.rs b/crates/engine/src/cmd/edit/clipboard.rs index d416d7b12..78057691f 100644 --- a/crates/engine/src/cmd/edit/clipboard.rs +++ b/crates/engine/src/cmd/edit/clipboard.rs @@ -165,8 +165,8 @@ pub(super) fn paste(s: &mut Session, p: &Json) -> Result { } /// Drag and drop (dragging the selection's border): moves `range` so its top-left cell lands on -/// `to`, as Cut and Paste would. Like Excel, it asks before replacing cells that hold data (other -/// than the moved cells themselves); run it again with `overwrite: true` to replace them. A +/// `to`, as Cut and Paste would. Like Excel, it asks before replacing cells that hold data or show a +/// spilled value (other than the moved cells themselves); run it again with `overwrite: true` to replace them. A /// refused or failed move leaves the clipboard as it was. pub(super) fn range_move(s: &mut Session, p: &Json) -> Result { let sheet = target_sheet(s, p)?; @@ -178,11 +178,13 @@ pub(super) fn range_move(s: &mut Session, p: &Json) -> Result { if bool_param(p, "overwrite") != Some(true) && let Some(dest) = dest && let Some(sh) = s.doc()?.wb.sheet(sheet) - && sh.cells.iter_range(dest).any(|(c, cell)| !src.contains(c) && cell.has_content()) + && (sh.cells.iter_range(dest).any(|(c, cell)| !src.contains(c) && cell.has_content()) + || sh.spill.range(dest.start..=dest.end).any(|(c, v)| dest.contains(*c) && !src.contains(*c) && *v != Value::Empty)) { return Err(EngineError::Confirm("There's already data here. Do you want to replace it?".into())); } - paste(s, &json!({"at": to.a1()})) + // On the range's sheet, which `paste` would leave to the active one. + paste_special(s, &json!({"what": "all", "at": to.a1(), "sheet": sheet})) }); if moved.is_err() { s.clipboard = kept; diff --git a/crates/engine/src/cmd/edit/tests.rs b/crates/engine/src/cmd/edit/tests.rs index 232a9955a..e5b223a91 100644 --- a/crates/engine/src/cmd/edit/tests.rs +++ b/crates/engine/src/cmd/edit/tests.rs @@ -381,6 +381,42 @@ fn drag_and_drop_asks_before_replacing_data() { assert!(s.execute("range.move", json!({"range": "C2"})).is_err(), "`to` is required"); } +#[test] +fn drag_and_drop_asks_before_replacing_spilled_values() { + // Cells showing a dynamic array's spilled values hold no cell of their own, but they are data. + let mut s = Session::new(); + s.new_workbook(); + s.execute("cell.set", json!({"cell": "A1", "input": "=SEQUENCE(3)"})).unwrap(); + s.execute("range.setValues", json!({"range": "C2:C3", "values": [["Kiwi"], ["Plum"]]})).unwrap(); + let r = s.execute("range.move", json!({"range": "C2:C3", "to": "A2"})); + let Err(EngineError::Confirm(q)) = r else { panic!("{r:?}") }; + assert_eq!(q, "There's already data here. Do you want to replace it?"); + let sh = s.doc().unwrap().wb.active().unwrap(); + assert_eq!(sh.value(CellRef::new(2, 2)), Value::text("Plum")); + s.execute("range.move", json!({"range": "C2:C3", "to": "A2", "overwrite": true})).unwrap(); + let sh = s.doc().unwrap().wb.active().unwrap(); + assert_eq!(sh.value(CellRef::new(1, 0)), Value::text("Kiwi")); +} + +#[test] +fn drag_and_drop_on_another_sheet_stays_on_that_sheet() { + // The move was checked on the range's sheet but pasted on the active one, replacing its data + // without asking. + let mut s = Session::new(); + s.new_workbook(); + s.execute("home.insertSheet", json!({"name": "Two"})).unwrap(); + s.execute("range.setValues", json!({"range": "B1:B2", "values": [["Kiwi"], ["Plum"]]})).unwrap(); + s.execute("sheet.activate", json!({"sheet": 0})).unwrap(); + s.execute("cell.set", json!({"cell": "A1", "input": "Keep"})).unwrap(); + s.execute("range.move", json!({"range": "Two!B1:B2", "to": "A1"})).unwrap(); + let wb = &s.doc().unwrap().wb; + let two = wb.sheet_index("Two").unwrap(); + assert_eq!(wb.sheet(0).unwrap().value(CellRef::new(0, 0)), Value::text("Keep")); + assert_eq!(wb.sheet(two).unwrap().value(CellRef::new(0, 0)), Value::text("Kiwi")); + assert_eq!(wb.sheet(two).unwrap().value(CellRef::new(1, 0)), Value::text("Plum")); + assert_eq!(wb.sheet(two).unwrap().value(CellRef::new(0, 1)), Value::Empty); +} + #[test] fn replace_case_insensitive() { assert_eq!(replace_ci("Apple apple APPLE", "apple", "pear"), "pear pear pear"); From ba1c62469da1f52c3ef8041f26a128c588ffb697 Mon Sep 17 00:00:00 2001 From: Hector Villarreal Date: Tue, 3 Nov 2026 00:31:59 -0600 Subject: [PATCH 3/3] #3136: a move on another sheet keeps the active sheet's selection Addresses CodeRabbit review on #3216, "Keep the selection on the sheet it describes": moving Two!B1:B2 with Sheet1 active selected A1:A2 on Sheet1, where nothing moved. The selection belongs to the active sheet, so range.move now keeps it when the moved cells are on another sheet; a move on the active sheet still selects the moved cells. Test: drag_and_drop_on_another_sheet_stays_on_that_sheet (now also checks the selection). Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/edit/clipboard.rs | 10 ++++++++-- crates/engine/src/cmd/edit/tests.rs | 4 ++++ 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/cmd/edit/clipboard.rs b/crates/engine/src/cmd/edit/clipboard.rs index 78057691f..bbef17163 100644 --- a/crates/engine/src/cmd/edit/clipboard.rs +++ b/crates/engine/src/cmd/edit/clipboard.rs @@ -183,8 +183,14 @@ pub(super) fn range_move(s: &mut Session, p: &Json) -> Result { { return Err(EngineError::Confirm("There's already data here. Do you want to replace it?".into())); } - // On the range's sheet, which `paste` would leave to the active one. - paste_special(s, &json!({"what": "all", "at": to.a1(), "sheet": sheet})) + // On the range's sheet, which `paste` would leave to the active one. The selection + // belongs to the active sheet: it follows the moved cells only when they are on it. + let selection = s.doc()?.selection.clone(); + let pasted = paste_special(s, &json!({"what": "all", "at": to.a1(), "sheet": sheet}))?; + if sheet != s.doc()?.wb.active_sheet() { + s.doc_mut()?.selection = selection; + } + Ok(pasted) }); if moved.is_err() { s.clipboard = kept; diff --git a/crates/engine/src/cmd/edit/tests.rs b/crates/engine/src/cmd/edit/tests.rs index e5b223a91..d24559d20 100644 --- a/crates/engine/src/cmd/edit/tests.rs +++ b/crates/engine/src/cmd/edit/tests.rs @@ -408,7 +408,11 @@ fn drag_and_drop_on_another_sheet_stays_on_that_sheet() { s.execute("range.setValues", json!({"range": "B1:B2", "values": [["Kiwi"], ["Plum"]]})).unwrap(); s.execute("sheet.activate", json!({"sheet": 0})).unwrap(); s.execute("cell.set", json!({"cell": "A1", "input": "Keep"})).unwrap(); + s.execute("selection.set", json!({"range": "C5"})).unwrap(); s.execute("range.move", json!({"range": "Two!B1:B2", "to": "A1"})).unwrap(); + // Sheet1 stays active with its own selection; the moved cells are on Two. + assert_eq!(s.doc().unwrap().wb.active_sheet(), 0); + assert_eq!(s.doc().unwrap().selection.a1(), "C5"); let wb = &s.doc().unwrap().wb; let two = wb.sheet_index("Two").unwrap(); assert_eq!(wb.sheet(0).unwrap().value(CellRef::new(0, 0)), Value::text("Keep"));