Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions docs/architecture/07-cursor.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)).
Expand Down
23 changes: 18 additions & 5 deletions src/main.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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 };
Expand Down Expand Up @@ -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<TransformModel*>(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'
Expand All @@ -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) {
Expand All @@ -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
Expand Down Expand Up @@ -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;
Expand Down
26 changes: 26 additions & 0 deletions src/native_cursor.h
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
4 changes: 2 additions & 2 deletions src/version.h
Original file line number Diff line number Diff line change
Expand Up @@ -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"
26 changes: 26 additions & 0 deletions tests/test_native_cursor.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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));
}
Loading