diff --git a/additional/dom-env.js b/additional/dom-env.js index fb64368..e8fbff6 100644 --- a/additional/dom-env.js +++ b/additional/dom-env.js @@ -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 diff --git a/implementations/Crafter.Graphics-Device.cpp b/implementations/Crafter.Graphics-Device.cpp index b766b9b..016242a 100644 --- a/implementations/Crafter.Graphics-Device.cpp +++ b/implementations/Crafter.Graphics-Device.cpp @@ -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 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(static_cast(whole))); + } } diff --git a/implementations/Crafter.Graphics-Input.cpp b/implementations/Crafter.Graphics-Input.cpp index 74a7222..9d1a91f 100644 --- a/implementations/Crafter.Graphics-Input.cpp +++ b/implementations/Crafter.Graphics-Input.cpp @@ -258,8 +258,9 @@ void Map::Attach(Window& w) { scrollListener = std::make_unique>( &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(); diff --git a/implementations/Crafter.Graphics-Window.cpp b/implementations/Crafter.Graphics-Window.cpp index 18fe84c..1388a03 100644 --- a/implementations/Crafter.Graphics-Window.cpp +++ b/implementations/Crafter.Graphics-Window.cpp @@ -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(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(static_cast(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(static_cast(deltaY))); + g_domWindow->onMouseScroll.Invoke(static_cast(static_cast(detents))); } __attribute__((export_name("__crafterDom_keyDown"))) diff --git a/interfaces/Crafter.Graphics-Device.cppm b/interfaces/Crafter.Graphics-Device.cppm index 3708507..8ec80a7 100644 --- a/interfaces/Crafter.Graphics-Device.cppm +++ b/interfaces/Crafter.Graphics-Device.cppm @@ -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*); diff --git a/interfaces/Crafter.Graphics-Window.cppm b/interfaces/Crafter.Graphics-Window.cppm index 2d4b9bd..b957882 100644 --- a/interfaces/Crafter.Graphics-Window.cppm +++ b/interfaces/Crafter.Graphics-Window.cppm @@ -103,6 +103,15 @@ export namespace Crafter { Event onMouseMove; Event onMouseEnter; Event 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 onMouseScroll; Vector currentMousePos; Vector lastMousePos; diff --git a/project.cpp b/project.cpp index 62df28d..e7c0541 100644 --- a/project.cpp +++ b/project.cpp @@ -237,6 +237,34 @@ extern "C" Configuration CrafterBuildProject(std::span 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 scrollImpls(impls.begin(), impls.end()); + scrollImpls.emplace_back("tests/MouseScroll/main"); + sc.GetInterfacesAndImplementations(ifaces, scrollImpls); + cfg.tests.push_back(std::move(scrollTest)); + } } return cfg; diff --git a/tests/MouseScroll/main.cpp b/tests/MouseScroll/main.cpp new file mode 100644 index 0000000..04be40e --- /dev/null +++ b/tests/MouseScroll/main.cpp @@ -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 +#include + +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 deltas; + EventListener sub(&window.onMouseScroll, + [&](std::uint32_t delta) { + // Same reinterpretation consumers (Input::Map) use. + deltas.push_back(static_cast(delta)); + }); + + Device::focusedWindow = &window; + + // One detent down / one detent up. + SendAxis(15.0); + Check(deltas == std::vector{1}, "one detent down → +1"); + deltas.clear(); + SendAxis(-15.0); + Check(deltas == std::vector{-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{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{-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{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{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{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; +}