Skip to content

UI: Paste button and menus paste the system clipboard; regression test for the Paste freeze (#273) - #461

Merged
h-a-n-a-s-h-i merged 5 commits into
storytold:mainfrom
neilcawse:fix/ribbon-paste
Oct 13, 2026
Merged

h-a-n-a-s-h-i merged 5 commits into
storytold:mainfrom
neilcawse:fix/ribbon-paste

Conversation

@neilcawse

@neilcawse neilcawse commented Oct 12, 2026 •

Copy link
Copy Markdown
Contributor

The freeze in #273 is already fixed on main by #272. This PR adds the regression test that was missing. It also fixes the next thing a user hits once the freeze is gone: the Paste button ignored text copied in other apps. I didn't put Fixes #273 on it because the freeze fix is #272's. #273 can be closed once a release includes #272.

The freeze (#273)

In 0.5.0, clicking anywhere on the Home ribbon's Paste button (the label, the icon or the dropdown arrow) froze the app for good. Both reports use 0.5.0 or older: the macOS comment, and the Fedora RPM gridcraft 0.5.0 in the Linux comment. #272 merged on 2026-10-11, after the v0.5.0 release on 2026-10-10. git tag --contains e1939aa lists no tag.

Cause: ribbon::home called paste.secondary_clicked() inside ui.ctx().data_mut(|data| …). In egui 0.36, Response::clicked_by is self.flags.contains(CLICKED) && self.ctx.input(…), so it only touches the context after a click. When it does, it tries to take the context lock that data_mut already holds, and the UI thread waits on itself. Which button was clicked and what is on the clipboard make no difference.

Reproduced on macOS (arm64) by building v0.5.0 with cargo build --release -p gridcraft and running --sample sales --control 7981:

  • ui.inspect answered. Then ui.click {x:44,y:100} (the Paste label) went through, and every ui.inspect after it timed out. I relaunched and repeated with ui.click {x:61,y:125} (the dropdown chevron): same freeze.
  • sample <pid> 3, main thread (abridged):
    gridcraft_ui_egui::ribbon::home
      egui::context::Context::write  <data_mut<…ribbon::home…>>
        parking_lot::raw_rwlock::RawRwLock::lock_exclusive_slow
          __psynch_cvwait
    
    The control-connection thread was waiting in RawRwLock::lock_shared_slow behind it.

The Linux stack in the issue fits this: the main thread sits in a bare syscall (parking_lot's futex wait) and the other threads are idle. In release builds egui's lock just waits forever, so the SIGABRT a few seconds later probably came from outside the process (the session or launcher killing a hung app). That part is an inference; the coredump has no symbols.

On 95c8776 (main + #449) I ran 21 combinations: a left click on the label, a left click on the chevron and a right click, each with 7 clipboard contents (empty; plain text; ~10 MB of text; a PNG image; an RTF/HTML table; a file copied in Finder; a GridCraft copy). ui.inspect answered within 0.4 s every time.

The Paste button ignored the system clipboard

On main, with text copied in another app, clicking Paste showed "Nothing to paste." (control channel: ui.click on the button, then sheet.read returned an empty cell and ui.inspect returned message: "Nothing to paste."). The button pasted GridState::system_clipboard, which nothing ever assigns, so it always ran edit.paste with no text and pasted only GridCraft's own copy. The options menu's Paste, the cell and header context menus and the macOS Edit menu behaved the same way. Cmd/Ctrl+V did work, because it gets the text from egui's Event::Paste.

First I tried asking egui for the clipboard (ViewportCommand::RequestPaste) and waiting for the Event::Paste it produces. On macOS that stalled the event loop. After the request, no frame ran until the next real input event. The control channel's sheet.read timed out for 4 s at a time, and the log showed the text arriving about 26 s later, when some unrelated event finally woke the loop. Without the request, frames kept running. So I dropped that approach.

What this PR changes

  • Services::paste_text (new, crates/ui-egui/src/lib.rs): the host reads the clipboard's plain text. SheetApp::paste_from_clipboard() pastes that text through the existing paste_external, the same route Cmd/Ctrl+V uses, so the HTML lookup from Clipboard: pasted currency cells keep the number from the clipboard HTML (#257) #285 still applies. When there is no text (an empty clipboard, an image) or no service (the web build), it pastes GridCraft's own copy, as before. The ribbon button, the options menu's Paste, the cell and header context menus and the macOS Edit menu all call it. The unused GridState::system_clipboard is removed.
  • Copy and Cut by keytip (H,C / H,X) now go through SheetApp::copy_to_clipboard in ribbon_keys, as the ribbon's Copy and Cut buttons do, so they publish to the system clipboard. Before, they ran edit.copy / edit.cut without it, and the Paste button then pasted older text from another app instead of the copy. Excel publishes every Copy to the clipboard.
  • Copies that leave the system clipboard alone on purpose (control-channel and MCP edit.copy / edit.cut, which go through SheetApp::run) still don't write to it. SheetApp::run now remembers the system clipboard's text at that moment (clipboard_text_at_copy). While the clipboard still holds that text, paste_from_clipboard pastes GridCraft's copy, because it is newer. Once other text is copied, that text is pasted. copy_to_clipboard clears the remembered text, because it publishes the copy itself; it calls the new private run_command (the old body of run) so that it doesn't read the clipboard first.
  • Excel pastes a cut once. A pasted cut clears GridCraft's clipboard, but its text (published by the ribbon or keytip Cut, or the older text noted for a control-channel or MCP cut) is still on the system clipboard, so a second Paste wrote it over the moved block: formulas became values, or pre-cut text landed there. Cmd/Ctrl+V did the same with the text egui hands it. SheetApp now remembers that text (cut_text, cleared by any copy, and by any paste of other text, since the clipboard has then moved on). When GridCraft's clipboard is empty and the pasted text is still that text (trailing line breaks ignored, as edit.paste does, and CRLF matching LF), paste_external (the one route for Cmd/Ctrl+V and for the ribbon, menu and keytip Paste) runs the internal edit.paste, which reports "Nothing to paste.". Text copied elsewhere afterwards still pastes, and so does the cut's text once it has been copied afresh after other text.
  • The Paste keytip (H,V,P) pointed at edit.pasteSpecial and pasted only GridCraft's copy. keytips.json now targets edit.paste (table and generated tests regenerated with cargo xtask keytips), and ribbon_keys sends edit.paste to paste_from_clipboard, like the button. Its badge now sits on the menu's Paste item instead of the edit.pasteSpecial items. H,V,S and the other Paste Special keytips are unchanged.
  • Desktop host (apps/gridcraft/src/main.rs): paste_text reads plain text through the same shared arboard handle that paste_html and copy_html already use (read_clipboard, factored out of the old paste_html closure).
  • Tests (crates/ui-egui/tests/clipboard.rs):
    • clicking_the_ribbon_paste_button_or_its_menu_does_not_freeze clicks Paste with real pointer events and checks the pasted cells. Then it secondary-clicks Paste, picks Paste Values and checks those cells. It runs on a worker thread with a 60 s recv_timeout, so a deadlock fails the test instead of hanging cargo test.
    • ribbon_paste_pastes_the_system_clipboard_like_cmd_v: tab-separated CRLF text from the host lands in D4:E5. A GridCraft copy coming back through the clipboard is pasted with its formulas. An empty clipboard pastes GridCraft's own copy. One clipboard read per click.
    • paste_menu_items_paste_the_system_clipboard covers the options menu's Paste and the cell context menu's Paste.
    • keytip_copy_and_cut_reach_the_system_clipboard_and_ribbon_paste_pastes_them: F10, H, C (and F10, H, X) with "stale text from earlier" on the host clipboard. The host clipboard then holds the copied text, and the ribbon Paste button pastes A1:B2 at D4.
    • a_pasted_ribbon_cut_is_not_pasted_again_from_the_system_clipboard: A1:B3 with B3 =B2*2, F10,H,X, Paste at D4, Paste again: E6 keeps its formula and "Nothing to paste." shows; new host text then pastes.
    • cmd_v_after_a_pasted_cut_does_not_paste_the_cut_text_again: A1:B3 with B3 =B2*2, Event::Cut, Event::Paste of the published text at D4, then again with CRLF line endings: E6 keeps its formula.
    • a_pasted_programmatic_cut_does_not_paste_the_older_system_clipboard_text: run("edit.cut") with "stale" on the host, Paste, Paste: D4 stays "Name"; new host text then pastes.
    • the_cut_text_pastes_again_once_the_clipboard_has_held_other_text: cut A1 ("Name"), Cmd/Ctrl+V at D4, paste "other" at G1, then paste "Name" (CRLF) at H1: H1 is "Name", not "Nothing to paste.".
    • the_paste_keytip_pastes_the_system_clipboard_like_the_paste_button: host text external\t7, no GridCraft copy, F10,H,V,P pastes it at D4:E4.
    • ribbon_paste_prefers_a_programmatic_copy_until_other_text_is_copied: run("edit.copy") leaves the host's stale text in place, and the Paste button pastes the copy. After the host text changes, the Paste button pastes the new text.
  • crates/ui-egui/src/ribbon.rs: paste.widget_info(…"Paste"). big_button registers no accessible name, so neither kittest nor a screen reader could find the button. This uses the same one-line pattern as Pictures and Border presets.
  • docs/ui-parity.md: header line and a revision row. The row no longer says the desktop reads the clipboard off the UI thread with a 5 s timeout; read_clipboard reads it on the UI thread.

Verification

  • With Fix Paste button deadlock in ribbon #272's change reverted locally (secondary_clicked() back inside data_mut), cargo test -p gridcraft-ui-egui --test clipboard clicking_the_ribbon FAILED in 10.2 s with DEBUG PANIC: Failed to acquire RwLock write after 10s. Deadlock?. That is egui's debug-build lock check; the test's own timeout covers release builds. The two system-clipboard tests use Services::paste_text, which doesn't exist before this PR.
  • With the keytip routing reverted, keytip_copy_and_cut_reach_the_system_clipboard_and_ribbon_paste_pastes_them fails (the host clipboard still holds the stale text). With the clipboard_text_at_copy check reverted, ribbon_paste_prefers_a_programmatic_copy_until_other_text_is_copied fails (D4 gets "stale text from earlier").
  • With the lib.rs and keytip changes reverted (tests kept), all three new tests fail on e16373d.
  • With the lib.rs change reverted, cmd_v_after_a_pasted_cut_does_not_paste_the_cut_text_again fails on ea690ce (E6 becomes the constant 84). With the cut_text = None reset reverted, the_cut_text_pastes_again_once_the_clipboard_has_held_other_text fails (H1 stays empty).
  • cargo test -p gridcraft-ui-egui --test clipboard: 15 passed. cargo xtask keytips --check passes.
  • cargo test -p gridcraft-ui-egui, cargo clippy -p gridcraft-ui-egui --all-targets -- -D warnings and cargo check -p gridcraft-web --target wasm32-unknown-unknown pass.
  • The checks below were run on the first commit (4571343), before the keytip and precedence changes.
  • Release build of this branch on macOS, driven over the control channel (--sample sales --control 7987):
    • Clipboard ext\t7\nrow2\t8 from pbcopy, then a click on the Paste label, and separately on the chevron: sheet.read shows ext / 7 / row2 / 8 at the selection.
    • Right click, then the menu's Paste item with menu\t9 on the clipboard: W30:X30 = menu, 9.
    • Empty clipboard right after an edit.copy of A1:B2: the button and the chevron paste A1:B2.
    • ~12 MB of text (2,000,000 rows): pasted, and ui.inspect answered 1.4–2.1 s after the click.
    • Image on the clipboard with no GridCraft copy: "Nothing to paste.", same as before.
    • ui.inspect stayed responsive throughout. I saved the clipboard text before the runs and restored it afterwards.
  • cargo xtask ci: CI summary: all 8 steps passed (fmt, clippy, test, assets, layers, wasm, parity, keytips).

Not done

  • Web build. The browser only gives clipboard text asynchronously, after a permission prompt, so the web build still pastes GridCraft's own copy from the button and menus. Ctrl+V reads the system clipboard there, as before.
  • Dropdown arrow. The chevron is drawn but has no hit area of its own. A left click on it pastes, and the options menu opens only on a right click (in Excel the arrow opens the menu). Open PR UI: theme tokens with a contrast test, ribbon overflow arrows and split button, multi-line formula bar, code editor widget #456 adds a shared big_split_button widget; Paste should move to it once UI: theme tokens with a contrast test, ribbon overflow arrows and split button, multi-line formula bar, code editor widget #456 merges, as a follow-up.
  • Blocking reads on Wayland. arboard's Wayland backend reads the source app's pipe with no timeout. The new paste_text read (on Paste, and in SheetApp::run after a control-channel or MCP copy) has the same exposure as the existing paste_html read on Cmd/Ctrl+V; I haven't seen it hang, so this PR doesn't change it.
  • Cmd/Ctrl+V after a control-channel or MCP copy. egui hands the app the system clipboard's text in Event::Paste, and that path still pastes it, as on main. Only the ribbon and menu Paste use clipboard_text_at_copy.

neilcawse and others added 5 commits October 12, 2026 20:51
…ion test (storytold#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. storytold#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 storytold#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 <noreply@anthropic.com>
@h-a-n-a-s-h-i
h-a-n-a-s-h-i merged commit d43eefe into storytold:main Oct 13, 2026
1 check passed
@echelon

echelon commented Oct 13, 2026

Copy link
Copy Markdown
Member

Merged — thank you @neilcawse for the contribution!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants