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..bbef17163 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,40 @@ 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 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)?; + 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()) + || 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())); + } + // 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; + } + 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..d24559d20 100644 --- a/crates/engine/src/cmd/edit/tests.rs +++ b/crates/engine/src/cmd/edit/tests.rs @@ -353,6 +353,74 @@ 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 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("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")); + 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"); 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 |