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 <noreply@anthropic.com>
This commit is contained in:
vangel76
2026-10-11 12:04:52 +02:00
co-authored by Claude Opus 5.5
parent 9f896c1249
commit 33afc7d3a5
16 changed files with 325 additions and 10 deletions
+1
View File
@@ -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 |
+2 -1
View File
@@ -610,6 +610,7 @@ fn copy_impl(s: &mut Session, p: &Json, cut: bool) -> Result<Json> {
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<Json> {
// 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 {
+131
View File
@@ -36,6 +36,15 @@ pub fn specs() -> Vec<CommandSpec> {
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<Json> {
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<Json> {
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<Json> {
if s.doc()?.wb.protected_structure {
return Err(EngineError::Other("The workbook is protected and cannot be changed.".into()));
+3
View File
@@ -65,6 +65,9 @@ pub struct Clipboard {
pub wb: Arc<Workbook>,
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,
+65
View File
@@ -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<Value> = (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::<Vec<_>>());
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<Value> = (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();
+7
View File
@@ -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",
+33 -6
View File
@@ -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"
+4
View File
@@ -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)", "書式のコピー/貼り付け (ダブルクリックで固定)"),
+4
View File
@@ -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)" => "서식 복사 (두 번 클릭하여 유지)",
+4
View File
@@ -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)",
+4
View File
@@ -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)" => "Формат по образцу (дважды щёлкните, чтобы закрепить)",
+4
View File
@@ -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)" => "格式刷(双击可持续使用)",
@@ -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));
}
+1 -1
View File
@@ -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)
+1 -1
View File
@@ -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 |
+1 -1
View File
@@ -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 |