From c62109caf385d8c8dd3b1c2d2f248d6971e53658 Mon Sep 17 00:00:00 2001 From: Jorijn van der Graaf Date: Sat, 31 Oct 2026 03:29:50 +0100 Subject: [PATCH] fix(win32): restore mouse moves, add enter and leave, capture drags, and close and destroy windows cleanly Win32 - WM_MOUSEMOVE is handled again: c9ebd44 put the WM_CHAR case where it was, so currentMousePos stopped following the mouse and onMouseMove never fired. Positions are read signed (GET_X_LPARAM), since under capture they run past the client area. - onMouseEnter and onMouseLeave. The first move over the client area after a leave is the enter, and arms the TME_LEAVE whose WM_MOUSELEAVE is the leave. An enter counts only once that is armed, a leave only after an enter, and a move past the client area is a move only. - A press captures the mouse, so a drag that leaves the window still sends its moves and its release, as Wayland's implicit grab does. Losing the capture releases what is held, and a button-up without its press fires nothing. - WM_CLOSE fires onClose and clears open, as xdg_toplevel.close does, instead of destroying the HWND under the swapchain. WM_DESTROY no longer posts WM_QUIT, which nothing read. - ~Window detaches the HWND and destroys it. It stayed on screen, and its messages went to the freed Window. WndProc hands messages without a Window to DefWindowProc, and looks the Window up again after an event that more work follows, in case a listener destroyed it. - The constructor's message pump leaves input queued for StartSync: the WM_MOUSEMOVE sent when a window appears under the cursor made an onMouseEnter nobody could hear yet. The mouse state is reset before the platform code, no longer after it. - A window after the first finds its class registered, which showed an error box. - The wheel remainder is the window's, no longer a function static that every window shared, and a leave drops it. Window - PointerEnter, PointerMove, PointerLeave and PointerScroll hold the rules both native backends report the pointer by. An enter puts the entry position in currentMousePos before onMouseEnter fires, and is followed by a move, on Wayland too now. scrollRemainder replaces Device::scrollDetentRemainder, and mouseInside is shared. Tests - PointerEnterLeave: enter, move and leave through the wl_pointer callbacks. - PointerWin32: posts a real HWND the messages above and pumps them as StartSync does. Built for x86_64-w64-mingw32 it runs under Wine, and is skipped where Wine is not on PATH. Co-Authored-By: Claude Opus 5.5 --- implementations/Crafter.Graphics-Device.cpp | 32 ++-- implementations/Crafter.Graphics-Window.cpp | 175 +++++++++++++++---- interfaces/Crafter.Graphics-Device.cppm | 6 - interfaces/Crafter.Graphics-Window.cppm | 34 +++- project.cpp | 17 ++ tests/PointerEnterLeave/main.cpp | 122 +++++++++++++ tests/PointerWin32/main.cpp | 180 ++++++++++++++++++++ 7 files changed, 504 insertions(+), 62 deletions(-) create mode 100644 tests/PointerEnterLeave/main.cpp create mode 100644 tests/PointerWin32/main.cpp diff --git a/implementations/Crafter.Graphics-Device.cpp b/implementations/Crafter.Graphics-Device.cpp index 31a26a6..3e6606e 100644 --- a/implementations/Crafter.Graphics-Device.cpp +++ b/implementations/Crafter.Graphics-Device.cpp @@ -493,10 +493,7 @@ void Device::PointerListenerHandleMotion(void* data, wl_pointer* wl_pointer, std return; } Vector pos(wl_fixed_to_double(surface_x), wl_fixed_to_double(surface_y)); - //Device::focusedWindow->lastMousePos = Device::focusedWindow->currentMousePos; - Device::focusedWindow->currentMousePos = pos * Device::focusedWindow->scale; - //Device::focusedWindow->mouseDelta = {Device::focusedWindow->currentMousePos.x-Device::focusedWindow->lastMousePos.x, Device::focusedWindow->currentMousePos.y-Device::focusedWindow->lastMousePos.y}; - Device::focusedWindow->onMouseMove.Invoke(); + Device::focusedWindow->PointerMove(pos * Device::focusedWindow->scale); } void Device::PointerListenerHandleEnter(void* data, wl_pointer* wl_pointer, std::uint32_t serial, wl_surface* surface, wl_fixed_t surface_x, wl_fixed_t surface_y) { @@ -509,7 +506,12 @@ void Device::PointerListenerHandleEnter(void* data, wl_pointer* wl_pointer, std: window->cursorHotspotX_, window->cursorHotspotY_); } focusedWindow = window; - window->onMouseEnter.Invoke(); + const Vector pos = Vector(wl_fixed_to_double(surface_x), wl_fixed_to_double(surface_y)) * window->scale; + window->PointerEnter(pos); + // ~Window clears focusedWindow, should a listener destroy it. + if (focusedWindow == window) { + window->PointerMove(pos); + } return; } } @@ -519,12 +521,9 @@ void Device::PointerListenerHandleLeave(void* data, wl_pointer*, std::uint32_t, // Sway has been observed to send a second leave after focus is already // gone (input-device hot-plug) — don't crash on it. if (focusedWindow != nullptr) { - focusedWindow->onMouseLeave.Invoke(); + focusedWindow->PointerLeave(); focusedWindow = nullptr; } - // Drop any sub-detent scroll remainder so it can't leak into whatever - // window the pointer enters next. - scrollDetentRemainder = 0.0; } void Device::PointerListenerHandleAxis(void*, wl_pointer*, std::uint32_t, std::uint32_t axis, wl_fixed_t value) { @@ -535,18 +534,11 @@ void Device::PointerListenerHandleAxis(void*, wl_pointer*, std::uint32_t, std::u // libinput convention (followed by wlroots, Mutter and KWin): one wheel // detent = 15 axis units, positive = wheel down / toward the user — the // same sign as DOM deltaY. Window::onMouseScroll speaks whole detents - // (±1 per notch on every backend), so divide and accumulate the - // remainder: smooth-scroll devices (touchpads) deliver many sub-detent - // events that must add up instead of each truncating to zero. + // (±1 per notch on every backend), so divide; smooth-scroll devices + // (touchpads) deliver many sub-detent events, which PointerScroll adds + // up instead of each truncating to zero. constexpr double axisUnitsPerDetent = 15.0; - scrollDetentRemainder += wl_fixed_to_double(value) / axisUnitsPerDetent; - const double whole = std::trunc(scrollDetentRemainder); - if (whole != 0.0) { - scrollDetentRemainder -= whole; - // Event payload is uint32 — preserve sign via two's complement. - focusedWindow->onMouseScroll.Invoke( - static_cast(static_cast(whole))); - } + focusedWindow->PointerScroll(wl_fixed_to_double(value) / axisUnitsPerDetent); } diff --git a/implementations/Crafter.Graphics-Window.cpp b/implementations/Crafter.Graphics-Window.cpp index bbcab7d..ec98c7e 100644 --- a/implementations/Crafter.Graphics-Window.cpp +++ b/implementations/Crafter.Graphics-Window.cpp @@ -29,6 +29,7 @@ module; #endif #ifdef CRAFTER_GRAPHICS_WINDOW_WIN32 #include +#include #include #endif #ifndef CRAFTER_GRAPHICS_WINDOW_DOM @@ -125,31 +126,45 @@ static inline KeyCode KeyCodeFromLParam(LPARAM lParam) { // Define a window class name const char g_szClassName[] = "myWindowClass"; +// The Window an HWND belongs to, or nullptr once ~Window has detached it. +// Looked up again after an Invoke that more work follows: a listener may +// have destroyed the Window. +static Window* WindowOf(HWND hwnd) { + return reinterpret_cast(GetWindowLongPtr(hwnd, GWLP_USERDATA)); +} + // Window procedure function that processes messages LRESULT CALLBACK WndProc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam) { - Window* window = nullptr; - if (msg == WM_NCCREATE) { CREATESTRUCT* pCreate = reinterpret_cast(lParam); - window = static_cast(pCreate->lpCreateParams); + Window* window = static_cast(pCreate->lpCreateParams); SetWindowLongPtr(hwnd, GWLP_USERDATA, reinterpret_cast(window)); return TRUE; } - else - { - window = reinterpret_cast( - GetWindowLongPtr(hwnd, GWLP_USERDATA) - ); + + // Messages before WM_NCCREATE, and those DestroyWindow sends from + // ~Window, have no Window to go to. + Window* window = WindowOf(hwnd); + if (window == nullptr) { + return DefWindowProc(hwnd, msg, wParam, lParam); } switch (msg) { - case WM_DESTROY:{ - PostQuitMessage(0); - break; + case WM_CLOSE: { + // A request, as xdg_toplevel.close is: StartSync's loop ends + // and the HWND lives on until ~Window destroys it, so the + // swapchain never presents to a window that is gone. + // DefWindowProc would destroy it here. + window->onClose.Invoke(); + window = WindowOf(hwnd); + if (window != nullptr) { + window->open = false; + } + return 0; } case WM_SIZE: { // SIZE_MINIMIZED reports (0, 0) — Resize() short-circuits, so @@ -220,46 +235,95 @@ LRESULT CALLBACK WndProc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam) { } break; } + case WM_MOUSEMOVE: { + // Signed: under capture, a drag that has left the window keeps + // reporting positions past the client area. + const POINT point{GET_X_LPARAM(lParam), GET_Y_LPARAM(lParam)}; + const Vector position(static_cast(point.x), static_cast(point.y)); + // Win32 has no enter message: the first move over the client + // area after a leave is the enter. TME_LEAVE is one-shot, so it + // is armed again for each enter, and the enter only counts once + // it is armed, or its leave would never come. + RECT client; + if (!window->mouseInside && GetClientRect(hwnd, &client) && PtInRect(&client, point)) { + TRACKMOUSEEVENT track{sizeof(track), TME_LEAVE, hwnd, 0}; + if (TrackMouseEvent(&track)) { + window->PointerEnter(position); + window = WindowOf(hwnd); + if (window == nullptr) break; + } + } + window->PointerMove(position); + break; + } + case WM_MOUSELEAVE: { + // Also sent when the pointer moves onto the non-client area + // (title bar, borders) — it has left the drawable surface. + window->PointerLeave(); + break; + } + // A press captures the mouse, so a drag that leaves the window + // still sends its moves and its release here, as Wayland's + // implicit grab does. The capture goes when no button is held. case WM_LBUTTONDOWN: { + SetCapture(hwnd); window->mouseLeftHeld = true; window->onMouseLeftClick.Invoke(); break; } case WM_LBUTTONUP: { + // Not held: the press went to another window, or losing the + // capture has released it already. + if (!window->mouseLeftHeld) break; window->mouseLeftHeld = false; - window->onMouseLeftRelease.Invoke(); + if (!window->mouseRightHeld && GetCapture() == hwnd) ReleaseCapture(); + window->onMouseLeftRelease.Invoke(); break; } case WM_RBUTTONDOWN: { + SetCapture(hwnd); window->mouseRightHeld = true; window->onMouseRightClick.Invoke(); break; } case WM_RBUTTONUP: { + if (!window->mouseRightHeld) break; window->mouseRightHeld = false; + if (!window->mouseLeftHeld && GetCapture() == hwnd) ReleaseCapture(); window->onMouseRightRelease.Invoke(); break; } + case WM_CAPTURECHANGED: { + // The capture was taken mid-drag (another window took it, the + // window lost activation), so the release will not come here: + // release what is still held. The ReleaseCapture above lands + // here with nothing held. + if (reinterpret_cast(lParam) == hwnd) break; + if (window->mouseLeftHeld) { + window->mouseLeftHeld = false; + window->onMouseLeftRelease.Invoke(); + window = WindowOf(hwnd); + if (window == nullptr) break; + } + if (window->mouseRightHeld) { + window->mouseRightHeld = false; + window->onMouseRightRelease.Invoke(); + } + break; + } + case WM_MOUSEWHEEL: { // High word of wParam is the signed wheel movement, WHEEL_DELTA // (120) per detent, positive = wheel away from the user (scroll - // up). Window::onMouseScroll speaks whole detents with +1 = - // wheel down (the DOM deltaY / Wayland axis sign), so negate. - // Free-spinning wheels report sub-WHEEL_DELTA steps — accumulate - // so they add up instead of each truncating to zero. - static int wheelRemainder = 0; - wheelRemainder += -static_cast(GET_WHEEL_DELTA_WPARAM(wParam)); - int detents = wheelRemainder / WHEEL_DELTA; // trunc toward zero - if (detents != 0) { - wheelRemainder -= detents * WHEEL_DELTA; - // Event payload is uint32 — preserve sign via two's complement. - window->onMouseScroll.Invoke( - static_cast(static_cast(detents))); - } + // up). Window::onMouseScroll speaks detents with +1 = wheel down + // (the DOM deltaY / Wayland axis sign), so negate. Free-spinning + // wheels report sub-WHEEL_DELTA steps, which PointerScroll adds + // up instead of each truncating to zero. + window->PointerScroll(-GET_WHEEL_DELTA_WPARAM(wParam) / static_cast(WHEEL_DELTA)); break; } @@ -284,6 +348,11 @@ Window::Window(std::uint32_t width, std::uint32_t height, const std::string_view } Window::Window(std::uint32_t width, std::uint32_t height) : width(width), height(height) { + // Before the platform code below, which may report the pointer. + lastMousePos = {0,0}; + mouseDelta = {0,0}; + currentMousePos = {0,0}; + #ifdef CRAFTER_GRAPHICS_WINDOW_WAYLAND Device::windows.push_back(this); surface = wl_compositor_create_surface(Device::compositor); @@ -318,14 +387,15 @@ Window::Window(std::uint32_t width, std::uint32_t height) : width(width), height wc.lpszClassName = g_szClassName; wc.hCursor = LoadCursor(NULL, IDC_ARROW); - if (!RegisterClass(&wc)) { + // The first window registers the class; every later one finds it there. + if (!RegisterClass(&wc) && GetLastError() != ERROR_CLASS_ALREADY_EXISTS) { MessageBox(NULL, "Window Class Registration Failed!", "Error", MB_ICONERROR); } RECT rc = {0, 0, static_cast(width), static_cast(height)}; AdjustWindowRect(&rc, WS_OVERLAPPEDWINDOW, FALSE); - HWND hwnd = CreateWindowEx( + hwnd = CreateWindowEx( 0, g_szClassName, "", @@ -345,8 +415,12 @@ Window::Window(std::uint32_t width, std::uint32_t height) : width(width), height ShowWindow(hwnd, SW_SHOWNORMAL); UpdateWindow(hwnd); + // Input stays queued for StartSync. Dispatched here, it would reach the + // window before anyone could listen: the WM_MOUSEMOVE Windows sends + // when a window appears under the cursor would make an onMouseEnter + // nobody hears, and later an onMouseLeave without one. MSG msg; - while (PeekMessage(&msg, NULL, 0, 0, PM_REMOVE)) { + while (PeekMessage(&msg, NULL, 0, 0, PM_REMOVE | PM_QS_POSTMESSAGE | PM_QS_PAINT | PM_QS_SENDMESSAGE)) { TranslateMessage(&msg); DispatchMessage(&msg); } @@ -511,10 +585,6 @@ Window::Window(std::uint32_t width, std::uint32_t height) : width(width), height presentInfo.pSwapchains = &swapChain; presentInfo.pImageIndices = ¤tBuffer; presentInfo.waitSemaphoreCount = 1; - - lastMousePos = {0,0}; - mouseDelta = {0,0}; - currentMousePos = {0,0}; } #ifndef CRAFTER_GRAPHICS_WINDOW_DOM @@ -566,9 +636,50 @@ Window::~Window() { } if (Device::textInputWindow == this) Device::textInputWindow = nullptr; #endif + + #ifdef CRAFTER_GRAPHICS_WINDOW_WIN32 + // Detached first: what DestroyWindow sends (WM_CAPTURECHANGED, + // WM_DESTROY) and anything still queued for the HWND must not reach + // this Window. + if (hwnd != nullptr) { + SetWindowLongPtr(hwnd, GWLP_USERDATA, 0); + DestroyWindow(hwnd); + hwnd = nullptr; + } + #endif } #endif +void Window::PointerEnter(Vector position) { + currentMousePos = position; + if (mouseInside) return; + mouseInside = true; + onMouseEnter.Invoke(); +} + +void Window::PointerMove(Vector position) { + currentMousePos = position; + onMouseMove.Invoke(); +} + +void Window::PointerLeave() { + // A partial detent from before must not count towards the next scroll, + // after a leave without an enter too. + scrollRemainder = 0.0; + if (!mouseInside) return; + mouseInside = false; + onMouseLeave.Invoke(); +} + +void Window::PointerScroll(double detents) { + scrollRemainder += detents; + const double whole = std::trunc(scrollRemainder); + if (whole == 0.0) return; + scrollRemainder -= whole; + // Event payload is uint32 — preserve sign via two's complement. + onMouseScroll.Invoke(static_cast(static_cast(whole))); +} + void Window::Resize(std::uint32_t newWidth, std::uint32_t newHeight) { // Skip degenerate resizes. Win32 minimised windows give (0, 0); Wayland // sometimes echoes the current size in a configure. diff --git a/interfaces/Crafter.Graphics-Device.cppm b/interfaces/Crafter.Graphics-Device.cppm index 3f00b53..2b25dda 100644 --- a/interfaces/Crafter.Graphics-Device.cppm +++ b/interfaces/Crafter.Graphics-Device.cppm @@ -83,12 +83,6 @@ export namespace Crafter { // the manager — Clipboard::SetText silently no-ops there. inline static wl_data_device_manager* dataDeviceManager = nullptr; inline static wl_data_device* dataDevice = nullptr; - // Sub-detent scroll accumulator for PointerListenerHandleAxis. - // wl_pointer.axis values are normalized to wheel detents (15 axis - // units each, the libinput convention); whatever doesn't make a - // whole detent yet is carried here so smooth-scroll devices add up - // across events. Reset on pointer leave. - inline static double scrollDetentRemainder = 0.0; // zwp_text_input_v3: one object per seat; the compositor says which // surface it follows (enter/leave), the events are state that done() // applies as a whole. diff --git a/interfaces/Crafter.Graphics-Window.cppm b/interfaces/Crafter.Graphics-Window.cppm index 45dcb0a..e8057ae 100644 --- a/interfaces/Crafter.Graphics-Window.cppm +++ b/interfaces/Crafter.Graphics-Window.cppm @@ -122,8 +122,9 @@ export namespace Crafter { // 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. + // touchpads, free-spinning wheels) is accumulated until a whole + // detent is reached — by PointerScroll natively, by dom-env.js in + // the browser — and dropped when the pointer leaves. 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 @@ -144,6 +145,28 @@ export namespace Crafter { bool mouseLeftHeld = false; bool mouseRightHeld = false; + #ifndef CRAFTER_GRAPHICS_WINDOW_DOM + // The pointer is over the window: set before onMouseEnter fires, + // cleared before onMouseLeave does. + bool mouseInside = false; + // The part of a wheel detent PointerScroll has not reported yet. + double scrollRemainder = 0.0; + // The backends report the pointer through these, so that enter, + // move, leave and scroll mean the same on each. PointerEnter puts + // the entry position in currentMousePos before onMouseEnter fires, + // and an enter is a move too: the backend follows it with + // PointerMove. PointerLeave drops the scroll remainder, and fires + // only after an enter. Each fires its event last, since a listener + // may destroy the window, so a backend that makes a second call + // first checks that the window still exists. + void PointerEnter(Vector position); + void PointerMove(Vector position); + void PointerLeave(); + // Adds wheel motion in detents, fractions included, and fires + // onMouseScroll for each whole detent it adds up to. + void PointerScroll(double detents); + #endif + 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); @@ -160,8 +183,10 @@ export namespace Crafter { // 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. + // focus pointers, or (Win32) the HWND, detached from this Window + // first so that no message still meant for it arrives here — 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 @@ -232,6 +257,7 @@ export namespace Crafter { #endif #ifdef CRAFTER_GRAPHICS_WINDOW_WIN32 + HWND hwnd = nullptr; HBITMAP cursorBitmap = nullptr; HCURSOR cursorHandle = nullptr; std::uint16_t cursorSizeX = 0; diff --git a/project.cpp b/project.cpp index bf84248..4a8b7e1 100644 --- a/project.cpp +++ b/project.cpp @@ -326,6 +326,18 @@ extern "C" Configuration CrafterBuildProject(std::span a pcTest.requires_ = { "tool:glslang", "tool:spirv-val" }; cfg.tests.push_back(std::move(pcTest)); + // The Win32 backend's pointer, capture, close and teardown handling, + // through a real HWND: posts it the messages Windows would and checks + // what the Window made of them. The Window needs Vulkan for its + // swapchain. Built on Linux it runs under Wine, which may be absent. + if (windows) { + Test win32PointerTest = LibraryTest("PointerWin32"); + #ifndef _WIN32 + win32PointerTest.requires_ = { "tool:wine" }; + #endif + cfg.tests.push_back(std::move(win32PointerTest)); + } + // Regression test for issue #32: the Wayland scroll-wheel chain // (wl_pointer.axis → Window::onMouseScroll). Drives the listener // callback directly, so it needs no compositor — but it does need @@ -333,6 +345,11 @@ extern "C" Configuration CrafterBuildProject(std::span a if (!windows) { cfg.tests.push_back(LibraryTest("MouseScroll")); + // The pointer's enter, move and leave through the helpers both + // native backends report with (Window::PointerEnter and co.). + // Drives the wl_pointer callbacks directly, as MouseScroll does. + cfg.tests.push_back(LibraryTest("PointerEnterLeave")); + // Issue #40: multi-frame-in-flight frame pacing (per-image fences // + per-frame semaphores, no steady-state wait-idle). Drives the // real frame loop against a live Wayland compositor for many more diff --git a/tests/PointerEnterLeave/main.cpp b/tests/PointerEnterLeave/main.cpp new file mode 100644 index 0000000..18c7955 --- /dev/null +++ b/tests/PointerEnterLeave/main.cpp @@ -0,0 +1,122 @@ +//SPDX-License-Identifier: LGPL-3.0-only +//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® + +// The pointer's enter, move and leave as the Wayland backend reports them, +// through Window::PointerEnter/PointerMove/PointerLeave — the helpers the +// Win32 backend reports through as well. Drives the wl_pointer listener +// callbacks directly, as MouseScroll does, so no compositor is needed, and +// asserts: +// +// - onMouseEnter finds the entry position (in physical pixels) in +// currentMousePos, and mouseInside set +// - an enter is also a move: onMouseMove follows it, at the entry position +// - onMouseLeave finds mouseInside cleared, and fires once per enter: a +// second leave (sway, on input-device hot-plug) and a leave without an +// enter fire nothing +// - an enter listener that takes the window away stops the move that +// would follow +// +// The Win32 WM_MOUSEMOVE/WM_MOUSELEAVE path calls the same helpers but +// can't be exercised from a Linux test runner. + +#include +#include + +import Crafter.Graphics; +import Crafter.Event; +import std; +using namespace Crafter; + +namespace { + +int failures = 0; + +void Check(bool ok, std::string_view what) { + std::println("{} {}", ok ? "PASS" : "FAIL", what); + if (!ok) ++failures; +} + +// A default-constructed window has no wl_surface, so a null surface is the +// one PointerListenerHandleEnter finds it by. +void Enter(double x, double y) { + Device::PointerListenerHandleEnter(nullptr, nullptr, /*serial=*/1, /*surface=*/nullptr, + wl_fixed_from_double(x), wl_fixed_from_double(y)); +} + +void Motion(double x, double y) { + Device::PointerListenerHandleMotion(nullptr, nullptr, /*time=*/0, + wl_fixed_from_double(x), wl_fixed_from_double(y)); +} + +void Leave() { + Device::PointerListenerHandleLeave(nullptr, nullptr, /*serial=*/2, nullptr); +} + +// An event as it fired, and what the window looked like then. +struct Seen { + std::string_view event; + float x; + float y; + bool inside; + bool operator==(const Seen&) const = default; +}; + +} // namespace + +int main() { + Window window; // default ctor creates no surface — safe without a compositor + window.scale = 2.0f; + Device::windows.push_back(&window); + + std::vector seen; + auto record = [&](std::string_view event) { + return [&seen, &window, event] { + seen.push_back({ event, window.currentMousePos.x, window.currentMousePos.y, window.mouseInside }); + }; + }; + EventListener enterSub(&window.onMouseEnter, record("enter")); + EventListener moveSub(&window.onMouseMove, record("move")); + EventListener leaveSub(&window.onMouseLeave, record("leave")); + + Enter(10.5, 20.25); + Check(seen == std::vector{ { "enter", 21.0f, 40.5f, true }, { "move", 21.0f, 40.5f, true } }, + "enter: entry position in currentMousePos, then a move to it"); + Check(Device::focusedWindow == &window, "enter focuses the window"); + seen.clear(); + + Motion(30.0, 5.0); + Check(seen == std::vector{ { "move", 60.0f, 10.0f, true } }, "motion: a move"); + seen.clear(); + + Enter(1.0, 1.0); + Check(seen == std::vector{ { "move", 2.0f, 2.0f, true } }, "a second enter without a leave is only a move"); + seen.clear(); + + Leave(); + Check(seen == std::vector{ { "leave", 2.0f, 2.0f, false } }, "leave: mouseInside cleared before onMouseLeave"); + Check(Device::focusedWindow == nullptr, "leave clears the focused window"); + seen.clear(); + + Leave(); + Device::focusedWindow = &window; // as touch_down sets it, with no enter + Leave(); + Check(seen.empty(), "a second leave, and a leave without an enter, fire nothing"); + + // ~Window clears focusedWindow, which is how the enter handler tells + // that a listener destroyed the window before it reports the move. + // Destroying it for real here would leave Event::Invoke reading the + // destroyed event once the listener returns, so this listener clears + // focusedWindow itself, as ~Window does. + EventListener goneSub(&window.onMouseEnter, [] { Device::focusedWindow = nullptr; }); + Enter(3.0, 4.0); + Check(seen == std::vector{ { "enter", 6.0f, 8.0f, true } }, "no move after an enter listener takes the window away"); + + std::erase(Device::windows, &window); + Device::focusedWindow = nullptr; + if (failures != 0) { + std::println("{} check(s) failed", failures); + return EXIT_FAILURE; + } + std::println("all checks passed"); + return EXIT_SUCCESS; +} diff --git a/tests/PointerWin32/main.cpp b/tests/PointerWin32/main.cpp new file mode 100644 index 0000000..bd4b725 --- /dev/null +++ b/tests/PointerWin32/main.cpp @@ -0,0 +1,180 @@ +//SPDX-License-Identifier: LGPL-3.0-only +//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® + +// The Win32 backend's pointer, capture, close and teardown handling. Posts +// a real Window's HWND the messages Windows would send it, pumps them as +// StartSync does, and checks what the Window made of them: +// +// - a move into the client area is an enter, with the entry position +// already in currentMousePos, and a move; WM_MOUSELEAVE is one leave +// - a position past the client area (a drag under capture) arrives +// signed, and is a move only +// - a press captures the mouse and its release lets go; losing the +// capture mid-drag releases the button, and the button-up after that +// fires nothing +// - the wheel adds up sub-detent steps, and a leave drops them +// - WM_CLOSE fires onClose and clears `open`, but leaves the HWND alone; +// ~Window destroys it, what is still queued for it reaches nothing, +// and another window can be made after it +// - an enter listener that takes the window away stops the move that +// would follow +// +// The window is moved off-screen first, so the real cursor stays out of +// it. Built on Linux, it runs under Wine: +// crafter-build test --target=x86_64-w64-mingw32 PointerWin32 + +#include +#include + +import Crafter.Graphics; +import Crafter.Event; +import std; +using namespace Crafter; + +namespace { + +int failures = 0; + +void Check(bool ok, std::string_view what) { + std::println("{} {}", ok ? "PASS" : "FAIL", what); + if (!ok) ++failures; +} + +// One pass of StartSync's pump. +void Pump() { + MSG msg; + while (PeekMessage(&msg, NULL, 0, 0, PM_REMOVE)) { + TranslateMessage(&msg); + DispatchMessage(&msg); + } +} + +void Post(HWND hwnd, UINT msg, WPARAM wParam = 0, LPARAM lParam = 0) { + PostMessage(hwnd, msg, wParam, lParam); + Pump(); +} + +// An event as it fired, and what the window looked like then. +struct Seen { + std::string_view event; + float x; + float y; + bool inside; + bool operator==(const Seen&) const = default; +}; + +} // namespace + +int main() { + Device::Initialize(); + auto window = std::make_unique(320, 240); + const HWND hwnd = window->hwnd; + SetWindowPos(hwnd, nullptr, -20000, -20000, 0, 0, SWP_NOSIZE | SWP_NOZORDER | SWP_NOACTIVATE); + // Whatever the real cursor did while the window was on screen. + Pump(); + Post(hwnd, WM_MOUSELEAVE); + + std::vector seen; + auto record = [&](std::string_view event) { + return [&seen, &window, event] { + seen.push_back({ event, window->currentMousePos.x, window->currentMousePos.y, window->mouseInside }); + }; + }; + auto count = [&](std::string_view event) { return std::ranges::count(seen, event, &Seen::event); }; + EventListener enterSub(&window->onMouseEnter, record("enter")); + EventListener moveSub(&window->onMouseMove, record("move")); + EventListener leaveSub(&window->onMouseLeave, record("leave")); + + Post(hwnd, WM_MOUSEMOVE, 0, MAKELPARAM(10, 20)); + Check(seen.size() >= 2 && seen[0] == Seen{ "enter", 10.0f, 20.0f, true } && seen[1] == Seen{ "move", 10.0f, 20.0f, true }, + "a move into the client area: an enter at the entry position, then a move"); + // Windows answers a TME_LEAVE for a window the cursor is not over with + // a leave at once, Wine with none, so the leave posted here is the + // second on Windows and the first on Wine: either way, one leave. + Post(hwnd, WM_MOUSELEAVE); + Check(count("leave") == 1 && seen.back() == Seen{ "leave", 10.0f, 20.0f, false }, + "WM_MOUSELEAVE: a leave, with mouseInside cleared"); + Post(hwnd, WM_MOUSELEAVE); + Check(count("leave") == 1, "a second WM_MOUSELEAVE fires nothing"); + seen.clear(); + + Post(hwnd, WM_MOUSEMOVE, MK_LBUTTON, MAKELPARAM(-5, -7)); + Check(seen == std::vector{ { "move", -5.0f, -7.0f, false } }, + "a position past the client area: a move only, signed"); + seen.clear(); + + int clicks = 0; + int releases = 0; + EventListener clickSub(&window->onMouseLeftClick, [&] { ++clicks; }); + EventListener releaseSub(&window->onMouseLeftRelease, [&] { ++releases; }); + Post(hwnd, WM_LBUTTONDOWN, MK_LBUTTON, MAKELPARAM(10, 20)); + Check(clicks == 1 && window->mouseLeftHeld && GetCapture() == hwnd, "a press: a click, held, and the mouse captured"); + Post(hwnd, WM_LBUTTONUP, 0, MAKELPARAM(10, 20)); + Check(releases == 1 && !window->mouseLeftHeld && GetCapture() != hwnd, "its release: a release, and the capture let go"); + Post(hwnd, WM_LBUTTONDOWN, MK_LBUTTON, MAKELPARAM(10, 20)); + // As when the capture is taken away mid-drag. + ReleaseCapture(); + Pump(); + Check(releases == 2 && !window->mouseLeftHeld, "losing the capture mid-drag releases the button"); + Post(hwnd, WM_LBUTTONUP, 0, MAKELPARAM(10, 20)); + Check(releases == 2, "and the button-up after that fires nothing"); + + std::vector scrolls; + EventListener scrollSub(&window->onMouseScroll, [&](std::uint32_t delta) { + scrolls.push_back(static_cast(delta)); + }); + auto wheel = [&](int delta) { Post(hwnd, WM_MOUSEWHEEL, MAKEWPARAM(0, delta), 0); }; + wheel(-WHEEL_DELTA); + Check(scrolls == std::vector{ 1 }, "one detent toward the user: +1"); + scrolls.clear(); + wheel(WHEEL_DELTA / 2); + wheel(WHEEL_DELTA / 2); + Check(scrolls == std::vector{ -1 }, "two half detents away from the user add up to -1"); + scrolls.clear(); + wheel(-WHEEL_DELTA / 2); + Post(hwnd, WM_MOUSELEAVE); + wheel(-WHEEL_DELTA / 2); + Check(scrolls.empty(), "a leave drops a half detent"); + wheel(-WHEEL_DELTA / 2); + Check(scrolls == std::vector{ 1 }, "after which two add up again"); + + int closes = 0; + EventListener closeSub(&window->onClose, [&] { ++closes; }); + Post(hwnd, WM_CLOSE); + Check(closes == 1 && !window->open && IsWindow(hwnd), "WM_CLOSE: onClose, open cleared, the HWND still there"); + + // ~Window detaches the HWND before destroying it, which is how WndProc + // tells that a listener destroyed the window before it reports the + // move. Destroying it for real here would leave Event::Invoke reading + // the destroyed event once the listener returns, so this listener + // detaches the HWND itself, as ~Window does. + seen.clear(); + { + EventListener goneSub(&window->onMouseEnter, [hwnd] { SetWindowLongPtr(hwnd, GWLP_USERDATA, 0); }); + Post(hwnd, WM_MOUSEMOVE, 0, MAKELPARAM(30, 40)); + SetWindowLongPtr(hwnd, GWLP_USERDATA, reinterpret_cast(window.get())); + } + Check(seen == std::vector{ { "enter", 30.0f, 40.0f, true } }, "no move after an enter listener takes the window away"); + + PostMessage(hwnd, WM_MOUSEMOVE, 0, MAKELPARAM(50, 60)); + PostMessage(hwnd, WM_MOUSELEAVE, 0, 0); + PostMessage(hwnd, WM_LBUTTONDOWN, MK_LBUTTON, MAKELPARAM(50, 60)); + seen.clear(); + clicks = 0; + window.reset(); + Check(!IsWindow(hwnd), "~Window destroys the HWND"); + Pump(); + Check(seen.empty() && clicks == 0, "and what was still queued for it reaches nothing"); + + // As a resident app does on every hide and show. The window class is + // still registered from the first. + window = std::make_unique(320, 240); + Check(IsWindow(window->hwnd), "another window can be made after it"); + + if (failures != 0) { + std::println("{} check(s) failed", failures); + return EXIT_FAILURE; + } + std::println("all checks passed"); + return EXIT_SUCCESS; +}