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>
180 lines
7.5 KiB
C++
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 (PeekMessageW(&msg, NULL, 0, 0, PM_REMOVE)) {
|
|
TranslateMessage(&msg);
|
|
DispatchMessageW(&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;
|
|
}
|