Skip to content

clipboard: reach the primary (middle-click) selection (#67) - #167

Merged
changkun merged 2 commits into
mainfrom
claude/issue-67-primary-selection
Aug 21, 2026
Merged

clipboard: reach the primary (middle-click) selection (#67)#167
changkun merged 2 commits into
mainfrom
claude/issue-67-primary-selection

Conversation

@changkun

Copy link
Copy Markdown
Member

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 — copying does not change the selection. 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.

API

You picked options on the existing calls, and then the Format-implements-
Option resolution for the variadic clash:

type Option interface{ apply(*config) }

func FromPrimary() Option

sel := clipboard.Read(clipboard.FmtText, clipboard.FromPrimary())
ch  := clipboard.Watch(ctx, clipboard.FmtText, clipboard.FromPrimary())

Format and Item implementing Option is load-bearing, not decoration:
Watch and WriteAll had already spent their variadic slot, and Go allows only
one, so opts ...Option could not have been added to them at all. One
variadic slot now carries both what an operation acts on and how — and every
existing call site keeps compiling, which matters because Read, Write and
Watch have been public since v0.1. TestOptionsAreSourceCompatible pins that.

WriteFiles changed shape: (paths ...string)(paths []string, opts ...Option). A string cannot implement Option without swallowing every stray
string 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

Backend Mechanism
X11, BSD PRIMARY instead of CLIPBOARD as the selection atom. Ownership, TARGETS and the serve loop are untouched — selections are a general mechanism there and CLIPBOARD is just one atom.
Wayland set_primary_selection / the primary_selection event. Needed the data-control manager bound at version 2 instead of the hardcoded 1; below that it reports unsupported without disturbing the ordinary clipboard.
Windows, macOS, mobile, CGO-disabled No second clipboard: read returns nil, write is refused.

Refusing the write is the point. Redirecting it to the ordinary clipboard would
silently destroy whatever the user had copied, so
TestPrimarySelectionDegradesSafely asserts exactly that — and that a watch
which 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 FromPrimary read cannot race a normal one. That
meant renaming two Wayland locals that would otherwise have shadowed it:
selectioncurrent, and a closure's selofferID. 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.

Design notes in specs/primary-selection.md.

Tests

  • TestPrimarySelectionIsIndependent — the one that fails without this: write
    different 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 — enumeration
    and watching are separate paths on every backend, asserted against a selection
    the other clipboard does not hold.
  • TestPrimarySelectionDegradesSafely — the platforms without a second
    clipboard, per above.
  • TestWaylandPrimarySelection — cross-process via wl-paste --primary. 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.

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

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.
@changkun
changkun merged commit 5f89cbb into main Aug 21, 2026
7 checks passed
@changkun
changkun deleted the claude/issue-67-primary-selection branch August 21, 2026 07:22
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.

linux: support 2nd clipboard

1 participant