UI: keytip Copy/Cut publish to the system clipboard; ribbon Paste keeps a newer programmatic copy

This commit is contained in:
neilcawse 2026-10-12 14:54:17 -04:00
parent fa1dcd73e2
commit cc65915d16
3 changed files with 89 additions and 8 deletions

View File

@ -250,6 +250,10 @@ pub struct SheetApp {
pub(crate) shots: control::Shots,
/// Chart selected on the sheet (id).
pub selected_chart: Option<u32>,
/// 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<String>,
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<Json, String> {
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<Json, String> {
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<String> {
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

View File

@ -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)));
}

View File

@ -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) |