mirror of
https://github.com/storytold/gridcraft.git
synced 2026-10-27 04:06:52 -06:00
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:
co-authored by
Claude Opus 5.5
parent
9f896c1249
commit
33afc7d3a5
@@ -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 |
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()));
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)", "書式のコピー/貼り付け (ダブルクリックで固定)"),
|
||||
|
||||
@@ -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)" => "서식 복사 (두 번 클릭하여 유지)",
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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)" => "Формат по образцу (дважды щёлкните, чтобы закрепить)",
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
|
||||
@@ -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
@@ -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 |
|
||||
|
||||
Reference in New Issue
Block a user