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
|
|
|
//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;
|
fix(window): set the title on Win32 through a Unicode window class, and send only the view's bytes on Wayland
Win32
- SetTitle had a body only for Wayland, so the title passed to Window's
constructor and every later SetTitle left a Win32 window untitled. It
converts the title from UTF-8 and hands it to SetWindowTextW.
- The window class is registered with RegisterClassW, the window made
with CreateWindowExW, and WndProc hands what it does not handle to
DefWindowProcW. An ANSI class has the text of every message converted
to the process code page on its way to WndProc, so SetWindowTextW
alone would not do: under Wine, in CP1252, 日本 in a title came back
as ??. WM_CHAR, whose handler takes wParam for a UTF-16 code unit, got
€ as its CP1252 byte 0x80 and passed it on as U+0080, and 日 and both
halves of a surrogate pair as ?.
- Both message pumps use PeekMessageW and DispatchMessageW. PeekMessageA
converts WM_CHAR to the code page whatever the window's class: on the
Unicode class, an ANSI pump still turned 日 and the surrogates into ?.
Wayland
- SetTitle handed title.data() to xdg_toplevel_set_title, which reads up
to a NUL: a view into a longer string sent the whole string, and an
empty view's null data() made libwayland fail the display with EINVAL.
It copies the view into a std::string, as SetAppId does.
Tests
- TextWin32: the constructor's title, SetTitle with characters outside
the code page, with a view into a longer string and with an empty
title, read back with GetWindowTextW; and é, €, 日 and a surrogate
pair posted as WM_CHAR and delivered by StartSync's own loop, which
returns on the WM_CLOSE posted after them, so a StartSync pumping with
the A functions fails it. The window is disabled, so a real keystroke
can't reach the check. Without the fix every check but the empty title
fails. Like PointerWin32, built on Linux it runs under Wine.
- TitleWayland: SetTitle's set_title requests, read off a wl_display
connected to a socketpair, for a view into a longer string and for an
empty view. Both checks fail without the fix. No compositor.
- PointerWin32 pumps with the W functions, as StartSync now does.
Docs
- AGENTS.md names TextWin32 beside PointerWin32 as the Win32 tests, says
what their Wine runs needed (the vulkan-1 import library, -luuid, which
the link flags lack, #184, and a __libc_single_threaded definition),
and lists TitleWayland among the tests that need no GPU.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 18:27:24 +01:00
|
|
|
while (PeekMessageW(&msg, NULL, 0, 0, PM_REMOVE)) {
|
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
|
|
|
TranslateMessage(&msg);
|
fix(window): set the title on Win32 through a Unicode window class, and send only the view's bytes on Wayland
Win32
- SetTitle had a body only for Wayland, so the title passed to Window's
constructor and every later SetTitle left a Win32 window untitled. It
converts the title from UTF-8 and hands it to SetWindowTextW.
- The window class is registered with RegisterClassW, the window made
with CreateWindowExW, and WndProc hands what it does not handle to
DefWindowProcW. An ANSI class has the text of every message converted
to the process code page on its way to WndProc, so SetWindowTextW
alone would not do: under Wine, in CP1252, 日本 in a title came back
as ??. WM_CHAR, whose handler takes wParam for a UTF-16 code unit, got
€ as its CP1252 byte 0x80 and passed it on as U+0080, and 日 and both
halves of a surrogate pair as ?.
- Both message pumps use PeekMessageW and DispatchMessageW. PeekMessageA
converts WM_CHAR to the code page whatever the window's class: on the
Unicode class, an ANSI pump still turned 日 and the surrogates into ?.
Wayland
- SetTitle handed title.data() to xdg_toplevel_set_title, which reads up
to a NUL: a view into a longer string sent the whole string, and an
empty view's null data() made libwayland fail the display with EINVAL.
It copies the view into a std::string, as SetAppId does.
Tests
- TextWin32: the constructor's title, SetTitle with characters outside
the code page, with a view into a longer string and with an empty
title, read back with GetWindowTextW; and é, €, 日 and a surrogate
pair posted as WM_CHAR and delivered by StartSync's own loop, which
returns on the WM_CLOSE posted after them, so a StartSync pumping with
the A functions fails it. The window is disabled, so a real keystroke
can't reach the check. Without the fix every check but the empty title
fails. Like PointerWin32, built on Linux it runs under Wine.
- TitleWayland: SetTitle's set_title requests, read off a wl_display
connected to a socketpair, for a view into a longer string and for an
empty view. Both checks fail without the fix. No compositor.
- PointerWin32 pumps with the W functions, as StartSync now does.
Docs
- AGENTS.md names TextWin32 beside PointerWin32 as the Win32 tests, says
what their Wine runs needed (the vulkan-1 import library, -luuid, which
the link flags lack, #184, and a __libc_single_threaded definition),
and lists TitleWayland among the tests that need no GPU.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 18:27:24 +01:00
|
|
|
DispatchMessageW(&msg);
|
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
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
}
|