mirror of
https://github.com/storytold/gridcraft.git
synced 2026-10-24 10:46:50 -06:00
UI: Paste doesn't re-paste a pasted cut's clipboard text; Paste keytip (H,V,P) pastes the system clipboard
This commit is contained in:
@@ -200,7 +200,7 @@ mod tests {
|
||||
#[test]
|
||||
fn item_letter_is_one_level_deep() {
|
||||
assert_eq!(item_letter("HV", "dialog:pasteSpecial"), Some("HVS"));
|
||||
assert_eq!(item_letter("HV", "edit.pasteSpecial"), Some("HVP"));
|
||||
assert_eq!(item_letter("HV", "edit.paste"), Some("HVP"));
|
||||
assert_eq!(item_letter("H", "dialog:pasteSpecial"), None, "the item badge waits for its group letter");
|
||||
assert_eq!(item_letter("HO", "home.autofitColumnWidth"), Some("HOI"));
|
||||
}
|
||||
|
||||
@@ -147,7 +147,7 @@ pub const SEQUENCES: &[(&str, Entry)] = &[
|
||||
("HUI", Entry::Finish(Action::Command("formulas.autoSum"))), // Excel: Auto Min; AutoSum: Min (variant TBD)
|
||||
("HUM", Entry::Finish(Action::Command("formulas.autoSum"))), // Excel: Auto Max; AutoSum: Max (variant TBD)
|
||||
("HV", Entry::Continue), // Excel: Paste; group level:
|
||||
("HVP", Entry::Finish(Action::Command("edit.pasteSpecial"))), // Excel: Paste
|
||||
("HVP", Entry::Finish(Action::Command("edit.paste"))), // Excel: Paste
|
||||
("HW", Entry::Continue), // Excel: Wrap Text Menu; group level: containment(Wrap Text)
|
||||
("HWW", Entry::Finish(Action::Command("home.wrapText"))), // Excel: Wrap Text
|
||||
("HX", Entry::Finish(Action::Command("edit.cut"))), // Excel: Cut
|
||||
|
||||
@@ -958,7 +958,7 @@
|
||||
{
|
||||
"seq": "HVP",
|
||||
"kind": "command",
|
||||
"target": "edit.pasteSpecial",
|
||||
"target": "edit.paste",
|
||||
"excel": "Paste",
|
||||
"note": ""
|
||||
},
|
||||
|
||||
@@ -254,6 +254,10 @@ pub struct SheetApp {
|
||||
/// control-channel or MCP `edit.copy`/`edit.cut`). While the system clipboard still holds this
|
||||
/// text, GridCraft's copy is the newer one and Paste uses it.
|
||||
clipboard_text_at_copy: Option<String>,
|
||||
/// The system clipboard's text right after GridCraft's last cut (the published text, or the
|
||||
/// text noted for an unpublished cut); `None` after a copy. Once the cut has been pasted, Paste
|
||||
/// leaves this text alone.
|
||||
cut_text: Option<String>,
|
||||
pub started: f64,
|
||||
}
|
||||
|
||||
@@ -296,6 +300,7 @@ impl SheetApp {
|
||||
shots: control::Shots::default(),
|
||||
selected_chart: None,
|
||||
clipboard_text_at_copy: None,
|
||||
cut_text: None,
|
||||
started: now_ms(),
|
||||
}
|
||||
}
|
||||
@@ -336,6 +341,7 @@ impl SheetApp {
|
||||
// that GridCraft's copy is newer while that text is still there.
|
||||
if matches!(id, "edit.copy" | "edit.cut") && r.is_ok() {
|
||||
self.clipboard_text_at_copy = self.system_clipboard_text();
|
||||
self.cut_text = if id == "edit.cut" { self.clipboard_text_at_copy.clone() } else { None };
|
||||
}
|
||||
r
|
||||
}
|
||||
@@ -391,6 +397,7 @@ impl SheetApp {
|
||||
let Some(text) = result.get("text").and_then(Json::as_str) else { return };
|
||||
// The system clipboard gets this copy, so its text decides what Paste pastes.
|
||||
self.clipboard_text_at_copy = None;
|
||||
self.cut_text = (command == "edit.cut").then(|| text.replace("\r\n", "\n"));
|
||||
if let Some(html) = result.get("html").and_then(Json::as_str)
|
||||
&& let Some(copy_html) = &mut self.services.copy_html
|
||||
{
|
||||
@@ -416,9 +423,16 @@ impl SheetApp {
|
||||
/// Ribbon and menu Paste: paste the text on the system clipboard, as Cmd/Ctrl+V does. With no
|
||||
/// text there, no host clipboard (web), or the text that was there when GridCraft last copied
|
||||
/// without publishing, GridCraft's own clipboard is pasted.
|
||||
///
|
||||
/// Excel pastes a cut once: after GridCraft's cut has been pasted, the system clipboard text
|
||||
/// that came with it is not pasted again over the moved block.
|
||||
pub fn paste_from_clipboard(&mut self) {
|
||||
match self.system_clipboard_text() {
|
||||
Some(text) if self.session.clipboard.is_none() || self.clipboard_text_at_copy.as_ref() != Some(&text) => self.paste_external(text),
|
||||
let text = self.system_clipboard_text();
|
||||
let cut_pasted = self.session.clipboard.is_none() && Self::same_clipboard_text(text.as_deref(), self.cut_text.as_deref());
|
||||
match text {
|
||||
Some(text) if !cut_pasted && (self.session.clipboard.is_none() || self.clipboard_text_at_copy.as_ref() != Some(&text)) => {
|
||||
self.paste_external(text)
|
||||
}
|
||||
_ => self.run_or_alert("edit.paste", json!({})),
|
||||
}
|
||||
}
|
||||
@@ -429,6 +443,15 @@ impl SheetApp {
|
||||
self.services.paste_text.as_mut().and_then(|read| read()).filter(|t| !t.is_empty()).map(|t| t.replace("\r\n", "\n"))
|
||||
}
|
||||
|
||||
/// Whether two clipboard texts are the same copy: trailing line breaks don't count, as in the
|
||||
/// engine's `edit.paste`.
|
||||
fn same_clipboard_text(a: Option<&str>, b: Option<&str>) -> bool {
|
||||
match (a, b) {
|
||||
(Some(a), Some(b)) => a.trim_end_matches(['\r', '\n']) == b.trim_end_matches(['\r', '\n']),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Web: `file.exportPdf` without a path returns the PDF as base64; hand it to the browser as a download.
|
||||
fn deliver_pdf(&self, result: &Json) {
|
||||
if result.get("path").is_some() {
|
||||
@@ -1117,6 +1140,7 @@ impl SheetApp {
|
||||
self.ui.ribbon_collapsed = false;
|
||||
}
|
||||
keytips::Action::Command(id @ ("edit.copy" | "edit.cut")) => self.copy_to_clipboard(ctx, id),
|
||||
keytips::Action::Command("edit.paste") => self.paste_from_clipboard(),
|
||||
keytips::Action::Command(id) => self.run_or_alert(id, json!({})),
|
||||
keytips::Action::Dialog(name) => self.open_dialog(name, json!({})),
|
||||
// Reserved sequence: fires silently while its command is missing. When the
|
||||
|
||||
@@ -305,3 +305,92 @@ fn ribbon_paste_prefers_a_programmatic_copy_until_other_text_is_copied() {
|
||||
assert_eq!(value_at(&h, "G1"), Some(Value::from("copied elsewhere")));
|
||||
assert_eq!(value_at(&h, "H1"), Some(Value::number(7.0)));
|
||||
}
|
||||
|
||||
fn has_formula(h: &Harness, a1: &str) -> bool {
|
||||
let at = CellRef::parse(a1).unwrap();
|
||||
h.state().session.active().and_then(|d| d.wb.active().and_then(|s| s.cell(at)).map(|c| c.formula.is_some())).unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Close the "Nothing to paste." alert so the ribbon takes clicks again.
|
||||
fn dismiss(h: &mut Harness) {
|
||||
h.state_mut().message = None;
|
||||
h.state_mut().toast = None;
|
||||
h.run_steps(2);
|
||||
}
|
||||
|
||||
fn click_paste(h: &mut Harness) {
|
||||
h.get_by_role_and_label(Role::Button, "Paste").click();
|
||||
h.run_steps(2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_pasted_ribbon_cut_is_not_pasted_again_from_the_system_clipboard() {
|
||||
// Excel pastes a cut once. After the move, the cut's text is still on the system clipboard:
|
||||
// a second Paste must not write it over the moved block (formulas would become values).
|
||||
let (clipboard, mut services) = system_clipboard("stale text from earlier");
|
||||
let host = clipboard.clone();
|
||||
services.copy_html = Some(Box::new(move |_, text| {
|
||||
host.borrow_mut().0 = text.to_string();
|
||||
Ok(())
|
||||
}));
|
||||
let mut h = harness(services);
|
||||
h.state_mut().run("cell.set", json!({"cell": "B3", "input": "=B2*2"})).unwrap();
|
||||
h.state_mut().run("selection.set", json!({"range": "A1:B3"})).unwrap();
|
||||
h.run_steps(2);
|
||||
for key in [egui::Key::F10, egui::Key::H, egui::Key::X] {
|
||||
press(&mut h, key);
|
||||
}
|
||||
assert_eq!(clipboard.borrow().0, "Name\tScore\nAda\t42\n\t84\n", "the cut is published");
|
||||
h.state_mut().run("selection.set", json!({"cell": "D4"})).unwrap();
|
||||
click_paste(&mut h);
|
||||
assert_eq!(value_at(&h, "E6"), Some(Value::number(84.0)));
|
||||
assert!(has_formula(&h, "E6"), "the cut moves the formula");
|
||||
|
||||
click_paste(&mut h);
|
||||
assert!(has_formula(&h, "E6"), "a second Paste leaves the moved formula alone");
|
||||
assert_eq!(value_at(&h, "D4"), Some(Value::from("Name")));
|
||||
assert!(h.state().toast.is_some() || h.state().message.is_some(), "Paste reports there is nothing to paste");
|
||||
|
||||
// Text copied in another app afterwards still pastes.
|
||||
dismiss(&mut h);
|
||||
clipboard.borrow_mut().0 = "copied elsewhere\t7".into();
|
||||
h.state_mut().run("selection.set", json!({"cell": "H1"})).unwrap();
|
||||
click_paste(&mut h);
|
||||
assert_eq!(value_at(&h, "H1"), Some(Value::from("copied elsewhere")));
|
||||
assert_eq!(value_at(&h, "I1"), Some(Value::number(7.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_pasted_programmatic_cut_does_not_paste_the_older_system_clipboard_text() {
|
||||
// A control-channel or MCP cut leaves the system clipboard alone; once the cut has been
|
||||
// pasted, the text that was there before it must not land on the moved data.
|
||||
let (clipboard, services) = system_clipboard("stale");
|
||||
let mut h = harness(services);
|
||||
h.state_mut().run("edit.cut", json!({"range": "A1:B2"})).unwrap();
|
||||
h.state_mut().run("selection.set", json!({"cell": "D4"})).unwrap();
|
||||
click_paste(&mut h);
|
||||
assert_eq!(value_at(&h, "D4"), Some(Value::from("Name")));
|
||||
|
||||
click_paste(&mut h);
|
||||
assert_eq!(value_at(&h, "D4"), Some(Value::from("Name")), "the moved data stays");
|
||||
assert_eq!(value_at(&h, "E5"), Some(Value::number(42.0)));
|
||||
|
||||
dismiss(&mut h);
|
||||
clipboard.borrow_mut().0 = "copied elsewhere".into();
|
||||
click_paste(&mut h);
|
||||
assert_eq!(value_at(&h, "D4"), Some(Value::from("copied elsewhere")), "newer external text pastes");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_paste_keytip_pastes_the_system_clipboard_like_the_paste_button() {
|
||||
// Alt/F10, H, V, P is Home › Paste: it pastes text copied in another app.
|
||||
let (_clipboard, services) = system_clipboard("external\t7");
|
||||
let mut h = harness(services);
|
||||
h.state_mut().run("selection.set", json!({"cell": "D4"})).unwrap();
|
||||
for key in [egui::Key::F10, egui::Key::H, egui::Key::V, egui::Key::P] {
|
||||
press(&mut h, key);
|
||||
}
|
||||
assert_eq!(value_at(&h, "D4"), Some(Value::from("external")));
|
||||
assert_eq!(value_at(&h, "E4"), Some(Value::number(7.0)));
|
||||
assert!(h.state().toast.is_none() && h.state().message.is_none());
|
||||
}
|
||||
|
||||
@@ -1062,7 +1062,7 @@ fn keytip_hv_() {
|
||||
fn keytip_hvp_() {
|
||||
let mut h = harness(blank());
|
||||
sequence(&mut h, "HVP", false);
|
||||
assert!(h.state().message.is_some() || h.state().toast.is_some(), "HVP must invoke edit.pasteSpecial and surface its missing-params error");
|
||||
assert!(h.state().message.is_some() || h.state().toast.is_some(), "HVP must invoke edit.paste and surface its missing-params error");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
+1
-1
@@ -160,7 +160,7 @@ scrollbar bugs 2–4 · pixel/feel pass against observed Excel 7–12.
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-12 | minor | Paste from the ribbon (button and options menu), the cell and header context menus and the macOS menu bar's Edit menu pastes what is on the system clipboard, as Cmd/Ctrl+V does, instead of only GridCraft's own copy (the web build, which can't read the clipboard on request, still pastes GridCraft's copy). Copy and Cut by keytip (H,C / H,X) publish to the system clipboard like the ribbon buttons. After a control-channel or MCP copy, which leaves the system clipboard alone, Paste pastes that copy until other text is copied. Regression test for the Paste button click freeze in 0.5.0 (#273, fixed by #272); the Paste button gets an accessible "Paste" name for screen readers |
|
||||
| 2026-10-12 | minor | Paste from the ribbon (button and options menu), the cell and header context menus and the macOS menu bar's Edit menu pastes what is on the system clipboard, as Cmd/Ctrl+V does, instead of only GridCraft's own copy (the web build, which can't read the clipboard on request, still pastes GridCraft's copy). Copy and Cut by keytip (H,C / H,X) publish to the system clipboard like the ribbon buttons. After a control-channel or MCP copy, which leaves the system clipboard alone, Paste pastes that copy until other text is copied. Once a cut has been pasted, Paste doesn't paste the cut's clipboard text again over the moved cells (Excel pastes a cut once), and the Paste keytip (H,V,P) pastes the system clipboard like the button. Regression test for the Paste button click freeze in 0.5.0 (#273, fixed by #272); the Paste button gets an accessible "Paste" name for screen readers |
|
||||
| 2026-10-11 | minor | AutoFit accounts for table and ordinary filter dropdowns, including low zoom; header text excludes the button area |
|
||||
| 2026-10-11 | minor | Outline band for grouped rows and columns, Show / Hide Detail on the Data tab, numpad navigation keys in shortcuts (#373) |
|
||||
| 2026-10-11 | minor | Shift+Space / Ctrl+Space no longer start an edit; Control+Space selects the column on Mac (#338) |
|
||||
|
||||
Reference in New Issue
Block a user