diff --git a/docs/architecture/07-cursor.md b/docs/architecture/07-cursor.md index 36e9724..f283408 100644 --- a/docs/architecture/07-cursor.md +++ b/docs/architecture/07-cursor.md @@ -165,6 +165,10 @@ slightly during zoom ramps, nearest keeps it pixelated and steady. Pure rules: - **DWM's learned offset.** DWM learns the gap between `GetCursorPos` and its own cursor point and relearns it only when the cursor HANDLE changes, so a learn taken mid-jump can sit a few px off until the next shape change. Windows Magnifier has the same behaviour. + Measured 2026-10-11 (#445): Wind's own pixel-and-back nudges leave DWM centred 1 desktop px off + the pointer in x (either side, about 11 screen px at 10x). With every zoomed nudge removed DWM + matched Wind's centre exactly, but panning then shook the pointer up to 30 px, so the nudges stay. + A zero-distance injected move is dropped by Windows, so it is no substitute. - No cursor sprite, no blanking and no shell-panel handling in a normal zoom. The hide-cursor hotkey and `cursorVisibility=never` blank the pointer the way Inspect does (below). Inspect keeps its crosshair window. Games pay nothing extra for the native cursor: a zoomed full-screen window is @@ -264,6 +268,15 @@ Tracking (below) can then detach the view from all of these. Inspect (`cursorLockVk`) freezes the cursor and adds a free-look crosshair. It runs in `RunTick`; the mouse hook only swallows clicks. +- **No shift at the toggle (#445).** In a transform session DWM centring stays on after entry + until the look point first moves (`InspectKeepsDwmView`), and leaving without having moved skips + the warp and its 1 px shape jiggle (`InspectExitWarps`). Once the look point moved, Inspect shows Wind's own + centre, so after the exit Wind keeps writing it until the hand first moves the pointer + (`HoldWindViewAfterInspect`): handing the view back to DWM at the toggle shifted it (field + 2026-10-11), during the first movement it is hidden. Switching to Wind's own centre at the + toggle moved the view by DWM's 1 px offset (above): measured at 10.5x, entry shifted 10-12 px in + every run, now 0; exit shifted in about half the runs, now in 1 of 3 (DWM relearns its offset + when the pointer is shown again). - **Entry.** The real cursor is frozen in place with a 1 px `ClipCursor` (`t.frozenCursor`) and hidden, so a hover or tooltip under it stays alive. The look point is the mapper centre and pans from ballistics-cooked raw mickeys ([06](06-input.md)). diff --git a/src/main.cpp b/src/main.cpp index 5b59618..3ea12ce 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -349,6 +349,8 @@ struct TickState { POINT frozenCursor{}; // where the real cursor is frozen while Inspect is on (virtual px) int clickReleaseTicks = 0; // after a committed click: ticks to keep the freeze clip released so // the synthesized click reaches the look point (then re-freeze) + bool inspectLookMoved = false; // the Inspect look point moved since entry (#445) + bool postInspectHold = false; // after Inspect: Wind keeps its centre until the hand moves (#445) double inspectPanRemX = 0.0; // sub-pixel carry for the cooked Inspect-mode pan (slow motion not lost) double inspectPanRemY = 0.0; double lockedPanRemX = 0.0; // sub-pixel carry for the locked pan @@ -1444,6 +1446,7 @@ static void RunTick(TickState& t) { // detached view survived the 1x gap and the next zoom-in opened on the previous // session's caret. Every session starts with the mouse in charge. wind::ResetViewOwnerForSession(t.viewOwner); + t.postInspectHold = false; // a new session starts with DWM centring (#445) t.viewDetached = false; t.viewVx = 0; t.viewVy = 0; t.keyPan.reset(); @@ -1585,6 +1588,8 @@ static void RunTick(TickState& t) { // pans by zero. rawDx = 0; rawDy = 0; t.inspectPanRemX = 0.0; t.inspectPanRemY = 0.0; + t.inspectLookMoved = false; + t.postInspectHold = false; t.mapper.reset(pt.x - t.mon.x, pt.y - t.mon.y); t.lastSetVirtual = pt; RECT fz{ pt.x, pt.y, pt.x + 1, pt.y + 1 }; @@ -1627,16 +1632,21 @@ static void RunTick(TickState& t) { ClipCursor(nullptr); 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); - t.lastSetVirtual = lp; + const bool transformTx = dynamic_cast(t.model) != nullptr; + const bool warp = wind::InspectExitWarps(t.inspectLookMoved, transformTx); + t.postInspectHold = wind::HoldWindViewAfterInspect(t.inspectLookMoved, transformTx); + if (warp) SetCursorPos(lp.x, lp.y); + t.lastSetVirtual = warp ? lp : t.frozenCursor; // The cursor SHAPE is stale after the warp (issue #229): Windows re-evaluates the // shape only on a cursor event, so GetCursorInfo keeps reporting whatever cursor // was active at the FROZEN spot (an I-beam over text, a link hand) and the sprite // draws that stale shape until the user happens to move. Same 1px jiggle the // transform model's session-end path uses - a zero-delta injected move is DROPPED // by Windows and refreshes nothing (field-verified). - SetCursorPos(lp.x + 1, lp.y); - SetCursorPos(lp.x, lp.y); + if (warp) { + SetCursorPos(lp.x + 1, lp.y); + SetCursorPos(lp.x, lp.y); + } } if (recenter) { POINT pt; GetCursorPos(&pt); t.mapper.reset(pt.x - t.mon.x, pt.y - t.mon.y); t.lastSetVirtual = pt; } // Resolve the pan delta. FREE: the OS cursor's own motion since we last placed it - Windows' @@ -1648,6 +1658,7 @@ static void RunTick(TickState& t) { POINT cur; GetCursorPos(&cur); int curDx = cur.x - t.lastSetVirtual.x; int curDy = cur.y - t.lastSetVirtual.y; + t.postInspectHold = wind::PostInspectHoldKeeps(t.postInspectHold && !inspect, curDx != 0 || curDy != 0); int dx, dy; bool dragFollow = false; // set in the free render branch below; drives ex.suppressCursorSync if (inspect) { @@ -1662,6 +1673,7 @@ static void RunTick(TickState& t) { t.inspectPanRemY += rawDy * g * t.cfg.cursorSensitivity; dx = (int)t.inspectPanRemX; t.inspectPanRemX -= dx; // truncate toward zero, carry the rest dy = (int)t.inspectPanRemY; t.inspectPanRemY -= dy; + if (dx != 0 || dy != 0) t.inspectLookMoved = true; } else { RECT clip{}; GetClipCursor(&clip); // A clip is a lock signal only when it is meaningfully SMALLER than the monitor @@ -2325,7 +2337,8 @@ static void RunTick(TickState& t) { { wind::DwmCentreIn dc; dc.zoomed = lvl > 1.001; - dc.freeCursor = freeCursor && !gameCursor; // a delayed view is Wind's to write (#443) + dc.freeCursor = (freeCursor && !gameCursor && !t.postInspectHold) || // #443, #445 + wind::InspectKeepsDwmView(inspect, t.inspectLookMoved); // #445 dc.viewDetached = t.viewDetached; dc.wallNeeded = wind::NearWall(wallNeeded, r.srcLeft, r.srcTop, lvl, kMaxSafeTxMagnitude, 64.0); dc.quiesce = quiesceHold; diff --git a/src/native_cursor.h b/src/native_cursor.h index 7727634..8d77e72 100644 --- a/src/native_cursor.h +++ b/src/native_cursor.h @@ -55,6 +55,32 @@ inline bool NearWall(bool wallArmed, double srcLeft, double srcTop, double level return srcLeft > wall - marginSrc || srcTop > wall - marginSrc; } +// Inspect keeps DWM's view until the look point first moves (#445). DWM centring sits a desktop +// pixel off Wind's own centre (it learns that from Wind's pixel-and-back nudges, which panning needs: +// without them the pointer shook up to 30 px), so switching it off at the Inspect toggle shifted the +// view about 11 screen px at 10x (measured 2026-10-11). With the frozen pointer still at the look point +// DWM's view is the right one; the switch waits for the first look-point motion, which hides it. +inline bool InspectKeepsDwmView(bool inspect, bool lookMoved) { + return inspect && !lookMoved; +} + +// Leaving Inspect warps the pointer to the look point and jiggles it a pixel to refresh its shape. +// In a transform session where the look point never moved the pointer is already there with the +// right shape, and the jiggle is what made DWM re-centre a pixel off on the way out (#445). +inline bool InspectExitWarps(bool lookMoved, bool transformEngine) { + return lookMoved || !transformEngine; +} + +// The way back (#445, field 2026-10-11): once the look point moved, Inspect showed Wind's own centre, +// and handing the view back to DWM at the exit toggle moved it by DWM's pixel. Wind keeps writing +// its centre after the exit until the hand first moves the pointer, which hides the hand-over. +inline bool HoldWindViewAfterInspect(bool lookMoved, bool transformEngine) { + return lookMoved && transformEngine; +} +inline bool PostInspectHoldKeeps(bool held, bool handMoved) { + return held && !handMoved; +} + inline bool WantDwmCentring(const DwmCentreIn& in) { return in.zoomed && in.freeCursor && !in.viewDetached && !in.wallNeeded && !in.quiesce; } diff --git a/src/version.h b/src/version.h index 3f2834b..f8a9f21 100644 --- a/src/version.h +++ b/src/version.h @@ -4,7 +4,7 @@ #define WIND_VER_MAJOR 0 #define WIND_VER_MINOR 29 -#define WIND_VER_PATCH 1 +#define WIND_VER_PATCH 2 // String form for logs/snapshot/UI. Keep in sync with the numeric parts above. -#define WIND_VERSION_STR "0.29.1" +#define WIND_VERSION_STR "0.29.2" diff --git a/tests/test_native_cursor.cpp b/tests/test_native_cursor.cpp index 6d5bc74..8af04d1 100644 --- a/tests/test_native_cursor.cpp +++ b/tests/test_native_cursor.cpp @@ -111,3 +111,29 @@ TEST_CASE("a press held past the click window is a drag and may be nudged") { CHECK(NudgeBlockedByClick(true, 1000, 0) == true); // released, inside the window after it CHECK(NudgeBlockedByClick(true, 1000, 1003) == true); // stamp newer than the clock read } + +TEST_CASE("Inspect keeps DWM's view until the look point moves (#445)") { + CHECK(InspectKeepsDwmView(true, false)); + CHECK_FALSE(InspectKeepsDwmView(true, true)); + CHECK_FALSE(InspectKeepsDwmView(false, false)); + DwmCentreIn in; in.zoomed = true; in.freeCursor = InspectKeepsDwmView(true, false); + CHECK(WantDwmCentring(in)); + in.freeCursor = InspectKeepsDwmView(true, true); + CHECK_FALSE(WantDwmCentring(in)); +} + +TEST_CASE("leaving Inspect warps only when the look point moved or the render engine draws (#445)") { + CHECK_FALSE(InspectExitWarps(false, true)); + CHECK(InspectExitWarps(true, true)); + CHECK(InspectExitWarps(false, false)); + CHECK(InspectExitWarps(true, false)); +} + +TEST_CASE("after Inspect, Wind keeps its centre until the hand moves, only if the look point moved (#445)") { + CHECK(HoldWindViewAfterInspect(true, true)); + CHECK_FALSE(HoldWindViewAfterInspect(false, true)); // DWM kept the view: nothing to hand back + CHECK_FALSE(HoldWindViewAfterInspect(true, false)); // render engine: no DWM centring + CHECK(PostInspectHoldKeeps(true, false)); + CHECK_FALSE(PostInspectHoldKeeps(true, true)); + CHECK_FALSE(PostInspectHoldKeeps(false, false)); +}