esc

Externally Scriptable Editor

git clone git://mccd.space/esc

commit 2e672b43b33d8158e7c6876b887480cefd2bff12
parent 10a81c56c8d241cb8a20acdd0a21ecfffd068f61
Author: Marc Coquand <marc@coquand.email>
Date:   Wed, 25 Feb 2026 16:21:12 +0100

Test wayland primary selection

Diffstat:
MMakefile | 21+++++++++++++++++----
Mmain.c | 5++++-
Aprimary-selection-unstable-v1-client-protocol.h | 582++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aprimary-selection-unstable-v1-protocol.c | 116+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Munix_utils.h | 5+++++
Awayland_primary.c | 260+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Awayland_primary.h | 17+++++++++++++++++
7 files changed, 1001 insertions(+), 5 deletions(-)
diff --git a/Makefile b/Makefile
@@ -5,15 +5,28 @@ SANITIZE = -fsanitize=address,undefined
 SDL3_CFLAGS = $(shell pkg-config --cflags sdl3)
 SDL3_LIBS   = $(shell pkg-config --libs sdl3)
 
+WAYLAND_SCANNER ?= wayland-scanner
+PS_XML = /usr/share/wayland-protocols/unstable/primary-selection/primary-selection-unstable-v1.xml
+PS_HDR = primary-selection-unstable-v1-client-protocol.h
+PS_SRC = primary-selection-unstable-v1-protocol.c
+
 .PHONY: all test clean
 
 all: esc
 
+$(PS_HDR): $(PS_XML)
+	$(WAYLAND_SCANNER) client-header $< $@
+
+$(PS_SRC): $(PS_XML)
+	$(WAYLAND_SCANNER) private-code $< $@
+
 esc: main.c unix_utils.c unix_utils.h editor.c editor.h \
-     strbuf.c strbuf.h fuse_ipc.c fuse_ipc.h renderer.c renderer.h
+     strbuf.c strbuf.h fuse_ipc.c fuse_ipc.h renderer.c renderer.h \
+     wayland_primary.c wayland_primary.h $(PS_HDR) $(PS_SRC)
 	$(CC) $(CFLAGS) $(CFLAGS_OPT) main.c unix_utils.c editor.c strbuf.c \
-	      fuse_ipc.c renderer.c -o esc \
-	      $(shell pkg-config --cflags --libs sdl3 sdl3-ttf fuse)
+	      fuse_ipc.c renderer.c wayland_primary.c $(PS_SRC) -o esc \
+	      $(shell pkg-config --cflags --libs sdl3 sdl3-ttf fuse) \
+	      $(shell pkg-config --libs wayland-client)
 
 test: tests/test_strbuf tests/test_editor
 	@echo "--- running test_strbuf ---"
@@ -32,4 +45,4 @@ tests/test_editor: tests/test_editor.c tests/test_harness.h \
 	      $(SDL3_LIBS) -o tests/test_editor
 
 clean:
-	rm -f esc tests/test_strbuf tests/test_editor
+	rm -f esc tests/test_strbuf tests/test_editor $(PS_HDR) $(PS_SRC)
diff --git a/main.c b/main.c
@@ -2,6 +2,7 @@
 #include "fuse_ipc.h"
 #include "renderer.h"
 #include "unix_utils.h"
+#include "wayland_primary.h"
 #include <SDL3/SDL.h>
 #include <SDL3/SDL_clipboard.h>
 #include <SDL3/SDL_events.h>
@@ -277,7 +278,7 @@ void handle_events(Editor *ed, SDL_Renderer *renderer, float *scroll_x,
 		if (editor_has_selection(ed)) {
 			char *sel = editor_get_selection(ed);
 			if (sel) {
-				SDL_SetPrimarySelectionText(sel);
+				wayland_primary_set(sel);
 				free(sel);
 			}
 		}
@@ -318,6 +319,7 @@ int main(int argc, char *argv[]) {
 	float line_height = (float)TTF_GetFontHeight(font) * 1.5;
 	float cursor_height = (float)TTF_GetFontHeight(font);
 	Editor *ed = editor_create(char_width, line_height);
+	wayland_primary_init(window);
 	if (argc > 1) {
 		editor_load_file(ed, argv[1]);
 		editor_parse_ansi_codes(ed);
@@ -338,6 +340,7 @@ int main(int argc, char *argv[]) {
 	while (running) {
 		handle_events(ed, renderer, &scroll_x, &scroll_y, line_height,
 			      &running, &is_dragging);
+		wayland_primary_dispatch();
 
 		ctx.scroll_x = scroll_x;
 		ctx.scroll_y = scroll_y;
diff --git a/primary-selection-unstable-v1-client-protocol.h b/primary-selection-unstable-v1-client-protocol.h
@@ -0,0 +1,582 @@
+/* Generated by wayland-scanner 1.23.1 */
+
+#ifndef WP_PRIMARY_SELECTION_UNSTABLE_V1_CLIENT_PROTOCOL_H
+#define WP_PRIMARY_SELECTION_UNSTABLE_V1_CLIENT_PROTOCOL_H
+
+#include <stdint.h>
+#include <stddef.h>
+#include "wayland-client.h"
+
+#ifdef  __cplusplus
+extern "C" {
+#endif
+
+/**
+ * @page page_wp_primary_selection_unstable_v1 The wp_primary_selection_unstable_v1 protocol
+ * Primary selection protocol
+ *
+ * @section page_desc_wp_primary_selection_unstable_v1 Description
+ *
+ * This protocol provides the ability to have a primary selection device to
+ * match that of the X server. This primary selection is a shortcut to the
+ * common clipboard selection, where text just needs to be selected in order
+ * to allow copying it elsewhere. The de facto way to perform this action
+ * is the middle mouse button, although it is not limited to this one.
+ *
+ * Clients wishing to honor primary selection should create a primary
+ * selection source and set it as the selection through
+ * wp_primary_selection_device.set_selection whenever the text selection
+ * changes. In order to minimize calls in pointer-driven text selection,
+ * it should happen only once after the operation finished. Similarly,
+ * a NULL source should be set when text is unselected.
+ *
+ * wp_primary_selection_offer objects are first announced through the
+ * wp_primary_selection_device.data_offer event. Immediately after this event,
+ * the primary data offer will emit wp_primary_selection_offer.offer events
+ * to let know of the mime types being offered.
+ *
+ * When the primary selection changes, the client with the keyboard focus
+ * will receive wp_primary_selection_device.selection events. Only the client
+ * with the keyboard focus will receive such events with a non-NULL
+ * wp_primary_selection_offer. Across keyboard focus changes, previously
+ * focused clients will receive wp_primary_selection_device.events with a
+ * NULL wp_primary_selection_offer.
+ *
+ * In order to request the primary selection data, the client must pass
+ * a recent serial pertaining to the press event that is triggering the
+ * operation, if the compositor deems the serial valid and recent, the
+ * wp_primary_selection_source.send event will happen in the other end
+ * to let the transfer begin. The client owning the primary selection
+ * should write the requested data, and close the file descriptor
+ * immediately.
+ *
+ * If the primary selection owner client disappeared during the transfer,
+ * the client reading the data will receive a
+ * wp_primary_selection_device.selection event with a NULL
+ * wp_primary_selection_offer, the client should take this as a hint
+ * to finish the reads related to the no longer existing offer.
+ *
+ * The primary selection owner should be checking for errors during
+ * writes, merely cancelling the ongoing transfer if any happened.
+ *
+ * @section page_ifaces_wp_primary_selection_unstable_v1 Interfaces
+ * - @subpage page_iface_zwp_primary_selection_device_manager_v1 - X primary selection emulation
+ * - @subpage page_iface_zwp_primary_selection_device_v1 - 
+ * - @subpage page_iface_zwp_primary_selection_offer_v1 - offer to transfer primary selection contents
+ * - @subpage page_iface_zwp_primary_selection_source_v1 - offer to replace the contents of the primary selection
+ * @section page_copyright_wp_primary_selection_unstable_v1 Copyright
+ * <pre>
+ *
+ * Copyright © 2015, 2016 Red Hat
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a
+ * copy of this software and associated documentation files (the "Software"),
+ * to deal in the Software without restriction, including without limitation
+ * the rights to use, copy, modify, merge, publish, distribute, sublicense,
+ * and/or sell copies of the Software, and to permit persons to whom the
+ * Software is furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice (including the next
+ * paragraph) shall be included in all copies or substantial portions of the
+ * Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.  IN NO EVENT SHALL
+ * THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+ * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+ * DEALINGS IN THE SOFTWARE.
+ * </pre>
+ */
+struct wl_seat;
+struct zwp_primary_selection_device_manager_v1;
+struct zwp_primary_selection_device_v1;
+struct zwp_primary_selection_offer_v1;
+struct zwp_primary_selection_source_v1;
+
+#ifndef ZWP_PRIMARY_SELECTION_DEVICE_MANAGER_V1_INTERFACE
+#define ZWP_PRIMARY_SELECTION_DEVICE_MANAGER_V1_INTERFACE
+/**
+ * @page page_iface_zwp_primary_selection_device_manager_v1 zwp_primary_selection_device_manager_v1
+ * @section page_iface_zwp_primary_selection_device_manager_v1_desc Description
+ *
+ * The primary selection device manager is a singleton global object that
+ * provides access to the primary selection. It allows to create
+ * wp_primary_selection_source objects, as well as retrieving the per-seat
+ * wp_primary_selection_device objects.
+ * @section page_iface_zwp_primary_selection_device_manager_v1_api API
+ * See @ref iface_zwp_primary_selection_device_manager_v1.
+ */
+/**
+ * @defgroup iface_zwp_primary_selection_device_manager_v1 The zwp_primary_selection_device_manager_v1 interface
+ *
+ * The primary selection device manager is a singleton global object that
+ * provides access to the primary selection. It allows to create
+ * wp_primary_selection_source objects, as well as retrieving the per-seat
+ * wp_primary_selection_device objects.
+ */
+extern const struct wl_interface zwp_primary_selection_device_manager_v1_interface;
+#endif
+#ifndef ZWP_PRIMARY_SELECTION_DEVICE_V1_INTERFACE
+#define ZWP_PRIMARY_SELECTION_DEVICE_V1_INTERFACE
+/**
+ * @page page_iface_zwp_primary_selection_device_v1 zwp_primary_selection_device_v1
+ * @section page_iface_zwp_primary_selection_device_v1_api API
+ * See @ref iface_zwp_primary_selection_device_v1.
+ */
+/**
+ * @defgroup iface_zwp_primary_selection_device_v1 The zwp_primary_selection_device_v1 interface
+ */
+extern const struct wl_interface zwp_primary_selection_device_v1_interface;
+#endif
+#ifndef ZWP_PRIMARY_SELECTION_OFFER_V1_INTERFACE
+#define ZWP_PRIMARY_SELECTION_OFFER_V1_INTERFACE
+/**
+ * @page page_iface_zwp_primary_selection_offer_v1 zwp_primary_selection_offer_v1
+ * @section page_iface_zwp_primary_selection_offer_v1_desc Description
+ *
+ * A wp_primary_selection_offer represents an offer to transfer the contents
+ * of the primary selection clipboard to the client. Similar to
+ * wl_data_offer, the offer also describes the mime types that the data can
+ * be converted to and provides the mechanisms for transferring the data
+ * directly to the client.
+ * @section page_iface_zwp_primary_selection_offer_v1_api API
+ * See @ref iface_zwp_primary_selection_offer_v1.
+ */
+/**
+ * @defgroup iface_zwp_primary_selection_offer_v1 The zwp_primary_selection_offer_v1 interface
+ *
+ * A wp_primary_selection_offer represents an offer to transfer the contents
+ * of the primary selection clipboard to the client. Similar to
+ * wl_data_offer, the offer also describes the mime types that the data can
+ * be converted to and provides the mechanisms for transferring the data
+ * directly to the client.
+ */
+extern const struct wl_interface zwp_primary_selection_offer_v1_interface;
+#endif
+#ifndef ZWP_PRIMARY_SELECTION_SOURCE_V1_INTERFACE
+#define ZWP_PRIMARY_SELECTION_SOURCE_V1_INTERFACE
+/**
+ * @page page_iface_zwp_primary_selection_source_v1 zwp_primary_selection_source_v1
+ * @section page_iface_zwp_primary_selection_source_v1_desc Description
+ *
+ * The source side of a wp_primary_selection_offer, it provides a way to
+ * describe the offered data and respond to requests to transfer the
+ * requested contents of the primary selection clipboard.
+ * @section page_iface_zwp_primary_selection_source_v1_api API
+ * See @ref iface_zwp_primary_selection_source_v1.
+ */
+/**
+ * @defgroup iface_zwp_primary_selection_source_v1 The zwp_primary_selection_source_v1 interface
+ *
+ * The source side of a wp_primary_selection_offer, it provides a way to
+ * describe the offered data and respond to requests to transfer the
+ * requested contents of the primary selection clipboard.
+ */
+extern const struct wl_interface zwp_primary_selection_source_v1_interface;
+#endif
+
+#define ZWP_PRIMARY_SELECTION_DEVICE_MANAGER_V1_CREATE_SOURCE 0
+#define ZWP_PRIMARY_SELECTION_DEVICE_MANAGER_V1_GET_DEVICE 1
+#define ZWP_PRIMARY_SELECTION_DEVICE_MANAGER_V1_DESTROY 2
+
+
+/**
+ * @ingroup iface_zwp_primary_selection_device_manager_v1
+ */
+#define ZWP_PRIMARY_SELECTION_DEVICE_MANAGER_V1_CREATE_SOURCE_SINCE_VERSION 1
+/**
+ * @ingroup iface_zwp_primary_selection_device_manager_v1
+ */
+#define ZWP_PRIMARY_SELECTION_DEVICE_MANAGER_V1_GET_DEVICE_SINCE_VERSION 1
+/**
+ * @ingroup iface_zwp_primary_selection_device_manager_v1
+ */
+#define ZWP_PRIMARY_SELECTION_DEVICE_MANAGER_V1_DESTROY_SINCE_VERSION 1
+
+/** @ingroup iface_zwp_primary_selection_device_manager_v1 */
+static inline void
+zwp_primary_selection_device_manager_v1_set_user_data(struct zwp_primary_selection_device_manager_v1 *zwp_primary_selection_device_manager_v1, void *user_data)
+{
+	wl_proxy_set_user_data((struct wl_proxy *) zwp_primary_selection_device_manager_v1, user_data);
+}
+
+/** @ingroup iface_zwp_primary_selection_device_manager_v1 */
+static inline void *
+zwp_primary_selection_device_manager_v1_get_user_data(struct zwp_primary_selection_device_manager_v1 *zwp_primary_selection_device_manager_v1)
+{
+	return wl_proxy_get_user_data((struct wl_proxy *) zwp_primary_selection_device_manager_v1);
+}
+
+static inline uint32_t
+zwp_primary_selection_device_manager_v1_get_version(struct zwp_primary_selection_device_manager_v1 *zwp_primary_selection_device_manager_v1)
+{
+	return wl_proxy_get_version((struct wl_proxy *) zwp_primary_selection_device_manager_v1);
+}
+
+/**
+ * @ingroup iface_zwp_primary_selection_device_manager_v1
+ *
+ * Create a new primary selection source.
+ */
+static inline struct zwp_primary_selection_source_v1 *
+zwp_primary_selection_device_manager_v1_create_source(struct zwp_primary_selection_device_manager_v1 *zwp_primary_selection_device_manager_v1)
+{
+	struct wl_proxy *id;
+
+	id = wl_proxy_marshal_flags((struct wl_proxy *) zwp_primary_selection_device_manager_v1,
+			 ZWP_PRIMARY_SELECTION_DEVICE_MANAGER_V1_CREATE_SOURCE, &zwp_primary_selection_source_v1_interface, wl_proxy_get_version((struct wl_proxy *) zwp_primary_selection_device_manager_v1), 0, NULL);
+
+	return (struct zwp_primary_selection_source_v1 *) id;
+}
+
+/**
+ * @ingroup iface_zwp_primary_selection_device_manager_v1
+ *
+ * Create a new data device for a given seat.
+ */
+static inline struct zwp_primary_selection_device_v1 *
+zwp_primary_selection_device_manager_v1_get_device(struct zwp_primary_selection_device_manager_v1 *zwp_primary_selection_device_manager_v1, struct wl_seat *seat)
+{
+	struct wl_proxy *id;
+
+	id = wl_proxy_marshal_flags((struct wl_proxy *) zwp_primary_selection_device_manager_v1,
+			 ZWP_PRIMARY_SELECTION_DEVICE_MANAGER_V1_GET_DEVICE, &zwp_primary_selection_device_v1_interface, wl_proxy_get_version((struct wl_proxy *) zwp_primary_selection_device_manager_v1), 0, NULL, seat);
+
+	return (struct zwp_primary_selection_device_v1 *) id;
+}
+
+/**
+ * @ingroup iface_zwp_primary_selection_device_manager_v1
+ *
+ * Destroy the primary selection device manager.
+ */
+static inline void
+zwp_primary_selection_device_manager_v1_destroy(struct zwp_primary_selection_device_manager_v1 *zwp_primary_selection_device_manager_v1)
+{
+	wl_proxy_marshal_flags((struct wl_proxy *) zwp_primary_selection_device_manager_v1,
+			 ZWP_PRIMARY_SELECTION_DEVICE_MANAGER_V1_DESTROY, NULL, wl_proxy_get_version((struct wl_proxy *) zwp_primary_selection_device_manager_v1), WL_MARSHAL_FLAG_DESTROY);
+}
+
+/**
+ * @ingroup iface_zwp_primary_selection_device_v1
+ * @struct zwp_primary_selection_device_v1_listener
+ */
+struct zwp_primary_selection_device_v1_listener {
+	/**
+	 * introduce a new wp_primary_selection_offer
+	 *
+	 * Introduces a new wp_primary_selection_offer object that may be
+	 * used to receive the current primary selection. Immediately
+	 * following this event, the new wp_primary_selection_offer object
+	 * will send wp_primary_selection_offer.offer events to describe
+	 * the offered mime types.
+	 */
+	void (*data_offer)(void *data,
+			   struct zwp_primary_selection_device_v1 *zwp_primary_selection_device_v1,
+			   struct zwp_primary_selection_offer_v1 *offer);
+	/**
+	 * advertise a new primary selection
+	 *
+	 * The wp_primary_selection_device.selection event is sent to
+	 * notify the client of a new primary selection. This event is sent
+	 * after the wp_primary_selection.data_offer event introducing this
+	 * object, and after the offer has announced its mimetypes through
+	 * wp_primary_selection_offer.offer.
+	 *
+	 * The data_offer is valid until a new offer or NULL is received or
+	 * until the client loses keyboard focus. The client must destroy
+	 * the previous selection data_offer, if any, upon receiving this
+	 * event.
+	 */
+	void (*selection)(void *data,
+			  struct zwp_primary_selection_device_v1 *zwp_primary_selection_device_v1,
+			  struct zwp_primary_selection_offer_v1 *id);
+};
+
+/**
+ * @ingroup iface_zwp_primary_selection_device_v1
+ */
+static inline int
+zwp_primary_selection_device_v1_add_listener(struct zwp_primary_selection_device_v1 *zwp_primary_selection_device_v1,
+					     const struct zwp_primary_selection_device_v1_listener *listener, void *data)
+{
+	return wl_proxy_add_listener((struct wl_proxy *) zwp_primary_selection_device_v1,
+				     (void (**)(void)) listener, data);
+}
+
+#define ZWP_PRIMARY_SELECTION_DEVICE_V1_SET_SELECTION 0
+#define ZWP_PRIMARY_SELECTION_DEVICE_V1_DESTROY 1
+
+/**
+ * @ingroup iface_zwp_primary_selection_device_v1
+ */
+#define ZWP_PRIMARY_SELECTION_DEVICE_V1_DATA_OFFER_SINCE_VERSION 1
+/**
+ * @ingroup iface_zwp_primary_selection_device_v1
+ */
+#define ZWP_PRIMARY_SELECTION_DEVICE_V1_SELECTION_SINCE_VERSION 1
+
+/**
+ * @ingroup iface_zwp_primary_selection_device_v1
+ */
+#define ZWP_PRIMARY_SELECTION_DEVICE_V1_SET_SELECTION_SINCE_VERSION 1
+/**
+ * @ingroup iface_zwp_primary_selection_device_v1
+ */
+#define ZWP_PRIMARY_SELECTION_DEVICE_V1_DESTROY_SINCE_VERSION 1
+
+/** @ingroup iface_zwp_primary_selection_device_v1 */
+static inline void
+zwp_primary_selection_device_v1_set_user_data(struct zwp_primary_selection_device_v1 *zwp_primary_selection_device_v1, void *user_data)
+{
+	wl_proxy_set_user_data((struct wl_proxy *) zwp_primary_selection_device_v1, user_data);
+}
+
+/** @ingroup iface_zwp_primary_selection_device_v1 */
+static inline void *
+zwp_primary_selection_device_v1_get_user_data(struct zwp_primary_selection_device_v1 *zwp_primary_selection_device_v1)
+{
+	return wl_proxy_get_user_data((struct wl_proxy *) zwp_primary_selection_device_v1);
+}
+
+static inline uint32_t
+zwp_primary_selection_device_v1_get_version(struct zwp_primary_selection_device_v1 *zwp_primary_selection_device_v1)
+{
+	return wl_proxy_get_version((struct wl_proxy *) zwp_primary_selection_device_v1);
+}
+
+/**
+ * @ingroup iface_zwp_primary_selection_device_v1
+ *
+ * Replaces the current selection. The previous owner of the primary
+ * selection will receive a wp_primary_selection_source.cancelled event.
+ *
+ * To unset the selection, set the source to NULL.
+ */
+static inline void
+zwp_primary_selection_device_v1_set_selection(struct zwp_primary_selection_device_v1 *zwp_primary_selection_device_v1, struct zwp_primary_selection_source_v1 *source, uint32_t serial)
+{
+	wl_proxy_marshal_flags((struct wl_proxy *) zwp_primary_selection_device_v1,
+			 ZWP_PRIMARY_SELECTION_DEVICE_V1_SET_SELECTION, NULL, wl_proxy_get_version((struct wl_proxy *) zwp_primary_selection_device_v1), 0, source, serial);
+}
+
+/**
+ * @ingroup iface_zwp_primary_selection_device_v1
+ *
+ * Destroy the primary selection device.
+ */
+static inline void
+zwp_primary_selection_device_v1_destroy(struct zwp_primary_selection_device_v1 *zwp_primary_selection_device_v1)
+{
+	wl_proxy_marshal_flags((struct wl_proxy *) zwp_primary_selection_device_v1,
+			 ZWP_PRIMARY_SELECTION_DEVICE_V1_DESTROY, NULL, wl_proxy_get_version((struct wl_proxy *) zwp_primary_selection_device_v1), WL_MARSHAL_FLAG_DESTROY);
+}
+
+/**
+ * @ingroup iface_zwp_primary_selection_offer_v1
+ * @struct zwp_primary_selection_offer_v1_listener
+ */
+struct zwp_primary_selection_offer_v1_listener {
+	/**
+	 * advertise offered mime type
+	 *
+	 * Sent immediately after creating announcing the
+	 * wp_primary_selection_offer through
+	 * wp_primary_selection_device.data_offer. One event is sent per
+	 * offered mime type.
+	 */
+	void (*offer)(void *data,
+		      struct zwp_primary_selection_offer_v1 *zwp_primary_selection_offer_v1,
+		      const char *mime_type);
+};
+
+/**
+ * @ingroup iface_zwp_primary_selection_offer_v1
+ */
+static inline int
+zwp_primary_selection_offer_v1_add_listener(struct zwp_primary_selection_offer_v1 *zwp_primary_selection_offer_v1,
+					    const struct zwp_primary_selection_offer_v1_listener *listener, void *data)
+{
+	return wl_proxy_add_listener((struct wl_proxy *) zwp_primary_selection_offer_v1,
+				     (void (**)(void)) listener, data);
+}
+
+#define ZWP_PRIMARY_SELECTION_OFFER_V1_RECEIVE 0
+#define ZWP_PRIMARY_SELECTION_OFFER_V1_DESTROY 1
+
+/**
+ * @ingroup iface_zwp_primary_selection_offer_v1
+ */
+#define ZWP_PRIMARY_SELECTION_OFFER_V1_OFFER_SINCE_VERSION 1
+
+/**
+ * @ingroup iface_zwp_primary_selection_offer_v1
+ */
+#define ZWP_PRIMARY_SELECTION_OFFER_V1_RECEIVE_SINCE_VERSION 1
+/**
+ * @ingroup iface_zwp_primary_selection_offer_v1
+ */
+#define ZWP_PRIMARY_SELECTION_OFFER_V1_DESTROY_SINCE_VERSION 1
+
+/** @ingroup iface_zwp_primary_selection_offer_v1 */
+static inline void
+zwp_primary_selection_offer_v1_set_user_data(struct zwp_primary_selection_offer_v1 *zwp_primary_selection_offer_v1, void *user_data)
+{
+	wl_proxy_set_user_data((struct wl_proxy *) zwp_primary_selection_offer_v1, user_data);
+}
+
+/** @ingroup iface_zwp_primary_selection_offer_v1 */
+static inline void *
+zwp_primary_selection_offer_v1_get_user_data(struct zwp_primary_selection_offer_v1 *zwp_primary_selection_offer_v1)
+{
+	return wl_proxy_get_user_data((struct wl_proxy *) zwp_primary_selection_offer_v1);
+}
+
+static inline uint32_t
+zwp_primary_selection_offer_v1_get_version(struct zwp_primary_selection_offer_v1 *zwp_primary_selection_offer_v1)
+{
+	return wl_proxy_get_version((struct wl_proxy *) zwp_primary_selection_offer_v1);
+}
+
+/**
+ * @ingroup iface_zwp_primary_selection_offer_v1
+ *
+ * To transfer the contents of the primary selection clipboard, the client
+ * issues this request and indicates the mime type that it wants to
+ * receive. The transfer happens through the passed file descriptor
+ * (typically created with the pipe system call). The source client writes
+ * the data in the mime type representation requested and then closes the
+ * file descriptor.
+ *
+ * The receiving client reads from the read end of the pipe until EOF and
+ * closes its end, at which point the transfer is complete.
+ */
+static inline void
+zwp_primary_selection_offer_v1_receive(struct zwp_primary_selection_offer_v1 *zwp_primary_selection_offer_v1, const char *mime_type, int32_t fd)
+{
+	wl_proxy_marshal_flags((struct wl_proxy *) zwp_primary_selection_offer_v1,
+			 ZWP_PRIMARY_SELECTION_OFFER_V1_RECEIVE, NULL, wl_proxy_get_version((struct wl_proxy *) zwp_primary_selection_offer_v1), 0, mime_type, fd);
+}
+
+/**
+ * @ingroup iface_zwp_primary_selection_offer_v1
+ *
+ * Destroy the primary selection offer.
+ */
+static inline void
+zwp_primary_selection_offer_v1_destroy(struct zwp_primary_selection_offer_v1 *zwp_primary_selection_offer_v1)
+{
+	wl_proxy_marshal_flags((struct wl_proxy *) zwp_primary_selection_offer_v1,
+			 ZWP_PRIMARY_SELECTION_OFFER_V1_DESTROY, NULL, wl_proxy_get_version((struct wl_proxy *) zwp_primary_selection_offer_v1), WL_MARSHAL_FLAG_DESTROY);
+}
+
+/**
+ * @ingroup iface_zwp_primary_selection_source_v1
+ * @struct zwp_primary_selection_source_v1_listener
+ */
+struct zwp_primary_selection_source_v1_listener {
+	/**
+	 * send the primary selection contents
+	 *
+	 * Request for the current primary selection contents from the
+	 * client. Send the specified mime type over the passed file
+	 * descriptor, then close it.
+	 */
+	void (*send)(void *data,
+		     struct zwp_primary_selection_source_v1 *zwp_primary_selection_source_v1,
+		     const char *mime_type,
+		     int32_t fd);
+	/**
+	 * request for primary selection contents was canceled
+	 *
+	 * This primary selection source is no longer valid. The client
+	 * should clean up and destroy this primary selection source.
+	 */
+	void (*cancelled)(void *data,
+			  struct zwp_primary_selection_source_v1 *zwp_primary_selection_source_v1);
+};
+
+/**
+ * @ingroup iface_zwp_primary_selection_source_v1
+ */
+static inline int
+zwp_primary_selection_source_v1_add_listener(struct zwp_primary_selection_source_v1 *zwp_primary_selection_source_v1,
+					     const struct zwp_primary_selection_source_v1_listener *listener, void *data)
+{
+	return wl_proxy_add_listener((struct wl_proxy *) zwp_primary_selection_source_v1,
+				     (void (**)(void)) listener, data);
+}
+
+#define ZWP_PRIMARY_SELECTION_SOURCE_V1_OFFER 0
+#define ZWP_PRIMARY_SELECTION_SOURCE_V1_DESTROY 1
+
+/**
+ * @ingroup iface_zwp_primary_selection_source_v1
+ */
+#define ZWP_PRIMARY_SELECTION_SOURCE_V1_SEND_SINCE_VERSION 1
+/**
+ * @ingroup iface_zwp_primary_selection_source_v1
+ */
+#define ZWP_PRIMARY_SELECTION_SOURCE_V1_CANCELLED_SINCE_VERSION 1
+
+/**
+ * @ingroup iface_zwp_primary_selection_source_v1
+ */
+#define ZWP_PRIMARY_SELECTION_SOURCE_V1_OFFER_SINCE_VERSION 1
+/**
+ * @ingroup iface_zwp_primary_selection_source_v1
+ */
+#define ZWP_PRIMARY_SELECTION_SOURCE_V1_DESTROY_SINCE_VERSION 1
+
+/** @ingroup iface_zwp_primary_selection_source_v1 */
+static inline void
+zwp_primary_selection_source_v1_set_user_data(struct zwp_primary_selection_source_v1 *zwp_primary_selection_source_v1, void *user_data)
+{
+	wl_proxy_set_user_data((struct wl_proxy *) zwp_primary_selection_source_v1, user_data);
+}
+
+/** @ingroup iface_zwp_primary_selection_source_v1 */
+static inline void *
+zwp_primary_selection_source_v1_get_user_data(struct zwp_primary_selection_source_v1 *zwp_primary_selection_source_v1)
+{
+	return wl_proxy_get_user_data((struct wl_proxy *) zwp_primary_selection_source_v1);
+}
+
+static inline uint32_t
+zwp_primary_selection_source_v1_get_version(struct zwp_primary_selection_source_v1 *zwp_primary_selection_source_v1)
+{
+	return wl_proxy_get_version((struct wl_proxy *) zwp_primary_selection_source_v1);
+}
+
+/**
+ * @ingroup iface_zwp_primary_selection_source_v1
+ *
+ * This request adds a mime type to the set of mime types advertised to
+ * targets. Can be called several times to offer multiple types.
+ */
+static inline void
+zwp_primary_selection_source_v1_offer(struct zwp_primary_selection_source_v1 *zwp_primary_selection_source_v1, const char *mime_type)
+{
+	wl_proxy_marshal_flags((struct wl_proxy *) zwp_primary_selection_source_v1,
+			 ZWP_PRIMARY_SELECTION_SOURCE_V1_OFFER, NULL, wl_proxy_get_version((struct wl_proxy *) zwp_primary_selection_source_v1), 0, mime_type);
+}
+
+/**
+ * @ingroup iface_zwp_primary_selection_source_v1
+ *
+ * Destroy the primary selection source.
+ */
+static inline void
+zwp_primary_selection_source_v1_destroy(struct zwp_primary_selection_source_v1 *zwp_primary_selection_source_v1)
+{
+	wl_proxy_marshal_flags((struct wl_proxy *) zwp_primary_selection_source_v1,
+			 ZWP_PRIMARY_SELECTION_SOURCE_V1_DESTROY, NULL, wl_proxy_get_version((struct wl_proxy *) zwp_primary_selection_source_v1), WL_MARSHAL_FLAG_DESTROY);
+}
+
+#ifdef  __cplusplus
+}
+#endif
+
+#endif
diff --git a/primary-selection-unstable-v1-protocol.c b/primary-selection-unstable-v1-protocol.c
@@ -0,0 +1,116 @@
+/* Generated by wayland-scanner 1.23.1 */
+
+/*
+ * Copyright © 2015, 2016 Red Hat
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a
+ * copy of this software and associated documentation files (the "Software"),
+ * to deal in the Software without restriction, including without limitation
+ * the rights to use, copy, modify, merge, publish, distribute, sublicense,
+ * and/or sell copies of the Software, and to permit persons to whom the
+ * Software is furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice (including the next
+ * paragraph) shall be included in all copies or substantial portions of the
+ * Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.  IN NO EVENT SHALL
+ * THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+ * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+ * DEALINGS IN THE SOFTWARE.
+ */
+
+#include <stdbool.h>
+#include <stdlib.h>
+#include <stdint.h>
+#include "wayland-util.h"
+
+#ifndef __has_attribute
+# define __has_attribute(x) 0  /* Compatibility with non-clang compilers. */
+#endif
+
+#if (__has_attribute(visibility) || defined(__GNUC__) && __GNUC__ >= 4)
+#define WL_PRIVATE __attribute__ ((visibility("hidden")))
+#else
+#define WL_PRIVATE
+#endif
+
+extern const struct wl_interface wl_seat_interface;
+extern const struct wl_interface zwp_primary_selection_device_v1_interface;
+extern const struct wl_interface zwp_primary_selection_offer_v1_interface;
+extern const struct wl_interface zwp_primary_selection_source_v1_interface;
+
+static const struct wl_interface *wp_primary_selection_unstable_v1_types[] = {
+	NULL,
+	NULL,
+	&zwp_primary_selection_source_v1_interface,
+	&zwp_primary_selection_device_v1_interface,
+	&wl_seat_interface,
+	&zwp_primary_selection_source_v1_interface,
+	NULL,
+	&zwp_primary_selection_offer_v1_interface,
+	&zwp_primary_selection_offer_v1_interface,
+};
+
+static const struct wl_message zwp_primary_selection_device_manager_v1_requests[] = {
+	{ "create_source", "n", wp_primary_selection_unstable_v1_types + 2 },
+	{ "get_device", "no", wp_primary_selection_unstable_v1_types + 3 },
+	{ "destroy", "", wp_primary_selection_unstable_v1_types + 0 },
+};
+
+WL_PRIVATE const struct wl_interface zwp_primary_selection_device_manager_v1_interface = {
+	"zwp_primary_selection_device_manager_v1", 1,
+	3, zwp_primary_selection_device_manager_v1_requests,
+	0, NULL,
+};
+
+static const struct wl_message zwp_primary_selection_device_v1_requests[] = {
+	{ "set_selection", "?ou", wp_primary_selection_unstable_v1_types + 5 },
+	{ "destroy", "", wp_primary_selection_unstable_v1_types + 0 },
+};
+
+static const struct wl_message zwp_primary_selection_device_v1_events[] = {
+	{ "data_offer", "n", wp_primary_selection_unstable_v1_types + 7 },
+	{ "selection", "?o", wp_primary_selection_unstable_v1_types + 8 },
+};
+
+WL_PRIVATE const struct wl_interface zwp_primary_selection_device_v1_interface = {
+	"zwp_primary_selection_device_v1", 1,
+	2, zwp_primary_selection_device_v1_requests,
+	2, zwp_primary_selection_device_v1_events,
+};
+
+static const struct wl_message zwp_primary_selection_offer_v1_requests[] = {
+	{ "receive", "sh", wp_primary_selection_unstable_v1_types + 0 },
+	{ "destroy", "", wp_primary_selection_unstable_v1_types + 0 },
+};
+
+static const struct wl_message zwp_primary_selection_offer_v1_events[] = {
+	{ "offer", "s", wp_primary_selection_unstable_v1_types + 0 },
+};
+
+WL_PRIVATE const struct wl_interface zwp_primary_selection_offer_v1_interface = {
+	"zwp_primary_selection_offer_v1", 1,
+	2, zwp_primary_selection_offer_v1_requests,
+	1, zwp_primary_selection_offer_v1_events,
+};
+
+static const struct wl_message zwp_primary_selection_source_v1_requests[] = {
+	{ "offer", "s", wp_primary_selection_unstable_v1_types + 0 },
+	{ "destroy", "", wp_primary_selection_unstable_v1_types + 0 },
+};
+
+static const struct wl_message zwp_primary_selection_source_v1_events[] = {
+	{ "send", "sh", wp_primary_selection_unstable_v1_types + 0 },
+	{ "cancelled", "", wp_primary_selection_unstable_v1_types + 0 },
+};
+
+WL_PRIVATE const struct wl_interface zwp_primary_selection_source_v1_interface = {
+	"zwp_primary_selection_source_v1", 1,
+	2, zwp_primary_selection_source_v1_requests,
+	2, zwp_primary_selection_source_v1_events,
+};
+
diff --git a/unix_utils.h b/unix_utils.h
@@ -11,4 +11,9 @@ char *unix_run_command(const char *command, const char *input);
 
 // Ignores output
 void unix_exec_command(const char *command, const char *input);
+
+// Sets the Wayland primary selection via wl-copy --primary.
+// Text is piped exactly (no trailing newline added).
+// Skips re-spawning if the text hasn't changed.
+void unix_set_primary_selection(const char *text);
 #endif
diff --git a/wayland_primary.c b/wayland_primary.c
@@ -0,0 +1,260 @@
+#include "wayland_primary.h"
+#include "primary-selection-unstable-v1-client-protocol.h"
+#include <SDL3/SDL.h>
+#include <wayland-client.h>
+#include <poll.h>
+#include <stdlib.h>
+#include <string.h>
+#include <unistd.h>
+
+static struct wl_display   *s_display;
+static struct wl_registry  *s_registry;
+static struct wl_seat      *s_seat;
+static struct wl_pointer   *s_pointer;
+static struct wl_keyboard  *s_keyboard;
+static struct zwp_primary_selection_device_manager_v1 *s_manager;
+static struct zwp_primary_selection_device_v1         *s_device;
+static struct zwp_primary_selection_source_v1         *s_source;
+static struct zwp_primary_selection_offer_v1          *s_pending_offer;
+
+static uint32_t  s_last_serial;
+static char     *s_current_text;
+
+/* ── source events ─────────────────────────────────────────────────────── */
+
+static void source_send(void *data,
+	struct zwp_primary_selection_source_v1 *source,
+	const char *mime_type, int32_t fd)
+{
+	(void)data; (void)source; (void)mime_type;
+	if (s_current_text)
+		write(fd, s_current_text, strlen(s_current_text));
+	close(fd);
+}
+
+static void source_cancelled(void *data,
+	struct zwp_primary_selection_source_v1 *source)
+{
+	(void)data;
+	zwp_primary_selection_source_v1_destroy(source);
+	if (s_source == source)
+		s_source = NULL;
+	free(s_current_text);
+	s_current_text = NULL;
+}
+
+static const struct zwp_primary_selection_source_v1_listener s_source_listener = {
+	.send      = source_send,
+	.cancelled = source_cancelled,
+};
+
+/* ── device events ──────────────────────────────────────────────────────── */
+
+static void device_data_offer(void *data,
+	struct zwp_primary_selection_device_v1 *device,
+	struct zwp_primary_selection_offer_v1 *offer)
+{
+	(void)data; (void)device;
+	if (s_pending_offer)
+		zwp_primary_selection_offer_v1_destroy(s_pending_offer);
+	s_pending_offer = offer;
+}
+
+static void device_selection(void *data,
+	struct zwp_primary_selection_device_v1 *device,
+	struct zwp_primary_selection_offer_v1 *id)
+{
+	(void)data; (void)device; (void)id;
+	if (s_pending_offer) {
+		zwp_primary_selection_offer_v1_destroy(s_pending_offer);
+		s_pending_offer = NULL;
+	}
+}
+
+static const struct zwp_primary_selection_device_v1_listener s_device_listener = {
+	.data_offer = device_data_offer,
+	.selection  = device_selection,
+};
+
+/* ── pointer listener (serial capture) ─────────────────────────────────── */
+
+static void ptr_enter(void *d, struct wl_pointer *p, uint32_t serial,
+	struct wl_surface *surf, wl_fixed_t x, wl_fixed_t y)
+{ (void)d; (void)p; (void)serial; (void)surf; (void)x; (void)y; }
+
+static void ptr_leave(void *d, struct wl_pointer *p, uint32_t serial,
+	struct wl_surface *surf)
+{ (void)d; (void)p; (void)serial; (void)surf; }
+
+static void ptr_motion(void *d, struct wl_pointer *p, uint32_t t,
+	wl_fixed_t x, wl_fixed_t y)
+{ (void)d; (void)p; (void)t; (void)x; (void)y; }
+
+static void ptr_button(void *d, struct wl_pointer *p, uint32_t serial,
+	uint32_t t, uint32_t btn, uint32_t state)
+{ (void)d; (void)p; (void)t; (void)btn; (void)state; s_last_serial = serial; }
+
+static void ptr_axis(void *d, struct wl_pointer *p, uint32_t t,
+	uint32_t axis, wl_fixed_t val)
+{ (void)d; (void)p; (void)t; (void)axis; (void)val; }
+
+/* v5+ events — never sent to a version-1 proxy, but slots must be non-NULL
+ * only for the version we bind at.  We bind at v1, so these are safe NULL. */
+static const struct wl_pointer_listener s_pointer_listener = {
+	.enter                   = ptr_enter,
+	.leave                   = ptr_leave,
+	.motion                  = ptr_motion,
+	.button                  = ptr_button,
+	.axis                    = ptr_axis,
+	.frame                   = NULL,
+	.axis_source             = NULL,
+	.axis_stop               = NULL,
+	.axis_discrete           = NULL,
+	.axis_value120           = NULL,
+	.axis_relative_direction = NULL,
+};
+
+/* ── keyboard listener (serial capture) ────────────────────────────────── */
+
+static void kbd_keymap(void *d, struct wl_keyboard *kb, uint32_t fmt,
+	int32_t fd, uint32_t size)
+{ (void)d; (void)kb; (void)fmt; (void)size; close(fd); }
+
+static void kbd_enter(void *d, struct wl_keyboard *kb, uint32_t serial,
+	struct wl_surface *surf, struct wl_array *keys)
+{ (void)d; (void)kb; (void)serial; (void)surf; (void)keys; }
+
+static void kbd_leave(void *d, struct wl_keyboard *kb, uint32_t serial,
+	struct wl_surface *surf)
+{ (void)d; (void)kb; (void)serial; (void)surf; }
+
+static void kbd_key(void *d, struct wl_keyboard *kb, uint32_t serial,
+	uint32_t t, uint32_t key, uint32_t state)
+{ (void)d; (void)kb; (void)t; (void)key; (void)state; s_last_serial = serial; }
+
+static void kbd_modifiers(void *d, struct wl_keyboard *kb, uint32_t serial,
+	uint32_t dep, uint32_t lat, uint32_t lock, uint32_t grp)
+{ (void)d; (void)kb; (void)serial; (void)dep; (void)lat; (void)lock; (void)grp; }
+
+/* .repeat_info is v4 — never sent to a v1 proxy */
+static const struct wl_keyboard_listener s_keyboard_listener = {
+	.keymap      = kbd_keymap,
+	.enter       = kbd_enter,
+	.leave       = kbd_leave,
+	.key         = kbd_key,
+	.modifiers   = kbd_modifiers,
+	.repeat_info = NULL,
+};
+
+/* ── seat listener ──────────────────────────────────────────────────────── */
+
+static void seat_capabilities(void *data, struct wl_seat *seat, uint32_t caps)
+{
+	(void)data;
+	if ((caps & WL_SEAT_CAPABILITY_POINTER) && !s_pointer) {
+		s_pointer = wl_seat_get_pointer(seat);
+		wl_pointer_add_listener(s_pointer, &s_pointer_listener, NULL);
+	}
+	if ((caps & WL_SEAT_CAPABILITY_KEYBOARD) && !s_keyboard) {
+		s_keyboard = wl_seat_get_keyboard(seat);
+		wl_keyboard_add_listener(s_keyboard, &s_keyboard_listener, NULL);
+	}
+}
+
+/* .name is v2 — never sent to a v1 seat proxy */
+static const struct wl_seat_listener s_seat_listener = {
+	.capabilities = seat_capabilities,
+	.name         = NULL,
+};
+
+/* ── registry listener ──────────────────────────────────────────────────── */
+
+static void registry_global(void *data, struct wl_registry *registry,
+	uint32_t name, const char *interface, uint32_t version)
+{
+	(void)data;
+	if (strcmp(interface, wl_seat_interface.name) == 0 && !s_seat) {
+		s_seat = wl_registry_bind(registry, name, &wl_seat_interface,
+				version < 1 ? version : 1);
+		wl_seat_add_listener(s_seat, &s_seat_listener, NULL);
+	} else if (strcmp(interface,
+			zwp_primary_selection_device_manager_v1_interface.name) == 0) {
+		s_manager = wl_registry_bind(registry, name,
+				&zwp_primary_selection_device_manager_v1_interface, 1);
+	}
+}
+
+static void registry_global_remove(void *data, struct wl_registry *registry,
+	uint32_t name)
+{ (void)data; (void)registry; (void)name; }
+
+static const struct wl_registry_listener s_registry_listener = {
+	.global        = registry_global,
+	.global_remove = registry_global_remove,
+};
+
+/* ── public API ─────────────────────────────────────────────────────────── */
+
+void wayland_primary_init(SDL_Window *window)
+{
+	SDL_PropertiesID props = SDL_GetWindowProperties(window);
+	s_display = SDL_GetPointerProperty(props,
+			SDL_PROP_WINDOW_WAYLAND_DISPLAY_POINTER, NULL);
+	if (!s_display)
+		return;
+
+	s_registry = wl_display_get_registry(s_display);
+	wl_registry_add_listener(s_registry, &s_registry_listener, NULL);
+	/* Roundtrip: triggers registry_global and seat_capabilities callbacks. */
+	wl_display_roundtrip(s_display);
+
+	if (!s_manager || !s_seat)
+		return;
+
+	s_device = zwp_primary_selection_device_manager_v1_get_device(
+			s_manager, s_seat);
+	zwp_primary_selection_device_v1_add_listener(s_device,
+			&s_device_listener, NULL);
+	wl_display_flush(s_display);
+}
+
+void wayland_primary_set(const char *text)
+{
+	if (!s_device || !s_manager || !text || !*text)
+		return;
+	if (s_current_text && strcmp(s_current_text, text) == 0)
+		return;
+
+	if (s_source) {
+		zwp_primary_selection_source_v1_destroy(s_source);
+		s_source = NULL;
+	}
+	free(s_current_text);
+	s_current_text = strdup(text);
+
+	s_source = zwp_primary_selection_device_manager_v1_create_source(s_manager);
+	zwp_primary_selection_source_v1_add_listener(s_source,
+			&s_source_listener, NULL);
+	zwp_primary_selection_source_v1_offer(s_source, "text/plain;charset=utf-8");
+	zwp_primary_selection_source_v1_offer(s_source, "text/plain");
+	zwp_primary_selection_source_v1_offer(s_source, "UTF8_STRING");
+	zwp_primary_selection_source_v1_offer(s_source, "STRING");
+	zwp_primary_selection_source_v1_offer(s_source, "TEXT");
+
+	zwp_primary_selection_device_v1_set_selection(s_device, s_source,
+			s_last_serial);
+	wl_display_flush(s_display);
+}
+
+void wayland_primary_dispatch(void)
+{
+	if (!s_display)
+		return;
+	/*
+	 * SDL's Wayland read thread reads events from the socket and places
+	 * them in their respective queues.  Our proxies use the default queue,
+	 * so dispatch_pending processes whatever SDL's thread has already read.
+	 * No socket read is needed here.
+	 */
+	wl_display_dispatch_pending(s_display);
+}
diff --git a/wayland_primary.h b/wayland_primary.h
@@ -0,0 +1,17 @@
+#ifndef WAYLAND_PRIMARY_H
+#define WAYLAND_PRIMARY_H
+
+#include <SDL3/SDL_video.h>
+
+/* Initialize Wayland primary selection using SDL3's wl_display.
+ * Call once after the SDL window is created, before the event loop. */
+void wayland_primary_init(SDL_Window *window);
+
+/* Set the primary selection text.  Skips if text is unchanged. */
+void wayland_primary_set(const char *text);
+
+/* Dispatch pending events on our Wayland proxies (default queue).
+ * Call once per main-loop iteration, after handle_events(). */
+void wayland_primary_dispatch(void);
+
+#endif