implement primary selection v1

Advertises zwp_primary_selection_v1 so text editors can publish and
middle-click-paste without a full clipboard roundtrip.  The device-manager
global hooks the seat; the request_set_primary_selection handler installs
the source on the seat (wlroots only emits the request), and the seat's
focus-change/set fan-out delivers the offer to the keyboard-focused tab's
device, mirroring the clipboard path.  No host bridging: the selection
stays within hosted tabs for now.

wltest gains --primary-set <text> and --primary-demand <file>.  The setter
defers its set_selection until a seat-granted serial arrives (wlroots
rejects serials it never handed out), the demander pulls the payload over a
pipe polling both the pipe and the display socket (the compositor forwards
the fd to the owning tab, whose reply arrives with no display event of
ours).  The smoke test cycles between the two tabs so the setter gets a
serial and the demander is refocused to flush the offer, then verifies the
payload lands in the file.
This commit is contained in:
Andrew Hamilton 2026-08-19 19:36:56 +10:00
parent 3eda7016e6
commit f1d92afe19
4 changed files with 273 additions and 4 deletions

23
kick.c
View file

@ -87,6 +87,8 @@
#include <wlr/types/wlr_pointer.h> #include <wlr/types/wlr_pointer.h>
#include <wlr/types/wlr_pointer_constraints_v1.h> #include <wlr/types/wlr_pointer_constraints_v1.h>
#include <wlr/types/wlr_presentation_time.h> #include <wlr/types/wlr_presentation_time.h>
#include <wlr/types/wlr_primary_selection.h>
#include <wlr/types/wlr_primary_selection_v1.h>
#include <wlr/types/wlr_relative_pointer_v1.h> #include <wlr/types/wlr_relative_pointer_v1.h>
#include <wlr/types/wlr_scene.h> #include <wlr/types/wlr_scene.h>
#include <wlr/types/wlr_seat.h> #include <wlr/types/wlr_seat.h>
@ -202,6 +204,11 @@ struct kick_server {
struct wlr_presentation *presentation; struct wlr_presentation *presentation;
struct wlr_xdg_output_manager_v1 *xdg_output_mgr; struct wlr_xdg_output_manager_v1 *xdg_output_mgr;
struct wlr_viewporter *viewporter; struct wlr_viewporter *viewporter;
/* Primary selection (zwp_primary_selection_v1). Clients request the
selection on their seat device; the request handler below is what
actually installs it on the seat, mirroring the clipboard path. */
struct wlr_primary_selection_v1_device_manager *primary_selection_mgr;
struct wl_listener request_set_primary_selection;
/* Pointer constraints + relative pointer (zwp_pointer_constraints_v1 / /* Pointer constraints + relative pointer (zwp_pointer_constraints_v1 /
zwp_relative_pointer_v1). Only the focused surface's constraint is zwp_relative_pointer_v1). Only the focused surface's constraint is
active; its region lives in the surface's local space, so the surface's active; its region lives in the surface's local space, so the surface's
@ -3384,6 +3391,13 @@ static void bridge_seat_request_set_selection(struct wl_listener *l, void *data)
wlr_seat_set_selection(server.seat, event->source, event->serial); wlr_seat_set_selection(server.seat, event->source, event->serial);
} }
static void seat_request_set_primary_selection(struct wl_listener *l,
void *data) {
(void)l;
struct wlr_seat_request_set_primary_selection_event *event = data;
wlr_seat_set_primary_selection(server.seat, event->source, event->serial);
}
static void bridge_host_set_selection(struct wlr_data_source *src) { static void bridge_host_set_selection(struct wlr_data_source *src) {
bridge_set_inner_src(src); bridge_set_inner_src(src);
@ -5969,6 +5983,15 @@ int main(int argc, char *argv[]) {
server.seat_pointer_focus.notify = handle_seat_pointer_focus; server.seat_pointer_focus.notify = handle_seat_pointer_focus;
wl_signal_add(&server.seat->pointer_state.events.focus_change, wl_signal_add(&server.seat->pointer_state.events.focus_change,
&server.seat_pointer_focus); &server.seat_pointer_focus);
/* Primary selection (zwp_primary_selection_v1): advertise the manager so
clients like text editors can set the selection with their own serial
(no roundtrip needed); the request handler installs it on the seat,
which distributes it to the other focused tab clients. */
server.primary_selection_mgr =
wlr_primary_selection_v1_device_manager_create(server.wl_display);
server.request_set_primary_selection.notify = seat_request_set_primary_selection;
wl_signal_add(&server.seat->events.request_set_primary_selection,
&server.request_set_primary_selection);
/* The request_set_selection handler is what actually performs the /* The request_set_selection handler is what actually performs the
wlr_seat_set_selection for clients using the data-control manager; the wlr_seat_set_selection for clients using the data-control manager; the
host-forwarding it triggers is a no-op without an outer host (backend host-forwarding it triggers is a no-op without an outer host (backend

View file

@ -92,6 +92,10 @@ protocols = {
wl_protocols_dir / 'unstable/relative-pointer/relative-pointer-unstable-v1.xml', wl_protocols_dir / 'unstable/relative-pointer/relative-pointer-unstable-v1.xml',
'relative-pointer-unstable-v1', 'relative-pointer-unstable-v1',
], ],
'primary-selection': [
wl_protocols_dir / 'unstable/primary-selection/primary-selection-unstable-v1.xml',
'primary-selection-unstable-v1',
],
} }
protocol_sources = [] protocol_sources = []
@ -153,6 +157,7 @@ executable('wltest',
code_by_name['tablet-v2'], code_by_name['tablet-v2'],
code_by_name['pointer-constraints'], code_by_name['pointer-constraints'],
code_by_name['relative-pointer'], code_by_name['relative-pointer'],
code_by_name['primary-selection'],
client_headers['xdg-shell'], client_headers['xdg-shell'],
client_headers['cursor-shape'], client_headers['cursor-shape'],
dependencies: dependency('wayland-client')) dependencies: dependency('wayland-client'))

View file

@ -995,6 +995,46 @@ def test_standalone_headless_protocol_globals(tmp_path):
s.close() s.close()
def test_standalone_headless_primary_selection_roundtrip(tmp_path):
"""zwp_primary_selection_v1: a tab's primary selection must reach a
second tab. The device-manager global plus the request handler install
the source on the seat, which fans the offer out to the keyboard-focused
tab's device; wltest --primary-set installs a source (the first time a
seat-granted serial arrives) and --primary-demand pulls the "text/plain"
payload into a file."""
out = tmp_path / "primary.out"
s = _spawn_standalone({}, "%s --primary-set 'hello' '#ff0000'" % WL,
"%s --primary-demand '%s' '#00ff00'" % (WL, out))
try:
s.wait_any_active()
s.wait_active_frame()
assert s.proc.poll() is None
# Active on boot is the last tab (the demander). Give the setter
# keyboard focus so it receives a seat-granted serial to set with --
# wlroots rejects a set_selection serial it never handed out.
if s.active() != 0:
s.cycle(-1)
assert s.wait_active_change(1) == 0
# Any key press delivers a serial to the focused setter, which
# installs the primary selection on the receipt.
s.tap(23) # 'i', harmless
# Cycle back so the demander is (re)focused; the focus change flushes
# the current primary selection to its device. No active-change assert
# here: the demander may exit as soon as it has written the file, which
# flips active back to the setter -- so just wait for the payload.
s.cycle(+1)
deadline = time.monotonic() + 15.0
while time.monotonic() < deadline:
if out.exists():
break
time.sleep(0.1)
assert out.exists(), "primary-demand tab never received the selection"
assert out.read_text() == "hello", \
f"primary round-trip payload mismatch: {out.read_text()!r}"
finally:
s.close()
def test_standalone_headless_cursor_theme_none_boots(tmp_path): def test_standalone_headless_cursor_theme_none_boots(tmp_path):
"""No theme requested and no cursor theme installed: the builtin arrow is """No theme requested and no cursor theme installed: the builtin arrow is
used silently (a request named literally 'default' is indistinguishable used silently (a request named literally 'default' is indistinguishable

View file

@ -3,6 +3,7 @@
#include <fcntl.h> #include <fcntl.h>
#include <stdbool.h> #include <stdbool.h>
#include <stdint.h> #include <stdint.h>
#include <poll.h>
#include <stdio.h> #include <stdio.h>
#include <stdlib.h> #include <stdlib.h>
#include <string.h> #include <string.h>
@ -14,6 +15,7 @@
#include "xdg-shell-client.h" #include "xdg-shell-client.h"
#include "cursor-shape-v1-client.h" #include "cursor-shape-v1-client.h"
#include "pointer-constraints-unstable-v1-client.h" #include "pointer-constraints-unstable-v1-client.h"
#include "primary-selection-unstable-v1-client.h"
#include "relative-pointer-unstable-v1-client.h" #include "relative-pointer-unstable-v1-client.h"
/* Minimal deterministic xdg toplevel test client: shows a solid-color shm /* Minimal deterministic xdg toplevel test client: shows a solid-color shm
@ -57,6 +59,36 @@ static uint32_t cursor_shape; /* --cursor-shape value, 0 = none requested */
static bool shape_sent; static bool shape_sent;
static bool fullscreen; static bool fullscreen;
/* Primary selection: --primary-set <text> installs a source on the seat;
--primary-demand [<outfile>] pulls that selection's "text/plain" content
into the outfile and exits. Together they exercise the
zwp_primary_selection_device_manager_v1 round trip between two tabs. */
static const char *primary_text;
static const char *primary_out;
static struct zwp_primary_selection_device_manager_v1 *ps_mgr;
static struct zwp_primary_selection_device_v1 *ps_dev;
static struct zwp_primary_selection_offer_v1 *ps_offer;
static bool ps_demanded;
static bool ps_set_done;
static const struct zwp_primary_selection_source_v1_listener ps_source_listener;
/* A primary-selection set_selection serial must be one the seat actually
gave THIS client (wlr_seat_client_validate_event_serial), so the setter
cannot hammer a byzantine constant. Defer the request until the first
keyboard/pointer event serial arrives, then use it. */
static void maybe_set_primary(uint32_t serial) {
if (!primary_text || ps_set_done || !ps_dev)
return;
ps_set_done = true;
struct zwp_primary_selection_source_v1 *s =
zwp_primary_selection_device_manager_v1_create_source(ps_mgr);
zwp_primary_selection_source_v1_offer(s, "text/plain;charset=utf-8");
zwp_primary_selection_source_v1_offer(s, "text/plain");
zwp_primary_selection_source_v1_add_listener(s, &ps_source_listener, NULL);
zwp_primary_selection_device_v1_set_selection(ps_dev, s, serial);
wl_display_flush(display);
}
static int W = 1280, H = 800; static int W = 1280, H = 800;
static uint8_t PIXEL[4] = { 0xff, 0x00, 0x00, 0xff }; /* RGBA */ static uint8_t PIXEL[4] = { 0xff, 0x00, 0x00, 0xff }; /* RGBA */
@ -134,7 +166,7 @@ static void keyboard_keymap(void *data, struct wl_keyboard *kb,
static void keyboard_key(void *data, struct wl_keyboard *kb, uint32_t serial, static void keyboard_key(void *data, struct wl_keyboard *kb, uint32_t serial,
uint32_t time, uint32_t key, uint32_t state) { uint32_t time, uint32_t key, uint32_t state) {
(void)data; (void)kb; (void)serial; (void)time; (void)data; (void)kb; (void)time;
/* 'F' (evdev 33) toggles fullscreen, giving the smoke tests a real /* 'F' (evdev 33) toggles fullscreen, giving the smoke tests a real
client-initiated fullscreen request to feed the compositor. */ client-initiated fullscreen request to feed the compositor. */
if (key == 33 && state == WL_KEYBOARD_KEY_STATE_PRESSED) { if (key == 33 && state == WL_KEYBOARD_KEY_STATE_PRESSED) {
@ -144,11 +176,14 @@ static void keyboard_key(void *data, struct wl_keyboard *kb, uint32_t serial,
else else
xdg_toplevel_unset_fullscreen(toplevel); xdg_toplevel_unset_fullscreen(toplevel);
} }
if (state == WL_KEYBOARD_KEY_STATE_PRESSED)
maybe_set_primary(serial);
} }
static void keyboard_enter(void *data, struct wl_keyboard *kb, uint32_t serial, static void keyboard_enter(void *data, struct wl_keyboard *kb, uint32_t serial,
struct wl_surface *srf, struct wl_array *keys) { struct wl_surface *srf, struct wl_array *keys) {
(void)data; (void)kb; (void)serial; (void)srf; (void)keys; (void)data; (void)kb; (void)srf; (void)keys;
maybe_set_primary(serial);
} }
static void keyboard_leave(void *data, struct wl_keyboard *kb, uint32_t serial, static void keyboard_leave(void *data, struct wl_keyboard *kb, uint32_t serial,
@ -184,6 +219,7 @@ static const struct wl_keyboard_listener keyboard_listener = {
static void pointer_enter(void *data, struct wl_pointer *p, uint32_t serial, static void pointer_enter(void *data, struct wl_pointer *p, uint32_t serial,
struct wl_surface *srf, wl_fixed_t sx, wl_fixed_t sy) { struct wl_surface *srf, wl_fixed_t sx, wl_fixed_t sy) {
(void)data; (void)p; (void)srf; (void)sx; (void)sy; (void)data; (void)p; (void)srf; (void)sx; (void)sy;
maybe_set_primary(serial);
/* wl_display_dispatch does not flush queued requests, so push the shape /* wl_display_dispatch does not flush queued requests, so push the shape
out immediately; otherwise the compositor only sees it after the next out immediately; otherwise the compositor only sees it after the next
roundtrip the client happens to do. */ roundtrip the client happens to do. */
@ -309,6 +345,11 @@ static void registry_global(void *data, struct wl_registry *reg, uint32_t name,
rel_mgr = wl_registry_bind(reg, name, rel_mgr = wl_registry_bind(reg, name,
&zwp_relative_pointer_manager_v1_interface, &zwp_relative_pointer_manager_v1_interface,
version < 1 ? version : 1); version < 1 ? version : 1);
} else if (strcmp(interface,
zwp_primary_selection_device_manager_v1_interface.name) == 0) {
ps_mgr = wl_registry_bind(reg, name,
&zwp_primary_selection_device_manager_v1_interface,
version < 1 ? version : 1);
} }
} }
@ -443,10 +484,139 @@ static const struct { uint32_t shape; const char *name; } SHAPES[] = {
{ WP_CURSOR_SHAPE_DEVICE_V1_SHAPE_CROSSHAIR, "crosshair" }, { WP_CURSOR_SHAPE_DEVICE_V1_SHAPE_CROSSHAIR, "crosshair" },
}; };
static void ps_source_send(void *data, struct zwp_primary_selection_source_v1 *s,
const char *mime, int32_t fd) {
(void)data; (void)s; (void)mime;
size_t n = primary_text ? strlen(primary_text) : 0;
if (n > 0 && write(fd, primary_text, n) < 0) {
/* best effort; the reader will see a short/empty transfer */
}
close(fd);
}
static void ps_source_cancelled(void *data,
struct zwp_primary_selection_source_v1 *s) {
(void)data; (void)s;
}
static const struct zwp_primary_selection_source_v1_listener ps_source_listener = {
.send = ps_source_send,
.cancelled = ps_source_cancelled,
};
static void demand_primary(void);
static void ps_offer_offer(void *data, struct zwp_primary_selection_offer_v1 *o,
const char *mime) {
(void)data; (void)o;
if (ps_demanded || !primary_out)
return;
if (strcmp(mime, "text/plain") == 0) {
/* Deferred to the main loop: demand_primary() dispatches the display
to let the setter answer, which cannot nest inside this callback. */
ps_demanded = true;
}
}
static const struct zwp_primary_selection_offer_v1_listener ps_offer_listener = {
.offer = ps_offer_offer,
};
static void ps_device_data_offer(void *data,
struct zwp_primary_selection_device_v1 *d,
struct zwp_primary_selection_offer_v1 *offer) {
(void)data; (void)d;
ps_offer = offer;
zwp_primary_selection_offer_v1_add_listener(offer, &ps_offer_listener, NULL);
}
static void ps_device_selection(void *data,
struct zwp_primary_selection_device_v1 *d,
struct zwp_primary_selection_offer_v1 *offer) {
(void)data; (void)d; (void)offer;
}
static const struct zwp_primary_selection_device_v1_listener ps_device_listener = {
.data_offer = ps_device_data_offer,
.selection = ps_device_selection,
};
/* Receive the selection over a pipe. Reading can't be driven by display
events alone: the compositor forwards the fd to the owning *setter* tab
via its `send` event, so the data arrives on the pipe with no display
event of ours to wake a wl_display_dispatch(). Poll both descriptors and
only dispatch when the display socket actually has events. */
static void demand_primary(void) {
int pfd[2];
if (pipe(pfd) != 0) {
fprintf(stderr, "wltest: primary pipe failed\n");
exit(1);
}
zwp_primary_selection_offer_v1_receive(ps_offer, "text/plain", pfd[1]);
close(pfd[1]);
wl_display_flush(display);
int disp_fd = wl_display_get_fd(display);
char *acc = NULL;
size_t acc_len = 0, acc_cap = 0;
char buf[4096];
for (;;) {
struct pollfd pfds[2];
pfds[0].fd = pfd[0];
pfds[0].events = POLLIN;
pfds[1].fd = disp_fd;
pfds[1].events = POLLIN;
int r = poll(pfds, 2, -1);
if (r < 0) {
if (errno == EINTR)
continue;
break;
}
if (pfds[0].revents & (POLLIN | POLLHUP)) {
ssize_t n = read(pfd[0], buf, sizeof buf);
if (n > 0) {
if (acc_len + (size_t)n > acc_cap) {
acc_cap = (acc_cap ? acc_cap * 2 : 1024) + (size_t)n;
acc = realloc(acc, acc_cap);
if (!acc) {
fprintf(stderr, "wltest: primary alloc failed\n");
exit(1);
}
}
memcpy(acc + acc_len, buf, (size_t)n);
acc_len += (size_t)n;
continue;
}
if (n == 0)
break;
if (errno == EINTR)
continue;
if (errno == EAGAIN || errno == EWOULDBLOCK)
continue;
break;
}
if (pfds[1].revents & (POLLIN | POLLHUP | POLLERR)) {
if (wl_display_dispatch(display) < 0)
break;
}
}
int fd = open(primary_out, O_CREAT | O_TRUNC | O_WRONLY, 0600);
if (fd < 0) {
fprintf(stderr, "wltest: cannot open %s\n", primary_out);
exit(1);
}
ssize_t w = write(fd, acc ? acc : "", acc_len);
(void)w;
close(fd);
close(pfd[0]);
free(acc);
exit(0);
}
static void print_usage(const char *argv0) { static void print_usage(const char *argv0) {
fprintf(stderr, "usage: %s [--app-id <id>] [--extra #RRGGBB] " fprintf(stderr, "usage: %s [--app-id <id>] [--extra #RRGGBB] "
"[--cursor-shape name] [--globals-out <file>] " "[--cursor-shape name] [--globals-out <file>] "
"[--constrain locked|confined] [--confine-rect X Y W H] " "[--constrain locked|confined] [--confine-rect X Y W H] "
"[--primary-set <text>] [--primary-demand <file>] "
"#RRGGBB [WIDTHxHEIGHT]\n" "#RRGGBB [WIDTHxHEIGHT]\n"
" %s shows a solid-color xdg_toplevel, e.g. #ff0000\n" " %s shows a solid-color xdg_toplevel, e.g. #ff0000\n"
" --extra also opens a second toplevel from the same client\n" " --extra also opens a second toplevel from the same client\n"
@ -454,7 +624,9 @@ static void print_usage(const char *argv0) {
"pointer enter (default|text|pointer|crosshair)\n" "pointer enter (default|text|pointer|crosshair)\n"
" --globals-out lists advertised globals to <file> and exits\n" " --globals-out lists advertised globals to <file> and exits\n"
" --constrain requests a pointer constraint (locked/confined)\n" " --constrain requests a pointer constraint (locked/confined)\n"
" --confine-rect sets the confined pointer's region\n", " --confine-rect sets the confined pointer's region\n"
" --primary-set installs <text> as the primary selection\n"
" --primary-demand pulls the selection into <file> and exits\n",
argv0, argv0); argv0, argv0);
} }
@ -525,6 +697,20 @@ int main(int argc, char **argv) {
return 1; return 1;
} }
i += 2; i += 2;
} else if (strcmp(argv[i], "--primary-set") == 0) {
if (i + 1 >= argc) {
fprintf(stderr, "wltest: --primary-set needs a value\n");
return 1;
}
primary_text = argv[i + 1];
i += 2;
} else if (strcmp(argv[i], "--primary-demand") == 0) {
if (i + 1 >= argc) {
fprintf(stderr, "wltest: --primary-demand needs a path\n");
return 1;
}
primary_out = argv[i + 1];
i += 2;
} else if (strcmp(argv[i], "--confine-rect") == 0) { } else if (strcmp(argv[i], "--confine-rect") == 0) {
if (i + 4 >= argc) { if (i + 4 >= argc) {
fprintf(stderr, "wltest: --confine-rect needs X Y W H\n"); fprintf(stderr, "wltest: --confine-rect needs X Y W H\n");
@ -615,13 +801,28 @@ int main(int argc, char **argv) {
rel_ptr = zwp_relative_pointer_manager_v1_get_relative_pointer(rel_mgr, pointer); rel_ptr = zwp_relative_pointer_manager_v1_get_relative_pointer(rel_mgr, pointer);
} }
/* Primary selection: the setter installs a source; the demander just
listens and pulls on offer. get_device is what hooks the device into
the seat's set_primary_selection fan-out, so both sides need it. The
setter's request itself is deferred to maybe_set_primary() so it can
attach a seat-granted serial. */
if (ps_mgr && seat) {
ps_dev = zwp_primary_selection_device_manager_v1_get_device(ps_mgr, seat);
zwp_primary_selection_device_v1_add_listener(ps_dev, &ps_device_listener, NULL);
}
if (constrain_mode != NO_CONSTRAINT) if (constrain_mode != NO_CONSTRAINT)
request_constraint(); request_constraint();
wl_surface_commit(surface); wl_surface_commit(surface);
wl_display_roundtrip(display); wl_display_roundtrip(display);
while (wl_display_dispatch(display) >= 0) { for (;;) {
if (ps_demanded && primary_out) {
demand_primary(); /* never returns: reads, writes, exits */
}
if (wl_display_dispatch(display) < 0)
break;
} }
return 0; return 0;
} }