feat(input): wire native mouse-wheel scroll on Wayland + Win32, normalize all backends to ±1/detent #34
8 changed files with 254 additions and 10 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
// 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)));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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")))
|
||||
|
|
|
|||
|
|
@ -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*);
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
28
project.cpp
28
project.cpp
|
|
@ -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
131
tests/MouseScroll/main.cpp
Normal 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;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue