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/docs/architecture/07-cursor.md b/docs/architecture/07-cursor.md index 708c5d06..d5ecdc3e 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 @@ -184,8 +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. 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 @@ -198,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 @@ -313,8 +318,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/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/core/main.cpp b/src/core/main.cpp index 0cdcd8e2..b847675f 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 @@ -1973,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; @@ -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 @@ -3049,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; @@ -3695,6 +3726,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_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_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/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/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/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/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. 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..3beb0276 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; @@ -303,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). @@ -489,6 +499,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 @@ -979,7 +996,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 @@ -1010,12 +1027,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/src/transform/transform_model.h b/src/transform/transform_model.h index 353c6009..3d4f8ca1 100644 --- a/src/transform/transform_model.h +++ b/src/transform/transform_model.h @@ -53,6 +53,16 @@ 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; + } + // 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). @@ -97,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_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. 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)); 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);