clipboard

This commit is contained in:
Jorijn van der Graaf 2026-05-19 00:45:22 +02:00
commit 850ef7bfb3
4 changed files with 325 additions and 10 deletions

View file

@ -23,6 +23,8 @@ module;
#include <wayland-client-protocol.h>
#include <unistd.h>
#include <errno.h>
#include <poll.h>
#include <fcntl.h>
#endif
#ifdef CRAFTER_GRAPHICS_WINDOW_WIN32
#define WIN32_LEAN_AND_MEAN
@ -36,6 +38,8 @@ using namespace Crafter;
#ifdef CRAFTER_GRAPHICS_WINDOW_WAYLAND
namespace {
// ─── write path ──────────────────────────────────────────────────
//
// Heap-allocated state attached to each wl_data_source via its
// user_data slot. The text must outlive the source (the compositor
// can call `send` minutes after we set the selection, every time a
@ -95,6 +99,113 @@ namespace {
.dnd_finished = OnSourceDndFinished,
.action = OnSourceAction,
};
// ─── read path ───────────────────────────────────────────────────
//
// The compositor announces every clipboard selection (and every
// drag-and-drop) via a `wl_data_offer` we have to register a
// listener on to learn its advertised mime types. The lifecycle:
//
// 1. wl_data_device.data_offer → fresh wl_data_offer ID. We
// attach an offer listener so we receive the mime list.
// 2. wl_data_offer.offer (N×) → one event per advertised mime.
// 3. wl_data_device.selection → the offer becomes "the
// clipboard". Replaces (and invalidates) the previous one.
// An offer parameter of nullptr means the clipboard was
// cleared.
//
// DnD offers travel through the same data_offer event and end up
// delivered via `enter`/`leave`/`drop`. We don't implement DnD,
// but we still need to destroy those offers so we don't leak the
// mime vector attached to them — we stash the most recent DnD
// offer on enter and destroy it on leave/drop.
struct OfferState {
std::vector<std::string> mimes;
};
// Map keyed on raw wl_data_offer pointer. The compositor hands the
// same pointer back in subsequent events, so identity is stable.
std::unordered_map<wl_data_offer*, OfferState> g_offers;
wl_data_offer* g_selectionOffer = nullptr;
wl_data_offer* g_dndOffer = nullptr;
bool g_listenerAttached = false;
void DestroyOffer(wl_data_offer* offer) {
if (offer == nullptr) return;
g_offers.erase(offer);
wl_data_offer_destroy(offer);
}
void OnOfferOffer(void*, wl_data_offer* offer, const char* mime) {
auto it = g_offers.find(offer);
if (it != g_offers.end()) it->second.mimes.emplace_back(mime);
}
void OnOfferSourceActions(void*, wl_data_offer*, std::uint32_t) {}
void OnOfferAction(void*, wl_data_offer*, std::uint32_t) {}
constexpr wl_data_offer_listener kOfferListener = {
.offer = OnOfferOffer,
.source_actions = OnOfferSourceActions,
.action = OnOfferAction,
};
void OnDeviceDataOffer(void*, wl_data_device*, wl_data_offer* offer) {
// First event in the sequence — register so we hear the mime list.
// The offer can still end up unused (DnD that doesn't enter our
// surface) but we always destroy it later in selection or
// leave/drop.
g_offers.emplace(offer, OfferState{});
wl_data_offer_add_listener(offer, &kOfferListener, nullptr);
}
void OnDeviceEnter(void*, wl_data_device*, std::uint32_t,
wl_surface*, wl_fixed_t, wl_fixed_t,
wl_data_offer* offer) {
// DnD started over our surface. We never accept, so we just
// hold the offer until leave/drop and free it then.
g_dndOffer = offer;
}
void OnDeviceLeave(void*, wl_data_device*) {
DestroyOffer(g_dndOffer);
g_dndOffer = nullptr;
}
void OnDeviceMotion(void*, wl_data_device*, std::uint32_t,
wl_fixed_t, wl_fixed_t) {}
void OnDeviceDrop(void*, wl_data_device*) {
DestroyOffer(g_dndOffer);
g_dndOffer = nullptr;
}
void OnDeviceSelection(void*, wl_data_device*, wl_data_offer* offer) {
// Selection changed; previous selection offer is invalid.
// `offer == nullptr` means the clipboard was cleared.
DestroyOffer(g_selectionOffer);
g_selectionOffer = offer;
}
constexpr wl_data_device_listener kDeviceListener = {
.data_offer = OnDeviceDataOffer,
.enter = OnDeviceEnter,
.leave = OnDeviceLeave,
.motion = OnDeviceMotion,
.drop = OnDeviceDrop,
.selection = OnDeviceSelection,
};
const char* PickTextMime(const OfferState& s) {
// Same priority order we advertise from SetText, so a round-trip
// through ourselves picks the richest mime first.
constexpr std::array kWantedMimes = {
"text/plain;charset=utf-8",
"text/plain",
"UTF8_STRING",
"TEXT",
};
for (const char* want : kWantedMimes) {
for (const auto& m : s.mimes) {
if (m == want) return want;
}
}
return nullptr;
}
}
#endif
@ -107,9 +218,24 @@ namespace {
namespace Crafter::DomEnv {
__attribute__((import_module("env"), import_name("clipboardSetText")))
bool DomClipboardSetText(const char* text, std::int32_t len);
// JS returns either 0 (no text latched) or a wasm-malloc'd NUL-
// terminated UTF-8 pointer that we own and free. Matches the
// existing `getValue` ABI used by HtmlElementPtr::GetValue.
__attribute__((import_module("env"), import_name("clipboardGetText")))
const char* DomClipboardGetText();
}
#endif
void Crafter::Clipboard::Initialize() {
#ifdef CRAFTER_GRAPHICS_WINDOW_WAYLAND
if (g_listenerAttached) return;
if (Device::dataDevice == nullptr) return; // compositor doesn't expose it
wl_data_device_add_listener(Device::dataDevice, &kDeviceListener, nullptr);
g_listenerAttached = true;
#endif
}
bool Crafter::Clipboard::SetText(std::string_view text) {
#ifdef CRAFTER_GRAPHICS_WINDOW_DOM
return Crafter::DomEnv::DomClipboardSetText(text.data(), static_cast<std::int32_t>(text.size()));
@ -186,3 +312,131 @@ bool Crafter::Clipboard::SetText(std::string_view text) {
return false;
#endif
}
std::optional<std::string> Crafter::Clipboard::GetText() {
#ifdef CRAFTER_GRAPHICS_WINDOW_DOM
const char* raw = Crafter::DomEnv::DomClipboardGetText();
if (raw == nullptr) return std::nullopt;
std::string out(raw);
std::free(const_cast<char*>(raw)); // JS side allocated via WasmAlloc/malloc
return out;
#elif defined(CRAFTER_GRAPHICS_WINDOW_WAYLAND)
if (g_selectionOffer == nullptr) return std::nullopt;
auto it = g_offers.find(g_selectionOffer);
if (it == g_offers.end()) return std::nullopt;
const char* mime = PickTextMime(it->second);
if (mime == nullptr) return std::nullopt; // selection holds no text mime
int fds[2];
if (::pipe(fds) != 0) return std::nullopt;
// Read end stays blocking; we drive it with poll() below.
wl_data_offer_receive(g_selectionOffer, mime, fds[1]);
// Flush so the compositor forwards `receive` to the source app.
wl_display_flush(Device::display);
// Drop our copy of the write end. The source app's copy stays open
// until it finishes writing; once it closes, our read sees EOF.
::close(fds[1]);
std::string out;
const int wlFd = wl_display_get_fd(Device::display);
// One-second budget. The poll loop also drives the Wayland event
// pump so that if *we* are the source of the selection, our own
// data_source.send callback can run and write into the pipe — a
// plain blocking read on the pipe would deadlock in that case.
constexpr int kTimeoutMs = 1000;
auto deadline = std::chrono::steady_clock::now()
+ std::chrono::milliseconds(kTimeoutMs);
bool done = false;
while (!done) {
// Prepare for a non-blocking read on the wayland fd. The
// prepare/read pair lets us safely interleave with poll().
while (wl_display_prepare_read(Device::display) != 0) {
wl_display_dispatch_pending(Device::display);
}
wl_display_flush(Device::display);
const auto now = std::chrono::steady_clock::now();
int remaining = static_cast<int>(
std::chrono::duration_cast<std::chrono::milliseconds>(
deadline - now).count());
if (remaining < 0) remaining = 0;
pollfd pfds[2] = {
{ fds[0], POLLIN, 0 },
{ wlFd, POLLIN, 0 },
};
const int n = ::poll(pfds, 2, remaining);
if (n < 0) {
wl_display_cancel_read(Device::display);
if (errno == EINTR) continue;
break;
}
if (n == 0) {
wl_display_cancel_read(Device::display);
break; // timeout
}
if (pfds[1].revents & POLLIN) {
wl_display_read_events(Device::display);
wl_display_dispatch_pending(Device::display);
} else {
wl_display_cancel_read(Device::display);
}
if (pfds[0].revents & (POLLIN | POLLHUP)) {
char buf[4096];
while (true) {
const ssize_t r = ::read(fds[0], buf, sizeof(buf));
if (r > 0) {
out.append(buf, static_cast<std::size_t>(r));
continue;
}
if (r == 0) { done = true; break; } // EOF
if (errno == EINTR) continue;
if (errno == EAGAIN) break; // shouldn't happen (blocking), but harmless
done = true;
break;
}
}
if (pfds[0].revents & POLLERR) {
break;
}
}
::close(fds[0]);
if (out.empty()) return std::nullopt;
return out;
#elif defined(CRAFTER_GRAPHICS_WINDOW_WIN32)
// OS owns the wide-char buffer until we CloseClipboard, so the
// copy-out into std::string must happen before we release the lock.
if (!OpenClipboard(nullptr)) return std::nullopt;
HANDLE h = GetClipboardData(CF_UNICODETEXT);
if (h == nullptr) { CloseClipboard(); return std::nullopt; }
const wchar_t* src = static_cast<const wchar_t*>(GlobalLock(h));
if (src == nullptr) { CloseClipboard(); return std::nullopt; }
// CF_UNICODETEXT is NUL-terminated; lstrlenW gives us the length
// without the terminator. WideCharToMultiByte with length excluding
// the terminator produces a length-prefixed UTF-8 buffer we can
// size std::string for exactly.
const int wlen = lstrlenW(src);
std::string out;
if (wlen > 0) {
const int u8len = WideCharToMultiByte(CP_UTF8, 0, src, wlen,
nullptr, 0, nullptr, nullptr);
if (u8len > 0) {
out.resize(static_cast<std::size_t>(u8len));
WideCharToMultiByte(CP_UTF8, 0, src, wlen,
out.data(), u8len, nullptr, nullptr);
}
}
GlobalUnlock(h);
CloseClipboard();
if (out.empty()) return std::nullopt;
return out;
#else
return std::nullopt;
#endif
}

View file

@ -44,6 +44,7 @@ module Crafter.Graphics:Device_impl;
import :Device;
import :Window;
import :Types;
import :Clipboard;
import std;
using namespace Crafter;
@ -418,6 +419,12 @@ void Device::Initialize() {
if (shm == NULL || compositor == NULL || xdgWmBase == NULL) {
throw std::runtime_error("No wl_shm, wl_compositor or xdg_wm_base support");
}
// After the registry roundtrip the data_device (if the compositor
// exposes one) is bound. Clipboard::Initialize attaches the
// selection listener that Clipboard::GetText reads from; doing it
// before the first wl_data_device.selection arrives is what lets
// GetText work the instant a frame is rendered.
Clipboard::Initialize();
#endif
VkApplicationInfo app{VK_STRUCTURE_TYPE_APPLICATION_INFO};