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 <noreply@anthropic.com>
This commit is contained in:
Jorijn van der Graaf 2026-10-31 03:29:50 +01:00
commit c62109caf3
Signed by: jorijnvdgraaf
GPG key ID: 2937E59CDCC1BCFB
7 changed files with 504 additions and 62 deletions

View file

@ -493,10 +493,7 @@ void Device::PointerListenerHandleMotion(void* data, wl_pointer* wl_pointer, std
return;
}
Vector<float, 2> 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<float, 2> pos = Vector<float, 2>(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<std::uint32_t>(static_cast<std::int32_t>(whole)));
}
focusedWindow->PointerScroll(wl_fixed_to_double(value) / axisUnitsPerDetent);
}

View file

@ -29,6 +29,7 @@ module;
#endif
#ifdef CRAFTER_GRAPHICS_WINDOW_WIN32
#include <windows.h>
#include <windowsx.h>
#include <cassert>
#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<Window*>(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<CREATESTRUCT*>(lParam);
window = static_cast<Window*>(pCreate->lpCreateParams);
Window* window = static_cast<Window*>(pCreate->lpCreateParams);
SetWindowLongPtr(hwnd, GWLP_USERDATA, reinterpret_cast<LONG_PTR>(window));
return TRUE;
}
else
{
window = reinterpret_cast<Window*>(
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<float, 2> position(static_cast<float>(point.x), static_cast<float>(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;
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<HWND>(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<int>(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<std::uint32_t>(static_cast<std::int32_t>(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<double>(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<LONG>(width), static_cast<LONG>(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 = &currentBuffer;
presentInfo.waitSemaphoreCount = 1;
lastMousePos = {0,0};
mouseDelta = {0,0};
currentMousePos = {0,0};
}
#ifndef CRAFTER_GRAPHICS_WINDOW_DOM
@ -566,8 +636,49 @@ 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<float, 2> position) {
currentMousePos = position;
if (mouseInside) return;
mouseInside = true;
onMouseEnter.Invoke();
}
void Window::PointerMove(Vector<float, 2> 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<std::uint32_t>(static_cast<std::int32_t>(whole)));
}
void Window::Resize(std::uint32_t newWidth, std::uint32_t newHeight) {
// Skip degenerate resizes. Win32 minimised windows give (0, 0); Wayland

View file

@ -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.

View file

@ -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<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
@ -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<float, 2> position);
void PointerMove(Vector<float, 2> 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;

View file

@ -326,6 +326,18 @@ extern "C" Configuration CrafterBuildProject(std::span<const std::string_view> 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<const std::string_view> 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

View file

@ -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 <wayland-client.h>
#include <cstdlib>
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> seen;
auto record = [&](std::string_view event) {
return [&seen, &window, event] {
seen.push_back({ event, window.currentMousePos.x, window.currentMousePos.y, window.mouseInside });
};
};
EventListener<void> enterSub(&window.onMouseEnter, record("enter"));
EventListener<void> moveSub(&window.onMouseMove, record("move"));
EventListener<void> leaveSub(&window.onMouseLeave, record("leave"));
Enter(10.5, 20.25);
Check(seen == std::vector<Seen>{ { "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<Seen>{ { "move", 60.0f, 10.0f, true } }, "motion: a move");
seen.clear();
Enter(1.0, 1.0);
Check(seen == std::vector<Seen>{ { "move", 2.0f, 2.0f, true } }, "a second enter without a leave is only a move");
seen.clear();
Leave();
Check(seen == std::vector<Seen>{ { "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<void> goneSub(&window.onMouseEnter, [] { Device::focusedWindow = nullptr; });
Enter(3.0, 4.0);
Check(seen == std::vector<Seen>{ { "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;
}

180
tests/PointerWin32/main.cpp Normal file
View file

@ -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 <windows.h>
#include <cstdlib>
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<Window>(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> 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<void> enterSub(&window->onMouseEnter, record("enter"));
EventListener<void> moveSub(&window->onMouseMove, record("move"));
EventListener<void> 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<Seen>{ { "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<void> clickSub(&window->onMouseLeftClick, [&] { ++clicks; });
EventListener<void> 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<std::int32_t> scrolls;
EventListener<std::uint32_t> scrollSub(&window->onMouseScroll, [&](std::uint32_t delta) {
scrolls.push_back(static_cast<std::int32_t>(delta));
});
auto wheel = [&](int delta) { Post(hwnd, WM_MOUSEWHEEL, MAKEWPARAM(0, delta), 0); };
wheel(-WHEEL_DELTA);
Check(scrolls == std::vector<std::int32_t>{ 1 }, "one detent toward the user: +1");
scrolls.clear();
wheel(WHEEL_DELTA / 2);
wheel(WHEEL_DELTA / 2);
Check(scrolls == std::vector<std::int32_t>{ -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<std::int32_t>{ 1 }, "after which two add up again");
int closes = 0;
EventListener<void> 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<void> goneSub(&window->onMouseEnter, [hwnd] { SetWindowLongPtr(hwnd, GWLP_USERDATA, 0); });
Post(hwnd, WM_MOUSEMOVE, 0, MAKELPARAM(30, 40));
SetWindowLongPtr(hwnd, GWLP_USERDATA, reinterpret_cast<LONG_PTR>(window.get()));
}
Check(seen == std::vector<Seen>{ { "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<Window>(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;
}