feat(input): wire native mouse-wheel scroll on Wayland + Win32, normalize all backends to ±1/detent #34

Merged
catbot merged 2 commits from claude/issue-32 into master 2026-06-13 02:10:53 +02:00
8 changed files with 254 additions and 10 deletions

View file

@ -472,7 +472,22 @@ function domAttachWindow(windowHandle) {
}
fire("__crafterDom_mouseUp", [e.button]);
};
__windowListeners.wheel = (e) => fire("__crafterDom_wheel", [e.deltaY]);
// Window::onMouseScroll speaks whole wheel detents (+1 = wheel down),
// the same unit the Wayland and Win32 backends deliver. WheelEvent
// deltas arrive in browser-specific units, so normalize per deltaMode
// (Chromium: ~100px per detent in pixel mode; Firefox: 3 lines in line
// mode) and carry the sub-detent remainder so smooth-scroll trackpads
// add up instead of each event truncating to zero.
let __wheelDetentRemainder = 0;
__windowListeners.wheel = (e) => {
const perDetent = e.deltaMode === 1 ? 3 : e.deltaMode === 2 ? 1 : 100;
__wheelDetentRemainder += e.deltaY / perDetent;
const whole = Math.trunc(__wheelDetentRemainder);
if (whole !== 0) {
__wheelDetentRemainder -= whole;
fire("__crafterDom_wheel", [whole]);
}
};
__windowListeners.contextmenu = (e) => { e.preventDefault(); };
__windowListeners.pointerlockchange = () => {
// Reset the synthetic accumulator when lock is released so the

View file

@ -223,6 +223,11 @@ void Device::handle_global_remove(void* data, wl_registry* registry, uint32_t na
}
void Device::pointer_handle_button(void* data, wl_pointer* pointer, std::uint32_t serial, std::uint32_t time, std::uint32_t button, std::uint32_t state) {
// Defensive: compositors can deliver pointer events without (or after)
// a matching enter — observed on sway during input-device hot-plug.
if (focusedWindow == nullptr) {
return;
}
if (button == BTN_LEFT) {
if(state == WL_POINTER_BUTTON_STATE_PRESSED) {
Device::focusedWindow->mouseLeftHeld = true;
@ -243,6 +248,9 @@ void Device::pointer_handle_button(void* data, wl_pointer* pointer, std::uint32_
}
void Device::PointerListenerHandleMotion(void* data, wl_pointer* wl_pointer, std::uint32_t time, wl_fixed_t surface_x, wl_fixed_t surface_y) {
if (focusedWindow == nullptr) {
return;
}
Vector<float, 2> pos(wl_fixed_to_double(surface_x), wl_fixed_to_double(surface_y));
//Device::focusedWindow->lastMousePos = Device::focusedWindow->currentMousePos;
Device::focusedWindow->currentMousePos = pos * Device::focusedWindow->scale;
@ -267,12 +275,37 @@ void Device::PointerListenerHandleEnter(void* data, wl_pointer* wl_pointer, std:
}
void Device::PointerListenerHandleLeave(void* data, wl_pointer*, std::uint32_t, wl_surface*) {
Device::focusedWindow->onMouseLeave.Invoke();
focusedWindow = nullptr;
// Sway has been observed to send a second leave after focus is already
// gone (input-device hot-plug) — don't crash on it.
if (focusedWindow != nullptr) {
focusedWindow->onMouseLeave.Invoke();
focusedWindow = nullptr;
}
// Drop any sub-detent scroll remainder so it can't leak into whatever
// window the pointer enters next.
scrollDetentRemainder = 0.0;
}
void Device::PointerListenerHandleAxis(void*, wl_pointer*, std::uint32_t, std::uint32_t, wl_fixed_t value) {
void Device::PointerListenerHandleAxis(void*, wl_pointer*, std::uint32_t, std::uint32_t axis, wl_fixed_t value) {
// Vertical wheel only — Window has no horizontal-scroll event (yet).
if (axis != WL_POINTER_AXIS_VERTICAL_SCROLL || focusedWindow == nullptr) {
return;
}
// libinput convention (followed by wlroots, Mutter and KWin): one wheel
// detent = 15 axis units, positive = wheel down / toward the user — the
// same sign as DOM deltaY. Window::onMouseScroll speaks whole detents
// (±1 per notch on every backend), so divide and accumulate the
// remainder: smooth-scroll devices (touchpads) deliver many sub-detent
// events that must add up instead of each truncating to zero.
constexpr double axisUnitsPerDetent = 15.0;
scrollDetentRemainder += wl_fixed_to_double(value) / axisUnitsPerDetent;
const double whole = std::trunc(scrollDetentRemainder);
if (whole != 0.0) {
scrollDetentRemainder -= whole;
// Event payload is uint32 — preserve sign via two's complement.
focusedWindow->onMouseScroll.Invoke(
static_cast<std::uint32_t>(static_cast<std::int32_t>(whole)));
}
}

View file

@ -258,8 +258,9 @@ void Map::Attach(Window& w) {
scrollListener = std::make_unique<EventListener<std::uint32_t>>(
&w.onMouseScroll,
[this](std::uint32_t delta) {
// Wayland and Win32 both deliver scroll as a signed delta
// packed into a uint32; we reinterpret to preserve sign.
// Every backend (DOM, Wayland, Win32) delivers scroll as
// signed whole detents (+1 = wheel down) packed into a
// uint32; we reinterpret to preserve sign.
scrollAccumulator += (std::int32_t)delta;
});
lastMousePos.reset();

View file

@ -257,6 +257,25 @@ LRESULT CALLBACK WndProc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam) {
break;
}
case WM_MOUSEWHEEL: {
// High word of wParam is the signed wheel movement, WHEEL_DELTA
// (120) per detent, positive = wheel away from the user (scroll
// up). Window::onMouseScroll speaks whole detents with +1 =
// wheel down (the DOM deltaY / Wayland axis sign), so negate.
// Free-spinning wheels report sub-WHEEL_DELTA steps — accumulate
// so they add up instead of each truncating to zero.
static int wheelRemainder = 0;
wheelRemainder += -static_cast<int>(GET_WHEEL_DELTA_WPARAM(wParam));
int detents = wheelRemainder / WHEEL_DELTA; // trunc toward zero
if (detents != 0) {
wheelRemainder -= detents * WHEEL_DELTA;
// Event payload is uint32 — preserve sign via two's complement.
window->onMouseScroll.Invoke(
static_cast<std::uint32_t>(static_cast<std::int32_t>(detents)));
}
break;
}
case WM_SETCURSOR: {
if (LOWORD(lParam) == HTCLIENT && window->cursorHandle) {
SetCursor(window->cursorHandle);
@ -1458,10 +1477,12 @@ extern "C" {
}
__attribute__((export_name("__crafterDom_wheel")))
void __crafterDom_wheel(std::int32_t /*handle*/, double deltaY) {
void __crafterDom_wheel(std::int32_t /*handle*/, double detents) {
if (!g_domWindow) return;
// dom-env.js has already normalized WheelEvent deltas to whole
// detents (+1 = wheel down) — see __windowListeners.wheel.
// Window::onMouseScroll is uint32 — preserve sign via two's complement.
g_domWindow->onMouseScroll.Invoke(static_cast<std::uint32_t>(static_cast<std::int32_t>(deltaY)));
g_domWindow->onMouseScroll.Invoke(static_cast<std::uint32_t>(static_cast<std::int32_t>(detents)));
}
__attribute__((export_name("__crafterDom_keyDown")))

View file

@ -78,6 +78,12 @@ export namespace Crafter {
// the manager — Clipboard::SetText silently no-ops there.
inline static wl_data_device_manager* dataDeviceManager = nullptr;
inline static wl_data_device* dataDevice = nullptr;
// Sub-detent scroll accumulator for PointerListenerHandleAxis.
// wl_pointer.axis values are normalized to wheel detents (15 axis
// units each, the libinput convention); whatever doesn't make a
// whole detent yet is carried here so smooth-scroll devices add up
// across events. Reset on pointer leave.
inline static double scrollDetentRemainder = 0.0;
static void seat_handle_capabilities(void* data, wl_seat* seat, uint32_t capabilities);
static void xdg_surface_handle_preferred_scale(void* data, wp_fractional_scale_v1*, std::uint32_t scale);
@ -92,7 +98,7 @@ export namespace Crafter {
static void keyboard_repeat_info(void *data, wl_keyboard *keyboard, int32_t rate, int32_t delay);
static void pointer_handle_button(void* data, wl_pointer* pointer, std::uint32_t serial, std::uint32_t time, std::uint32_t button, std::uint32_t state);
static void PointerListenerHandleMotion(void* data, wl_pointer* wl_pointer, std::uint32_t time, wl_fixed_t surface_x, wl_fixed_t surface_y);
static void PointerListenerHandleAxis(void*, wl_pointer*, std::uint32_t, std::uint32_t, wl_fixed_t value);
static void PointerListenerHandleAxis(void*, wl_pointer*, std::uint32_t time, std::uint32_t axis, wl_fixed_t value);
static void PointerListenerHandleEnter(void* data, wl_pointer* wl_pointer, std::uint32_t serial, wl_surface* surface, wl_fixed_t surface_x, wl_fixed_t surface_y);
static void PointerListenerHandleLeave(void*, wl_pointer*, std::uint32_t, wl_surface*);

View file

@ -103,6 +103,15 @@ export namespace Crafter {
Event<void> onMouseMove;
Event<void> onMouseEnter;
Event<void> onMouseLeave;
// Mouse wheel, in whole detents per event, sign packed into the
// uint32 payload (reinterpret as int32 to read it). Positive =
// wheel down / toward the user — the DOM deltaY sign. Every
// backend normalizes to ±1 per notch: dom-env.js divides
// WheelEvent deltas by the per-deltaMode detent size, Wayland
// divides the axis value by libinput's 15-units-per-detent,
// Win32 divides by WHEEL_DELTA. Sub-detent motion (smooth-scroll
// touchpads, free-spinning wheels) is accumulated backend-side
// until a whole detent is reached.
Event<std::uint32_t> onMouseScroll;
Vector<float, 2> currentMousePos;
Vector<float, 2> lastMousePos;

View file

@ -237,6 +237,34 @@ extern "C" Configuration CrafterBuildProject(std::span<const std::string_view> a
tc.GetInterfacesAndImplementations(ifaces, testImpls);
pcTest.requires_ = { "tool:glslang", "tool:spirv-val" };
cfg.tests.push_back(std::move(pcTest));
// Regression test for issue #32: the Wayland scroll-wheel chain
// (wl_pointer.axis → Window::onMouseScroll). Drives the listener
// callback directly, so it needs no compositor — but it does need
// the Wayland backend compiled in, hence Linux-only.
if (!windows) {
Test scrollTest;
Configuration& sc = scrollTest.config;
sc.path = cfg.path;
sc.name = "MouseScroll";
sc.outputName = "MouseScroll";
sc.type = ConfigurationType::Executable;
sc.target = cfg.target;
sc.march = cfg.march;
sc.mtune = cfg.mtune;
sc.debug = cfg.debug;
sc.sysroot = cfg.sysroot;
sc.dependencies = cfg.dependencies;
sc.externalDependencies = cfg.externalDependencies;
sc.compileFlags = cfg.compileFlags;
sc.linkFlags = cfg.linkFlags;
sc.defines = cfg.defines;
sc.cFiles = cfg.cFiles;
std::vector<fs::path> scrollImpls(impls.begin(), impls.end());
scrollImpls.emplace_back("tests/MouseScroll/main");
sc.GetInterfacesAndImplementations(ifaces, scrollImpls);
cfg.tests.push_back(std::move(scrollTest));
}
}
return cfg;

131
tests/MouseScroll/main.cpp Normal file
View file

@ -0,0 +1,131 @@
/*
Crafter®.Graphics
Copyright (C) 2026 Catcrafts®
catcrafts.net
This library is free software; you can redistribute it and/or
modify it under the terms of the GNU Lesser General Public
License version 3.0 as published by the Free Software Foundation;
This library is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
Lesser General Public License for more details.
You should have received a copy of the GNU Lesser General Public
License along with this library; if not, write to the Free Software
Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
*/
// Regression test for issue #32: the Wayland wl_pointer.axis handler used to
// be an empty stub, so Window::onMouseScroll (and with it MouseScrollBind)
// never fired on native Linux. This drives Device::PointerListenerHandleAxis
// directly — no compositor needed; a default-constructed Window is inert on
// Wayland — and asserts the normalization contract documented on
// Window::onMouseScroll:
//
// - one wheel detent (15 axis units, the libinput convention) → ±1,
// positive = wheel down, sign packed into the uint32 payload
// - sub-detent smooth-scroll values accumulate across events instead of
// each truncating to zero
// - horizontal axis events are ignored
// - no focused window → no event (and no crash)
// - the sub-detent remainder is dropped on pointer leave
//
// The Win32 WM_MOUSEWHEEL path added by the same issue follows the identical
// contract but can't be exercised from a Linux test runner.
#include <wayland-client.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;
}
// Convenience: feed one vertical (or horizontal) axis event, in axis units.
void SendAxis(double axisUnits, std::uint32_t axis = WL_POINTER_AXIS_VERTICAL_SCROLL) {
Device::PointerListenerHandleAxis(nullptr, nullptr, /*time=*/0, axis,
wl_fixed_from_double(axisUnits));
}
} // namespace
int main() {
Window window; // default ctor creates no surface — safe without a compositor
std::vector<std::int32_t> deltas;
EventListener<std::uint32_t> sub(&window.onMouseScroll,
[&](std::uint32_t delta) {
// Same reinterpretation consumers (Input::Map) use.
deltas.push_back(static_cast<std::int32_t>(delta));
});
Device::focusedWindow = &window;
// One detent down / one detent up.
SendAxis(15.0);
Check(deltas == std::vector<std::int32_t>{1}, "one detent down → +1");
deltas.clear();
SendAxis(-15.0);
Check(deltas == std::vector<std::int32_t>{-1}, "one detent up → -1");
deltas.clear();
// Smooth scroll: five 3-unit events must add up to exactly one detent,
// delivered on the event that completes it.
for (int i = 0; i < 4; ++i) SendAxis(3.0);
Check(deltas.empty(), "four 3-unit events → nothing yet");
SendAxis(3.0);
Check(deltas == std::vector<std::int32_t>{1}, "fifth 3-unit event completes the detent → +1");
deltas.clear();
// Direction reversal mid-accumulation must not misfire.
SendAxis(7.5);
SendAxis(-7.5);
SendAxis(-15.0);
Check(deltas == std::vector<std::int32_t>{-1}, "reversal cancels remainder, full detent up → -1");
deltas.clear();
// A big flick delivers multiple detents in one event.
SendAxis(45.0);
Check(deltas == std::vector<std::int32_t>{3}, "45 axis units in one event → +3");
deltas.clear();
// Horizontal scroll has no Window event — must be ignored entirely
// (including the accumulator).
SendAxis(15.0, WL_POINTER_AXIS_HORIZONTAL_SCROLL);
Check(deltas.empty(), "horizontal axis ignored");
SendAxis(14.0);
Check(deltas.empty(), "horizontal units did not leak into the vertical accumulator");
SendAxis(1.0);
Check(deltas == std::vector<std::int32_t>{1}, "vertical accumulator unaffected by horizontal events");
deltas.clear();
// Pointer leave drops the sub-detent remainder.
SendAxis(14.0);
Device::PointerListenerHandleLeave(nullptr, nullptr, 0, nullptr);
Check(Device::focusedWindow == nullptr, "leave clears the focused window");
SendAxis(14.0); // no focused window — must be a no-op, not a crash
Check(deltas.empty(), "no focused window → no event");
Device::focusedWindow = &window;
SendAxis(14.0);
Check(deltas.empty(), "remainder was reset on leave (14 + 14 stayed < a detent)");
SendAxis(1.0);
Check(deltas == std::vector<std::int32_t>{1}, "fresh accumulation completes normally");
Device::focusedWindow = nullptr;
if (failures != 0) {
std::println("{} check(s) failed", failures);
return EXIT_FAILURE;
}
std::println("all checks passed");
return EXIT_SUCCESS;
}