From 7a7e23cab71676e51ee2741bc6d88661756161aa Mon Sep 17 00:00:00 2001 From: Zarvinx Date: Sun, 11 Oct 2026 00:27:50 +0300 Subject: [PATCH] Grid: reorder selected columns by header drag --- crates/engine/src/cmd/sheet.rs | 100 +++++++++++++++++++++ crates/engine/src/tests.rs | 52 +++++++++++ crates/formula/src/adjust.rs | 19 ++-- crates/formula/src/tests.rs | 2 + crates/ui-egui/src/grid.rs | 35 +++++++- crates/ui-egui/tests/header_column_drag.rs | 65 ++++++++++++++ docs/ui-parity.md | 4 +- 7 files changed, 263 insertions(+), 14 deletions(-) create mode 100644 crates/ui-egui/tests/header_column_drag.rs diff --git a/crates/engine/src/cmd/sheet.rs b/crates/engine/src/cmd/sheet.rs index 24e6268..7affcdb 100644 --- a/crates/engine/src/cmd/sheet.rs +++ b/crates/engine/src/cmd/sheet.rs @@ -35,6 +35,7 @@ pub fn specs() -> Vec { p, Axis::Cols )), + cmd!("sheet.moveColumns", "Move Columns", [], None, "{cols?: \"B:C\", before: zero-based column index}", has_doc, move_columns), cmd!("home.insertCells", "Insert Cells…", ["Home", "Cells", "Insert"], None, "{range?, shift: right|down|row|column}", has_doc, insert_cells), cmd!("home.deleteCells", "Delete Cells…", ["Home", "Cells", "Delete"], None, "{range?, shift: left|up|row|column}", has_doc, delete_cells), cmd!("home.insertSheet", "Insert Sheet", ["Home", "Cells", "Insert"], Some("Shift+F11"), "{name?, before?: index}", has_doc, insert_sheet), @@ -321,6 +322,105 @@ fn delete_lines(s: &mut Session, p: &Json, axis: Axis) -> Result { Ok(out) } +/// Reorders a contiguous block of whole columns without replacing the destination columns. +/// `before` is a boundary in the original sheet: moving B:C before E produces A,D,B,C,E. +fn move_columns(s: &mut Session, p: &Json) -> Result { + let (from, count) = lines(s, p, Axis::Cols)?; + let before = u32_param(p, "before").ok_or_else(|| bad("sheet.moveColumns", "missing `before`"))?; + let end = + from.checked_add(count).filter(|end| *end <= MAX_COLS).ok_or_else(|| bad("sheet.moveColumns", "source columns are outside the worksheet"))?; + if before >= MAX_COLS || before.checked_add(count).is_none_or(|end| end > MAX_COLS) { + return Err(bad("sheet.moveColumns", "destination columns are outside the worksheet")); + } + if (from..=end).contains(&before) { + return Ok(json!({"cols": RangeRef::cols(from, end.saturating_sub(1)).a1()})); + } + let sheet = target_sheet(s, p)?; + if let Some(u) = s.doc()?.wb.sheet(sheet).and_then(|sh| sh.used_range()) + && u.end.col >= before + && u.end.col as u64 + count as u64 >= MAX_COLS as u64 + { + return Err(EngineError::Other("To prevent possible loss of data, columns cannot be shifted off of the worksheet.".into())); + } + check_lines_protection(s, &json!({"cols": RangeRef::cols(before, before).a1()}), Axis::Cols, before, count, true)?; + check_lines_protection(s, &json!({"cols": RangeRef::cols(from, end - 1).a1()}), Axis::Cols, from, count, false)?; + + let moved_from = if before <= from { from + count } else { from }; + if moved_from.checked_add(count).is_none_or(|end| end > MAX_COLS) { + return Err(bad("sheet.moveColumns", "the move is too close to the worksheet boundary")); + } + let destination = before; + let delete_at = moved_from; + let final_start = if before > from { before - count } else { before }; + let active_row = s.doc()?.selection.active.row; + let source = RangeRef::cols(moved_from, moved_from + count - 1); + let out = edit(s, |cx| { + let name = cx.wb.sheet(sheet).map(|sh| sh.name.clone()).unwrap_or_default(); + cx.protection_checked = true; + + { + let sh = cx.sheet_mut(sheet)?; + sh.cells.shift_cols(before, count as i64); + shift_sheet_features(sh, Axis::Cols, before, count, true); + } + rewrite(&mut cx.wb, &name, &Edit::Insert { axis: Axis::Cols, at: before, count }); + + let mut touched = Vec::new(); + { + let sh = cx.sheet_mut(sheet)?; + let pictures: Vec<_> = + sh.cell_pictures.iter().filter(|(cell, _)| source.contains(**cell)).map(|(cell, picture)| (*cell, picture.clone())).collect(); + let cells = sh.take_cells(source); + let moved_lines: Vec<_> = (0..count).filter_map(|offset| sh.cols.remove(&(moved_from + offset)).map(|info| (offset, info))).collect(); + let movement = Edit::Move { from: source, to_row: 0, to_col: destination }; + shift_cell_features(sh, &movement); + for object in sh + .charts + .iter_mut() + .map(|o| &mut o.anchor) + .chain(sh.images.iter_mut().map(|o| &mut o.anchor)) + .chain(sh.shapes.iter_mut().map(|o| &mut o.anchor)) + { + if object.mode != AnchorMode::Absolute && (moved_from..moved_from + count).contains(&object.cell.col) { + object.cell.col = destination + (object.cell.col - moved_from); + } + } + for (cell, value) in cells { + let dest = CellRef::new(cell.row, destination + (cell.col - moved_from)); + sh.cells.set(dest, value); + touched.extend([cell, dest]); + } + for (cell, picture) in pictures { + let dest = CellRef::new(cell.row, destination + (cell.col - moved_from)); + sh.cell_pictures.insert(dest, picture); + } + for (offset, info) in moved_lines { + sh.cols.insert(destination + offset, info); + } + } + for cell in touched { + cx.touch(sheet, cell); + } + rewrite(&mut cx.wb, &name, &Edit::Move { from: source, to_row: 0, to_col: destination }); + + { + let sh = cx.sheet_mut(sheet)?; + sh.cells.shift_cols(delete_at + count, -(count as i64)); + shift_sheet_features(sh, Axis::Cols, delete_at, count, false); + } + rewrite(&mut cx.wb, &name, &Edit::Delete { axis: Axis::Cols, at: delete_at, count }); + *cx.sel = crate::Selection { + active: CellRef::new(active_row, final_start), + anchor: CellRef::new(active_row, final_start), + ranges: vec![RangeRef::cols(final_start, final_start + count - 1)], + }; + cx.structural = true; + Ok(json!({"cols": RangeRef::cols(final_start, final_start + count - 1).a1()})) + })?; + cancel_copy_mode(s); + Ok(out) +} + fn insert_cells(s: &mut Session, p: &Json) -> Result { let shift = str_param(p, "shift").unwrap_or("down"); let r = target_range(s, p)?; diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 6bd03d5..890d297 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -417,6 +417,58 @@ fn insert_delete_rows_adjust() { assert_eq!(v(&s, "A1"), Value::Number(10.0)); } +#[test] +fn move_columns_reorders_without_overwriting_and_undoes_once() { + let mut s = s(); + s.execute("range.setValues", json!({"range": "A1", "values": [[1, 2, 3, 4, 5, 6, 7, 8]]})).unwrap(); + s.execute("cell.set", json!({"cell": "H2", "input": "=B1+D1"})).unwrap(); + s.execute("cell.set", json!({"cell": "H3", "input": "=SUM(B:B)"})).unwrap(); + s.execute("home.columnWidth", json!({"cols": "B:B", "width": 101})).unwrap(); + s.execute("home.columnWidth", json!({"cols": "C:C", "width": 102})).unwrap(); + let before = s.doc().unwrap().wb.clone(); + s.doc_mut().unwrap().undo.clear(); + + let result = s.execute("sheet.moveColumns", json!({"cols": "B:C", "before": 4})).unwrap(); + assert_eq!(result["cols"], "C:D"); + assert_eq!( + (0..8).map(|col| v(&s, &CellRef::new(0, col).a1())).collect::>(), + vec![ + Value::Number(1.0), + Value::Number(4.0), + Value::Number(2.0), + Value::Number(3.0), + Value::Number(5.0), + Value::Number(6.0), + Value::Number(7.0), + Value::Number(8.0), + ] + ); + assert_eq!(s.execute("cell.get", json!({"cell": "H2"})).unwrap()["formula"], "=C1+B1"); + assert_eq!(s.execute("cell.get", json!({"cell": "H3"})).unwrap()["formula"], "=SUM(C:C)"); + let sh = s.doc().unwrap().wb.active().unwrap(); + assert_eq!(sh.col_width(2), 101.0); + assert_eq!(sh.col_width(3), 102.0); + assert_eq!(s.doc().unwrap().selection.current().a1(), "C:D"); + assert_eq!(s.doc().unwrap().undo.len(), 1); + + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(*s.doc().unwrap().wb, *before); +} + +#[test] +fn move_columns_can_reorder_left_and_ignores_drops_inside_the_selection() { + let mut s = s(); + s.execute("range.setValues", json!({"range": "A1", "values": [["A", "B", "C", "D", "E"]]})).unwrap(); + s.execute("sheet.moveColumns", json!({"cols": "D:E", "before": 1})).unwrap(); + assert_eq!( + (0..5).map(|col| v(&s, &CellRef::new(0, col).a1())).collect::>(), + vec!["A".into(), "D".into(), "E".into(), "B".into(), "C".into()] + ); + let undo = s.doc().unwrap().undo.len(); + s.execute("sheet.moveColumns", json!({"cols": "B:C", "before": 2})).unwrap(); + assert_eq!(s.doc().unwrap().undo.len(), undo); +} + #[test] fn inserting_or_deleting_cells_cancels_copy_mode() { let mut s = s(); diff --git a/crates/formula/src/adjust.rs b/crates/formula/src/adjust.rs index 7601674..9424d7d 100644 --- a/crates/formula/src/adjust.rs +++ b/crates/formula/src/adjust.rs @@ -195,17 +195,6 @@ fn adjust_ref(r: &Reference, edit: &Edit) -> Option { let kind = match &r.kind { RefKind::Cell(a) => RefKind::Cell(mv(a)?), RefKind::Range(a, b) => RefKind::Range(mv(a)?, mv(b)?), - k => k.clone(), - }; - Some(Reference { sheet: r.sheet.clone(), kind }) - } - Edit::MoveToSheet { from, to_sheet, to_row, to_col } => { - if !from.contains_range(&r.range()) { - return Some(r.clone()); - } - let mut moved = adjust_ref(r, &Edit::Move { from: *from, to_row: *to_row, to_col: *to_col })?; - // Whole-row/column references have no anchors, but their indices must move too. - moved.kind = match &r.kind { RefKind::Rows(a, aa, b, ba) => { let dr = *to_row as i64 - from.start.row as i64; RefKind::Rows(shift_line(*a, false, dr, MAX_ROWS)?, *aa, shift_line(*b, false, dr, MAX_ROWS)?, *ba) @@ -214,8 +203,14 @@ fn adjust_ref(r: &Reference, edit: &Edit) -> Option { let dc = *to_col as i64 - from.start.col as i64; RefKind::Cols(shift_line(*a, false, dc, MAX_COLS)?, *aa, shift_line(*b, false, dc, MAX_COLS)?, *ba) } - _ => moved.kind, }; + Some(Reference { sheet: r.sheet.clone(), kind }) + } + Edit::MoveToSheet { from, to_sheet, to_row, to_col } => { + if !from.contains_range(&r.range()) { + return Some(r.clone()); + } + let mut moved = adjust_ref(r, &Edit::Move { from: *from, to_row: *to_row, to_col: *to_col })?; moved.sheet = SheetSel::Named(to_sheet.clone()); Some(moved) } diff --git a/crates/formula/src/tests.rs b/crates/formula/src/tests.rs index 9a6dab3..78dd7eb 100644 --- a/crates/formula/src/tests.rs +++ b/crates/formula/src/tests.rs @@ -171,6 +171,8 @@ fn moves_and_renames() { let mv = Edit::Move { from: gridcraft_core::RangeRef::parse("A1:B2").unwrap(), to_row: 9, to_col: 3 }; let e = parse("A1+B2+C3+SUM(A1:B2)").unwrap(); assert_eq!(print(&adjust(e, "S", "S", &mv)), "D10+E11+C3+SUM(D10:E11)"); + let mv = Edit::Move { from: gridcraft_core::RangeRef::parse("B:C").unwrap(), to_row: 0, to_col: 4 }; + assert_eq!(print(&adjust(parse("SUM(B:B)+SUM($C:$C)+D1").unwrap(), "S", "S", &mv)), "SUM(E:E)+SUM($F:$F)+D1"); let e = parse("Old!A1+'Old'!B2").unwrap(); assert_eq!(print(&rename_sheet(e, "Old", "New Name")), "'New Name'!A1+'New Name'!B2"); } diff --git a/crates/ui-egui/src/grid.rs b/crates/ui-egui/src/grid.rs index 72d33e2..7180d46 100644 --- a/crates/ui-egui/src/grid.rs +++ b/crates/ui-egui/src/grid.rs @@ -27,6 +27,11 @@ pub enum Drag { Select, Rows(u32), Cols(u32), + /// Reordering one contiguous whole-column selection by its header. + MoveCols { + source: RangeRef, + before: Option, + }, ResizeCol { col: u32, start: f32, @@ -962,6 +967,10 @@ fn paint_selection(p: &Painter, geo: &Geo, sh: &Sheet, sel: &gridcraft_engine::S let rr = geo.range_rect(sh, moved); p.rect_stroke(rr, 0.0, Stroke::new(2.0, Color32::from_gray(110)), StrokeKind::Middle); } + if let crate::grid::Drag::MoveCols { before: Some(before), .. } = &app.grid.drag { + let x = geo.x(sh, *before); + p.line_segment([pos2(x, geo.rect.top()), pos2(x, geo.rect.bottom())], Stroke::new(3.0, t.accent)); + } // Marching ants around the copied range. if let Some(clip) = &app.session.clipboard && app.session.active().is_some_and(|d| d.uid == clip.doc_uid && d.wb.active_sheet == clip.sheet) @@ -1086,7 +1095,9 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: & } else if in_cells { ctx.set_cursor_icon(CursorIcon::Cell); } else if in_col_header { - ctx.set_cursor_icon(CursorIcon::Default); + let col = geo.col_at(sh, p.x); + let selected = sel.ranges.len() == 1 && sel.current().is_full_cols() && sel.current().contains(CellRef::new(0, col)); + ctx.set_cursor_icon(if selected { CursorIcon::Move } else { CursorIcon::Default }); } app.grid.hover_cell = in_cells.then(|| geo.cell_at(sh, p)); } @@ -1202,6 +1213,15 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: & return; } let c = geo.col_at(sh, p.x); + if resp.drag_started() + && !mods.any() + && sel.ranges.len() == 1 + && sel.current().is_full_cols() + && sel.current().contains(CellRef::new(0, c)) + { + app.grid.drag = Drag::MoveCols { source: sel.current(), before: None }; + return; + } let range = if mods.shift { RangeRef::cols(sel.anchor.col.min(c), sel.anchor.col.max(c)) } else { RangeRef::cols(c, c) }; select_ranges(app, range, mods.command, CellRef::new(geo.row_at(sh, geo.cells.top() + 1.0), c)); app.grid.drag = Drag::Cols(if mods.shift { sel.anchor.col } else { c }); @@ -1355,6 +1375,14 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: & app.session.run("selection.set", json!({"range": RangeRef::cols(anchor.min(c), anchor.max(c)).a1(), "active": sel.active.a1()})); scroll_by(app, vec2(auto.x, 0.0)); } + Drag::MoveCols { source, .. } => { + let col = geo.col_at(sh, p.x.clamp(geo.cells.left() + 1.0, geo.cells.right() - 1.0)); + let midpoint = geo.x(sh, col) + sh.col_width(col) * geo.z * 0.5; + let before = (col + u32::from(p.x >= midpoint)).min(MAX_COLS - 1); + let before = (!(source.start.col..=source.end.col + 1).contains(&before)).then_some(before); + app.grid.drag = Drag::MoveCols { source, before }; + scroll_by(app, vec2(auto.x, 0.0)); + } Drag::ResizeCol { col, start, orig } => { let w = (orig + (p.x - start) / geo.z).max(0.0); app.toast = @@ -1434,6 +1462,11 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: & let _ = app.run("edit.paste", json!({"at": to.a1()})); } } + Drag::MoveCols { source, before: Some(before) } => { + if let Err(e) = app.run("sheet.moveColumns", json!({"cols": source.a1(), "before": before})) { + app.message = Some(("GridCraft".into(), crate::clean_error(&e))); + } + } _ => {} } app.grid.drag_select = false; diff --git a/crates/ui-egui/tests/header_column_drag.rs b/crates/ui-egui/tests/header_column_drag.rs new file mode 100644 index 0000000..d28f9ae --- /dev/null +++ b/crates/ui-egui/tests/header_column_drag.rs @@ -0,0 +1,65 @@ +//! Whole-column reordering driven through real egui pointer events. + +use egui::{Event, Modifiers, PointerButton, Pos2, pos2, vec2}; +use gridcraft_engine::{Session, core::CellRef}; +use gridcraft_ui_egui::{SheetApp, grid::Geo}; +use serde_json::json; + +fn harness() -> egui_kittest::Harness<'static, SheetApp> { + let mut session = Session::new(); + session.new_workbook(); + session.execute("range.setValues", json!({"range": "A1", "values": [["A", "B", "C", "D", "E"]]})).unwrap(); + session.execute("selection.set", json!({"range": "B:C"})).unwrap(); + let mut harness = egui_kittest::Harness::builder().with_step_dt(1.0 / 60.0).with_size(vec2(1200.0, 800.0)).build_ui_state( + |ui, app: &mut SheetApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + SheetApp::new(session, Default::default()), + ); + harness.run_steps(4); + harness.state_mut().session.active_mut().unwrap().undo.clear(); + harness.state_mut().session.journal.clear(); + harness +} + +fn header_center(harness: &egui_kittest::Harness<'_, SheetApp>, col: u32) -> Pos2 { + let app = harness.state(); + let sheet = app.session.active().unwrap().wb.active().unwrap(); + let geo = Geo::new(sheet, app.grid.rect.unwrap(), app.view().scroll); + pos2(geo.x(sheet, col) + sheet.col_width(col) * geo.z * 0.5, geo.rect.top() + geo.header_h * 0.5) +} + +fn pointer_button(harness: &mut egui_kittest::Harness<'_, SheetApp>, pos: Pos2, pressed: bool) { + harness + .input_mut() + .events + .extend([Event::PointerMoved(pos), Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }]); + harness.step(); +} + +#[test] +fn dragging_selected_column_headers_reorders_the_block() { + let mut harness = harness(); + let start = header_center(&harness, 1); + let target = { + let center = header_center(&harness, 4); + pos2(center.x - 20.0, center.y) + }; + + pointer_button(&mut harness, start, true); + harness.input_mut().events.push(Event::PointerMoved(target)); + harness.step(); + harness.input_mut().events.push(Event::PointerMoved(target + vec2(1.0, 0.0))); + harness.step(); + pointer_button(&mut harness, target + vec2(1.0, 0.0), false); + harness.run_steps(2); + + let app = harness.state(); + let sheet = app.session.active().unwrap().wb.active().unwrap(); + let values: Vec<_> = (0..5).map(|col| sheet.value(CellRef::new(0, col))).collect(); + assert_eq!(values, vec!["A".into(), "D".into(), "B".into(), "C".into(), "E".into()]); + assert_eq!(app.session.active().unwrap().selection.current().a1(), "C:D"); + assert_eq!(app.session.journal.iter().filter(|(id, _)| id == "sheet.moveColumns").count(), 1); + assert_eq!(app.session.active().unwrap().undo.len(), 1); +} diff --git a/docs/ui-parity.md b/docs/ui-parity.md index e85bca1..4d1feb4 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-10-10 · **Change:** major (created from a source audit and the issue tracker) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (selected columns can be reordered by dragging their headers) · **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 @@ -38,6 +38,7 @@ Sources: `crates/ui-egui/src` (ribbon, grid, editor, formula bar, dialogs, panes | Point mode (click/drag/arrow references), reference colouring, F4 | done | | AutoComplete in columns, function autocomplete and argument tips | done | | Header resize and double-click AutoFit | done (#42, #88 for header dividers) | +| Reorder selected whole columns by dragging their headers | done (10-11) | | Freeze panes, split panes | freeze done; split to verify | | Copy as HTML table for other apps | done (#36, 10-10) | | Context menu on cells | 16 items (Cut, Copy, Paste, Paste Special, Insert, Delete, Clear Contents, Filter by value, Sort, New Comment, New Note, Format Cells, Pick From Drop-down List, Define Name, Link). Missing: Insert Copied/Cut Cells (#21), paste-option icons, Quick Analysis, Get Data from Table, Show Changes, Smart Lookup | @@ -91,5 +92,6 @@ scrollbar bugs 2–4 · pixel/feel pass against observed Excel 7–12. | Date | Change | Summary | |---|---|---| +| 2026-10-11 | minor | Selected whole columns can be reordered by dragging their headers to an insertion point | | 2026-10-10 | minor | Chart resize bug (#190) | | 2026-10-10 | major | Created: chrome, grid, keyboard, dialogs, accessibility; key tips, Page Layout view and accessibility tree flagged |