diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 23cc3e544..82c15aac2 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -616,6 +616,8 @@ fn copy_impl(s: &mut Session, p: &Json, cut: bool) -> Result { if ranges.len() > 1 && cut { return Err(multiple()); } + // The selection as made (untrimmed), for Insert Copied Cells. + let selected = ranges.first().copied().unwrap_or_default(); let d = s.doc()?; let Some(sh) = d.wb.sheet(sheet) else { return Err(EngineError::NoDocument) }; let trim = |range: RangeRef| { @@ -637,7 +639,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(&wb, sheet, range, &text) } else { None }; - let clip = Clipboard { wb, sheet, range, cut, doc_uid: d.uid, text: text.clone(), areas }; + let clip = Clipboard { wb, sheet, range, cut, doc_uid: d.uid, text: text.clone(), areas, selected }; 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 175cb81ab..03cb5c285 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), @@ -385,6 +394,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 ce39c0816..6885dc4d2 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -67,6 +67,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 78ade6315..cf24ab3e7 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -521,6 +521,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 a2db48ead..52f2df509 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -100,6 +100,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 c49e7bd4c..29085bdd4 100644 --- a/crates/ui-egui/src/grid.rs +++ b/crates/ui-egui/src/grid.rs @@ -2172,7 +2172,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"), @@ -2194,15 +2195,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!({})), @@ -2414,7 +2420,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"), @@ -2448,14 +2455,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" => { @@ -2472,6 +2484,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 f0b1d9116..9ebde7eda 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -171,6 +171,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/cs.rs b/crates/ui-egui/src/i18n/cs.rs index 0d01ba921..96b760408 100644 --- a/crates/ui-egui/src/i18n/cs.rs +++ b/crates/ui-egui/src/i18n/cs.rs @@ -184,6 +184,10 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Gridlines" => "Mřížka", "Headings" => "Záhlaví", "Zoom" => "Lupa", + "Insert Copied Cells" => "Vložit zkopírované buňky", + "Insert Copied Cells…" => "Vložit zkopírované buňky…", + "Insert Cut Cells" => "Vložit vyjmuté buňky", + "Insert Paste" => "Vložit a posunout", "Previous" => "Předchozí", "Next" => "Další", "Previous Comment" => "Předchozí komentář", diff --git a/crates/ui-egui/src/i18n/de.rs b/crates/ui-egui/src/i18n/de.rs index c5f71196e..1fbf2e3bc 100644 --- a/crates/ui-egui/src/i18n/de.rs +++ b/crates/ui-egui/src/i18n/de.rs @@ -184,6 +184,10 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Gridlines" => "Gitternetzlinien", "Headings" => "Überschriften", "Zoom" => "Zoom", + "Insert Copied Cells" => "Kopierte Zellen einfügen", + "Insert Copied Cells…" => "Kopierte Zellen einfügen…", + "Insert Cut Cells" => "Ausgeschnittene Zellen einfügen", + "Insert Paste" => "Einfügen und verschieben", "Previous" => "Zurück", "Next" => "Weiter", "Previous Comment" => "Vorheriger Kommentar", diff --git a/crates/ui-egui/src/i18n/es.rs b/crates/ui-egui/src/i18n/es.rs index 7cf8c9a5a..00fa6b93d 100644 --- a/crates/ui-egui/src/i18n/es.rs +++ b/crates/ui-egui/src/i18n/es.rs @@ -185,6 +185,10 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Gridlines" => "Líneas de cuadrícula", "Headings" => "Encabezados", "Zoom" => "Zoom", + "Insert Copied Cells" => "Insertar celdas copiadas", + "Insert Copied Cells…" => "Insertar celdas copiadas…", + "Insert Cut Cells" => "Insertar celdas cortadas", + "Insert Paste" => "Insertar y pegar", "Previous" => "Anterior", "Next" => "Siguiente", "Previous Comment" => "Comentario anterior", diff --git a/crates/ui-egui/src/i18n/ko.rs b/crates/ui-egui/src/i18n/ko.rs index 013fad6f7..334a96c41 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 b5c5c2d3f..a22e95f39 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 5db05715d..2ec76dad7 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 6561c625d..26ba89e52 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 fecd06cc4..51fdd3e34 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/ui-parity.md b/docs/ui-parity.md index a4f3b448f..41e19b8b7 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -42,7 +42,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 |