//SPDX-License-Identifier: LGPL-3.0-only //SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® module; #ifdef CRAFTER_GRAPHICS_WINDOW_WAYLAND #include #include #include #include #include #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 #include #include #include #include #include #include #include #include #endif #ifndef CRAFTER_GRAPHICS_WINDOW_DOM #include "vulkan/vulkan.h" #endif #ifdef CRAFTER_GRAPHICS_WINDOW_WIN32 #include #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 // How a window hands finished frames to the display. Fifo waits for the // display's refresh; it is the default and the one mode every surface // has. Mailbox replaces a frame still waiting to be shown, so presenting // never waits, which suits a window that updates on its own clock // (Window::updateInterval). Immediate shows each frame at once and may // tear. A mode the surface lacks falls back to Fifo, as does one whose // swapchain the driver makes with more images than the frame loop's // Window::numFrames (Mesa's Mailbox has at least four). In the browser // the page presents the canvas and the mode has no effect. enum class PresentMode { Fifo, Mailbox, Immediate, }; struct Window { FrameTime currentFrameTime; std::uint32_t width; std::uint32_t height; std::chrono::time_point lastFrameBegin; Event onClose; Event onBeforeUpdate; Event 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 onResize; bool open = true; bool updating = false; // Zero (the default): Update runs when the compositor's frame // callback says the window is about to show a new frame, so it // follows the display and stops while the window is hidden or // minimized. Non-zero: Update runs on this clock instead, visible or // not, for an app whose output goes somewhere besides the screen. // Pair it with PresentMode::Mailbox so a present never waits on the // display. Set it before StartUpdate. Native only: in the browser, // requestAnimationFrame drives Update. std::chrono::nanoseconds updateInterval{0}; // The mode SetPresentMode last asked for. PresentMode presentMode = PresentMode::Fifo; // 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 heldKeys; Event onRawKeyDown; Event onRawKeyHold; Event onRawKeyUp; Event onTextInput; Event onMouseRightClick; Event onMouseLeftClick; Event onMouseRightHold; Event onMouseLeftHold; Event onMouseRightRelease; Event onMouseLeftRelease; Event onMouseMove; Event onMouseEnter; Event 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 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 onTouchDown; Event onTouchMove; Event onTouchUp; Event 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 onPreedit; Event onTextInputDelete; Vector currentMousePos; Vector lastMousePos; Vector 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(); // Makes the swapchain again with the new mode and fires onResize, as // a resize does, so call it outside StartInit/FinishInit. The // environment variable CRAFTER_PRESENT_IMMEDIATE overrides the mode, // for benchmarks. void SetPresentMode(PresentMode mode); 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::chrono::nanoseconds>> updateTimings; std::chrono::nanoseconds totalRender; std::chrono::nanoseconds vblank; std::chrono::nanoseconds totalFrame; std::chrono::time_point 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 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 // Wakes the event loop when `fd` becomes readable and calls // `onReadable` from it, which must consume what made the descriptor // readable, or the loop wakes again at once. For a socket, or an // eventfd another thread signals, so new data can drive an Update // right away (call Update from the callback) instead of waiting for // the next one. StartSync's loop serves the descriptors of every // window. Remove a descriptor before closing it. void AddPollFd(int fd, std::function onReadable); void RemovePollFd(int fd); struct PollFd { int fd; std::function onReadable; }; std::vector pollFds; 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(); // Saves the frame the next Render() draws to a PNG at `path`, for // visual regression tests and screenshots: that Render copies the // swapchain image into a readback buffer before presenting it, waits // for the frame, and writes the file. The copy can't come later: a // presented image belongs to the presentation engine until it is // acquired again. void CaptureNextFrame(std::filesystem::path path); std::optional pendingCapture; static constexpr std::uint8_t numFrames = 3; VkSurfaceKHR vulkanSurface = VK_NULL_HANDLE; VkSwapchainKHR swapChain = VK_NULL_HANDLE; // What the swapchain images were created for: STORAGE | TRANSFER_SRC, // plus TRANSFER_DST where the surface allows it, so a pass can blit or // clear into the image instead of only writing it from a shader. VkImageUsageFlags swapchainUsage = 0; // The mode the swapchain was made with: presentMode, unless the // surface lacks it or CRAFTER_PRESENT_IMMEDIATE is set. VkPresentModeKHR swapchainPresentMode = VK_PRESENT_MODE_FIFO_KHR; // The mode presentMode asks for made a swapchain with more images than // numFrames once, so swapchains are FIFO until SetPresentMode is // called again, rather than being made twice at every resize. bool presentModeUnfit = false; 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 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 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 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 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 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> 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 passes; DescriptorHeapWebGPU* descriptorHeap = nullptr; std::optional> 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 }; }