clipboard: reach the primary (middle-click) selection (#67) - #167
Merged
Conversation
X11 and Wayland have two clipboards. CLIPBOARD is the Ctrl+C one; PRIMARY holds whatever was last selected with the mouse and is pasted with the middle button, and they are independent. This package only ever touched CLIPBOARD — the atom was hardcoded at three call sites — so a clipboard manager built on it silently missed half of what a Linux user does. Add an option rather than a second set of functions: sel := clipboard.Read(clipboard.FmtText, clipboard.FromPrimary()) ch := clipboard.Watch(ctx, clipboard.FmtText, clipboard.FromPrimary()) Option is an interface, and Format and Item implement it. That is not decoration: Watch and WriteAll had already spent their variadic slot on Format and Item, and Go allows only one, so an `opts ...Option` parameter could not have been added to them at all. Making Format an Option lets one variadic slot carry both what an operation acts on and how, and every existing call site keeps compiling — Read, Write and Watch have been public since v0.1, and TestOptionsAreSourceCompatible pins that. WriteFiles is the exception: its variadic slot holds []string, and a string cannot be an Option without swallowing every stray string argument, so it takes a slice and the options follow. It shipped in #152 and is in no tag. Per backend. X11 and BSD name PRIMARY instead of CLIPBOARD — selections are a general mechanism there and CLIPBOARD is just one atom, so ownership, TARGETS and the serve loop are untouched. Wayland uses set_primary_selection and the primary_selection event, which needed the data-control manager bound at version 2 instead of 1; below that the primary selection is reported unsupported without disturbing the ordinary clipboard. Windows, macOS, mobile and CGO-disabled builds have no second clipboard: a read returns nil and a write is refused. Refusing is the point — redirecting to the ordinary clipboard would destroy whatever the user had copied, so the degradation test asserts exactly that, and that a watch which can never fire closes rather than leaving the caller waiting forever. The selection is threaded through the backend entry points as an argument rather than read from a global, so a FromPrimary read cannot race a normal one. That meant renaming two Wayland locals that would have shadowed it — `selection` to `current`, a closure's `sel` to `offerID`. Worth the churn: a shadowed selection argument inside a loop that dispatches on selection opcodes is precisely the bug that hands back the other clipboard's data while every same-process test still passes. TestPrimarySelectionIsIndependent is the assertion that fails without this — write different content to each and read both back; before, the second write would land on the same clipboard and the first value would be gone. Formats and Watch are asserted against a selection the other clipboard does not hold, since enumeration and watching are separate paths on every backend. Wayland is checked cross-process with wl-paste --primary, because the two selections are set by different requests and announced by different events, so confusing them would look like success to any same-process test. Design notes in specs/primary-selection.md. Fixes #67
CI caught it on X11: TestPrimarySelectionWatch timed out at 20s while TestPrimarySelectionIsIndependent and TestPrimarySelectionEnumerates passed, so reading and enumerating honored the selection and only watching did not. The polling watchers go back through the public Read so the package lock is taken for them, and that call did not carry the selection. So a watcher started on the primary selection polled the ordinary clipboard. The timeout was the lucky outcome: had the clipboard changed during the test, the watcher would have delivered the other clipboard's data through a channel the caller believes is watching the selection, and the test would have passed while the behavior was wrong. withSelection hands an already-resolved selection back into a public call. Every polling watcher now uses it — including on darwin, Windows and mobile, where watch already returns a closed channel for the primary selection before reaching the loop. Leaning on that early return to keep the Read correct is how this bug comes back the next time the early return moves. watchEvent and watchPoll on Windows take the selection for the same reason.
This was referenced Aug 21, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
X11 and Wayland have two clipboards.
CLIPBOARDis the Ctrl+C one;PRIMARYholds whatever was last selected with the mouse and is pasted with the middle
button, and they are independent — copying does not change the selection. This
package only ever touched
CLIPBOARD(the atom was hardcoded at three callsites), so a clipboard manager built on it silently missed half of what a Linux
user does.
API
You picked options on the existing calls, and then the
Format-implements-Optionresolution for the variadic clash:FormatandItemimplementingOptionis load-bearing, not decoration:WatchandWriteAllhad already spent their variadic slot, and Go allows onlyone, so
opts ...Optioncould not have been added to them at all. Onevariadic slot now carries both what an operation acts on and how — and every
existing call site keeps compiling, which matters because
Read,WriteandWatchhave been public since v0.1.TestOptionsAreSourceCompatiblepins that.WriteFileschanged shape:(paths ...string)→(paths []string, opts ...Option). A string cannot implementOptionwithout swallowing every straystring argument. It shipped in #152 an hour ago and is in no tag, so nothing
depends on the old form — but it is a real signature change and worth a look.
Per backend
PRIMARYinstead ofCLIPBOARDas the selection atom. Ownership,TARGETSand the serve loop are untouched — selections are a general mechanism there andCLIPBOARDis just one atom.set_primary_selection/ theprimary_selectionevent. Needed the data-control manager bound at version 2 instead of the hardcoded 1; below that it reports unsupported without disturbing the ordinary clipboard.Refusing the write is the point. Redirecting it to the ordinary clipboard would
silently destroy whatever the user had copied, so
TestPrimarySelectionDegradesSafelyasserts exactly that — and that a watchwhich can never fire closes rather than leaving the caller waiting forever.
One thing worth reviewing closely
The selection is threaded through the backend entry points as an argument,
not read from a global, so a
FromPrimaryread cannot race a normal one. Thatmeant renaming two Wayland locals that would otherwise have shadowed it:
selection→current, and a closure'ssel→offerID. Worth the churn — ashadowed selection argument inside a loop that dispatches on selection opcodes
is precisely the bug that hands back the other clipboard's data while every
same-process test still passes.
Design notes in
specs/primary-selection.md.Tests
TestPrimarySelectionIsIndependent— the one that fails without this: writedifferent content to each, read both back. Before, the second write landed on
the same clipboard and the first value was gone, so it fails on both halves.
TestPrimarySelectionEnumerates/TestPrimarySelectionWatch— enumerationand watching are separate paths on every backend, asserted against a selection
the other clipboard does not hold.
TestPrimarySelectionDegradesSafely— the platforms without a secondclipboard, per above.
TestWaylandPrimarySelection— cross-process viawl-paste --primary. The twoselections are set by different requests and announced by different events, so
confusing them would look like success to any same-process test.
Verified locally on darwin (the degradation half). X11 and Wayland are covered
by CI; the Wayland test skips if sway's data-control manager predates v2.
Fixes #67