diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index d6900df..5900680 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -409,9 +409,16 @@ impl SheetApp { ctx.copy_text(text.to_string()); } - /// Paste text that came from the system clipboard, with the clipboard's HTML flavour when the - /// host can read it. + /// Paste text that came from the system clipboard (Cmd/Ctrl+V, or the ribbon and menu Paste), + /// with the clipboard's HTML flavour when the host can read it. + /// + /// 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; GridCraft's own (empty) + /// clipboard is pasted instead, which reports "Nothing to paste.". pub fn paste_external(&mut self, text: String) { + if self.session.clipboard.is_none() && Self::same_clipboard_text(Some(&text), self.cut_text.as_deref()) { + return self.run_or_alert("edit.paste", json!({})); + } let html = self.services.paste_html.as_mut().and_then(|read| read()); let params = match html { Some(html) => json!({"text": text, "html": html}), @@ -423,16 +430,9 @@ 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) { - 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) - } + 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), _ => self.run_or_alert("edit.paste", json!({})), } } @@ -444,10 +444,13 @@ impl SheetApp { } /// Whether two clipboard texts are the same copy: trailing line breaks don't count, as in the - /// engine's `edit.paste`. + /// engine's `edit.paste`, and CRLF matches LF (egui's `Event::Paste` text may keep the host's). fn same_clipboard_text(a: Option<&str>, b: Option<&str>) -> bool { + fn chars(t: &str) -> impl Iterator + '_ { + t.trim_end_matches(['\r', '\n']).chars().filter(|c| *c != '\r') + } match (a, b) { - (Some(a), Some(b)) => a.trim_end_matches(['\r', '\n']) == b.trim_end_matches(['\r', '\n']), + (Some(a), Some(b)) => chars(a).eq(chars(b)), _ => false, } } diff --git a/crates/ui-egui/tests/clipboard.rs b/crates/ui-egui/tests/clipboard.rs index 24743d0..89a3d34 100644 --- a/crates/ui-egui/tests/clipboard.rs +++ b/crates/ui-egui/tests/clipboard.rs @@ -394,3 +394,37 @@ fn the_paste_keytip_pastes_the_system_clipboard_like_the_paste_button() { assert_eq!(value_at(&h, "E4"), Some(Value::number(7.0))); assert!(h.state().toast.is_none() && h.state().message.is_none()); } + +#[test] +fn cmd_v_after_a_pasted_cut_does_not_paste_the_cut_text_again() { + // Cmd/Ctrl+V hands the app the system clipboard's text, which still holds the cut after the + // move. Excel pastes a cut once: the second Cmd/Ctrl+V must leave the moved formulas alone. + let mut h = harness(Services::default()); + 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.input_mut().events.push(Event::Cut); + h.step(); + let text = h + .output() + .platform_output + .commands + .iter() + .find_map(|c| match c { + OutputCommand::CopyText(text) => Some(text.clone()), + _ => None, + }) + .expect("the cut is published"); + h.run_steps(2); + h.state_mut().run("selection.set", json!({"cell": "D4"})).unwrap(); + h.input_mut().events.push(Event::Paste(text.clone())); + h.run_steps(2); + assert!(has_formula(&h, "E6"), "the cut moves the formula"); + + // The host may hand the text back with CRLF line endings. + h.input_mut().events.push(Event::Paste(text.replace('\n', "\r\n"))); + h.run_steps(2); + assert!(has_formula(&h, "E6"), "a second Cmd/Ctrl+V leaves the moved formula alone"); + assert_eq!(value_at(&h, "E6"), Some(Value::number(84.0))); + 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"); +} diff --git a/docs/ui-parity.md b/docs/ui-parity.md index fee0ec5..953c256 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -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. 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-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 (ribbon, menus or Cmd/Ctrl+V) 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) |