From 33afc7d3a5e40dabf19de42704671d0a09e04604 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Sun, 11 Oct 2026 12:04:52 +0200 Subject: [PATCH] Clipboard: Insert Copied Cells and Insert Cut Cells (#21) New command home.insertCopiedCells: makes room at the active cell and pastes the clipboard there, as one undo step. Whole copied rows or columns insert whole rows or columns; a copied block shifts the cells down or right (Insert Paste dialog from the menu). A cut of whole rows or columns moves them and closes the gap (Insert Cut Cells). The cell and header context menus show the entry while the clipboard holds a copy. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/engine/src/cmd/edit.rs | 3 +- crates/engine/src/cmd/sheet.rs | 131 ++++++++++++++++++++ crates/engine/src/lib.rs | 3 + crates/engine/src/tests.rs | 65 ++++++++++ crates/ui-egui/src/dialogs.rs | 7 ++ crates/ui-egui/src/grid.rs | 39 +++++- crates/ui-egui/src/i18n.rs | 4 + crates/ui-egui/src/i18n/ko.rs | 4 + crates/ui-egui/src/i18n/pt.rs | 4 + crates/ui-egui/src/i18n/ru.rs | 4 + crates/ui-egui/src/i18n/zh.rs | 4 + crates/ui-egui/tests/insert_copied_cells.rs | 60 +++++++++ docs/gaps.md | 2 +- docs/target-app-parity.md | 2 +- docs/ui-parity.md | 2 +- 16 files changed, 325 insertions(+), 10 deletions(-) create mode 100644 crates/ui-egui/tests/insert_copied_cells.rs diff --git a/ROADMAP.md b/ROADMAP.md index 1189fd721..8a5f89646 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -131,6 +131,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | Date | What landed | |---|---| +| 2026-10-11 | Insert Copied Cells / Insert Cut Cells in the cell and header context menus (#21) | | 2026-10-10 | Ribbon translated into Japanese, Chinese, Korean, Brazilian Portuguese and Russian with system-language default (#31, #104, #82); CJK/Thai/Hebrew font fallback (#38); clear messages for encrypted and `.xls` files (#17); smooth line charts (#62). Progress docs restructured to the craftrules standard; full re-measure (this page). Initial ribbon key tips (#43), in-place text box editing (#84), copy as HTML table (#36). ODS and XLSB data import (#100, #101); recalc linear in the number of range formulas (#66); RISC-V Linux build; Flatpak repository on Pages; web cell-commit trap fixed | | 2026-10-09 | Community fixes: spill range operator `A1#`, sheet-scoped names, protection refuses locked-cell edits, Insert/Delete Cells adjust references, `DATE(1900,2,29)`, SUMIF sizing, `ROUND` at 15 digits, `_xlpm.` LAMBDA parameters in XLSX, vertical scrollbar, system appearance, border previews; v0.4.0 | | 2026-10-08 | v0.2.0 and v0.3.0 releases; Flatpak, AppImage zsync, branded DMG; desktop log file; DirectX 12 default on Windows | diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index bb54fe03c..5052f8815 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -610,6 +610,7 @@ fn copy_impl(s: &mut Session, p: &Json, cut: bool) -> Result { return Err(EngineError::Other("This action won't work on multiple selections.".into())); } let range = ranges.first().copied().unwrap_or_default(); + let selected = range; let d = s.doc()?; let Some(sh) = d.wb.sheet(sheet) else { return Err(EngineError::NoDocument) }; let range = if range.is_full_cols() || range.is_full_rows() { @@ -622,7 +623,7 @@ fn copy_impl(s: &mut Session, p: &Json, cut: bool) -> Result { // The HTML rendering is for the host clipboard (UI); it can reach 4 MB, so agents and // scripts get it only when they ask for it. let html = if p.get("html").and_then(Json::as_bool) == Some(true) { crate::io::range_html(&d.wb, sheet, range, &text) } else { None }; - let clip = Clipboard { wb: d.wb.clone(), sheet, range, cut, doc_uid: d.uid, text: text.clone() }; + let clip = Clipboard { wb: d.wb.clone(), sheet, range, selected, cut, doc_uid: d.uid, text: text.clone() }; s.clipboard = Some(clip); let mut result = json!({"range": range.a1(), "text": text}); if let Some(html) = html { diff --git a/crates/engine/src/cmd/sheet.rs b/crates/engine/src/cmd/sheet.rs index 24e626828..c2a0f47e7 100644 --- a/crates/engine/src/cmd/sheet.rs +++ b/crates/engine/src/cmd/sheet.rs @@ -36,6 +36,15 @@ pub fn specs() -> Vec { Axis::Cols )), cmd!("home.insertCells", "Insert Cells…", ["Home", "Cells", "Insert"], None, "{range?, shift: right|down|row|column}", has_doc, insert_cells), + cmd!( + "home.insertCopiedCells", + "Insert Copied Cells", + ["Home", "Cells", "Insert"], + None, + "{at?: \"B3\", shift?: down|right}: inserts the copied cells at `at` (the active cell) and pastes them there. Whole copied rows or columns insert whole rows or columns; other blocks shift the cells at `at` down (default) or right. A cut of whole rows or columns moves them (Insert Cut Cells)", + has_clipboard, + insert_copied_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), cmd!("home.deleteSheet", "Delete Sheet", ["Home", "Cells", "Delete"], None, "{sheet?}", has_doc, delete_sheet), @@ -381,6 +390,128 @@ fn delete_cells(s: &mut Session, p: &Json) -> Result { Ok(out) } +/// Insert Copied Cells / Insert Cut Cells: makes room at the destination, then pastes (or, for a +/// cut, moves) the clipboard there, as one undo step. +fn insert_copied_cells(s: &mut Session, p: &Json) -> Result { + let Some(clip) = s.clipboard.clone() else { return Err(EngineError::Other("Nothing to paste.".into())) }; + let sheet = target_sheet(s, p)?; + let d = s.doc()?; + let at = match str_param(p, "at") { + Some(t) => cell_param(p, "at").ok_or_else(|| bad("home.insertCopiedCells", format!("not a cell: {t}")))?, + None => d.selection.current().start, + }; + let shift = str_param(p, "shift").unwrap_or("down"); + if shift != "down" && shift != "right" { + return Err(bad("home.insertCopiedCells", format!("unknown `shift`: {shift} (down or right)"))); + } + let sel = clip.selected; + let rows = sel.is_full_rows(); + let cols = sel.is_full_cols(); + if rows && cols { + return Err(EngineError::Other("The copy area is the whole sheet; there is no room to insert it.".into())); + } + let same_doc = d.uid == clip.doc_uid; + let cut = clip.cut && same_doc; + if cut && !rows && !cols { + return Err(EngineError::Other("Insert Cut Cells works on whole rows or columns; cut whole rows or columns, or use Paste.".into())); + } + // A moved block can't be inserted inside itself. + if cut && clip.sheet == sheet { + let inside = if rows { at.row > sel.start.row && at.row <= sel.end.row } else { at.col > sel.start.col && at.col <= sel.end.col }; + if inside { + return Err(EngineError::Other("The cut rows or columns can't be inserted inside themselves.".into())); + } + } + let revision = d.revision; + let before = (d.wb.clone(), d.selection.clone()); + let result = insert_copied_steps(s, &clip, sheet, at, shift, cut); + let d = s.doc_mut()?; + // The nested steps collapse into this command's single undo step. + for _ in 0..d.revision.saturating_sub(revision) { + d.undo.pop(); + } + match result { + Ok((final_range, out)) => { + d.selection = crate::Selection { active: final_range.start, anchor: final_range.start, ranges: vec![final_range] }; + // Like inserting cells, this ends copy mode (the copied block may have moved). + cancel_copy_mode(s); + Ok(out) + } + Err(e) => { + d.wb = before.0; + d.selection = before.1; + d.calc.rebuild(&d.wb); + s.clipboard = Some(clip); + Err(e) + } + } +} + +fn insert_copied_steps(s: &mut Session, clip: &crate::Clipboard, sheet: usize, at: CellRef, shift: &str, cut: bool) -> Result<(RangeRef, Json)> { + let sel = clip.selected; + let rows = sel.is_full_rows(); + let cols = sel.is_full_cols(); + // Where the copied block's top-left lands, relative to the (untrimmed) copied lines. + let (inserted, paste_at) = if rows { + let h = sel.height(); + let start = at.row; + let r = format!("{}:{}", start as u64 + 1, start as u64 + h as u64); + s.execute("home.insertRows", json!({"rows": r, "sheet": sheet}))?; + let dr = clip.range.start.row.saturating_sub(sel.start.row); + (RangeRef::rows(start, start.saturating_add(h - 1).min(MAX_ROWS - 1)), CellRef::new(start.saturating_add(dr), clip.range.start.col)) + } else if cols { + let w = sel.width(); + let start = at.col; + let end = start.saturating_add(w - 1).min(MAX_COLS - 1); + s.execute("home.insertColumns", json!({"cols": RangeRef::cols(start, end).a1(), "sheet": sheet}))?; + let dc = clip.range.start.col.saturating_sub(sel.start.col); + (RangeRef::cols(start, end), CellRef::new(clip.range.start.row, start.saturating_add(dc))) + } else { + let end = at + .offset(clip.range.height() as i64 - 1, clip.range.width() as i64 - 1) + .ok_or_else(|| EngineError::Other("The copied cells don't fit on the sheet at this position.".into()))?; + let block = RangeRef::new(at, end); + s.execute("home.insertCells", json!({"range": block.a1(), "shift": shift, "sheet": sheet}))?; + (block, at) + }; + // Inserting ended copy mode; bring the clipboard back for the paste. A cut reads the live + // workbook, so it follows the source if the insert pushed it along. + let mut clip = clip.clone(); + if cut { + let pushed = clip.sheet == sheet && if rows { sel.start.row >= at.row } else { sel.start.col >= at.col }; + if pushed { + let (dr, dc) = if rows { (inserted.height() as i64, 0) } else { (0, inserted.width() as i64) }; + clip.range = RangeRef::new(clip.range.start.offset_clamped(dr, dc), clip.range.end.offset_clamped(dr, dc)); + clip.selected = RangeRef::new(sel.start.offset_clamped(dr, dc), sel.end.offset_clamped(dr, dc)); + } + clip.wb = s.doc()?.wb.clone(); + } + let source_lines = clip.selected; + s.clipboard = Some(clip.clone()); + let out = s.execute("edit.pasteSpecial", json!({"what": "all", "at": paste_at.a1(), "sheet": sheet}))?; + if !cut { + return Ok((inserted, out)); + } + // Close the gap the moved lines left behind. + let (removed, before_dest) = if rows { + s.execute( + "home.deleteRows", + json!({"rows": format!("{}:{}", source_lines.start.row as u64 + 1, source_lines.end.row as u64 + 1), "sheet": clip.sheet}), + )?; + (source_lines.height(), clip.sheet == sheet && source_lines.start.row < inserted.start.row) + } else { + s.execute("home.deleteColumns", json!({"cols": RangeRef::cols(source_lines.start.col, source_lines.end.col).a1(), "sheet": clip.sheet}))?; + (source_lines.width(), clip.sheet == sheet && source_lines.start.col < inserted.start.col) + }; + let moved = if before_dest { + let (dr, dc) = if rows { (-(removed as i64), 0) } else { (0, -(removed as i64)) }; + RangeRef::new(inserted.start.offset_clamped(dr, dc), inserted.end.offset_clamped(dr, dc)) + } else { + inserted + }; + Ok((moved, out)) +} + fn insert_sheet(s: &mut Session, p: &Json) -> Result { if s.doc()?.wb.protected_structure { return Err(EngineError::Other("The workbook is protected and cannot be changed.".into())); diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index de786a1f4..e57650927 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -65,6 +65,9 @@ pub struct Clipboard { pub wb: Arc, pub sheet: usize, pub range: RangeRef, + /// The range as copied, before whole rows/columns were trimmed to the used range + /// (Insert Copied Cells inserts whole lines for a row or column copy). + pub selected: RangeRef, pub cut: bool, /// Document uid of the source (a cut only moves within the same workbook). pub doc_uid: u64, diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 6bd03d55e..437e7f0af 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -417,6 +417,71 @@ fn insert_delete_rows_adjust() { assert_eq!(v(&s, "A1"), Value::Number(10.0)); } +#[test] +fn insert_copied_cells_inserts_rows_columns_and_blocks() { + // Issue #21: Insert Copied Cells. + let mut s = s(); + s.execute("range.setValues", json!({"range": "A1", "values": [[1], [2], [3], [4], [5]]})).unwrap(); + s.execute("cell.set", json!({"cell": "B2", "input": "=A2*10"})).unwrap(); + s.execute("edit.copy", json!({"range": "2:2"})).unwrap(); + s.execute("selection.set", json!({"range": "C4"})).unwrap(); + s.execute("home.insertCopiedCells", json!({})).unwrap(); + assert_eq!(v(&s, "A4"), Value::Number(2.0)); + assert_eq!(v(&s, "B4"), Value::Number(20.0), "the formula follows its new row"); + assert_eq!(v(&s, "A5"), Value::Number(4.0)); + assert_eq!(v(&s, "A6"), Value::Number(5.0)); + assert_eq!(s.doc().unwrap().selection.current(), gridcraft_core::RangeRef::rows(3, 3)); + // One undo step puts everything back. + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(v(&s, "A4"), Value::Number(4.0)); + assert_eq!(v(&s, "A6"), Value::Empty); + // Whole columns. + s.execute("edit.copy", json!({"range": "A:A"})).unwrap(); + s.execute("home.insertCopiedCells", json!({"at": "A1"})).unwrap(); + assert_eq!(v(&s, "A3"), Value::Number(3.0)); + assert_eq!(v(&s, "B3"), Value::Number(3.0)); + assert_eq!(v(&s, "C2"), Value::Number(20.0), "the old column B moved right"); + // A block shifts cells right. + s.execute("cell.set", json!({"cell": "F1", "input": "x"})).unwrap(); + s.execute("cell.set", json!({"cell": "G1", "input": "y"})).unwrap(); + s.execute("edit.copy", json!({"range": "F1"})).unwrap(); + s.execute("home.insertCopiedCells", json!({"at": "G1", "shift": "right"})).unwrap(); + assert_eq!(v(&s, "G1"), Value::from("x")); + assert_eq!(v(&s, "H1"), Value::from("y")); + assert!(s.clipboard.is_none(), "inserting ends copy mode"); + s.execute("edit.copy", json!({"range": "F1"})).unwrap(); + assert!(s.execute("home.insertCopiedCells", json!({"shift": "up"})).is_err()); +} + +#[test] +fn insert_cut_cells_moves_whole_rows() { + 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": "=A5"})).unwrap(); + // Up: row 5 goes above row 2. + s.execute("edit.cut", json!({"range": "5:5"})).unwrap(); + s.execute("home.insertCopiedCells", json!({"at": "A2"})).unwrap(); + let col: Vec = (1..=6).map(|r| v(&s, &format!("A{r}"))).collect(); + assert_eq!(col, [1.0, 5.0, 2.0, 3.0, 4.0].map(Value::Number).into_iter().chain([Value::Empty]).collect::>()); + assert_eq!(v(&s, "C1"), Value::Number(5.0), "references follow the moved cells"); + assert!(s.clipboard.is_none()); + // Down: row 1 goes above row 4. + s.execute("edit.cut", json!({"range": "1:1"})).unwrap(); + s.execute("home.insertCopiedCells", json!({"at": "A4"})).unwrap(); + let col: Vec = (1..=5).map(|r| v(&s, &format!("A{r}"))).collect(); + assert_eq!(col, [5.0, 2.0, 1.0, 3.0, 4.0].map(Value::Number).to_vec()); + assert_eq!(s.doc().unwrap().selection.current(), gridcraft_core::RangeRef::rows(2, 2)); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(v(&s, "A1"), Value::Number(1.0)); + // Inside itself, and a cut block, are refused without changing anything. + s.execute("edit.cut", json!({"range": "2:4"})).unwrap(); + assert!(s.execute("home.insertCopiedCells", json!({"at": "A3"})).is_err()); + s.execute("edit.cut", json!({"range": "A1:A2"})).unwrap(); + assert!(s.execute("home.insertCopiedCells", json!({"at": "B5"})).is_err()); + assert_eq!(v(&s, "A1"), Value::Number(1.0)); + assert_eq!(v(&s, "B5"), Value::Empty); +} + #[test] fn inserting_or_deleting_cells_cancels_copy_mode() { let mut s = s(); diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 7dd3acb10..3290fb024 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -89,6 +89,13 @@ impl Dialog { }], json!({"shift": "down"}), ), + "insertPaste" => Dialog::form( + "insertPaste", + "Insert Paste", + "home.insertCopiedCells", + vec![Choice { key: "shift", label: "Insert", options: vec![("right", "Shift cells right"), ("down", "Shift cells down")] }], + json!({"shift": "down"}), + ), "deleteCells" => Dialog::form( "deleteCells", "Delete", diff --git a/crates/ui-egui/src/grid.rs b/crates/ui-egui/src/grid.rs index 72d33e2dc..6bf96ebc1 100644 --- a/crates/ui-egui/src/grid.rs +++ b/crates/ui-egui/src/grid.rs @@ -1864,7 +1864,8 @@ fn context_menu(app: &mut SheetApp, ui: &mut egui::Ui) { let resp = egui::Area::new(egui::Id::new("cell_context")).fixed_pos(at).order(egui::Order::Foreground).show(ui.ctx(), |ui| { egui::Frame::popup(ui.style()).show(ui, |ui| { ui.set_min_width(200.0); - let items: &[(&str, &str)] = &[ + let insert_copied = insert_copied_item(app); + let mut items: Vec<(&str, &str)> = vec![ ("Cut", "edit.cut"), ("Copy", "edit.copy"), ("Paste", "edit.paste"), @@ -1886,15 +1887,20 @@ fn context_menu(app: &mut SheetApp, ui: &mut egui::Ui) { ("Define Name…", "ui:defineName"), ("Link…", "ui:insertLink"), ]; + if let Some(item) = insert_copied { + items.insert(6, item); + } + let lang = crate::i18n::current(ui.ctx()); for (label, id) in items { - if *label == "-" { + if label == "-" { ui.separator(); continue; } - if ui.add(egui::Button::new(*label).frame(false).min_size(vec2(190.0, 20.0))).clicked() { + if ui.add(egui::Button::new(lang.tr(label)).frame(false).min_size(vec2(190.0, 20.0))).clicked() { close = true; - match *id { + match id { "ui:pasteSpecial" => app.open_dialog("pasteSpecial", json!({})), + "ui:insertPaste" => app.open_dialog("insertPaste", json!({})), "ui:insertCells" => app.open_dialog("insertCells", json!({})), "ui:deleteCells" => app.open_dialog("deleteCells", json!({})), "ui:formatCells" => app.open_dialog("formatCells", json!({})), @@ -2097,7 +2103,8 @@ fn header_menu(app: &mut SheetApp, ui: &mut egui::Ui, was_open: bool) { let response = egui::Area::new(egui::Id::new("header_menu")).fixed_pos(at).order(egui::Order::Foreground).show(ui.ctx(), |ui| { egui::Frame::popup(ui.style()).show(ui, |ui| { ui.set_min_width(190.0); - let items: Vec<(&str, &str)> = if rows { + let insert_copied = insert_copied_item(app); + let mut items: Vec<(&str, &str)> = if rows { vec![ ("Cut", "edit.cut"), ("Copy", "edit.copy"), @@ -2131,14 +2138,19 @@ fn header_menu(app: &mut SheetApp, ui: &mut egui::Ui, was_open: bool) { ("Group", "data.group"), ] }; + if let Some(item) = insert_copied { + items.insert(5, item); + } + let lang = crate::i18n::current(ui.ctx()); for (label, id) in items { if label == "-" { ui.separator(); continue; } - if ui.add(egui::Button::new(label).frame(false).min_size(vec2(180.0, 20.0))).clicked() { + if ui.add(egui::Button::new(lang.tr(label)).frame(false).min_size(vec2(180.0, 20.0))).clicked() { close = true; match id { + "ui:insertPaste" => app.open_dialog("insertPaste", json!({})), "ui:rowHeight" => app.open_dialog("rowHeight", json!({})), "ui:columnWidth" => app.open_dialog("columnWidth", json!({})), "edit.copy" | "edit.cut" => { @@ -2155,6 +2167,21 @@ fn header_menu(app: &mut SheetApp, ui: &mut egui::Ui, was_open: bool) { } } +/// The context-menu entry for Insert Copied Cells / Insert Cut Cells, when the clipboard holds a +/// copy or cut of this app: whole rows or columns insert at once, a block asks which way to shift +/// (Insert Paste). A cut block can only be pasted, so it gets no entry. +fn insert_copied_item(app: &SheetApp) -> Option<(&'static str, &'static str)> { + let clip = app.session.clipboard.as_ref()?; + let lines = clip.selected.is_full_rows() != clip.selected.is_full_cols(); + let cut = clip.cut && app.session.active().is_some_and(|d| d.uid == clip.doc_uid); + match (cut, lines) { + (true, true) => Some(("Insert Cut Cells", "home.insertCopiedCells")), + (true, false) => None, + (false, true) => Some(("Insert Copied Cells", "home.insertCopiedCells")), + (false, false) => Some(("Insert Copied Cells…", "ui:insertPaste")), + } +} + fn is_checkbox(sh: &Sheet, app: &SheetApp) -> bool { let Some(d) = app.session.active() else { return false }; d.wb.styles.get(sh.style_id(d.selection.active)).num_fmt.as_str() == "checkbox" diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 1e7b21245..52ed7e574 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -157,6 +157,10 @@ const JAPANESE: &[(&str, &str)] = &[ ("Transpose", "行列を入れ替え"), ("Paste Link", "リンクの貼り付け"), ("Paste Special…", "形式を選択して貼り付け…"), + ("Insert Copied Cells", "コピーしたセルの挿入"), + ("Insert Copied Cells…", "コピーしたセルの挿入…"), + ("Insert Cut Cells", "切り取ったセルの挿入"), + ("Insert Paste", "挿入貼り付け"), ("Cut (⌘X)", "切り取り (⌘X)"), ("Copy (⌘C)", "コピー (⌘C)"), ("Format Painter (double-click to keep it on)", "書式のコピー/貼り付け (ダブルクリックで固定)"), diff --git a/crates/ui-egui/src/i18n/ko.rs b/crates/ui-egui/src/i18n/ko.rs index c37ae3cab..75aa91794 100644 --- a/crates/ui-egui/src/i18n/ko.rs +++ b/crates/ui-egui/src/i18n/ko.rs @@ -9,6 +9,10 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Transpose" => "행/열 바꾸기", "Paste Link" => "연결하여 붙여넣기", "Paste Special…" => "선택하여 붙여넣기…", + "Insert Copied Cells" => "복사한 셀 삽입", + "Insert Copied Cells…" => "복사한 셀 삽입…", + "Insert Cut Cells" => "잘라낸 셀 삽입", + "Insert Paste" => "삽입 붙여넣기", "Cut (⌘X)" => "잘라내기 (⌘X)", "Copy (⌘C)" => "복사 (⌘C)", "Format Painter (double-click to keep it on)" => "서식 복사 (두 번 클릭하여 유지)", diff --git a/crates/ui-egui/src/i18n/pt.rs b/crates/ui-egui/src/i18n/pt.rs index 1bdc2c3f2..44f276206 100644 --- a/crates/ui-egui/src/i18n/pt.rs +++ b/crates/ui-egui/src/i18n/pt.rs @@ -10,6 +10,10 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Transpose" => "Transpor", "Paste Link" => "Colar Vínculo", "Paste Special…" => "Colar Especial…", + "Insert Copied Cells" => "Inserir Células Copiadas", + "Insert Copied Cells…" => "Inserir Células Copiadas…", + "Insert Cut Cells" => "Inserir Células Recortadas", + "Insert Paste" => "Inserir Colagem", "Cut (⌘X)" => "Recortar (⌘X)", "Copy (⌘C)" => "Copiar (⌘C)", "Format Painter (double-click to keep it on)" => "Pincel de Formatação (clique duas vezes para mantê-lo ativo)", diff --git a/crates/ui-egui/src/i18n/ru.rs b/crates/ui-egui/src/i18n/ru.rs index 8d5485658..4fcbfdfe5 100644 --- a/crates/ui-egui/src/i18n/ru.rs +++ b/crates/ui-egui/src/i18n/ru.rs @@ -9,6 +9,10 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Transpose" => "Транспонировать", "Paste Link" => "Вставить связь", "Paste Special…" => "Специальная вставка…", + "Insert Copied Cells" => "Вставить скопированные ячейки", + "Insert Copied Cells…" => "Вставить скопированные ячейки…", + "Insert Cut Cells" => "Вставить вырезанные ячейки", + "Insert Paste" => "Вставка со сдвигом", "Cut (⌘X)" => "Вырезать (⌘X)", "Copy (⌘C)" => "Копировать (⌘C)", "Format Painter (double-click to keep it on)" => "Формат по образцу (дважды щёлкните, чтобы закрепить)", diff --git a/crates/ui-egui/src/i18n/zh.rs b/crates/ui-egui/src/i18n/zh.rs index 9f04ada73..25e61c7f0 100644 --- a/crates/ui-egui/src/i18n/zh.rs +++ b/crates/ui-egui/src/i18n/zh.rs @@ -9,6 +9,10 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Transpose" => "转置", "Paste Link" => "粘贴链接", "Paste Special…" => "选择性粘贴…", + "Insert Copied Cells" => "插入复制的单元格", + "Insert Copied Cells…" => "插入复制的单元格…", + "Insert Cut Cells" => "插入剪切的单元格", + "Insert Paste" => "插入粘贴", "Cut (⌘X)" => "剪切 (⌘X)", "Copy (⌘C)" => "复制 (⌘C)", "Format Painter (double-click to keep it on)" => "格式刷(双击可持续使用)", diff --git a/crates/ui-egui/tests/insert_copied_cells.rs b/crates/ui-egui/tests/insert_copied_cells.rs new file mode 100644 index 000000000..89621f21b --- /dev/null +++ b/crates/ui-egui/tests/insert_copied_cells.rs @@ -0,0 +1,60 @@ +//! The context menus offer Insert Copied Cells while the clipboard holds a copy (#21). + +use egui::{Event, Modifiers, PointerButton, pos2}; +use egui_kittest::kittest::Queryable; +use gridcraft_engine::Session; +use gridcraft_engine::core::{CellRef, Value}; +use gridcraft_ui_egui::{SheetApp, grid}; +use serde_json::json; + +type Harness = egui_kittest::Harness<'static, SheetApp>; + +fn harness() -> Harness { + let mut session = Session::new(); + session.new_workbook(); + session.execute("range.setValues", json!({"range": "A1", "values": [[1], [2], [3], [4]]})).unwrap(); + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state( + |ui, app: &mut SheetApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + SheetApp::new(session, Default::default()), + ); + h.run_steps(4); + h +} + +fn value(h: &Harness, cell: &str) -> Value { + h.state().session.active().unwrap().wb.active().unwrap().value(CellRef::parse(cell).unwrap()) +} + +fn right_click_row_header(h: &mut Harness, cell: &str) { + let app = h.state(); + let sh = app.session.active().unwrap().wb.active().unwrap(); + let geo = grid::Geo::new(sh, app.grid.rect.unwrap(), app.view().scroll); + let y = geo.cell_rect(sh, CellRef::parse(cell).unwrap()).center().y; + let pos = pos2((geo.rect.left() + geo.cells.left()) / 2.0, y); + h.input_mut().events.push(Event::PointerMoved(pos)); + h.input_mut().events.push(Event::PointerButton { pos, button: PointerButton::Secondary, pressed: true, modifiers: Modifiers::NONE }); + h.input_mut().events.push(Event::PointerButton { pos, button: PointerButton::Secondary, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(2); +} + +#[test] +fn row_header_menu_inserts_copied_rows() { + let mut h = harness(); + // Nothing copied: no entry. + right_click_row_header(&mut h, "A3"); + assert!(h.state().grid.header_menu.is_some()); + assert!(h.query_by_label("Insert Copied Cells").is_none()); + h.state_mut().grid.header_menu = None; + h.run_steps(2); + + h.state_mut().run("edit.copy", json!({"range": "1:1"})).unwrap(); + right_click_row_header(&mut h, "A3"); + h.get_by_label("Insert Copied Cells").click(); + h.run_steps(2); + assert_eq!(value(&h, "A3"), Value::Number(1.0)); + assert_eq!(value(&h, "A4"), Value::Number(3.0)); + assert_eq!(value(&h, "A5"), Value::Number(4.0)); +} diff --git a/docs/gaps.md b/docs/gaps.md index aa752b8d8..0258393b3 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -77,7 +77,7 @@ No alpha blockers: every core workflow passes the [alpha gate](roadmap.md#alpha- - **Estimate:** 20–30 h for beta (25–40 h full). ### 9. Keyboard-first users: key tips, shortcuts, context menus (U) -- **Evidence:** #33, #49 (key tips: initial Home-tab support landed in #43 on 10-10, the rest of the ribbon not yet), #110, #21 (Insert Copied Cells), #28 (selection), +- **Evidence:** #33, #49 (key tips: initial Home-tab support landed in #43 on 10-10, the rest of the ribbon not yet), #110, #28 (selection), 59 command shortcuts vs Excel's 200+. - **Estimate:** 12–20 h. [ui-parity.md](ui-parity.md) diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 2e5ed01cd..628b88253 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -81,7 +81,7 @@ performance 8, stability 8, localization 4, hardware 2, platforms 2, ecosystem 2 |---|---|---|---|---|---| | Formula language and calculation | 18 | ~92% | ~72% | 20–30 | A1/R1C1 parser, reference adjustment, dependency-graph recalc, dynamic arrays and `#SPILL!`, `A1#`, LET/LAMBDA and helpers, structured references, names, 3-D references. Open correctness bugs: 15-digit equality (#57), stale spills after structural edits and cleared blockers (#133, #139), 3-D span endpoint deletion (#140), sheet rename and qualified names (#134). Single-threaded | | Worksheet functions | 12 | 95.8% (measured) | ~82% | 18–30 | 501 / 523 names, 492 working. Missing: FORECAST.ETS family, ODDF*/ODDL*, GETPIVOTDATA, TRIMRANGE, PERCENTOF, BAHTTEXT, IMAGE plus cloud functions. No conformance suite against Excel-computed oracles; edge-case bugs keep surfacing (#137, #138). [function-parity.md](function-parity.md) | -| Editing, navigation, clipboard, fill | 12 | ~90% | ~70% | 15–25 | Point mode, F4, AutoComplete, Paste Special, AutoFill, Flash Fill, Find/Replace, Go To Special. Bugs: rectangular drag-select (#28), monthly fill (#132), cut across sheets (#128), Insert Copied Cells missing (#21), key tips only on part of the Home tab (#43; #33, #49). [ui-parity.md](ui-parity.md) | +| Editing, navigation, clipboard, fill | 12 | ~90% | ~70% | 15–25 | Point mode, F4, AutoComplete, Paste Special, AutoFill, Flash Fill, Find/Replace, Go To Special. Bugs: rectangular drag-select (#28), monthly fill (#132), cut across sheets (#128), key tips only on part of the Home tab (#43; #33, #49). [ui-parity.md](ui-parity.md) | | Cell formatting, styles, conditional formatting | 10 | ~95% | ~80% | 8–14 | Full number-format language, borders, 47 cell styles, 60 table styles, all CF rule kinds including data bars, colour scales and icon sets. Gaps: font weights beyond regular/bold (#168), system fonts list (#4), themes | | Sort, filter, tables, validation, outline | 8 | ~82% | ~70% | 10–16 | Multi-level sort, AutoFilter, Advanced Filter, tables with totals, validation, outline, subtotals, consolidate. Bugs: subtotal header (#131), validation edits (#129), table style panic caught (#130); no slicers on tables | | PivotTables | 7 | ~65% | ~45% | 25–40 | Insert/Recommended, field list, three layouts, date grouping, show-values-as, refresh, XLSX round-trip. Missing: slicers, timelines, calculated fields/items, PivotCharts, value/label filters, number grouping, GETPIVOTDATA, data-model pivots. Round-trip never checked in Excel | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index e85bca103..4ce36332d 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -40,7 +40,7 @@ Sources: `crates/ui-egui/src` (ribbon, grid, editor, formula bar, dialogs, panes | Header resize and double-click AutoFit | done (#42, #88 for header dividers) | | 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 | +| 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). Insert Copied Cells (Insert Cut Cells for whole rows/columns) while the clipboard holds a copy (#21). Missing: Insert Cut Cells for a cell block, paste-option icons, Quick Analysis, Get Data from Table, Show Changes, Smart Lookup | | Move and resize charts, pictures and shapes | charts can't be resized (#190) | | Context menus on row/column headers, sheet tabs, charts, shapes | headers and tabs done; objects partial | | Quick Analysis (Ctrl+Q) | missing |