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>
This commit is contained in:
Jorijn van der Graaf 2026-10-31 18:27:24 +01:00
commit 578a3c6dc4
Signed by: jorijnvdgraaf
GPG key ID: 2937E59CDCC1BCFB
6 changed files with 292 additions and 19 deletions

120
tests/TitleWayland/main.cpp Normal file
View file

@ -0,0 +1,120 @@
//SPDX-License-Identifier: LGPL-3.0-only
//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
// SetTitle on the Wayland backend sends xdg_toplevel.set_title with exactly
// the view's bytes. It handed title.data() on, which set_title reads up to
// a NUL: a view into a longer string sent the whole string, and an empty
// view, whose data() is null, sent a null string, which libwayland refuses
// with a fatal error on the display.
//
// The toplevel is a proxy on a wl_display connected to one end of a
// socketpair, and the test reads the requests off the other end, so no
// compositor is needed. Needs the Wayland backend, hence Linux only.
#include <wayland-client.h>
#include <sys/socket.h>
#include <unistd.h>
#include <cerrno>
#include <cstdlib>
#include <cstring>
#include "../../lib/xdg-shell-client-protocol.h"
import Crafter.Graphics;
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;
}
// What the display has written so far.
std::vector<std::uint8_t> Sent(wl_display* display, int peer) {
wl_display_flush(display);
std::vector<std::uint8_t> wire;
std::uint8_t chunk[4096];
for (ssize_t n; (n = recv(peer, chunk, sizeof(chunk), MSG_DONTWAIT)) > 0;) {
wire.insert(wire.end(), chunk, chunk + n);
}
return wire;
}
// The titles of the set_title requests on `object` in `wire`. A request is
// its object id, then its size and opcode in one word, then its arguments;
// set_title's one argument is a string, its length with the NUL, then its
// bytes padded to a word.
std::vector<std::string> Titles(const std::vector<std::uint8_t>& wire, std::uint32_t object) {
std::vector<std::string> titles;
for (std::size_t at = 0; at + 8 <= wire.size();) {
std::uint32_t header[2];
std::memcpy(header, wire.data() + at, sizeof(header));
const std::uint32_t size = header[1] >> 16;
if (size < 8 || at + size > wire.size()) break;
if (header[0] == object && (header[1] & 0xFFFF) == XDG_TOPLEVEL_SET_TITLE && size >= 12) {
std::uint32_t length;
std::memcpy(&length, wire.data() + at + 8, sizeof(length));
if (length > 0 && 12 + length <= size) {
titles.emplace_back(reinterpret_cast<const char*>(wire.data() + at + 12), length - 1);
}
}
at += size;
}
return titles;
}
} // namespace
int main() {
int fds[2];
if (socketpair(AF_UNIX, SOCK_STREAM | SOCK_CLOEXEC, 0, fds) != 0) {
std::println("FAIL socketpair: {}", std::strerror(errno));
return EXIT_FAILURE;
}
// The display owns fds[0] from here.
wl_display* display = wl_display_connect_to_fd(fds[0]);
if (display == nullptr) {
std::println("FAIL wl_display_connect_to_fd: {}", std::strerror(errno));
return EXIT_FAILURE;
}
// libwayland makes whatever proxy it is asked to bind; nothing answers.
wl_registry* registry = wl_display_get_registry(display);
auto* toplevel = static_cast<xdg_toplevel*>(wl_registry_bind(registry, 1, &xdg_toplevel_interface, 1));
const std::uint32_t id = wl_proxy_get_id(reinterpret_cast<wl_proxy*>(toplevel));
Sent(display, fds[1]);
{
// A default-constructed window, which ~Window can tear down without
// a display, holding the proxy as its toplevel.
Window window;
window.xdgToplevel = toplevel;
const std::string longer = "Crafter.Graphics";
window.SetTitle(std::string_view(longer).substr(0, 7));
const std::vector<std::string> sliced = Titles(Sent(display, fds[1]), id);
Check(sliced == std::vector<std::string>{ "Crafter" }, "a view into a longer string: the view's bytes only");
if (sliced != std::vector<std::string>{ "Crafter" }) {
for (const std::string& title : sliced) std::println(" set_title \"{}\"", title);
}
window.SetTitle(std::string_view{});
const std::vector<std::string> empty = Titles(Sent(display, fds[1]), id);
const int error = wl_display_get_error(display);
Check(error == 0 && empty == std::vector<std::string>{ "" }, "an empty view: an empty title, not a null string");
if (error != 0) std::println(" the display failed: {}", std::strerror(error));
// ~Window destroys the toplevel.
}
wl_registry_destroy(registry);
wl_display_disconnect(display);
close(fds[1]);
if (failures != 0) {
std::println("{} check(s) failed", failures);
return EXIT_FAILURE;
}
std::println("all checks passed");
return EXIT_SUCCESS;
}