Skip to content
Merged
6 changes: 5 additions & 1 deletion docs/architecture/06-input.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
20 changes: 14 additions & 6 deletions docs/architecture/07-cursor.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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

Expand Down
4 changes: 2 additions & 2 deletions docs/architecture/08-config-profiles.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down Expand Up @@ -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) |
Expand Down
5 changes: 3 additions & 2 deletions src/config/config.h
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
42 changes: 37 additions & 5 deletions src/core/main.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -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) {
Expand All @@ -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<TransformModel*>(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
Expand Down Expand Up @@ -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<TransformModel*>(t.model) != nullptr;
const bool warp = wind::InspectExitWarps(t.inspectLookMoved, transformTx);
t.postInspectHold = wind::HoldWindViewAfterInspect(t.inspectLookMoved, transformTx);
Expand All @@ -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
Expand Down Expand Up @@ -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<TransformModel*>(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;
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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<TransformModel*>(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;
Expand Down Expand Up @@ -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;
Expand Down
35 changes: 29 additions & 6 deletions src/cursor/cursor_blanker.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -31,19 +31,42 @@ static void RestoreAll() {
SystemParametersInfoW(SPI_SETCURSORS, 0, nullptr, 0);
}

CursorBlanker::CursorBlanker() : st_(std::make_shared<State>()) {
// 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<HCURSOR, HCURSOR>& 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<HCURSOR, HCURSOR>& m) {
for (auto& kv : m) DestroyCursor(kv.second);
m.clear();
}

CursorBlanker::CursorBlanker() : st_(std::make_shared<State>()) {
// 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<HCURSOR, HCURSOR> m;
~Copies() { DestroyCopies(m); }
};
auto fresh = std::make_shared<Copies>();
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<std::mutex> lk(st_->mx);
Expand All @@ -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<State> st) {
Expand Down
6 changes: 6 additions & 0 deletions src/cursor/cursor_blanker.h
Original file line number Diff line number Diff line change
Expand Up @@ -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<HCURSOR, HCURSOR>& 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
Expand Down
4 changes: 2 additions & 2 deletions src/cursor/cursor_mapper.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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<int>(cx_ + 0.5);
r.clickDesktopY = static_cast<int>(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;
Expand Down
Loading
Loading