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

@ -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;
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<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,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<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
// sometimes echoes the current size in a configure.