enable resets the input method's state, so sending it with every caret
move (an application keeps the cursor rectangle current while typing)
dropped the composition at each keystroke: enable now goes once per
focus and a moved caret sends its rectangle alone; disable only takes
back an enable. The compositor's leave ends both, and a window still
showing composing text hears onPreedit("") then. A finger's up and motion
go to the window it went down on, not the window of the latest down. The
seat creates its pointer and keyboard once, as it already did the touch:
capabilities come again when a device appears or goes, and a second
keyboard proxy delivered every key twice.
391 lines
No EOL
19 KiB
C++
391 lines
No EOL
19 KiB
C++
//SPDX-License-Identifier: LGPL-3.0-only
|
|
//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
|
|
|
|
module;
|
|
|
|
#ifdef CRAFTER_GRAPHICS_WINDOW_WAYLAND
|
|
#include <stdio.h>
|
|
#include <stdlib.h>
|
|
#include <unistd.h>
|
|
#include <linux/input-event-codes.h>
|
|
#include <xkbcommon/xkbcommon.h>
|
|
#include "../lib/xdg-shell-client-protocol.h"
|
|
#include "../lib/wayland-xdg-decoration-unstable-v1-client-protocol.h"
|
|
#include "../lib/fractional-scale-v1.h"
|
|
#include "../lib/viewporter.h"
|
|
#include "../lib/text-input-unstable-v3-client-protocol.h"
|
|
#include <string.h>
|
|
#include <linux/input.h>
|
|
#include <sys/mman.h>
|
|
#include <wayland-cursor.h>
|
|
#include <errno.h>
|
|
#include <fcntl.h>
|
|
#include <print>
|
|
#include <wayland-client.h>
|
|
#include <wayland-client-protocol.h>
|
|
#endif
|
|
#ifndef CRAFTER_GRAPHICS_WINDOW_DOM
|
|
#include "vulkan/vulkan.h"
|
|
#endif
|
|
#ifdef CRAFTER_GRAPHICS_WINDOW_WIN32
|
|
#include <windows.h>
|
|
#endif
|
|
|
|
export module Crafter.Graphics:Window;
|
|
import std;
|
|
import :Types;
|
|
import :Keys;
|
|
import Crafter.Event;
|
|
#ifdef CRAFTER_GRAPHICS_WINDOW_DOM
|
|
import :WebGPU;
|
|
import :DescriptorHeapWebGPU;
|
|
#endif
|
|
|
|
export namespace Crafter {
|
|
#ifndef CRAFTER_GRAPHICS_WINDOW_DOM
|
|
struct Semaphores {
|
|
// Swap chain image presentation
|
|
VkSemaphore presentComplete;
|
|
// Command buffer submission and execution
|
|
VkSemaphore renderComplete;
|
|
};
|
|
struct RenderPass;
|
|
struct DescriptorHeapVulkan;
|
|
#else
|
|
struct RenderPass;
|
|
struct DescriptorHeapWebGPU;
|
|
#endif
|
|
|
|
struct Window {
|
|
FrameTime currentFrameTime;
|
|
std::uint32_t width;
|
|
std::uint32_t height;
|
|
std::chrono::time_point<std::chrono::high_resolution_clock> lastFrameBegin;
|
|
Event<void> onClose;
|
|
Event<void> onBeforeUpdate;
|
|
Event<FrameTime> onUpdate;
|
|
// Fires when the swapchain has been recreated for a new size.
|
|
// width/height already reflect the new size. Passes that hold
|
|
// descriptors referring to window.imageViews[] must re-write them
|
|
// here (the old VkImage handles have been destroyed).
|
|
Event<void> onResize;
|
|
bool open = true;
|
|
bool updating = false;
|
|
// Currently-pressed raw key codes. The runtime stores raw platform
|
|
// codes only (Win32 PS/2 scancode + extended bit; Wayland kernel
|
|
// keycode). Cross-platform default bindings use `Key(CrafterKeys::X)`
|
|
// from :Keys to obtain the right code at compile time.
|
|
std::unordered_set<KeyCode> heldKeys;
|
|
Event<KeyCode> onRawKeyDown;
|
|
Event<KeyCode> onRawKeyHold;
|
|
Event<KeyCode> onRawKeyUp;
|
|
Event<const std::string_view> onTextInput;
|
|
Event<void> onMouseRightClick;
|
|
Event<void> onMouseLeftClick;
|
|
Event<void> onMouseRightHold;
|
|
Event<void> onMouseLeftHold;
|
|
Event<void> onMouseRightRelease;
|
|
Event<void> onMouseLeftRelease;
|
|
Event<void> onMouseMove;
|
|
Event<void> onMouseEnter;
|
|
Event<void> onMouseLeave;
|
|
// Mouse wheel, in whole detents per event, sign packed into the
|
|
// uint32 payload (reinterpret as int32 to read it). Positive =
|
|
// wheel down / toward the user — the DOM deltaY sign. Every
|
|
// backend normalizes to ±1 per notch: dom-env.js divides
|
|
// WheelEvent deltas by the per-deltaMode detent size, Wayland
|
|
// divides the axis value by libinput's 15-units-per-detent,
|
|
// Win32 divides by WHEEL_DELTA. Sub-detent motion (smooth-scroll
|
|
// touchpads, free-spinning wheels) is accumulated backend-side
|
|
// until a whole detent is reached.
|
|
Event<std::uint32_t> onMouseScroll;
|
|
// Touch, from the seat's wl_touch. The first finger down also drives
|
|
// currentMousePos and the left-button events until it lifts, so a
|
|
// pointer-only application works on a touchscreen as it is.
|
|
Event<TouchPoint> onTouchDown;
|
|
Event<TouchPoint> onTouchMove;
|
|
Event<TouchPoint> onTouchUp;
|
|
Event<void> onTouchCancel;
|
|
std::int32_t primaryTouchId = -1;
|
|
// Text input (zwp_text_input_v3): the input method's composing text
|
|
// (an empty string clears it) and its deletions around the cursor;
|
|
// the text it commits arrives as onTextInput, like a key's.
|
|
Event<const std::string_view> onPreedit;
|
|
Event<TextDelete> onTextInputDelete;
|
|
Vector<float, 2> currentMousePos;
|
|
Vector<float, 2> lastMousePos;
|
|
Vector<float, 2> mouseDelta;
|
|
bool mouseLeftHeld = false;
|
|
bool mouseRightHeld = false;
|
|
|
|
Window() = default;
|
|
Window(std::uint32_t width, std::uint32_t height);
|
|
Window(std::uint32_t width, std::uint32_t height, const std::string_view title);
|
|
Window(Window&) = delete;
|
|
Window(Window&&) = delete;
|
|
Window& operator=(const Window&) = delete;
|
|
// DOM mode keeps a process-global pointer to the live Window so
|
|
// the JS bridge can deliver document/window events back. If the
|
|
// Window is destroyed (e.g. it was stack-allocated in main and
|
|
// main returned), the global must be cleared so subsequent
|
|
// browser callbacks become no-ops instead of dereferencing
|
|
// freed memory. Native builds release what the constructor made:
|
|
// after a device wait, the command buffers, semaphores and fences,
|
|
// the swapchain and the Vulkan surface, then (Wayland) the cursor,
|
|
// viewport, fractional scale, decoration, xdg toplevel, xdg surface
|
|
// and wl_surface, and the window leaves Device::windows and the
|
|
// focus pointers — so a window can be closed and another created,
|
|
// which a resident app does on every hide and show.
|
|
~Window();
|
|
|
|
// Enter the platform event loop and never return. In DOM mode this
|
|
// hands the loop to requestAnimationFrame and _Exit(0)s so the wasm
|
|
// instance survives without running static destructors. No caller
|
|
// code after this point ever runs, on any target.
|
|
void StartSync();
|
|
|
|
// As StartSync, but does NOT start the animation-frame loop in DOM
|
|
// mode — for event-driven pages that only touch the DOM and never
|
|
// draw. A rAF tick running forever to do nothing costs battery in
|
|
// every open tab. Still never returns.
|
|
//
|
|
// On native this is identical to StartSync: the event loop is what
|
|
// keeps a window alive there, so there is no cheaper option. Call it
|
|
// unconditionally and get the browser saving without an #ifdef.
|
|
void StayAlive();
|
|
|
|
void StartUpdate();
|
|
void StopUpdate();
|
|
void SetTitle(const std::string_view title);
|
|
// The xdg app id, which the compositor matches to a .desktop file
|
|
// for the icon, the task switcher and activation.
|
|
void SetAppId(const std::string_view id);
|
|
// Tells the compositor's input method that a text field has focus
|
|
// (an on-screen keyboard opens) and where its caret is, in physical
|
|
// pixels, or that none has. Remembered: it is applied again whenever
|
|
// the keyboard focus comes back to this window.
|
|
void SetTextInput(bool active, std::int32_t x = 0, std::int32_t y = 0, std::int32_t width = 0, std::int32_t height = 0, TextInputPurpose purpose = TextInputPurpose::Normal);
|
|
void Resize(std::uint32_t width, std::uint32_t height);
|
|
void Render();
|
|
void Update();
|
|
// Replace the system cursor with a custom image. `pixels` is
|
|
// `width*height*4` bytes in R8G8B8A8 memory order (matching
|
|
// stb_image's STBI_rgb_alpha output) with straight (non-premultiplied)
|
|
// alpha — the conversion to the compositor's expected format is
|
|
// handled internally. The hotspot is in image-pixel coordinates.
|
|
// Re-callable at any time.
|
|
void SetCursorImage(std::uint16_t width, std::uint16_t height,
|
|
std::uint16_t hotspotX, std::uint16_t hotspotY,
|
|
const std::uint8_t* pixels);
|
|
|
|
// Restore the default system cursor (releases any previously-uploaded
|
|
// cursor pixel buffer).
|
|
void SetDefaultCursor();
|
|
|
|
#ifdef CRAFTER_TIMING
|
|
std::chrono::nanoseconds totalUpdate;
|
|
std::vector<std::pair<const EventListener<FrameTime>*, std::chrono::nanoseconds>> updateTimings;
|
|
std::chrono::nanoseconds totalRender;
|
|
std::chrono::nanoseconds vblank;
|
|
std::chrono::nanoseconds totalFrame;
|
|
std::chrono::time_point<std::chrono::high_resolution_clock> frameEnd;
|
|
// Fixed-size ring buffer of the most recent frame times. LogTiming does
|
|
// order-independent sum/avg/min/max, so head position is irrelevant to
|
|
// the reported stats; this avoids the per-frame memmove a vector::erase
|
|
// at the front would incur once full.
|
|
static constexpr std::size_t frameTimeCapacity = 100;
|
|
std::array<std::chrono::nanoseconds, frameTimeCapacity> frameTimes{};
|
|
std::size_t frameTimesHead = 0;
|
|
std::size_t frameTimesCount = 0;
|
|
void LogTiming();
|
|
#endif
|
|
|
|
#ifdef CRAFTER_GRAPHICS_WINDOW_WIN32
|
|
HBITMAP cursorBitmap = nullptr;
|
|
HCURSOR cursorHandle = nullptr;
|
|
std::uint16_t cursorSizeX = 0;
|
|
std::uint16_t cursorSizeY = 0;
|
|
#endif
|
|
|
|
#ifdef CRAFTER_GRAPHICS_WINDOW_WAYLAND
|
|
float scale = 1.0f;
|
|
bool configured = false;
|
|
// Pending size from the most recent xdg_toplevel.configure, in
|
|
// surface-local (logical DP) units. Applied on xdg_surface.configure
|
|
// via Resize(); 0 means "compositor has no preference, keep current".
|
|
std::int32_t pendingLogicalWidth = 0;
|
|
std::int32_t pendingLogicalHeight = 0;
|
|
xdg_toplevel* xdgToplevel = nullptr;
|
|
zxdg_toplevel_decoration_v1* decoration = nullptr;
|
|
wp_viewport* wpViewport = nullptr;
|
|
wl_surface* surface = nullptr;
|
|
xdg_surface* xdgSurface = nullptr;
|
|
wl_callback* cb = nullptr;
|
|
wl_surface* cursorSurface = nullptr;
|
|
wl_buffer* cursorWlBuffer = nullptr;
|
|
std::uint32_t cursorBufferOldSize = 0;
|
|
// mmap'd view of the SHM cursor buffer — the user-supplied pixels
|
|
// are written here in BGRA8888 order. Lifetime matches cursorWlBuffer.
|
|
std::uint8_t* cursorMmap_ = nullptr;
|
|
std::uint16_t cursorHotspotX_ = 0;
|
|
std::uint16_t cursorHotspotY_ = 0;
|
|
// Most recent serial from a wl_pointer.enter on this window's surface.
|
|
// Needed so `SetCursorImage` can re-issue `wl_pointer_set_cursor`
|
|
// mid-session (the hotspot only updates when set_cursor is recalled).
|
|
std::uint32_t lastPointerSerial_ = 0;
|
|
bool textInputActive_ = false;
|
|
bool textInputEnabled_ = false; // enable sent and not taken back since
|
|
std::int32_t textInputRect_[4] = { 0, 0, 0, 0 };
|
|
TextInputPurpose textInputPurpose_ = TextInputPurpose::Normal;
|
|
|
|
static void xdg_surface_handle_preferred_scale(void* data, wp_fractional_scale_v1*, std::uint32_t scale);
|
|
static void wl_surface_frame_done(void *data, wl_callback *cb, uint32_t time);
|
|
static void xdg_toplevel_handle_close(void* data, xdg_toplevel*);
|
|
static void xdg_surface_handle_configure(void* data, xdg_surface* xdg_surface, std::uint32_t serial);
|
|
static void xdg_toplevel_configure(void*, xdg_toplevel*, std::int32_t, std::int32_t, wl_array*);
|
|
|
|
constexpr static xdg_toplevel_listener xdg_toplevel_listener = {
|
|
.configure = xdg_toplevel_configure,
|
|
.close = xdg_toplevel_handle_close,
|
|
};
|
|
constexpr static wl_callback_listener wl_callback_listener = {
|
|
.done = wl_surface_frame_done,
|
|
};
|
|
constexpr static xdg_surface_listener xdg_surface_listener = {
|
|
.configure = xdg_surface_handle_configure,
|
|
};
|
|
constexpr static wp_fractional_scale_v1_listener wp_fractional_scale_v1_listener = {
|
|
.preferred_scale = xdg_surface_handle_preferred_scale,
|
|
};
|
|
wp_fractional_scale_v1* wp_scale = nullptr;
|
|
#endif
|
|
|
|
#ifndef CRAFTER_GRAPHICS_WINDOW_DOM
|
|
VkCommandBuffer StartInit();
|
|
void FinishInit();
|
|
VkCommandBuffer GetCmd();
|
|
void EndCmd(VkCommandBuffer cmd);
|
|
void CreateSwapchain();
|
|
// Tear-and-rebuild helper used by Resize() and the OUT_OF_DATE
|
|
// recovery in Render(). Calls CreateSwapchain() and re-issues the
|
|
// initial PRESENT_SRC_KHR layout transition for the new images so
|
|
// Render()'s barriers (which assume oldLayout = PRESENT_SRC_KHR)
|
|
// stay valid. Does NOT fire onResize — callers do that.
|
|
void RecreateSwapchainAndImages();
|
|
|
|
// Save the current swapchain image (state after Render() returns) to
|
|
// a PNG file. Allocates a one-shot staging buffer + command buffer,
|
|
// copies image-to-buffer, waits idle, then writes PNG via stb. Useful
|
|
// for visual regression tests and screenshotting from headless code.
|
|
void SaveFrame(const std::filesystem::path& path);
|
|
static constexpr std::uint8_t numFrames = 3;
|
|
VkSurfaceKHR vulkanSurface = VK_NULL_HANDLE;
|
|
VkSwapchainKHR swapChain = VK_NULL_HANDLE;
|
|
VkFormat colorFormat;
|
|
VkColorSpaceKHR colorSpace;
|
|
// Supported composite-alpha mode for vulkanSurface. A surface property
|
|
// that does not change across swapchain recreation, so it is selected
|
|
// once at construction rather than re-derived on every CreateSwapchain
|
|
// (i.e. on every resize configure).
|
|
VkCompositeAlphaFlagBitsKHR compositeAlpha = VK_COMPOSITE_ALPHA_OPAQUE_BIT_KHR;
|
|
VkImage images[numFrames];
|
|
VkImageViewCreateInfo imageViews[numFrames];
|
|
// Tracks whether each swapchain image has been rendered (and thus
|
|
// left in PRESENT_SRC_KHR) at least once. Freshly created swapchain
|
|
// images start in VK_IMAGE_LAYOUT_UNDEFINED, so the first per-frame
|
|
// barrier must transition from UNDEFINED, not PRESENT_SRC_KHR.
|
|
// Reset in CreateSwapchain(). A presentable image may only be touched
|
|
// after it has been acquired, so this initial transition happens
|
|
// lazily in Render() (post-acquire) rather than up front.
|
|
std::array<bool, numFrames> imageInitialised{};
|
|
std::thread thread;
|
|
VkCommandBuffer drawCmdBuffers[numFrames] = {};
|
|
// Per-frame Vulkan info structs whose contents are almost entirely
|
|
// invariant. Initialised once in the constructor; Render() patches only
|
|
// the few fields that change each frame (the two barriers' image and
|
|
// the acquire barrier's oldLayout, and — for the multi-frame-in-flight
|
|
// sync below — submitInfo/presentInfo's wait/signal semaphores plus
|
|
// submitInfo's command buffer, which follow currentBuffer/frameCounter).
|
|
// presentInfo.pImageIndices and pSwapchains point at the
|
|
// currentBuffer/swapChain members, so they track value changes
|
|
// (including swapchain recreation) automatically.
|
|
VkSubmitInfo submitInfo;
|
|
VkCommandBufferBeginInfo cmdBufInfo;
|
|
VkImageMemoryBarrier acquireBarrier;
|
|
VkImageMemoryBarrier presentBarrier;
|
|
VkPresentInfoKHR presentInfo;
|
|
|
|
// ── Multi-frame-in-flight synchronisation (issue #40) ──────────────
|
|
// The renderer keeps up to numFrames frames in flight, so a single
|
|
// semaphore pair + a per-frame wait-idle is no longer enough. Note
|
|
// that drawCmdBuffers, the per-frame descriptor heap slots, and the
|
|
// swapchain images are ALL keyed by the acquired image index
|
|
// (currentBuffer) — WriteSwapchainDescriptors bakes heap slot i to
|
|
// write imageViews[i], so the index that selects a command buffer /
|
|
// heap slot must equal the acquired image index. Everything below
|
|
// follows from that.
|
|
|
|
// Signaled by the queue submit, waited by vkQueuePresentKHR. Keyed by
|
|
// the acquired IMAGE index: the presentation engine keeps this
|
|
// semaphore in use until the image is re-acquired, so a per-image
|
|
// semaphore is the only safe key. Keying it per-CPU-frame trips
|
|
// VUID-vkQueueSubmit-pSignalSemaphores-00067.
|
|
// https://docs.vulkan.org/guide/latest/swapchain_semaphore_reuse.html
|
|
std::array<VkSemaphore, numFrames> renderFinishedSemaphores{};
|
|
|
|
// Signaled by the queue submit, waited + reset before
|
|
// drawCmdBuffers[image] and that image's descriptor-heap slot are
|
|
// re-recorded. Keyed by image index. Created signaled so the first
|
|
// wait on each image passes through instead of deadlocking.
|
|
std::array<VkFence, numFrames> waitFences{};
|
|
|
|
// Signaled by vkAcquireNextImageKHR, waited by the queue submit. Keyed
|
|
// by a free-running CPU frame counter, because the image index isn't
|
|
// known until acquire returns. Sized numFrames+1: at most numFrames
|
|
// frames are ever in flight (each image's command buffer is gated by
|
|
// its own fence, and there are numFrames images), so numFrames+1
|
|
// distinct acquire semaphores guarantees the one being reused has no
|
|
// pending operation — required by
|
|
// VUID-vkAcquireNextImageKHR-semaphore-01779.
|
|
static constexpr std::uint8_t numAcquireSemaphores = numFrames + 1;
|
|
std::array<VkSemaphore, numAcquireSemaphores> imageAcquiredSemaphores{};
|
|
|
|
// Free-running count of Render() calls; drives the acquire-semaphore
|
|
// slot (frameCounter % numAcquireSemaphores).
|
|
std::uint64_t frameCounter = 0;
|
|
std::uint32_t currentBuffer = 0;
|
|
VkPipelineStageFlags submitPipelineStages = VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT;
|
|
std::vector<RenderPass*> passes;
|
|
DescriptorHeapVulkan* descriptorHeap = nullptr;
|
|
// Cached per-frame heap-bind structs. The heap address/size per slot
|
|
// are stable once a heap is assigned; only currentBuffer varies frame
|
|
// to frame, so the structs (and the reservedRangeOffset arithmetic)
|
|
// are computed once per heap rather than every frame in Render().
|
|
// Keyed on the heap pointer: whenever descriptorHeap differs from
|
|
// cachedDescriptorHeap the cache is rebuilt. This invalidates on any
|
|
// heap (re)assignment — heaps never resize today, so a stable pointer
|
|
// means stable ranges. Not tied to onResize (the heap is independent
|
|
// of swapchain size).
|
|
VkBindHeapInfoEXT resourceHeapInfos[numFrames];
|
|
VkBindHeapInfoEXT samplerHeapInfos[numFrames];
|
|
DescriptorHeapVulkan* cachedDescriptorHeap = nullptr;
|
|
std::optional<std::array<float, 4>> clearColor;
|
|
#else
|
|
// DOM mode: the page IS the window. WebGPU device and canvas are
|
|
// owned JS-side (see additional/dom-webgpu.js); this struct just
|
|
// holds the per-Window state Crafter::Window users expect:
|
|
// a list of render passes and a pointer to the descriptor heap.
|
|
static constexpr std::uint8_t numFrames = 1;
|
|
std::uint32_t currentBuffer = 0;
|
|
std::vector<RenderPass*> passes;
|
|
DescriptorHeapWebGPU* descriptorHeap = nullptr;
|
|
std::optional<std::array<float, 4>> clearColor;
|
|
|
|
// DOM-mode StartInit/FinishInit are no-ops returning an opaque
|
|
// command-buffer marker so cross-platform user code (HelloUI's
|
|
// `auto init = window.StartInit();`) compiles unchanged.
|
|
WebGPUCommandEncoderRef StartInit();
|
|
void FinishInit();
|
|
#endif
|
|
};
|
|
} |