Crafter.Graphics/tests/PointerWin32/main.cpp
Jorijn van der Graaf c62109caf3
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>
2026-10-31 03:29:50 +01:00

180 lines
7.5 KiB
C++

//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;
}