diff --git a/additional/dom-env.js b/additional/dom-env.js index 8e47650..e3dfc4d 100644 --- a/additional/dom-env.js +++ b/additional/dom-env.js @@ -441,6 +441,33 @@ function domStopFrameLoop() { } // ─── Clipboard ──────────────────────────────────────────────────────── +// +// Read path is necessarily a latched buffer: navigator.clipboard.readText +// is async (Promise) but the C++ Clipboard::GetText signature is +// synchronous to match the Wayland / Win32 backends. Two feeders keep +// the cache fresh: +// +// 1. A capture-phase `paste` listener on `window` reads +// event.clipboardData.getData('text/plain') synchronously — this +// covers the standard Ctrl+V flow, where the paste event fires +// in the same task as the C++ key handler that will call GetText. +// 2. Each GetText call also kicks off a navigator.clipboard.readText +// in the background. The promise resolves in a later task; the +// first call usually still returns the latched paste value (or +// nullopt), subsequent calls see the readText result. + +let __clipboardCache = null; + +window.addEventListener("paste", (event) => { + try { + const t = event.clipboardData && + event.clipboardData.getData("text/plain"); + if (typeof t === "string") __clipboardCache = t; + } catch (err) { + // Some browsers reject clipboardData access inside non-input + // targets; the async readText fallback still has a chance. + } +}, true); function clipboardSetText(strPtr, strLen) { try { @@ -452,6 +479,19 @@ function clipboardSetText(strPtr, strLen) { } } +function clipboardGetText() { + // Best-effort async refresh. Permission/focus rejections are + // expected in non-activated contexts — swallow them so we don't + // spam the console on every paste check. + if (navigator.clipboard && navigator.clipboard.readText) { + navigator.clipboard.readText() + .then((t) => { if (typeof t === "string") __clipboardCache = t; }) + .catch(() => {}); + } + if (__clipboardCache === null) return 0; + return __writeUtf8(__clipboardCache); +} + // ─── History / routing ──────────────────────────────────────────────── function pushState(dataPtr, dataLen, titlePtr, titleLen, urlPtr, urlLen) { @@ -547,7 +587,7 @@ Object.assign(window.crafter_webbuild_env, { domStartFrameLoop, domStopFrameLoop, // Clipboard - clipboardSetText, + clipboardSetText, clipboardGetText, // History pushState, addPopStateListener, removePopStateListener, getPathName, diff --git a/implementations/Crafter.Graphics-Clipboard.cpp b/implementations/Crafter.Graphics-Clipboard.cpp index bfa4773..3a6769a 100644 --- a/implementations/Crafter.Graphics-Clipboard.cpp +++ b/implementations/Crafter.Graphics-Clipboard.cpp @@ -23,6 +23,8 @@ module; #include #include #include +#include +#include #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 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 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(text.size())); @@ -186,3 +312,131 @@ bool Crafter::Clipboard::SetText(std::string_view text) { return false; #endif } + +std::optional 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(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( + std::chrono::duration_cast( + 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(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(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(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 +} diff --git a/implementations/Crafter.Graphics-Device.cpp b/implementations/Crafter.Graphics-Device.cpp index 0f9cf47..f5ef4a2 100644 --- a/implementations/Crafter.Graphics-Device.cpp +++ b/implementations/Crafter.Graphics-Device.cpp @@ -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}; diff --git a/interfaces/Crafter.Graphics-Clipboard.cppm b/interfaces/Crafter.Graphics-Clipboard.cppm index 2e97a01..836ea5c 100644 --- a/interfaces/Crafter.Graphics-Clipboard.cppm +++ b/interfaces/Crafter.Graphics-Clipboard.cppm @@ -20,17 +20,20 @@ Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA export module Crafter.Graphics:Clipboard; import std; -// Native system-clipboard writes. No popen, no helper binaries — just -// the platform's own clipboard API. Implementation lives next to the -// other window-backend code (Wayland data_device on Linux, Win32 in -// the Windows build); callers don't pick a backend. -// -// `Get` is intentionally not exposed yet: paste-from-clipboard isn't -// a feature the game's UI wants right now, and the read path needs -// more lifecycle plumbing (mime negotiation, fd reads on the Wayland -// event loop) than the simple write path. Easy to add later. +// Native system-clipboard reads and writes. No popen, no helper +// binaries — just the platform's own clipboard API. Implementation +// lives next to the other window-backend code (Wayland data_device on +// Linux, Win32 in the Windows build, navigator.clipboard + +// `paste` event in the DOM build); callers don't pick a backend. export namespace Crafter::Clipboard { + // One-time backend setup. Currently only the Wayland path uses it + // (attaches a wl_data_device listener so we observe selection + // offers from other apps). Win32 and the DOM build are no-ops. + // Called from Device::Initialize after the Wayland registry + // roundtrip — calling it again is harmless. + void Initialize(); + // Place `text` on the system clipboard as UTF-8 plain text. Returns // true if the platform accepted the request — false means the // backend isn't initialised, no input event has been seen yet @@ -39,4 +42,15 @@ export namespace Crafter::Clipboard { // either another app replaces the selection or the application // exits; the caller doesn't need to keep `text` alive. bool SetText(std::string_view text); + + // Read the current clipboard contents as UTF-8 plain text. Returns + // nullopt if the clipboard is empty, holds no text-typed payload, + // the platform read failed, or — on the DOM build — the latched + // buffer is empty because the user hasn't pasted yet and + // navigator.clipboard.readText() has not resolved. The Wayland + // path negotiates one of the standard text mime types in order + // (text/plain;charset=utf-8, text/plain, UTF8_STRING, TEXT) and + // pumps the wl_display event loop while reading so the call also + // works when the source of the selection is our own process. + std::optional GetText(); }