From fa1dcd73e2a032fd93722dd05e9cba56f66de787 Mon Sep 17 00:00:00 2001 From: neilcawse <8085959+neilcawse@users.noreply.github.com> Date: Sun, 11 Oct 2026 20:57:04 -0400 Subject: [PATCH 1/5] UI: Paste button and menus paste the system clipboard; freeze regression test (#273) In 0.5.0 a click anywhere on the Home ribbon's Paste button (label, icon or dropdown arrow) froze the app for good. home() called paste.secondary_clicked() inside ctx.data_mut(); after any click egui answers that with ctx.input(), which takes the context lock the closure already holds, so the UI thread waited on itself. Sampling the hung 0.5.0 process on macOS shows the main thread parked in RawRwLock::lock_exclusive_slow under Context::write <- data_mut <- ribbon::home. #272 moved the query out of the closure; it landed after the 0.5.0 release, which is the build both reporters used. No test ever clicked the Paste button itself, which is how this shipped. The new test clicks it, then opens its menu with a secondary click and picks Paste Values, on a worker thread with a timeout so a regression fails instead of hanging the run. With #272 reverted it fails (egui's debug lock check panics after 10 s); with it, it passes. The Paste button gets an accessible "Paste" name so the test (and screen readers) can find it, the same way Pictures and Border presets are named. With the freeze gone, the button still did not do what it says: it pasted GridCraft's own copy only (GridState::system_clipboard was never assigned), so text copied in another app gave "Nothing to paste." The ribbon button, its options menu's Paste, the cell and header context menus and the macOS Edit menu now read the system clipboard through a new paste_text host service and paste it like Cmd/Ctrl+V does, falling back to GridCraft's own copy when there is no text (or no service, as on the web). Asking egui for the clipboard instead (RequestPaste) was tried and stalled the macOS event loop until the next input event. Co-Authored-By: Claude Opus 5.5 --- apps/gridcraft/src/main.rs | 23 +++-- apps/gridcraft/src/native_menu.rs | 2 + crates/ui-egui/src/grid.rs | 3 +- crates/ui-egui/src/lib.rs | 15 ++++ crates/ui-egui/src/ribbon.rs | 9 +- crates/ui-egui/tests/clipboard.rs | 135 +++++++++++++++++++++++++++++- docs/ui-parity.md | 3 +- 7 files changed, 173 insertions(+), 17 deletions(-) diff --git a/apps/gridcraft/src/main.rs b/apps/gridcraft/src/main.rs index 8093205..4fbac1e 100644 --- a/apps/gridcraft/src/main.rs +++ b/apps/gridcraft/src/main.rs @@ -135,10 +135,22 @@ fn save_prefs(app: &SheetApp) { } } +/// Read the system clipboard through the shared handle, creating it on first use. +fn read_clipboard( + handle: &RefCell>, + read: impl FnOnce(&mut arboard::Clipboard) -> Result, +) -> Option { + let mut clipboard = handle.try_borrow_mut().ok()?; + if clipboard.is_none() { + *clipboard = arboard::Clipboard::new().ok(); + } + read(clipboard.as_mut()?).ok() +} + fn services() -> Services { // Keep the owner alive: X11/Wayland serve clipboard data from this handle. let clipboard: Rc>> = Rc::default(); - let paste_clipboard = clipboard.clone(); + let (html_clipboard, text_clipboard) = (clipboard.clone(), clipboard.clone()); Services { copy_html: Some(Box::new(move |html, text| { let mut clipboard = clipboard.borrow_mut(); @@ -147,13 +159,8 @@ fn services() -> Services { } clipboard.as_mut().ok_or("clipboard unavailable")?.set_html(html, Some(text)).map_err(|e| e.to_string()) })), - paste_html: Some(Box::new(move || { - let mut clipboard = paste_clipboard.try_borrow_mut().ok()?; - if clipboard.is_none() { - *clipboard = arboard::Clipboard::new().ok(); - } - clipboard.as_mut()?.get().html().ok() - })), + paste_html: Some(Box::new(move || read_clipboard(&html_clipboard, |clipboard| clipboard.get().html()))), + paste_text: Some(Box::new(move || read_clipboard(&text_clipboard, |clipboard| clipboard.get_text()))), pick_open: Some(Box::new(|| { rfd::FileDialog::new() .add_filter("Spreadsheets", &["xlsx", "xlsm", "xls", "xlsb", "ods", "csv", "tsv", "txt", "json"]) diff --git a/apps/gridcraft/src/native_menu.rs b/apps/gridcraft/src/native_menu.rs index b127543..f4b6e80 100644 --- a/apps/gridcraft/src/native_menu.rs +++ b/apps/gridcraft/src/native_menu.rs @@ -298,6 +298,8 @@ impl NativeMenu { app.open_dialog(name, params); } else if id == "edit.copy" || id == "edit.cut" { app.copy_to_clipboard(ctx, &id); + } else if id == "edit.paste" { + app.paste_from_clipboard(); } else { app.run_or_alert(&id, json!({})); } diff --git a/crates/ui-egui/src/grid.rs b/crates/ui-egui/src/grid.rs index d0eef85..ece47cd 100644 --- a/crates/ui-egui/src/grid.rs +++ b/crates/ui-egui/src/grid.rs @@ -84,7 +84,6 @@ pub struct GridState { pub last_border: String, pub last_fill: String, pub last_font_color: String, - pub system_clipboard: Option, pub trace: Option, pub renaming_tab: Option, pub rename_text: String, @@ -2651,6 +2650,7 @@ fn context_menu(app: &mut SheetApp, ui: &mut egui::Ui) { "edit.copy" | "edit.cut" => { app.copy_to_clipboard(ui.ctx(), id); } + "edit.paste" => app.paste_from_clipboard(), other => app.run_or_alert(other, json!({})), } } @@ -2893,6 +2893,7 @@ fn header_menu(app: &mut SheetApp, ui: &mut egui::Ui, was_open: bool) { "edit.copy" | "edit.cut" => { app.copy_to_clipboard(ui.ctx(), id); } + "edit.paste" => app.paste_from_clipboard(), other => app.run_or_alert(other, json!({})), } } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 0b19b25..8655089 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -179,6 +179,10 @@ pub struct Services { /// source spreadsheet recorded as numbers stay numbers ("Rp10,000.00" → 10000). `None` from /// the host pastes the plain text alone. pub paste_html: Option Option>>, + /// Read the system clipboard's plain text for the ribbon and menu Paste commands (Cmd/Ctrl+V + /// gets it from egui's `Event::Paste`). `None` from the host, or no service (web), pastes + /// GridCraft's own clipboard. + pub paste_text: Option Option>>, /// Web: deliver a file to the user (name, bytes). pub download: Option>, /// Web: start an asynchronous file pick; the bytes arrive later in `inbox`. @@ -392,6 +396,17 @@ impl SheetApp { self.run_or_alert("edit.paste", params); } + /// Ribbon and menu Paste: paste the text on the system clipboard, as Cmd/Ctrl+V does. With no + /// text there, or no host clipboard (web), GridCraft's own clipboard is pasted. + pub fn paste_from_clipboard(&mut self) { + let text = self.services.paste_text.as_mut().and_then(|read| read()).filter(|t| !t.is_empty()); + match text { + // Same line endings as egui's `Event::Paste` text. + Some(text) => self.paste_external(text.replace("\r\n", "\n")), + None => self.run_or_alert("edit.paste", json!({})), + } + } + /// 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() { diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index e8291ac..e02316c 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -483,6 +483,8 @@ fn menu_items(app: &mut SheetApp, ui: &mut Ui, items: &[(&str, &str, serde_json: if response.clicked() { if let Some(d) = id.strip_prefix("dialog:") { app.open_dialog(d, p.clone()); + } else if *id == "edit.paste" { + app.paste_from_clipboard(); } else { act(app, id, p.clone()); } @@ -495,17 +497,14 @@ fn home(app: &mut SheetApp, ui: &mut Ui) { let st = active_style(app); // Clipboard let paste = big_button(ui, Icon::Paste, "Paste", "Paste (⌘V)", true); + paste.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), "Paste")); for sequence in keytip_table::group_extension(app.keytips.prefix().unwrap_or_default(), &["edit.paste", "edit.pasteSpecial", "dialog:pasteSpecial"]) { keytip(app, ui, sequence, paste.rect.center_bottom()); } if paste.clicked() { - if let Some(text) = app.grid.system_clipboard.take() { - app.paste_external(text); - } else { - act(app, "edit.paste", json!({})); - } + app.paste_from_clipboard(); } let paste_keys = matches!(app.keytips.prefix(), Some("HV" | "E")); // Keep a keyboard-opened menu anchored to the button when a pointer click diff --git a/crates/ui-egui/tests/clipboard.rs b/crates/ui-egui/tests/clipboard.rs index 71fe336..1e1181b 100644 --- a/crates/ui-egui/tests/clipboard.rs +++ b/crates/ui-egui/tests/clipboard.rs @@ -3,9 +3,12 @@ use std::cell::RefCell; use std::rc::Rc; -use egui::{Event, OutputCommand}; +use egui::accesskit::Role; +use egui::{Event, Modifiers, OutputCommand, PointerButton}; +use egui_kittest::kittest::Queryable as _; use gridcraft_engine::Session; -use gridcraft_ui_egui::{Services, SheetApp}; +use gridcraft_engine::core::{CellRef, Value}; +use gridcraft_ui_egui::{Services, SheetApp, grid}; use serde_json::json; fn harness(services: Services) -> egui_kittest::Harness<'static, SheetApp> { @@ -116,3 +119,131 @@ fn copying_multiple_areas_copies_lined_up_areas_and_explains_the_rest() { assert_eq!(h.state().message.as_ref().map(|(_, text)| text.as_str()), Some("This action won't work on multiple selections.")); assert_eq!(h.state().session.clipboard.as_ref().map(|c| c.areas.len()), Some(2), "the earlier copy stays"); } + +#[test] +fn clicking_the_ribbon_paste_button_or_its_menu_does_not_freeze() { + // #273: a click on Paste deadlocked the UI thread (a click-state query inside the egui context + // lock), so the frame never returned. Run the clicks on a worker and fail on a timeout instead of + // hanging the test run if that comes back. + let (done, finished) = std::sync::mpsc::channel(); + let worker = std::thread::spawn(move || { + let cell = value_at; + let mut h = harness(Services::default()); + h.state_mut().run("edit.copy", json!({"range": "A1:B2"})).unwrap(); + h.state_mut().run("selection.set", json!({"cell": "D4"})).unwrap(); + // No host clipboard here (as on the web): GridCraft's own copy is pasted. + h.get_by_role_and_label(Role::Button, "Paste").click(); + h.run_steps(3); + assert_eq!(cell(&h, "D4"), Some(Value::from("Name"))); + assert_eq!(cell(&h, "E5"), Some(Value::number(42.0))); + + // The secondary click opens the paste options menu; its items paste too. + h.state_mut().run("selection.set", json!({"cell": "G1"})).unwrap(); + h.get_by_role_and_label(Role::Button, "Paste").click_secondary(); + h.run_steps(3); + h.get_by_label("Paste Values").click(); + h.run_steps(3); + assert_eq!(cell(&h, "G1"), Some(Value::from("Name"))); + assert_eq!(cell(&h, "H2"), Some(Value::number(42.0))); + let _ = done.send(()); + }); + match finished.recv_timeout(std::time::Duration::from_secs(60)) { + Ok(()) => assert!(worker.join().is_ok()), + Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => { + if let Err(panic) = worker.join() { + std::panic::resume_unwind(panic); + } + } + Err(std::sync::mpsc::RecvTimeoutError::Timeout) => panic!("clicking Paste froze the UI thread"), + } +} + +type Harness = egui_kittest::Harness<'static, SheetApp>; + +fn value_at(h: &Harness, a1: &str) -> Option { + let at = gridcraft_engine::core::CellRef::parse(a1).unwrap(); + h.state().session.active().and_then(|d| d.wb.active().map(|s| s.value(at))) +} + +/// A host clipboard holding `text`, and how many times the app read it. +fn system_clipboard(text: &str) -> (Rc>, Services) { + let clipboard = Rc::new(RefCell::new((text.to_string(), 0))); + let read = clipboard.clone(); + let services = Services { + paste_text: Some(Box::new(move || { + let mut clipboard = read.borrow_mut(); + clipboard.1 += 1; + Some(clipboard.0.clone()) + })), + ..Default::default() + }; + (clipboard, services) +} + +#[test] +fn ribbon_paste_pastes_the_system_clipboard_like_cmd_v() { + // Text copied in another app: the Paste button pastes it, as Cmd/Ctrl+V does. + let (clipboard, services) = system_clipboard("x\t1\r\ny\t2\r\n"); + let mut h = harness(services); + h.state_mut().run("selection.set", json!({"cell": "D4"})).unwrap(); + h.get_by_role_and_label(Role::Button, "Paste").click(); + h.run_steps(2); + assert_eq!(clipboard.borrow().1, 1, "one clipboard read per click"); + assert_eq!(value_at(&h, "D4"), Some(Value::from("x"))); + assert_eq!(value_at(&h, "E4"), Some(Value::number(1.0))); + assert_eq!(value_at(&h, "D5"), Some(Value::from("y"))); + assert_eq!(value_at(&h, "E5"), Some(Value::number(2.0))); + assert!(h.state().message.is_none(), "{:?}", h.state().message); + + // The clipboard text is GridCraft's own copy: the copy is pasted with its formulas. + h.state_mut().run("cell.set", json!({"cell": "A3", "input": "=B2*2"})).unwrap(); + let copied = h.state_mut().run("edit.copy", json!({"range": "A3"})).unwrap(); + clipboard.borrow_mut().0 = copied["text"].as_str().unwrap().to_string(); + h.state_mut().run("selection.set", json!({"cell": "B9"})).unwrap(); + h.get_by_role_and_label(Role::Button, "Paste").click(); + h.run_steps(2); + let sh = h.state().session.active().unwrap().wb.active().unwrap(); + assert_eq!(sh.cell(CellRef::parse("B9").unwrap()).unwrap().formula.as_ref().unwrap().text, "C8*2"); + + // Nothing on the system clipboard (an image, say): GridCraft's own copy is pasted. + clipboard.borrow_mut().0.clear(); + h.state_mut().run("selection.set", json!({"cell": "B12"})).unwrap(); + h.get_by_role_and_label(Role::Button, "Paste").click(); + h.run_steps(2); + let sh = h.state().session.active().unwrap().wb.active().unwrap(); + assert_eq!(sh.cell(CellRef::parse("B12").unwrap()).unwrap().formula.as_ref().unwrap().text, "C11*2"); +} + +#[test] +fn paste_menu_items_paste_the_system_clipboard() { + let (clipboard, services) = system_clipboard("from menu"); + let mut h = harness(services); + let ribbon = h.get_by_role_and_label(Role::Button, "Paste").rect(); + let click_menu_paste = |h: &Harness| h.get_all_by_role_and_label(Role::Button, "Paste").find(|n| n.rect() != ribbon).unwrap().click(); + + // Home › Paste options menu › Paste. + h.state_mut().run("selection.set", json!({"cell": "D4"})).unwrap(); + h.get_by_role_and_label(Role::Button, "Paste").click_secondary(); + h.run_steps(2); + click_menu_paste(&h); + h.run_steps(2); + assert_eq!(value_at(&h, "D4"), Some(Value::from("from menu"))); + + // The cell's context menu › Paste. + clipboard.borrow_mut().0 = "from cell menu".into(); + h.state_mut().run("selection.set", json!({"cell": "F6"})).unwrap(); + 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 pos = geo.cell_rect(sh, CellRef::parse("F6").unwrap()).center(); + h.input_mut().events.push(Event::PointerMoved(pos)); + for pressed in [true, false] { + h.input_mut().events.push(Event::PointerButton { pos, button: PointerButton::Secondary, pressed, modifiers: Modifiers::NONE }); + h.run_steps(2); + } + assert!(h.state().grid.context_menu.is_some()); + click_menu_paste(&h); + h.run_steps(2); + assert_eq!(value_at(&h, "F6"), Some(Value::from("from cell menu"))); + assert_eq!(clipboard.borrow().1, 2); +} diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 10abcbc..065f335 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (AutoFit reserves filter dropdown space, observed against Windows Excel) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (ribbon and menu Paste paste the system clipboard like Cmd/Ctrl+V; regression test for the Paste button freeze, #273; Paste button gets an accessible name) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 Interaction, controls and feel, against Excel. Part of [target-app-parity.md](target-app-parity.md). **UI/UX fidelity: ~55% ready, 50–80 h** (estimated). Ribbon command coverage is measured @@ -160,6 +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). The desktop app reads the clipboard off the UI thread and gives up after 5 s, so a clipboard source that never answers can't freeze it. 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) | From cc65915d1640e58ad8403ee3472b85b784ed5ab0 Mon Sep 17 00:00:00 2001 From: neilcawse <8085959+neilcawse@users.noreply.github.com> Date: Mon, 12 Oct 2026 14:54:17 -0400 Subject: [PATCH 2/5] UI: keytip Copy/Cut publish to the system clipboard; ribbon Paste keeps a newer programmatic copy --- crates/ui-egui/src/lib.rs | 37 ++++++++++++++++---- crates/ui-egui/tests/clipboard.rs | 58 +++++++++++++++++++++++++++++++ docs/ui-parity.md | 2 +- 3 files changed, 89 insertions(+), 8 deletions(-) diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 8655089..feae8bd 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -250,6 +250,10 @@ pub struct SheetApp { pub(crate) shots: control::Shots, /// Chart selected on the sheet (id). pub selected_chart: Option, + /// The system clipboard's text when GridCraft last copied without publishing to it (a + /// 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, pub started: f64, } @@ -291,6 +295,7 @@ impl SheetApp { keytips: keytips::KeyTips::default(), shots: control::Shots::default(), selected_chart: None, + clipboard_text_at_copy: None, started: now_ms(), } } @@ -326,6 +331,16 @@ impl SheetApp { /// Runs an engine command (or a UI command) and handles UI requests it makes. pub fn run(&mut self, id: &str, params: Json) -> Result { + let r = self.run_command(id, params); + // This copy leaves the system clipboard alone: note what it holds, so Paste can tell + // 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(); + } + r + } + + fn run_command(&mut self, id: &str, params: Json) -> Result { if matches!(id, "file.save" | "file.saveAs" | "file.saveBytes") { self.store_view_state(); } @@ -369,11 +384,13 @@ impl SheetApp { /// their existing internal clipboard behavior and do not write to the host clipboard. pub fn copy_to_clipboard(&mut self, ctx: &egui::Context, command: &str) { // Only ask the engine for HTML when the host can publish it. - let result = match self.run(command, json!({"html": self.services.copy_html.is_some()})) { + let result = match self.run_command(command, json!({"html": self.services.copy_html.is_some()})) { Ok(result) => result, Err(e) => return self.alert(e), }; 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; if let Some(html) = result.get("html").and_then(Json::as_str) && let Some(copy_html) = &mut self.services.copy_html { @@ -397,16 +414,21 @@ impl SheetApp { } /// Ribbon and menu Paste: paste the text on the system clipboard, as Cmd/Ctrl+V does. With no - /// text there, or no host clipboard (web), GridCraft's own clipboard is pasted. + /// text there, no host clipboard (web), or the text that was there when GridCraft last copied + /// without publishing, GridCraft's own clipboard is pasted. pub fn paste_from_clipboard(&mut self) { - let text = self.services.paste_text.as_mut().and_then(|read| read()).filter(|t| !t.is_empty()); - match text { - // Same line endings as egui's `Event::Paste` text. - Some(text) => self.paste_external(text.replace("\r\n", "\n")), - None => self.run_or_alert("edit.paste", json!({})), + 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!({})), } } + /// The system clipboard's plain text, with the line endings of egui's `Event::Paste` text. + /// `None` when it holds no text or the host can't read it. + fn system_clipboard_text(&mut self) -> Option { + self.services.paste_text.as_mut().and_then(|read| read()).filter(|t| !t.is_empty()).map(|t| t.replace("\r\n", "\n")) + } + /// 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() { @@ -1094,6 +1116,7 @@ impl SheetApp { self.run_or_alert("ui.ribbonTab", json!({"tab": tab})); self.ui.ribbon_collapsed = false; } + keytips::Action::Command(id @ ("edit.copy" | "edit.cut")) => self.copy_to_clipboard(ctx, id), 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 diff --git a/crates/ui-egui/tests/clipboard.rs b/crates/ui-egui/tests/clipboard.rs index 1e1181b..d1aec52 100644 --- a/crates/ui-egui/tests/clipboard.rs +++ b/crates/ui-egui/tests/clipboard.rs @@ -247,3 +247,61 @@ fn paste_menu_items_paste_the_system_clipboard() { assert_eq!(value_at(&h, "F6"), Some(Value::from("from cell menu"))); assert_eq!(clipboard.borrow().1, 2); } + +fn press(h: &mut Harness, key: egui::Key) { + for pressed in [true, false] { + h.input_mut().events.push(Event::Key { key, physical_key: None, pressed, repeat: false, modifiers: Modifiers::NONE }); + if pressed && key != egui::Key::F10 { + h.input_mut().events.push(Event::Text(key.name().to_ascii_lowercase())); + } + } + h.run_steps(2); +} + +#[test] +fn keytip_copy_and_cut_reach_the_system_clipboard_and_ribbon_paste_pastes_them() { + // Home › Copy / Cut by keytip (Alt or F10, H, C / X) is the ribbon button: the host clipboard + // gets the copy, and Paste pastes it rather than older text from another app. + for (letter, cut) in [(egui::Key::C, false), (egui::Key::X, true)] { + 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); + for key in [egui::Key::F10, egui::Key::H, letter] { + press(&mut h, key); + } + assert_eq!(h.state().session.clipboard.as_ref().map(|c| c.cut), Some(cut), "{letter:?}"); + assert_eq!(clipboard.borrow().0, "Name\tScore\nAda\t42\n", "{letter:?} publishes to the system clipboard"); + + h.state_mut().run("selection.set", json!({"cell": "D4"})).unwrap(); + h.get_by_role_and_label(Role::Button, "Paste").click(); + h.run_steps(2); + assert_eq!(value_at(&h, "D4"), Some(Value::from("Name")), "{letter:?}"); + assert_eq!(value_at(&h, "E5"), Some(Value::number(42.0)), "{letter:?}"); + } +} + +#[test] +fn ribbon_paste_prefers_a_programmatic_copy_until_other_text_is_copied() { + // A control-channel or MCP copy leaves the system clipboard alone. Its older text must not + // win over the copy; text copied in another app afterwards must. + let (clipboard, services) = system_clipboard("stale text from earlier"); + let mut h = harness(services); + h.state_mut().run("edit.copy", json!({"range": "A1:B2"})).unwrap(); + assert_eq!(clipboard.borrow().0, "stale text from earlier", "the system clipboard is untouched"); + h.state_mut().run("selection.set", json!({"cell": "D4"})).unwrap(); + h.get_by_role_and_label(Role::Button, "Paste").click(); + h.run_steps(2); + assert_eq!(value_at(&h, "D4"), Some(Value::from("Name"))); + assert_eq!(value_at(&h, "E5"), Some(Value::number(42.0))); + + clipboard.borrow_mut().0 = "copied elsewhere\t7".into(); + h.state_mut().run("selection.set", json!({"cell": "G1"})).unwrap(); + h.get_by_role_and_label(Role::Button, "Paste").click(); + h.run_steps(2); + assert_eq!(value_at(&h, "G1"), Some(Value::from("copied elsewhere"))); + assert_eq!(value_at(&h, "H1"), Some(Value::number(7.0))); +} diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 065f335..bc62055 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). The desktop app reads the clipboard off the UI thread and gives up after 5 s, so a clipboard source that never answers can't freeze it. 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. 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) | From fd1ed6649be6f08dcd545899dde6d65c5dd19e85 Mon Sep 17 00:00:00 2001 From: neilcawse <8085959+neilcawse@users.noreply.github.com> Date: Mon, 12 Oct 2026 16:38:20 -0400 Subject: [PATCH 3/5] UI: Paste doesn't re-paste a pasted cut's clipboard text; Paste keytip (H,V,P) pastes the system clipboard --- crates/ui-egui/src/keytips/keytip_table.rs | 2 +- .../src/keytips/keytip_table/generated.rs | 2 +- crates/ui-egui/src/keytips/keytips.json | 2 +- crates/ui-egui/src/lib.rs | 28 +++++- crates/ui-egui/tests/clipboard.rs | 89 +++++++++++++++++++ crates/ui-egui/tests/keytips_generated.rs | 2 +- docs/ui-parity.md | 2 +- 7 files changed, 120 insertions(+), 7 deletions(-) diff --git a/crates/ui-egui/src/keytips/keytip_table.rs b/crates/ui-egui/src/keytips/keytip_table.rs index 9e2735f..5415b9d 100644 --- a/crates/ui-egui/src/keytips/keytip_table.rs +++ b/crates/ui-egui/src/keytips/keytip_table.rs @@ -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")); } diff --git a/crates/ui-egui/src/keytips/keytip_table/generated.rs b/crates/ui-egui/src/keytips/keytip_table/generated.rs index 143c157..79920b5 100644 --- a/crates/ui-egui/src/keytips/keytip_table/generated.rs +++ b/crates/ui-egui/src/keytips/keytip_table/generated.rs @@ -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 diff --git a/crates/ui-egui/src/keytips/keytips.json b/crates/ui-egui/src/keytips/keytips.json index cbb8e04..59aeadc 100644 --- a/crates/ui-egui/src/keytips/keytips.json +++ b/crates/ui-egui/src/keytips/keytips.json @@ -958,7 +958,7 @@ { "seq": "HVP", "kind": "command", - "target": "edit.pasteSpecial", + "target": "edit.paste", "excel": "Paste", "note": "" }, diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index feae8bd..d6900df 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -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, + /// 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, 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 diff --git a/crates/ui-egui/tests/clipboard.rs b/crates/ui-egui/tests/clipboard.rs index d1aec52..24743d0 100644 --- a/crates/ui-egui/tests/clipboard.rs +++ b/crates/ui-egui/tests/clipboard.rs @@ -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()); +} diff --git a/crates/ui-egui/tests/keytips_generated.rs b/crates/ui-egui/tests/keytips_generated.rs index 50c5731..6535548 100644 --- a/crates/ui-egui/tests/keytips_generated.rs +++ b/crates/ui-egui/tests/keytips_generated.rs @@ -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] diff --git a/docs/ui-parity.md b/docs/ui-parity.md index bc62055..fee0ec5 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. 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) | From 4d654c5185da30fc876e55241613903f0dca21bb Mon Sep 17 00:00:00 2001 From: neilcawse <8085959+neilcawse@users.noreply.github.com> Date: Mon, 12 Oct 2026 19:15:35 -0400 Subject: [PATCH 4/5] UI: Cmd/Ctrl+V doesn't re-paste a pasted cut's clipboard text either --- crates/ui-egui/src/lib.rs | 29 ++++++++++++++------------ crates/ui-egui/tests/clipboard.rs | 34 +++++++++++++++++++++++++++++++ docs/ui-parity.md | 2 +- 3 files changed, 51 insertions(+), 14 deletions(-) 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) | From 95012e03b46ce3ab5644eb7a151a61822f2e7e76 Mon Sep 17 00:00:00 2001 From: neilcawse <8085959+neilcawse@users.noreply.github.com> Date: Mon, 12 Oct 2026 21:19:05 -0400 Subject: [PATCH 5/5] UI: a cut's text pastes again once the clipboard has held other text --- crates/ui-egui/src/lib.rs | 15 +++++++++----- crates/ui-egui/tests/clipboard.rs | 33 +++++++++++++++++++++++++++++++ 2 files changed, 43 insertions(+), 5 deletions(-) diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 5900680..747a5e0 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -255,8 +255,8 @@ pub struct SheetApp { /// text, GridCraft's copy is the newer one and Paste uses it. clipboard_text_at_copy: Option, /// 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. + /// text noted for an unpublished cut); `None` after a copy, or once the clipboard is seen + /// holding other text. Once the cut has been pasted, Paste leaves this text alone. cut_text: Option, pub started: f64, } @@ -414,10 +414,15 @@ impl SheetApp { /// /// 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.". + /// clipboard is pasted instead, which reports "Nothing to paste.". Other text means the + /// clipboard has moved on, so the cut's text is forgotten and pastes normally later. 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!({})); + if Self::same_clipboard_text(Some(&text), self.cut_text.as_deref()) { + if self.session.clipboard.is_none() { + return self.run_or_alert("edit.paste", json!({})); + } + } else { + self.cut_text = None; } let html = self.services.paste_html.as_mut().and_then(|read| read()); let params = match html { diff --git a/crates/ui-egui/tests/clipboard.rs b/crates/ui-egui/tests/clipboard.rs index 89a3d34..c386e76 100644 --- a/crates/ui-egui/tests/clipboard.rs +++ b/crates/ui-egui/tests/clipboard.rs @@ -428,3 +428,36 @@ fn cmd_v_after_a_pasted_cut_does_not_paste_the_cut_text_again() { 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"); } + +#[test] +fn the_cut_text_pastes_again_once_the_clipboard_has_held_other_text() { + // Once other text has been pasted, the clipboard has moved on from the cut: the same text + // copied again later (from another app, or a cell editor) is a new copy and pastes. + let mut h = harness(Services::default()); + h.state_mut().run("selection.set", json!({"cell": "A1"})).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_eq!(value_at(&h, "D4"), Some(Value::from("Name")), "the cut moves A1"); + h.state_mut().run("selection.set", json!({"cell": "G1"})).unwrap(); + h.input_mut().events.push(Event::Paste("other".into())); + h.run_steps(2); + assert_eq!(value_at(&h, "G1"), Some(Value::from("other"))); + h.state_mut().run("selection.set", json!({"cell": "H1"})).unwrap(); + h.input_mut().events.push(Event::Paste(text.replace('\n', "\r\n"))); + h.run_steps(2); + assert_eq!(value_at(&h, "H1"), Some(Value::from("Name")), "the same text copied afresh pastes"); +}