From 2b34ef3cd9fdbf314841966f166fd903f76c7645 Mon Sep 17 00:00:00 2001 From: Max <112043822+Maxaubert@users.noreply.github.com> Date: Fri, 16 Oct 2026 03:48:30 +0200 Subject: [PATCH 1/8] fix(input): never swallow a key the system already holds, clear stale side-button records (#494) A reinstalled or resumed keyboard hook met a held bound key at its auto-repeat, treated it as a fresh press, swallowed it and then its UP, leaving the key stuck down system-wide (and the watchdog reinstalling every 250 ms). The firstDown branch now skips the swallow when GetAsyncKeyState shows the system saw the DOWN. An unswallowed XBUTTON DOWN now drops a stale swallow record, as the click and key paths do, so its UP is not eaten. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TdjopXZfsUiesYxYLYHEdH --- docs/architecture/06-input.md | 6 +++++- src/input/input_router.cpp | 9 ++++++++- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/docs/architecture/06-input.md b/docs/architecture/06-input.md index 88c604fe..349021b9 100644 --- a/docs/architecture/06-input.md +++ b/docs/architecture/06-input.md @@ -43,7 +43,11 @@ Bound inputs are eaten so they never also fire in the focused app. - **Balanced down/up.** A DOWN of a bound input is swallowed and recorded (`g_swallowedDown`, `g_kbSwallowedDown`); an UP is swallowed only if its DOWN was. Swallowing an UP whose DOWN the - system saw leaves the input held system-wide (the stuck side-button bug, issue #113). + system saw leaves the input held system-wide (the stuck side-button bug, issue #113). A key the + system already holds down is never swallowed (#494): a reinstalled or resumed hook meets a held + key at its auto-repeat, and `GetAsyncKeyState` (the pre-event state inside an LL hook) shows the + system saw its DOWN. Any DOWN that is not swallowed, key, click or side button, clears a stale + record so its UP passes. - **No stranded keys.** Key records are cleared on a key remap (`setKeys`; `setPanKeys` keeps the swallow records). Button records are NOT cleared by a remap (`setButtonBinds`, #301): an UP whose DOWN was swallowed must still be swallowed, or the app sees a lone XBUTTONUP (browser Back). diff --git a/src/input/input_router.cpp b/src/input/input_router.cpp index f0346cf1..27698e11 100644 --- a/src/input/input_router.cpp +++ b/src/input/input_router.cpp @@ -296,8 +296,13 @@ static LRESULT CALLBACK KbProc(int code, WPARAM wParam, LPARAM lParam) { // Decide ONCE per press: swallow only if a bind on this key has all its modifiers // held now (#285: Ctrl+F1 must not eat a plain F1). Auto-repeat then follows that // decision, so a key the app already saw going down is never swallowed mid-press. + // A (re)installed or resumed hook can meet a key mid-press: its first event is + // an auto-repeat whose DOWN the system already saw unhooked. Inside an LL hook + // GetAsyncKeyState is the pre-event state, so "already down" means exactly that, + // and swallowing now would eat the UP and leave the key stuck down (review #494). + const bool systemSawDown = (GetAsyncKeyState(vk) & 0x8000) != 0; const int held = HeldModsNow(); - if (g_router->swallowEnabled() && g_router->keyBindMatches(vk, held)) { + if (!systemSawDown && g_router->swallowEnabled() && g_router->keyBindMatches(vk, held)) { g_kbSwallowedDown.markDown(vk); g_router->notePanPress(vk); // a tap between two tick samples still nudges // Alt or Win held: mask it so its release is not a lone tap (Start / menu bar). @@ -429,6 +434,8 @@ static LRESULT CALLBACK MouseProc(int code, WPARAM wParam, LPARAM lParam) { swallow = true; const int held = HeldModsNow(); if (NeedsMaskKey(held)) InjectMaskKey(); + } else { + g_swallowedDown.clear(id); // a stale record must not eat this press's UP } } else { // up: swallow iff we swallowed its DOWN. Never swallow an UP whose DOWN the // system already saw - that is exactly what left the button stuck-down. From 59e53dc8acdf11ac873fd57ce45dce2312330e9d Mon Sep 17 00:00:00 2001 From: Max <112043822+Maxaubert@users.noreply.github.com> Date: Fri, 16 Oct 2026 03:48:31 +0200 Subject: [PATCH 2/8] fix(cursor): keep welds and warps on the monitor, give back the clip Inspect replaced (#494) The lens centre clamps to w/h, so the rounded click point could be the first pixel of the monitor to the right: render welds, Inspect clicks and Inspect exit warps then put the real pointer there. The click point and the exit warps are now held to [0, w-1] / [0, h-1] (PointerPixel); the un-rounded centre keeps the edge for the transform anchor. Inspect's exits set the clip to NULL, dropping a work-area utility's or windowed game's clip. Inspect now saves the clip its freeze replaced (the one beneath Wind's own edge clip when that holds the pointer) and gives it back at toggle-off, teardown and device-lost when it confined something (SavedClipWorthRestoring), re-reading the baseline afterwards. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TdjopXZfsUiesYxYLYHEdH --- docs/architecture/07-cursor.md | 7 +++++-- src/core/main.cpp | 33 ++++++++++++++++++++++++++++++--- src/cursor/cursor_mapper.cpp | 4 ++-- src/cursor/cursor_mapper.h | 9 +++++++++ src/cursor/lock_detector.h | 8 ++++++++ src/transform/transform_model.h | 7 +++++++ tests/test_cursor_mapper.cpp | 27 +++++++++++++++++++++++++++ tests/test_lock_detector.cpp | 13 +++++++++++++ 8 files changed, 101 insertions(+), 7 deletions(-) diff --git a/docs/architecture/07-cursor.md b/docs/architecture/07-cursor.md index 708c5d06..bd448f59 100644 --- a/docs/architecture/07-cursor.md +++ b/docs/architecture/07-cursor.md @@ -313,8 +313,11 @@ the mouse hook only swallows clicks. detector-only: a raw-input game may never clip or recentre the pointer (issue #158). Clicks are discarded, and foreground is handed back on every exit. - **Every exit releases the clip**: toggle-off, teardown to idle, device-lost recovery, `shutdown`, - the crash filter and the `atexit` restore. Exits warp the cursor to the look point and drain - pending click counts. + the crash filter and the `atexit` restore. Toggle-off, teardown and device-lost give back the + clip the freeze replaced when it confined something (a work-area utility, a windowed game; + `SavedClipWorthRestoring`, #494), as the shell-panel freeze does; Wind's own edge clip is never + saved, the clip beneath it is. Exits warp the cursor to the look point (held to the monitor's + last pixel, `PointerPixel`) and drain pending click counts. ## Tracking: caret, focus and mouse edge mode diff --git a/src/core/main.cpp b/src/core/main.cpp index 0cdcd8e2..4d4abb34 100644 --- a/src/core/main.cpp +++ b/src/core/main.cpp @@ -305,6 +305,7 @@ struct TickState { bool panelFreeze = false; // #283/#492: a Wind session's real pointer is frozen and moved by Wind double panelX = 0, panelY = 0; // its desktop position (sub-pixel) RECT panelSavedClip{}; // the clip to give back when the panel closes + RECT inspectSavedClip{}; // the clip Inspect's 1px freeze replaced, given back at its exit wind::KeyPan keyPan; // keyboard panning motion (#287) // Event-driven idle (#71): the loop slept before this tick (clamp the motion dt, keep the raw // one for wall-clock gates), and when the view was last active (the settle window). @@ -1630,6 +1631,21 @@ static void EndPanelFreeze(TickState& t) { t.panelFreeze = false; } +// Inspect exit: give back the clip its freeze replaced (review #494), as EndPanelFreeze does. A +// work-area utility or a windowed game owned it, and dropping it to NULL lost that confinement. +// Inspect re-asserts its pin every tick, so the clip here is ours; with nothing worth restoring +// (no clip, or a pin) it stays released. Runs after the exit warp, which must reach the look +// point: the restored clip may move the pointer back inside it, so the baseline is re-read. +static void RestoreInspectClip(TickState& t) { + const RECT s = t.inspectSavedClip; + t.inspectSavedClip = RECT{}; // used once: a later exit must not re-apply a stale clip + const VirtualBounds vb = QueryVirtualBounds(); + if (!wind::SavedClipWorthRestoring(s.left, s.top, s.right, s.bottom, vb.x, vb.y, vb.w, vb.h)) return; + ClipCursor(&s); + POINT p{}; + if (GetCursorPos(&p)) t.lastSetVirtual = p; +} + // Inspect enter: freeze the real cursor, seed the look point, and decide game-inspect. // Zeroes this tick's raw motion so the first tick after entry pans by zero. static void EnterInspect(TickState& t, bool zoomed, int& rawDx, int& rawDy) { @@ -1643,6 +1659,13 @@ static void EnterInspect(TickState& t, bool zoomed, int& rawDx, int& rawDy) { (int)(preClip.bottom - preClip.top), t.mon.w, t.mon.h) && wind::PointerPinnedAtEdge(pt.x, pt.y, preClip.left, preClip.top, preClip.right, preClip.bottom); + // The clip to give back at the exit: the one beneath Wind's edge clip when that holds the + // pointer (a session clip the transform model manages itself), else the one found. + t.inspectSavedClip = preClip; + if (auto* tmClip = dynamic_cast(t.model)) { + RECT beneath{}; + if (tmClip->clipBeneathEdgeClip(preClip, &beneath)) t.inspectSavedClip = beneath; + } t.frozenCursor = pt; t.clickReleaseTicks = 0; // start frozen (clear any stale click-release window) // Zero the sub-pixel carry and this tick's raw motion so the first tick after entry @@ -1693,8 +1716,8 @@ static void ExitInspect(TickState& t) { // position (which the transform model used to do, because it was forbidden from // placing the cursor) throws away the aim the user just spent the mode establishing. ClipCursor(nullptr); - POINT lp{ (int)(t.mapper.centerX() + 0.5) + t.mon.x, - (int)(t.mapper.centerY() + 0.5) + t.mon.y }; + POINT lp{ wind::PointerPixel(t.mapper.centerX(), t.mon.w) + t.mon.x, + wind::PointerPixel(t.mapper.centerY(), t.mon.h) + t.mon.y }; const bool transformTx = dynamic_cast(t.model) != nullptr; const bool warp = wind::InspectExitWarps(t.inspectLookMoved, transformTx); t.postInspectHold = wind::HoldWindViewAfterInspect(t.inspectLookMoved, transformTx); @@ -1710,6 +1733,7 @@ static void ExitInspect(TickState& t) { SetCursorPos(lp.x + 1, lp.y); SetCursorPos(lp.x, lp.y); } + RestoreInspectClip(t); } // Active tick: the pan delta in one of three regimes (free, locked, Inspect), with the lock @@ -2791,9 +2815,11 @@ static void TeardownSession(TickState& t) { // must not survive into the next activation. g_input.state().commitLeft.exchange(0); g_input.state().commitRight.exchange(0); - POINT lp{ (int)(t.mapper.centerX() + 0.5) + t.mon.x, (int)(t.mapper.centerY() + 0.5) + t.mon.y }; + POINT lp{ wind::PointerPixel(t.mapper.centerX(), t.mon.w) + t.mon.x, + wind::PointerPixel(t.mapper.centerY(), t.mon.h) + t.mon.y }; SetCursorPos(lp.x, lp.y); // resume at the look point t.lastSetVirtual = lp; + RestoreInspectClip(t); } t.revealPending = 0; // a quick tap may zoom out before the deferred reveal g_tint.invalidate(); // the engines put the scheme back: re-tint when idle @@ -3695,6 +3721,7 @@ int WINAPI wWinMain(HINSTANCE hInst, HINSTANCE, PWSTR, int) { // the post-recovery tick can't re-clip the cursor to the stale frozen pixel (honors the // documented "released on device-lost recovery" invariant; recovery returns to a clean 1x). ClipCursor(nullptr); + RestoreInspectClip(ts); // the clip an Inspect freeze replaced, if any (no-op otherwise) if (ts.restAfterReveal) { ts.restAfterReveal->setActive(false); ts.restAfterReveal = nullptr; } if (ts.cursorLock.locked()) { ts.cursorLock.reset(); ts.clickReleaseTicks = 0; diff --git a/src/cursor/cursor_mapper.cpp b/src/cursor/cursor_mapper.cpp index b2fab58e..95380335 100644 --- a/src/cursor/cursor_mapper.cpp +++ b/src/cursor/cursor_mapper.cpp @@ -39,8 +39,8 @@ MapResult CursorMapper::update(int dx, int dy, double level) { // source rect clamps against the desktop boundary so corners stay reachable. r.cursorScreenX = (cx_ - o.x) * level; r.cursorScreenY = (cy_ - o.y) * level; - r.clickDesktopX = static_cast(cx_ + 0.5); - r.clickDesktopY = static_cast(cy_ + 0.5); + r.clickDesktopX = PointerPixel(cx_, sw_); // never one past the monitor (review #494) + r.clickDesktopY = PointerPixel(cy_, sh_); r.centerX = cx_; // un-rounded, for the transform model's fixed-point anchor r.centerY = cy_; return r; diff --git a/src/cursor/cursor_mapper.h b/src/cursor/cursor_mapper.h index b253e753..99ef7848 100644 --- a/src/cursor/cursor_mapper.h +++ b/src/cursor/cursor_mapper.h @@ -9,6 +9,15 @@ struct MapResult { // SetCursorPos); kept separately so sub-pixel consumers never re-derive it from the rounded pair. double centerX, centerY; }; +// A lens-centre coordinate as a POINTER pixel on a monitor `n` px wide: rounded, then held to +// [0, n-1]. The centre itself may sit on n (the transform anchor's right edge), but n is the first +// column of the monitor to the right, so a weld, click or warp there leaves the session monitor. +inline int PointerPixel(double c, int n) { + int p = static_cast(c + 0.5); + if (p > n - 1) p = n - 1; + if (p < 0) p = 0; + return p; +} // Pure centered-mode mapper. Integrates per-tick pixel deltas into a float lens center // (desktop px), so the world pans with sub-pixel precision while the cursor stays at // screen center - shifting toward an edge only when the view clamps at the desktop edge. diff --git a/src/cursor/lock_detector.h b/src/cursor/lock_detector.h index c2d48725..23f9272c 100644 --- a/src/cursor/lock_detector.h +++ b/src/cursor/lock_detector.h @@ -19,6 +19,14 @@ inline bool ClipRectConfines(int clipW, int clipH, int monW, int monH) { if (monW <= 0 || monH <= 0) return false; // no monitor info: never claim a lock return clipW < monW * 9 / 10 || clipH < monH * 9 / 10; } +// Inspect replaces whatever clip held the pointer with its 1px freeze. Is the clip it found +// (left/top/right/bottom, right/bottom exclusive as GetClipCursor reports) worth giving back at +// the exit (review #494)? Only a real confinement is: a rect inside the virtual screen (vx, vy, +// vw, vh; no clip reads as the whole virtual screen) that is wider than a 1px pin. +inline bool SavedClipWorthRestoring(int l, int t, int r, int b, int vx, int vy, int vw, int vh) { + if (r - l <= 1 || b - t <= 1) return false; // empty, or a pin (nothing to give back) + return l > vx || t > vy || r < vx + vw || b < vy + vh; +} // Decides whether the OS cursor is "locked" by a game (so the magnifier must pan from raw mouse // input rather than the OS cursor's own motion). Pure, with hysteresis so a single contrary tick // never flips the state - panning never flickers. Fed per-tick Win32 signals by main.cpp. diff --git a/src/transform/transform_model.h b/src/transform/transform_model.h index 353c6009..de79d0b0 100644 --- a/src/transform/transform_model.h +++ b/src/transform/transform_model.h @@ -53,6 +53,13 @@ class TransformModel : public IMagnifierModel { // sprite standing in for it, which present() applies on its own). The tick loop mirrors it into // cursorHiddenByUs, the game-inspect tell, instead of tracking the calls itself. bool pointerHidden() const { return cursorHidden_; } + // While Wind's edge clip holds the pointer (cur is still ours), the clip it replaced: what + // Inspect saves to give back at its exit, never Wind's own session clip (review #494). + bool clipBeneathEdgeClip(const RECT& cur, RECT* beneath) const { + if (!edgeClipActive_ || !EqualRect(&cur, &edgeClipApplied_)) return false; + *beneath = edgeClipSaved_; + return true; + } // Whether MagSetInputTransform is usable (probed once at initialize; needs UIAccess). The // hybrid DESKTOP pick requires this: without the source-rect input transform, transform // desktop sessions have the pointer-framework hover dead zones (POINTER-HITTEST-FINDINGS.md). diff --git a/tests/test_cursor_mapper.cpp b/tests/test_cursor_mapper.cpp index 582af409..25912f6b 100644 --- a/tests/test_cursor_mapper.cpp +++ b/tests/test_cursor_mapper.cpp @@ -46,6 +46,33 @@ TEST_CASE("lens center clamps to the desktop bounds") { CHECK(m.centerY() == doctest::Approx(1080.0)); } +TEST_CASE("the click point stays on the monitor when the centre clamps to its far edge") { + CursorMapper m(1920, 1080); + m.reset(1920, 1080); + auto r = m.update(500, 500, 2.0); // centre on w/h: the first pixel of the next monitor + CHECK(r.centerX == doctest::Approx(1920.0)); // the anchor keeps the un-rounded edge + CHECK(r.clickDesktopX == 1919); + CHECK(r.clickDesktopY == 1079); + m.reset(1919.6, 1079.6); // rounds up onto w/h + r = m.update(0, 0, 2.0); + CHECK(r.clickDesktopX == 1919); + CHECK(r.clickDesktopY == 1079); + r = m.update(-5000, -5000, 2.0); + CHECK(r.clickDesktopX == 0); + CHECK(r.clickDesktopY == 0); +} + +TEST_CASE("PointerPixel rounds and holds a centre to the monitor's pixels") { + using wind::PointerPixel; + CHECK(PointerPixel(10.4, 1920) == 10); + CHECK(PointerPixel(10.5, 1920) == 11); + CHECK(PointerPixel(1919.4, 1920) == 1919); + CHECK(PointerPixel(1919.5, 1920) == 1919); + CHECK(PointerPixel(1920.0, 1920) == 1919); + CHECK(PointerPixel(0.0, 1920) == 0); + CHECK(PointerPixel(-3.0, 1920) == 0); +} + TEST_CASE("update integrates the pixel delta directly; zoom level does NOT change desktop speed") { CursorMapper m(1920, 1080); m.reset(960, 540); diff --git a/tests/test_lock_detector.cpp b/tests/test_lock_detector.cpp index 46fcd3bd..c0bd117e 100644 --- a/tests/test_lock_detector.cpp +++ b/tests/test_lock_detector.cpp @@ -1,6 +1,19 @@ #include "doctest.h" #include "lock_detector.h" +TEST_CASE("SavedClipWorthRestoring: only a real confinement is given back after Inspect") { + using wind::SavedClipWorthRestoring; + // No clip: GetClipCursor reports the whole virtual screen. + CHECK_FALSE(SavedClipWorthRestoring(0, 0, 3840, 1080, 0, 0, 3840, 1080)); + CHECK_FALSE(SavedClipWorthRestoring(-1920, 0, 1920, 1080, -1920, 0, 3840, 1080)); + // A work-area clip (desktop minus the taskbar) and a windowed game's clip. + CHECK(SavedClipWorthRestoring(0, 0, 1920, 1032, 0, 0, 1920, 1080)); + CHECK(SavedClipWorthRestoring(400, 300, 1200, 900, 0, 0, 1920, 1080)); + // A 1px pin (another freeze) or an empty rect: nothing worth restoring. + CHECK_FALSE(SavedClipWorthRestoring(500, 500, 501, 501, 0, 0, 1920, 1080)); + CHECK_FALSE(SavedClipWorthRestoring(0, 0, 0, 0, 0, 0, 1920, 1080)); +} + using wind::LockDetector; // Mirrors the constants in lock_detector.cpp: kLockTicks=6, kFreeTicks=3, kRawActive=4, kCursorMoved=1. From 04520eb0fb7fede3ebaaf176c75ab7da18a05c65 Mon Sep 17 00:00:00 2001 From: Max <112043822+Maxaubert@users.noreply.github.com> Date: Fri, 16 Oct 2026 03:48:46 +0200 Subject: [PATCH 3/8] fix(transform): nudge inside the clip, draw the Wind sprite at every level above 1x (#494) NudgePointer always stepped +1 x, which SetCursorPos clamps back onto the pointer at the right edge of the clip (the session edge clip holds it exactly there) or the screen: no cursor event, so the composed pointer and DWM centring were lost there. The step now goes to a pixel the clip allows (PickNudgeStep), and the shell-panel clip nudge stays on the virtual screen. A Wind (sprite) session is active from the first tick above 1.0 with the cursor set blanked, but the sprite branch needed level > 1.001, so a zoom settling in (1.0, 1.001] showed no pointer at all. The sprite branch now covers any level above 1.0. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TdjopXZfsUiesYxYLYHEdH --- docs/architecture/07-cursor.md | 6 ++++-- src/transform/native_cursor.h | 16 ++++++++++++++++ src/transform/transform_model.cpp | 23 ++++++++++++++++++++--- tests/test_native_cursor.cpp | 15 +++++++++++++++ 4 files changed, 55 insertions(+), 5 deletions(-) diff --git a/docs/architecture/07-cursor.md b/docs/architecture/07-cursor.md index bd448f59..91fd0533 100644 --- a/docs/architecture/07-cursor.md +++ b/docs/architecture/07-cursor.md @@ -130,7 +130,8 @@ slightly during zoom ramps, nearest keeps it pixelated and steady. Pure rules: at 1x (`idleTick`), so a zoom bound to a mouse button never leaves a still pointer invisible. The session-end reset of `ixPubLevel_` keeps a quick zoom back to the same level from skipping the publish nudge, and the hide-cursor hotkey's show-again transition nudges after the blanker - restore like the zoom-out does. + restore like the zoom-out does. A nudge steps to a pixel the clip allows (`PickNudgeStep`, #494): + at the edge clip's right column a +1 x step is clamped back onto the pointer, which is no event. - **Game-drawn cursor (#443, `gameCursorLagMs`, -1 = one display frame, 0 = off).** Some games hide the pointer and draw their own cursor at its position (Cyberpunk 2077 menus). That cursor reaches the screen about a display frame after the pointer moved, while DWM centring shows the @@ -185,7 +186,8 @@ was before the native cursor (#369), and the default since the owner chose it on Wind writes every view itself (no DWM centring, no cursor lens), hides the real pointer (blanker plus `MagShowSystemCursor`) and paints its shape into the sprite window (`CursorSprite::refreshShape`, from the blanker's copies of the standard cursors), placed at the lens point in desktop -coordinates so DWM magnifies it with the content. At zoom-in the sprite is stood up on the pointer +coordinates so DWM magnifies it with the content. The sprite shows at every level above 1x, since +the cursor set is blanked from the first tick of the session. At zoom-in the sprite is stood up on the pointer before the blank (two `DwmFlush` passes) so the hand-off does not blink. The lock rule is the old one: a locked session stays locked with the pointer shown (`LockApplies` gets `nativeSession()`). Read at zoom-in, so a change applies to the next session. Shell input panels get the real pointer diff --git a/src/transform/native_cursor.h b/src/transform/native_cursor.h index fac26be5..f01f0e07 100644 --- a/src/transform/native_cursor.h +++ b/src/transform/native_cursor.h @@ -119,6 +119,22 @@ inline bool NudgeDue(bool owed, bool clickInProgress) { return owed && !clickInProgress; } +// WHICH WAY THE NUDGE GOES (review #494). SetCursorPos is clamped to the clip (and the screen), +// and a same-position SetCursorPos is no cursor event, so a +1 x nudge at the right edge of the +// clip did nothing. The session edge clip holds the pointer at most at its right edge, exactly +// where +1 is clamped, so every nudge the composed pointer needs went silent there. The step goes +// +1 x when that pixel is inside the clip (left/top/right/bottom, right/bottom exclusive as +// GetClipCursor reports; no clip reads as the virtual screen), else -1 x, else +1 y, else -1 y. +// {0, 0}: a 1px clip, nowhere to go. +struct NudgeStep { int dx, dy; }; +inline NudgeStep PickNudgeStep(int x, int y, int clipL, int clipT, int clipR, int clipB) { + if (x + 1 < clipR && x + 1 >= clipL) return { 1, 0 }; + if (x - 1 >= clipL && x - 1 < clipR) return { -1, 0 }; + if (y + 1 < clipB && y + 1 >= clipT) return { 0, 1 }; + if (y - 1 >= clipT && y - 1 < clipB) return { 0, -1 }; + return { 0, 0 }; +} + // A PRESS HELD PAST THE CLICK WINDOW IS A DRAG (field report 2026-10-08: zooming while holding a // drag-select made the view jump about 90 px back and forth every frame). A level write still goes // out during a click (HoldWriteForClick), and without its nudge DWM showed Wind's centre for that diff --git a/src/transform/transform_model.cpp b/src/transform/transform_model.cpp index 8405885b..b60334d9 100644 --- a/src/transform/transform_model.cpp +++ b/src/transform/transform_model.cpp @@ -57,7 +57,14 @@ static void NudgePointer() { if (NudgeBlocked()) { g_nudgeOwed.store(true, std::memory_order_relaxed); return; } g_nudgeOwed.store(false, std::memory_order_relaxed); POINT np; - if (GetCursorPos(&np)) { SetCursorPos(np.x + 1, np.y); SetCursorPos(np.x, np.y); } + RECT clip{}; + if (!GetCursorPos(&np) || !GetClipCursor(&clip)) return; + // Step to a pixel the clip allows (review #494): at the edge clip's right column a +1 x step + // is clamped back onto the pointer, which is no cursor event at all. + const NudgeStep s = PickNudgeStep(np.x, np.y, clip.left, clip.top, clip.right, clip.bottom); + if (s.dx == 0 && s.dy == 0) return; + SetCursorPos(np.x + s.dx, np.y + s.dy); + SetCursorPos(np.x, np.y); } static void DeliverOwedNudge() { if (!NudgeDue(g_nudgeOwed.load(std::memory_order_relaxed), NudgeBlocked())) return; @@ -1010,12 +1017,22 @@ void TransformModel::presentCursor(const MapResult& r, double level, const Prese // same-position SetCursorPos is none; it is pinned by its 1px clip, so nudge the clip a // pixel and back (review #284). The weld above has already placed the pin this frame. if (repaint && haveLastClick_) { - const RECT off{ lastClickX_ + 1, lastClickY_, lastClickX_ + 2, lastClickY_ + 1 }; + // The step stays on the virtual screen (review #494): ClipCursor clamps the rect to the + // screen, so a +1 x clip at the last column landed on the pin itself, which is no event. + const int vx = GetSystemMetrics(SM_XVIRTUALSCREEN), vy = GetSystemMetrics(SM_YVIRTUALSCREEN); + const NudgeStep s = PickNudgeStep(lastClickX_, lastClickY_, vx, vy, + vx + GetSystemMetrics(SM_CXVIRTUALSCREEN), + vy + GetSystemMetrics(SM_CYVIRTUALSCREEN)); + const int ox = lastClickX_ + s.dx, oy = lastClickY_ + s.dy; + const RECT off{ ox, oy, ox + 1, oy + 1 }; const RECT pin{ lastClickX_, lastClickY_, lastClickX_ + 1, lastClickY_ + 1 }; ClipCursor(&off); ClipCursor(&pin); } - } else if (!nativeSession_ && ex.drawCursor && level > 1.001) { + } else if (!nativeSession_ && ex.drawCursor && level > 1.0) { + // Any level above 1x, not just above 1.001 (review #494): the session is active from the + // first tick above 1.0 with the cursor set blanked, and a zoom can settle just above 1.0, so + // a gap here left neither the sprite nor the real pointer on screen. endPanelPointer(); // a panel just closed: the sprite takes over again // LEGACY SPRITE SESSION (txNativeCursor=0, issue #463): the pre-#369 cursor. The real pointer // is hidden (blanker for standard shapes, MagShowSystemCursor for app-custom ones) and its diff --git a/tests/test_native_cursor.cpp b/tests/test_native_cursor.cpp index f8593ec4..2429f1af 100644 --- a/tests/test_native_cursor.cpp +++ b/tests/test_native_cursor.cpp @@ -117,6 +117,21 @@ TEST_CASE("a press held past the click window is a drag and may be nudged") { CHECK(NudgeBlockedByClick(true, 1000, 1003) == true); // stamp newer than the clock read } +TEST_CASE("the pointer nudge steps inside the clip, so the edge clip cannot swallow it (#494)") { + auto step = [](int x, int y, int l, int t, int r, int b) { + const NudgeStep s = PickNudgeStep(x, y, l, t, r, b); + return s.dx * 10 + s.dy; // 10 = +x, -10 = -x, 1 = +y, -1 = -y + }; + CHECK(step(500, 500, 0, 0, 1920, 1080) == 10); // open desktop: +1 x as before + // The edge clip (1 px inside a 1920 monitor) holds the pointer at x = 1918, its last column. + CHECK(step(1918, 500, 1, 1, 1919, 1079) == -10); + CHECK(step(1919, 500, 0, 0, 1920, 1080) == -10); // the virtual screen's last column, no clip + CHECK(step(1, 500, 1, 1, 1919, 1079) == 10); // the left edge still steps right + CHECK(step(700, 300, 700, 300, 701, 600) == 1); // a one-column clip: step in y + CHECK(step(700, 599, 700, 300, 701, 600) == -1); + CHECK(step(700, 300, 700, 300, 701, 301) == 0); // a 1px pin: nowhere to go +} + TEST_CASE("Inspect keeps DWM's view until the look point moves (#445)") { CHECK(InspectKeepsDwmView(true, false)); CHECK_FALSE(InspectKeepsDwmView(true, true)); From a6db2e8a7cf570c2600c1d96bdbcce9448191963 Mon Sep 17 00:00:00 2001 From: Max <112043822+Maxaubert@users.noreply.github.com> Date: Fri, 16 Oct 2026 03:49:00 +0200 Subject: [PATCH 4/8] fix(cursor): Wind sprite draws large pointers at full size, follows pointer scheme changes (#494) refreshShape clamped any cursor over 64 px to 32 px while keeping the native hotspot, so large accessibility pointers drew tiny and centred hotspots sat away from the click point. The shape canvas now grows with the pointer up to 256 px, and a larger shape scales with its hotspot (FitSpriteShape, tested). The crosshair keeps its 64 px canvas. The blanker's shape copies were taken once at construction, so after a pointer scheme or size change the sprite drew the old one until Wind restarted. WM_SETTINGCHANGE / SPI_SETCURSORS now marks them stale, and the next idle tick re-takes them (CursorBlanker::recapture, on the worker behind any queued restore) and makes the sprite repaint. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TdjopXZfsUiesYxYLYHEdH --- docs/architecture/07-cursor.md | 11 ++++++---- src/core/main.cpp | 7 ++++++- src/cursor/cursor_blanker.cpp | 35 +++++++++++++++++++++++++------ src/cursor/cursor_blanker.h | 6 ++++++ src/cursor/cursor_sprite.cpp | 25 +++++++++++++--------- src/cursor/cursor_sprite.h | 4 ++++ src/cursor/sprite_layer.h | 21 +++++++++++++++++++ src/transform/transform_model.cpp | 7 +++++++ src/transform/transform_model.h | 4 ++++ tests/test_sprite_layer.cpp | 31 +++++++++++++++++++++++++++ 10 files changed, 130 insertions(+), 21 deletions(-) diff --git a/docs/architecture/07-cursor.md b/docs/architecture/07-cursor.md index 91fd0533..d5ecdc3e 100644 --- a/docs/architecture/07-cursor.md +++ b/docs/architecture/07-cursor.md @@ -185,9 +185,11 @@ Settings > View > Mouse tracking picks Native (above) or Wind (#463), the transf was before the native cursor (#369), and the default since the owner chose it on 2026-10-14: Wind writes every view itself (no DWM centring, no cursor lens), hides the real pointer (blanker plus `MagShowSystemCursor`) and paints its shape into the sprite window (`CursorSprite::refreshShape`, -from the blanker's copies of the standard cursors), placed at the lens point in desktop -coordinates so DWM magnifies it with the content. The sprite shows at every level above 1x, since -the cursor set is blanked from the first tick of the session. At zoom-in the sprite is stood up on the pointer +from the blanker's copies of the standard cursors, re-taken at the next idle tick after a pointer +scheme or size change), placed at the lens point in desktop coordinates so DWM magnifies it with +the content. Its canvas grows with the pointer up to 256 px, and a larger shape scales with its +hotspot (`FitSpriteShape`, #494). The sprite shows at every level above 1x, since the cursor set is +blanked from the first tick of the session. At zoom-in the sprite is stood up on the pointer before the blank (two `DwmFlush` passes) so the hand-off does not blink. The lock rule is the old one: a locked session stays locked with the pointer shown (`LockApplies` gets `nativeSession()`). Read at zoom-in, so a change applies to the next session. Shell input panels get the real pointer @@ -200,7 +202,8 @@ Inspect and the hide-cursor hotkey (`cursorVisibility=never`) hide DWM's pointer session; the Inspect crosshair is then a window of Wind's. - **`CursorBlanker`** (`src/cursor/cursor_blanker.*`) swaps the 14 system cursors for transparent ones and - restores them by reloading the user's scheme (no cached originals). The constructor reloads it + restores them by reloading the user's scheme (the copies it keeps are only the Wind sprite's shape + source, re-taken by `recapture()` after a scheme change). The constructor reloads it first, so a previously killed Wind's blanks never outlive it. `MagShowSystemCursor(FALSE)` covers app-custom cursors. The swaps run in order on the `Wind cursor swaps` worker thread (#363): the restore is a full `SPI_SETCURSORS` scheme reload (8 ms median, up to 90 ms under load) and the blank 14 diff --git a/src/core/main.cpp b/src/core/main.cpp index 4d4abb34..4d2560ac 100644 --- a/src/core/main.cpp +++ b/src/core/main.cpp @@ -3075,7 +3075,12 @@ static LRESULT CALLBACK WndProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp) { if (msg == WM_DISPLAYCHANGE) g_hdrOn.store(PrimaryHdrOn()); // colour space (#288); falls through // A pointer-scheme reload (the user changed scheme or size, or an engine healed it) leaves the // clean scheme in place: take fresh pristine copies; the next idle tick re-tints (#288). - if (msg == WM_SETTINGCHANGE && wp == SPI_SETCURSORS) g_tint.capture(); + // The legacy sprite draws standard shapes from the blanker's copies, which must follow too. + if (msg == WM_SETTINGCHANGE && wp == SPI_SETCURSORS) { + g_tint.capture(); + if (auto* tm = g_tick ? dynamic_cast(g_tick->mTransform) : nullptr) + tm->cursorSchemeChanged(); + } if (msg == WM_HOTKEY && wp == kQuitHotkeyId) { PostQuitMessage(0); return 0; } if (msg == WM_HOTKEY && wp == kHideCursorHotkeyId) { if (g_tick) g_tick->cursorHidden = !g_tick->cursorHidden; diff --git a/src/cursor/cursor_blanker.cpp b/src/cursor/cursor_blanker.cpp index fd9bdb76..59458646 100644 --- a/src/cursor/cursor_blanker.cpp +++ b/src/cursor/cursor_blanker.cpp @@ -31,19 +31,42 @@ static void RestoreAll() { SystemParametersInfoW(SPI_SETCURSORS, 0, nullptr, 0); } -CursorBlanker::CursorBlanker() : st_(std::make_shared()) { - // If a previous Wind was hard-killed while cursors were blanked, the desktop still has blank - // shared cursors; reload the user's scheme FIRST, or the blanks get captured as "originals". - RestoreAll(); +// Copies of the standard cursors as they are now, keyed by their shared handle. +static void CaptureOriginals(std::unordered_map& out) { for (UINT id : kStandardIds) { HCURSOR shared = LoadCursorW(nullptr, MAKEINTRESOURCEW(id)); if (!shared) continue; HCURSOR copy = CopyCursor(shared); - if (copy) originals_[shared] = copy; + if (copy) out[shared] = copy; } +} +static void DestroyCopies(std::unordered_map& m) { + for (auto& kv : m) DestroyCursor(kv.second); + m.clear(); +} + +CursorBlanker::CursorBlanker() : st_(std::make_shared()) { + // If a previous Wind was hard-killed while cursors were blanked, the desktop still has blank + // shared cursors; reload the user's scheme FIRST, or the blanks get captured as "originals". + RestoreAll(); + CaptureOriginals(originals_); worker_ = std::thread([st = st_] { run(st); }); } +bool CursorBlanker::recapture() { + if (blanked_) return false; + // Owned by a shared set so a worker that answers after the 2 s wait still frees its copies. + struct Copies { + std::unordered_map m; + ~Copies() { DestroyCopies(m); } + }; + auto fresh = std::make_shared(); + if (!runSync([fresh] { CaptureOriginals(fresh->m); })) return false; + if (fresh->m.empty()) return false; // nothing captured: keep the old copies + originals_.swap(fresh->m); // the old copies are destroyed with `fresh` + return true; +} + CursorBlanker::~CursorBlanker() { if (blanked_) restoreSync(); std::unique_lock lk(st_->mx); @@ -54,7 +77,7 @@ CursorBlanker::~CursorBlanker() { const bool exited = st_->exitCv.wait_for(lk, std::chrono::seconds(2), [this] { return st_->exited; }); lk.unlock(); if (worker_.joinable()) { if (exited) worker_.join(); else worker_.detach(); } - for (auto& kv : originals_) DestroyCursor(kv.second); + DestroyCopies(originals_); } void CursorBlanker::run(std::shared_ptr st) { diff --git a/src/cursor/cursor_blanker.h b/src/cursor/cursor_blanker.h index 69a028d1..01485b6c 100644 --- a/src/cursor/cursor_blanker.h +++ b/src/cursor/cursor_blanker.h @@ -25,6 +25,12 @@ class CursorBlanker { // set is blanked GetCursorInfo still returns the shared handle, so the legacy sprite (#463) draws // the shape from the copy. const std::unordered_map& originals() const { return originals_; } + // Re-take those copies after a pointer scheme or size change (review #494): the shared handles + // survive a scheme reload, so the sprite kept finding the old copies and drew the old scheme + // and size until Wind restarted. Only while not blanked (the shared cursors would be blanks). + // The copies are taken on the worker, behind any queued restore, and swapped in on the caller's + // thread (the sprite reads the map there). False when blanked or the worker did not answer. + bool recapture(); bool blanked() const { return blanked_; } void blank(); // after (optional) runs on the worker once the cursors are back: the repaint nudge, which diff --git a/src/cursor/cursor_sprite.cpp b/src/cursor/cursor_sprite.cpp index 53cfb034..d40ba818 100644 --- a/src/cursor/cursor_sprite.cpp +++ b/src/cursor/cursor_sprite.cpp @@ -143,19 +143,22 @@ CursorSprite::ShapeStatus CursorSprite::refreshShape() { // (needed to scale DrawIconEx) and the hotspot, then free them immediately - otherwise they // leak every tick the cursor shape changes. Mask-only cursors report a double-height mask. BITMAP bm{}; + int nw = 0, nh = 0; if (iconInfo.hbmColor && GetObjectW(iconInfo.hbmColor, sizeof(bm), &bm)) { - natW_ = bm.bmWidth; natH_ = bm.bmHeight; + nw = bm.bmWidth; nh = bm.bmHeight; } else if (iconInfo.hbmMask && GetObjectW(iconInfo.hbmMask, sizeof(bm), &bm)) { - natW_ = bm.bmWidth; natH_ = bm.bmHeight / 2; + nw = bm.bmWidth; nh = bm.bmHeight / 2; } - if (natW_ <= 0 || natW_ > kSize) natW_ = 32; - if (natH_ <= 0 || natH_ > kSize) natH_ = 32; if (iconInfo.hbmMask) DeleteObject(iconInfo.hbmMask); if (iconInfo.hbmColor) DeleteObject(iconInfo.hbmColor); - int hotX = (int)iconInfo.xHotspot * scale_; // hotspots live in FINAL (scaled) pixels - int hotY = (int)iconInfo.yHotspot * scale_; - - const int S = bufSize(); + // The canvas grows with the pointer (review #494: a >64 px pointer was drawn at 32 px with its + // native hotspot); a shape over the cap scales with its hotspot (FitSpriteShape, tested). + const SpriteShapeFit fit = FitSpriteShape(nw, nh, (int)iconInfo.xHotspot, (int)iconInfo.yHotspot, kSize); + natW_ = fit.drawW; natH_ = fit.drawH; + int hotX = fit.hotX * scale_; // hotspots live in FINAL (scaled) pixels + int hotY = fit.hotY * scale_; + + const int S = fit.canvas * scale_; HDC screenDc = GetDC(nullptr); HDC memDc = CreateCompatibleDC(screenDc); BITMAPINFO bmi{}; @@ -209,6 +212,7 @@ CursorSprite::ShapeStatus CursorSprite::refreshShape() { iconCopy_ = hIconCopy; hotX_ = hotX; hotY_ = hotY; + shapeSize_ = fit.canvas; lastCursor_ = info.hCursor; lastVerdict_ = ShapeStatus::Rendered; renderMaskShape(); @@ -239,6 +243,7 @@ CursorSprite::ShapeStatus CursorSprite::refreshShape() { iconCopy_ = hIconCopy; hotX_ = hotX; hotY_ = hotY; + shapeSize_ = fit.canvas; lastCursor_ = info.hCursor; lastVerdict_ = ShapeStatus::Rendered; crosshairMode_ = false; // the window now holds the cursor shape again @@ -265,7 +270,7 @@ CursorSprite::ShapeStatus CursorSprite::refreshShape() { // underneath, so the caret is legible on any background and there is no verdict // left to oscillate. void CursorSprite::renderMaskShape() { - const int S = bufSize(); + const int S = shapeSize_ * scale_; // the shape's canvas (refreshShape sized it) HDC screenDc = GetDC(nullptr); HDC memDc = CreateCompatibleDC(screenDc); BITMAPINFO bmi{}; @@ -303,7 +308,7 @@ void CursorSprite::renderMaskShape() { // shape[i] = 1 for every opaque pixel of the cursor: a baked colour pixel, or an inverting // pixel we are inking white. The outline pass below dilates this, so it must be recorded // BEFORE the outline is drawn, or the outline would feed on itself and keep growing. - std::vector shape((size_t)S * S); // heap: S*S is up to 512x512 at max scale + std::vector shape((size_t)S * S); // heap: S*S is up to 256x256 (the canvas cap) for (int i = 0; i < S * S; i++) { uint32_t rgbB = black[i] & 0x00FFFFFFu; uint32_t rgbW = white[i] & 0x00FFFFFFu; diff --git a/src/cursor/cursor_sprite.h b/src/cursor/cursor_sprite.h index 639c3837..bdfeb9c8 100644 --- a/src/cursor/cursor_sprite.h +++ b/src/cursor/cursor_sprite.h @@ -29,6 +29,9 @@ class CursorSprite { // Legacy sprite session: repaint from the current system cursor when its handle changed. // Hidden = the app hides its pointer (or the shape could not be read): show nothing of ours. ShapeStatus refreshShape(); + // The shape source changed under an unchanged handle (a pointer scheme or size change, review + // #494): the next refreshShape() repaints. + void invalidateShape() { lastCursor_ = nullptr; } void moveTo(int desktopX, int desktopY); void reapplyPosition(); // re-place after a hotspot change (the crosshair centres its hotspot) void show(); @@ -55,6 +58,7 @@ class CursorSprite { void renderCrosshair(); bool displaced() const; // a visible, overlapping window sits above us in z-order static const int kSize = 64; // base (1x) logical canvas; buffers are kSize * scale_ + int shapeSize_ = kSize; // the cursor shape's canvas: grows with the pointer (FitSpriteShape) int bufSize() const { return kSize * scale_; } const std::unordered_map* originals_ = nullptr; HWND hwnd_ = nullptr; // the active window: hwndLow_ or hwndHigh_ diff --git a/src/cursor/sprite_layer.h b/src/cursor/sprite_layer.h index 67f0a1e7..b42eea86 100644 --- a/src/cursor/sprite_layer.h +++ b/src/cursor/sprite_layer.h @@ -29,4 +29,25 @@ inline SpriteLayer PickSpriteLayer(bool autoEnabled, bool highAvailable, int for return SpriteLayer::High; } +// The legacy sprite's canvas for a cursor shape of native size natW x natH with its hotspot +// (review #494). Pointer sizes above 64 px are common for magnifier users (Mouse pointer size 4+, +// high DPI), and clamping only the size drew them at 32 px with the native hotspot, so the shape +// shrank and centred hotspots (I-beam, cross) sat away from the click point. The canvas grows to +// the shape (never under baseSize, the crosshair's canvas); a shape over maxSize is scaled down +// to fit with its hotspot scaled by the same ratio. An unreadable size falls back to 32 px. +struct SpriteShapeFit { + int canvas; // square canvas side (px) + int drawW, drawH; // size to draw the shape at + int hotX, hotY; // hotspot in drawn pixels +}; +inline SpriteShapeFit FitSpriteShape(int natW, int natH, int hotX, int hotY, + int baseSize = 64, int maxSize = 256) { + if (natW <= 0) natW = 32; + if (natH <= 0) natH = 32; + const int big = natW > natH ? natW : natH; + if (big <= maxSize) + return { big > baseSize ? big : baseSize, natW, natH, hotX, hotY }; + return { maxSize, natW * maxSize / big, natH * maxSize / big, hotX * maxSize / big, hotY * maxSize / big }; +} + } // namespace wind diff --git a/src/transform/transform_model.cpp b/src/transform/transform_model.cpp index b60334d9..0b5e3a54 100644 --- a/src/transform/transform_model.cpp +++ b/src/transform/transform_model.cpp @@ -496,6 +496,13 @@ void TransformModel::setActive(bool active) { void TransformModel::idleTick() { DeliverOwedNudge(); // a nudge a click skipped at the zoom-out is still owed at 1x + // A pointer scheme or size change since the copies were taken (review #494): re-take them while + // the set is ours to read (not blanked), and make the sprite repaint from the new ones. One + // attempt per change, so a wedged blanker worker cannot stall every idle tick. + if (shapesStale_ && !active_ && !blanker_->blanked()) { + shapesStale_ = false; + if (blanker_->recapture()) sprite_->invalidateShape(); + } // NATIVE CURSOR (issue #369): keep the context and the cursor lens alive at 1x instead of // releasing them. With the lens style OFF this costs nothing (measured: a pointer-toggling // full-screen app keeps Independent Flip, 0 spike frames), and it moves the one-time 60-125 ms diff --git a/src/transform/transform_model.h b/src/transform/transform_model.h index de79d0b0..3d4f8ca1 100644 --- a/src/transform/transform_model.h +++ b/src/transform/transform_model.h @@ -60,6 +60,9 @@ class TransformModel : public IMagnifierModel { *beneath = edgeClipSaved_; return true; } + // A pointer scheme or size change (WM_SETTINGCHANGE / SPI_SETCURSORS): the legacy sprite's + // shape copies are re-taken at the next idle tick (review #494). + void cursorSchemeChanged() { shapesStale_ = true; } // Whether MagSetInputTransform is usable (probed once at initialize; needs UIAccess). The // hybrid DESKTOP pick requires this: without the source-rect input transform, transform // desktop sessions have the pointer-framework hover dead zones (POINTER-HITTEST-FINDINGS.md). @@ -104,6 +107,7 @@ class TransformModel : public IMagnifierModel { bool edgeClipActive_ = false; RECT edgeClipSaved_{}; // the clip that existed before ours RECT edgeClipApplied_{}; // what we set (dedupe + foreign-change test) + bool shapesStale_ = false; // the blanker's shape copies predate a scheme change void edgeClipManage(bool wantActive); unsigned long long ghostSessionStartMs_ = 0; // 0 = not started; drives the opening burst bool mpoExposed_ = false; // apply the 16-bit write clamp diff --git a/tests/test_sprite_layer.cpp b/tests/test_sprite_layer.cpp index 719fbe46..3f573091 100644 --- a/tests/test_sprite_layer.cpp +++ b/tests/test_sprite_layer.cpp @@ -4,6 +4,37 @@ using wind::PickSpriteLayer; using wind::SpriteLayer; +TEST_CASE("sprite shape fit: small shapes keep their size on the base canvas") { + const auto f = wind::FitSpriteShape(32, 32, 0, 0); + CHECK(f.canvas == 64); + CHECK(f.drawW == 32); CHECK(f.drawH == 32); + CHECK(f.hotX == 0); CHECK(f.hotY == 0); +} + +TEST_CASE("sprite shape fit: a large pointer draws at its native size with its native hotspot") { + const auto f = wind::FitSpriteShape(96, 96, 48, 48); // pointer size 5, centred I-beam hotspot + CHECK(f.canvas == 96); + CHECK(f.drawW == 96); CHECK(f.drawH == 96); + CHECK(f.hotX == 48); CHECK(f.hotY == 48); + const auto g = wind::FitSpriteShape(256, 256, 10, 20); // the largest shape that fits unscaled + CHECK(g.canvas == 256); + CHECK(g.drawW == 256); CHECK(g.hotX == 10); CHECK(g.hotY == 20); +} + +TEST_CASE("sprite shape fit: over the cap, shape and hotspot scale together") { + const auto f = wind::FitSpriteShape(512, 512, 256, 100); + CHECK(f.canvas == 256); + CHECK(f.drawW == 256); CHECK(f.drawH == 256); + CHECK(f.hotX == 128); CHECK(f.hotY == 50); +} + +TEST_CASE("sprite shape fit: an unreadable size falls back to 32 px") { + const auto f = wind::FitSpriteShape(0, -1, 5, 5); + CHECK(f.canvas == 64); + CHECK(f.drawW == 32); CHECK(f.drawH == 32); + CHECK(f.hotX == 5); CHECK(f.hotY == 5); +} + TEST_CASE("sprite layer: auto off or no band-16 window always means the low window") { CHECK(PickSpriteLayer(false, true, 0) == SpriteLayer::Low); CHECK(PickSpriteLayer(false, true, 17) == SpriteLayer::Low); From 650ed94f9d0d8e8b7f949dfca588757e34edb362 Mon Sep 17 00:00:00 2001 From: Max <112043822+Maxaubert@users.noreply.github.com> Date: Fri, 16 Oct 2026 04:04:30 +0200 Subject: [PATCH 5/8] fix(cursor): shell panel freeze and pointer branch at every level above 1x (#494) The legacy sprite branch already runs at level > 1.0; the panel branch in presentCursor and the panel freeze in MapPointer still used 1.001, so a zoom settling just above 1.0 with a panel open got the sprite under the panel. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TdjopXZfsUiesYxYLYHEdH --- src/core/main.cpp | 2 +- src/transform/transform_model.cpp | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/core/main.cpp b/src/core/main.cpp index 4d2560ac..b847675f 100644 --- a/src/core/main.cpp +++ b/src/core/main.cpp @@ -1997,7 +1997,7 @@ static PointerMap MapPointer(TickState& t, bool inspect, double lvl, double dt, // A native session needs none of this: DWM draws its pointer above the panels and keeps it // centred, so the hand moves the pointer directly. const auto* tmPanel = freeCursor ? dynamic_cast(t.model) : nullptr; - const bool panel = tmPanel && !tmPanel->nativeSession() && lvl > 1.001 && g_track.shellPanelOpen(); + const bool panel = tmPanel && !tmPanel->nativeSession() && lvl > 1.0 && g_track.shellPanelOpen(); if (panel && !t.panelFreeze) { GetClipCursor(&t.panelSavedClip); t.panelX = cur.x; t.panelY = cur.y; diff --git a/src/transform/transform_model.cpp b/src/transform/transform_model.cpp index 0b5e3a54..8437b41a 100644 --- a/src/transform/transform_model.cpp +++ b/src/transform/transform_model.cpp @@ -993,7 +993,7 @@ void TransformModel::presentCursor(const MapResult& r, double level, const Prese sprite_->moveTo(r.clickDesktopX + mon_.x, r.clickDesktopY + mon_.y); lastSpriteX_ = INT_MIN; // the legacy sprite's move dedupe must not skip its next move sprite_->keepOnTop(); - } else if (!nativeSession_ && ex.realPointer && ex.drawCursor && level > 1.001) { + } else if (!nativeSession_ && ex.realPointer && ex.drawCursor && level > 1.0) { // SHELL INPUT PANEL in a Wind session (#283, #492). The emoji picker and its siblings are // composed by the shell above every window band, so the sprite goes under them; the real // pointer is the one thing drawn above. The cursor lens style makes DWM draw it magnified From 6dba746f14e663e81a49d1f9391952b9c29b1d08 Mon Sep 17 00:00:00 2001 From: Max <112043822+Maxaubert@users.noreply.github.com> Date: Fri, 16 Oct 2026 04:44:56 +0200 Subject: [PATCH 6/8] revert(cursor): let the cursor slide off the right and bottom edges again (#494) The edge clamp (finding from the code review, not a field report) held the drawn cursor on the last pixel, so part of it always stayed visible at the right and bottom edges while zoomed. Back to the old behaviour, which hides it fully like 1x. The Inspect clip restore from the same commit stays. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TdjopXZfsUiesYxYLYHEdH --- docs/architecture/07-cursor.md | 5 +++-- src/core/main.cpp | 7 +++---- src/cursor/cursor_mapper.cpp | 4 ++-- src/cursor/cursor_mapper.h | 9 --------- tests/test_cursor_mapper.cpp | 27 --------------------------- 5 files changed, 8 insertions(+), 44 deletions(-) diff --git a/docs/architecture/07-cursor.md b/docs/architecture/07-cursor.md index d5ecdc3e..b3c5b8f9 100644 --- a/docs/architecture/07-cursor.md +++ b/docs/architecture/07-cursor.md @@ -321,8 +321,9 @@ the mouse hook only swallows clicks. the crash filter and the `atexit` restore. Toggle-off, teardown and device-lost give back the clip the freeze replaced when it confined something (a work-area utility, a windowed game; `SavedClipWorthRestoring`, #494), as the shell-panel freeze does; Wind's own edge clip is never - saved, the clip beneath it is. Exits warp the cursor to the look point (held to the monitor's - last pixel, `PointerPixel`) and drain pending click counts. + saved, the clip beneath it is. Exits warp the cursor to the look point and drain pending click + counts. The look point may sit one pixel past the last column or row: that is what lets the + cursor slide fully off the right and bottom edges, as it does at 1x (owner, 2026-10-16). ## Tracking: caret, focus and mouse edge mode diff --git a/src/core/main.cpp b/src/core/main.cpp index b847675f..a6655db2 100644 --- a/src/core/main.cpp +++ b/src/core/main.cpp @@ -1716,8 +1716,8 @@ static void ExitInspect(TickState& t) { // position (which the transform model used to do, because it was forbidden from // placing the cursor) throws away the aim the user just spent the mode establishing. ClipCursor(nullptr); - POINT lp{ wind::PointerPixel(t.mapper.centerX(), t.mon.w) + t.mon.x, - wind::PointerPixel(t.mapper.centerY(), t.mon.h) + t.mon.y }; + POINT lp{ (int)(t.mapper.centerX() + 0.5) + t.mon.x, + (int)(t.mapper.centerY() + 0.5) + t.mon.y }; const bool transformTx = dynamic_cast(t.model) != nullptr; const bool warp = wind::InspectExitWarps(t.inspectLookMoved, transformTx); t.postInspectHold = wind::HoldWindViewAfterInspect(t.inspectLookMoved, transformTx); @@ -2815,8 +2815,7 @@ static void TeardownSession(TickState& t) { // must not survive into the next activation. g_input.state().commitLeft.exchange(0); g_input.state().commitRight.exchange(0); - POINT lp{ wind::PointerPixel(t.mapper.centerX(), t.mon.w) + t.mon.x, - wind::PointerPixel(t.mapper.centerY(), t.mon.h) + t.mon.y }; + POINT lp{ (int)(t.mapper.centerX() + 0.5) + t.mon.x, (int)(t.mapper.centerY() + 0.5) + t.mon.y }; SetCursorPos(lp.x, lp.y); // resume at the look point t.lastSetVirtual = lp; RestoreInspectClip(t); diff --git a/src/cursor/cursor_mapper.cpp b/src/cursor/cursor_mapper.cpp index 95380335..b2fab58e 100644 --- a/src/cursor/cursor_mapper.cpp +++ b/src/cursor/cursor_mapper.cpp @@ -39,8 +39,8 @@ MapResult CursorMapper::update(int dx, int dy, double level) { // source rect clamps against the desktop boundary so corners stay reachable. r.cursorScreenX = (cx_ - o.x) * level; r.cursorScreenY = (cy_ - o.y) * level; - r.clickDesktopX = PointerPixel(cx_, sw_); // never one past the monitor (review #494) - r.clickDesktopY = PointerPixel(cy_, sh_); + r.clickDesktopX = static_cast(cx_ + 0.5); + r.clickDesktopY = static_cast(cy_ + 0.5); r.centerX = cx_; // un-rounded, for the transform model's fixed-point anchor r.centerY = cy_; return r; diff --git a/src/cursor/cursor_mapper.h b/src/cursor/cursor_mapper.h index 99ef7848..b253e753 100644 --- a/src/cursor/cursor_mapper.h +++ b/src/cursor/cursor_mapper.h @@ -9,15 +9,6 @@ struct MapResult { // SetCursorPos); kept separately so sub-pixel consumers never re-derive it from the rounded pair. double centerX, centerY; }; -// A lens-centre coordinate as a POINTER pixel on a monitor `n` px wide: rounded, then held to -// [0, n-1]. The centre itself may sit on n (the transform anchor's right edge), but n is the first -// column of the monitor to the right, so a weld, click or warp there leaves the session monitor. -inline int PointerPixel(double c, int n) { - int p = static_cast(c + 0.5); - if (p > n - 1) p = n - 1; - if (p < 0) p = 0; - return p; -} // Pure centered-mode mapper. Integrates per-tick pixel deltas into a float lens center // (desktop px), so the world pans with sub-pixel precision while the cursor stays at // screen center - shifting toward an edge only when the view clamps at the desktop edge. diff --git a/tests/test_cursor_mapper.cpp b/tests/test_cursor_mapper.cpp index 25912f6b..582af409 100644 --- a/tests/test_cursor_mapper.cpp +++ b/tests/test_cursor_mapper.cpp @@ -46,33 +46,6 @@ TEST_CASE("lens center clamps to the desktop bounds") { CHECK(m.centerY() == doctest::Approx(1080.0)); } -TEST_CASE("the click point stays on the monitor when the centre clamps to its far edge") { - CursorMapper m(1920, 1080); - m.reset(1920, 1080); - auto r = m.update(500, 500, 2.0); // centre on w/h: the first pixel of the next monitor - CHECK(r.centerX == doctest::Approx(1920.0)); // the anchor keeps the un-rounded edge - CHECK(r.clickDesktopX == 1919); - CHECK(r.clickDesktopY == 1079); - m.reset(1919.6, 1079.6); // rounds up onto w/h - r = m.update(0, 0, 2.0); - CHECK(r.clickDesktopX == 1919); - CHECK(r.clickDesktopY == 1079); - r = m.update(-5000, -5000, 2.0); - CHECK(r.clickDesktopX == 0); - CHECK(r.clickDesktopY == 0); -} - -TEST_CASE("PointerPixel rounds and holds a centre to the monitor's pixels") { - using wind::PointerPixel; - CHECK(PointerPixel(10.4, 1920) == 10); - CHECK(PointerPixel(10.5, 1920) == 11); - CHECK(PointerPixel(1919.4, 1920) == 1919); - CHECK(PointerPixel(1919.5, 1920) == 1919); - CHECK(PointerPixel(1920.0, 1920) == 1919); - CHECK(PointerPixel(0.0, 1920) == 0); - CHECK(PointerPixel(-3.0, 1920) == 0); -} - TEST_CASE("update integrates the pixel delta directly; zoom level does NOT change desktop speed") { CursorMapper m(1920, 1080); m.reset(960, 540); From 57a53ec987790a0a9e4b4ec1bcb977b7717856f7 Mon Sep 17 00:00:00 2001 From: Max <112043822+Maxaubert@users.noreply.github.com> Date: Fri, 16 Oct 2026 04:49:33 +0200 Subject: [PATCH 7/8] fix(transform): edge clip insets only the left and top edges (#494) The 1 px edge clip (#240) exists for the contested outermost pixel at the left/top edge, but it inset all four sides, so while zoomed the pointer could never reach the last column or row and part of the cursor stayed visible at the right and bottom edges in both mouse tracking modes (owner field report). Right and bottom are open again; the cursor slides off the screen there as at 1x. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TdjopXZfsUiesYxYLYHEdH --- docs/architecture/08-config-profiles.md | 4 ++-- src/config/config.h | 5 +++-- src/transform/transform_model.cpp | 5 ++++- 3 files changed, 9 insertions(+), 5 deletions(-) diff --git a/docs/architecture/08-config-profiles.md b/docs/architecture/08-config-profiles.md index 15885186..21279ad6 100644 --- a/docs/architecture/08-config-profiles.md +++ b/docs/architecture/08-config-profiles.md @@ -203,7 +203,7 @@ Every key works in the ini whether or not Settings shows it. Keys hot-reload unl | Keys | Meaning | |---|---| | `txSamplingMode` (0) | Transform sampling: 0 nearest, 1 smooth (Settings: Transform scaling, formerly "High resolution cursor", [05](05-transform-engine.md)) | -| `edgeClip` (1, hot) | While a transform session is zoomed, clips the pointer 1 px inside the monitor so it cannot rest on the contested outermost pixel (cursor-shape flicker at the left/top edge). Side effect: in a native-cursor session the pointer cannot reach the last pixel row and column. A tighter existing clip (game confine, Inspect) always wins; 0 turns it off. Rationale: `Config::edgeClip` in `src/config/config.h`, mechanics in `TransformModel::edgeClipManage` | +| `edgeClip` (1, hot) | While a transform session is zoomed, clips the pointer 1 px inside the monitor's left and top edges so it cannot rest on the contested outermost pixel (cursor-shape flicker there). The right and bottom stay open so the cursor can slide off the screen, as at 1x. A tighter existing clip (game confine, Inspect) always wins; 0 turns it off. Rationale: `Config::edgeClip` in `src/config/config.h`, mechanics in `TransformModel::edgeClipManage` | | `renderScaling` (1), `upscaleTiming` (0) | Render engine scaling: 0 nearest, 1 xBR, 2 NVIDIA Image Scaling (Settings: Render scaling, [04](04-render-engine.md)); `bilinear` is retired and only migrates (0 keeps nearest). `upscaleTiming=1` logs the filter's GPU time | | `sharpness`, `brightness`, `hdrTonemap` (1) | Render engine image | | `cursorConstantSize` (0), `cursorVisibility` (`auto`) | Render cursor size and when it is drawn | @@ -233,7 +233,7 @@ failed to init. | Keys | Meaning | |---|---| -| `edgeClip` (1) | While a transform session is zoomed, `ClipCursor` 1 px inside the monitor keeps the pointer off the contested outermost pixels (edge cursor-shape flicker); 0 lets the pointer reach the corner pixel. The trade-off is recorded at `Config::edgeClip` in `src/config/config.h` | +| `edgeClip` (1) | While a transform session is zoomed, `ClipCursor` 1 px inside the monitor's left and top edges keeps the pointer off the contested outermost pixels (edge cursor-shape flicker); 0 lets the pointer reach the top-left corner pixel. The trade-off is recorded at `Config::edgeClip` in `src/config/config.h` | | `lockedBallistics` (1) | Locked games pan with the learned input-to-output gain (`GainLearner`); 0 pans with plain raw mickeys | | `smoothPan` (0, restart) | Hold the display composited while zoomed (a 1 px pin) so flip-model games keep DWM's pan path | | `fastPan` (1, restart) | Pan through the private `SetMagnificationDesktopMagnification` channel (finer than the public write) | diff --git a/src/config/config.h b/src/config/config.h index ec815fce..4ccf5137 100644 --- a/src/config/config.h +++ b/src/config/config.h @@ -290,8 +290,9 @@ struct Config { // pixel is contested by shell edge zones and third-party edge hooks. Two weld-side fixes // failed: an inset target alone cannot hold (the hand re-pins the pointer past the deduped // weld), and re-asserting the weld per tick fights the hand everywhere (field-rejected hard). - // So the OS does it instead: while a transform session is zoomed, a ClipCursor 1px INSIDE the - // monitor keeps the pointer off the contested pixels with ZERO writes. Snapshot-and-restore + // So the OS does it instead: while a transform session is zoomed, a ClipCursor 1px inside the + // monitor's LEFT and TOP edges keeps the pointer off the contested pixels with ZERO writes (right + // and bottom stay open, 2026-10-16, so the cursor can leave the screen there as at 1x). Snapshot-and-restore // (this rig has a permanent external work-area clip that a nullptr release would destroy), // intersected with whatever clip already exists, and never fighting a TIGHTER clip (a game // confine, Inspect's 1px freeze). 0 = off (hot). diff --git a/src/transform/transform_model.cpp b/src/transform/transform_model.cpp index 8437b41a..3beb0276 100644 --- a/src/transform/transform_model.cpp +++ b/src/transform/transform_model.cpp @@ -310,7 +310,10 @@ void TransformModel::hideSystemCursor(bool hide) { void TransformModel::edgeClipManage(bool wantActive) { RECT cur{}; if (!GetClipCursor(&cur)) return; - const RECT inset{ mon_.x + 1, mon_.y + 1, mon_.x + mon_.w - 1, mon_.y + mon_.h - 1 }; + // Left and top only: the contested pixel (shell edge zones, edge hooks) is the outermost column and + // row there. The right and bottom stay open so the pointer can slide off the screen as at 1x + // (owner, 2026-10-16: insetting them left part of the cursor showing at those edges). + const RECT inset{ mon_.x + 1, mon_.y + 1, mon_.x + mon_.w, mon_.y + mon_.h }; if (!wantActive) { if (edgeClipActive_) { // Restore the snapshot unless someone else took the clip meanwhile (theirs wins). From a22d255eb925549fac3052d1a9a2d6a0c3df5baf Mon Sep 17 00:00:00 2001 From: Max <112043822+Maxaubert@users.noreply.github.com> Date: Fri, 16 Oct 2026 04:49:33 +0200 Subject: [PATCH 8/8] fix(cursor): restore the monitor-edge click clamp (#494) Reverts the revert: the clamp was not what kept the cursor visible at the right and bottom edges (that was the edge clip), and it keeps welds, Inspect clicks and warps off a monitor to the right. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TdjopXZfsUiesYxYLYHEdH --- docs/architecture/07-cursor.md | 5 ++--- src/core/main.cpp | 7 ++++--- src/cursor/cursor_mapper.cpp | 4 ++-- src/cursor/cursor_mapper.h | 9 +++++++++ tests/test_cursor_mapper.cpp | 27 +++++++++++++++++++++++++++ 5 files changed, 44 insertions(+), 8 deletions(-) diff --git a/docs/architecture/07-cursor.md b/docs/architecture/07-cursor.md index b3c5b8f9..d5ecdc3e 100644 --- a/docs/architecture/07-cursor.md +++ b/docs/architecture/07-cursor.md @@ -321,9 +321,8 @@ the mouse hook only swallows clicks. the crash filter and the `atexit` restore. Toggle-off, teardown and device-lost give back the clip the freeze replaced when it confined something (a work-area utility, a windowed game; `SavedClipWorthRestoring`, #494), as the shell-panel freeze does; Wind's own edge clip is never - saved, the clip beneath it is. Exits warp the cursor to the look point and drain pending click - counts. The look point may sit one pixel past the last column or row: that is what lets the - cursor slide fully off the right and bottom edges, as it does at 1x (owner, 2026-10-16). + saved, the clip beneath it is. Exits warp the cursor to the look point (held to the monitor's + last pixel, `PointerPixel`) and drain pending click counts. ## Tracking: caret, focus and mouse edge mode diff --git a/src/core/main.cpp b/src/core/main.cpp index a6655db2..b847675f 100644 --- a/src/core/main.cpp +++ b/src/core/main.cpp @@ -1716,8 +1716,8 @@ static void ExitInspect(TickState& t) { // position (which the transform model used to do, because it was forbidden from // placing the cursor) throws away the aim the user just spent the mode establishing. ClipCursor(nullptr); - POINT lp{ (int)(t.mapper.centerX() + 0.5) + t.mon.x, - (int)(t.mapper.centerY() + 0.5) + t.mon.y }; + POINT lp{ wind::PointerPixel(t.mapper.centerX(), t.mon.w) + t.mon.x, + wind::PointerPixel(t.mapper.centerY(), t.mon.h) + t.mon.y }; const bool transformTx = dynamic_cast(t.model) != nullptr; const bool warp = wind::InspectExitWarps(t.inspectLookMoved, transformTx); t.postInspectHold = wind::HoldWindViewAfterInspect(t.inspectLookMoved, transformTx); @@ -2815,7 +2815,8 @@ static void TeardownSession(TickState& t) { // must not survive into the next activation. g_input.state().commitLeft.exchange(0); g_input.state().commitRight.exchange(0); - POINT lp{ (int)(t.mapper.centerX() + 0.5) + t.mon.x, (int)(t.mapper.centerY() + 0.5) + t.mon.y }; + POINT lp{ wind::PointerPixel(t.mapper.centerX(), t.mon.w) + t.mon.x, + wind::PointerPixel(t.mapper.centerY(), t.mon.h) + t.mon.y }; SetCursorPos(lp.x, lp.y); // resume at the look point t.lastSetVirtual = lp; RestoreInspectClip(t); diff --git a/src/cursor/cursor_mapper.cpp b/src/cursor/cursor_mapper.cpp index b2fab58e..95380335 100644 --- a/src/cursor/cursor_mapper.cpp +++ b/src/cursor/cursor_mapper.cpp @@ -39,8 +39,8 @@ MapResult CursorMapper::update(int dx, int dy, double level) { // source rect clamps against the desktop boundary so corners stay reachable. r.cursorScreenX = (cx_ - o.x) * level; r.cursorScreenY = (cy_ - o.y) * level; - r.clickDesktopX = static_cast(cx_ + 0.5); - r.clickDesktopY = static_cast(cy_ + 0.5); + r.clickDesktopX = PointerPixel(cx_, sw_); // never one past the monitor (review #494) + r.clickDesktopY = PointerPixel(cy_, sh_); r.centerX = cx_; // un-rounded, for the transform model's fixed-point anchor r.centerY = cy_; return r; diff --git a/src/cursor/cursor_mapper.h b/src/cursor/cursor_mapper.h index b253e753..99ef7848 100644 --- a/src/cursor/cursor_mapper.h +++ b/src/cursor/cursor_mapper.h @@ -9,6 +9,15 @@ struct MapResult { // SetCursorPos); kept separately so sub-pixel consumers never re-derive it from the rounded pair. double centerX, centerY; }; +// A lens-centre coordinate as a POINTER pixel on a monitor `n` px wide: rounded, then held to +// [0, n-1]. The centre itself may sit on n (the transform anchor's right edge), but n is the first +// column of the monitor to the right, so a weld, click or warp there leaves the session monitor. +inline int PointerPixel(double c, int n) { + int p = static_cast(c + 0.5); + if (p > n - 1) p = n - 1; + if (p < 0) p = 0; + return p; +} // Pure centered-mode mapper. Integrates per-tick pixel deltas into a float lens center // (desktop px), so the world pans with sub-pixel precision while the cursor stays at // screen center - shifting toward an edge only when the view clamps at the desktop edge. diff --git a/tests/test_cursor_mapper.cpp b/tests/test_cursor_mapper.cpp index 582af409..25912f6b 100644 --- a/tests/test_cursor_mapper.cpp +++ b/tests/test_cursor_mapper.cpp @@ -46,6 +46,33 @@ TEST_CASE("lens center clamps to the desktop bounds") { CHECK(m.centerY() == doctest::Approx(1080.0)); } +TEST_CASE("the click point stays on the monitor when the centre clamps to its far edge") { + CursorMapper m(1920, 1080); + m.reset(1920, 1080); + auto r = m.update(500, 500, 2.0); // centre on w/h: the first pixel of the next monitor + CHECK(r.centerX == doctest::Approx(1920.0)); // the anchor keeps the un-rounded edge + CHECK(r.clickDesktopX == 1919); + CHECK(r.clickDesktopY == 1079); + m.reset(1919.6, 1079.6); // rounds up onto w/h + r = m.update(0, 0, 2.0); + CHECK(r.clickDesktopX == 1919); + CHECK(r.clickDesktopY == 1079); + r = m.update(-5000, -5000, 2.0); + CHECK(r.clickDesktopX == 0); + CHECK(r.clickDesktopY == 0); +} + +TEST_CASE("PointerPixel rounds and holds a centre to the monitor's pixels") { + using wind::PointerPixel; + CHECK(PointerPixel(10.4, 1920) == 10); + CHECK(PointerPixel(10.5, 1920) == 11); + CHECK(PointerPixel(1919.4, 1920) == 1919); + CHECK(PointerPixel(1919.5, 1920) == 1919); + CHECK(PointerPixel(1920.0, 1920) == 1919); + CHECK(PointerPixel(0.0, 1920) == 0); + CHECK(PointerPixel(-3.0, 1920) == 0); +} + TEST_CASE("update integrates the pixel delta directly; zoom level does NOT change desktop speed") { CursorMapper m(1920, 1080); m.reset(960, 540);