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:
parent
397ff2db51
commit
c62109caf3
7 changed files with 504 additions and 62 deletions
180
tests/PointerWin32/main.cpp
Normal file
180
tests/PointerWin32/main.cpp
Normal 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;
|
||||
}
|
||||
Loading…
Reference in a new issue