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
12 changes: 10 additions & 2 deletions docs/architecture/07-cursor.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ Inspect crosshair, lock detection, Inspect, tracking and keyboard panning.
## The mapper

`CursorMapper` (`src/cursor_mapper.*`, pure, tested) integrates per-tick deltas into a float lens
centre `(cx_, cy_)` in monitor-local pixels, eased by `cursorSmoothing`. Each tick `update` returns
centre `(cx_, cy_)` in monitor-local pixels (no easing: `cursorSmoothing` was removed in #430). Each tick `update` returns
one `MapResult`:

| Field | Meaning | Used by |
Expand Down Expand Up @@ -39,7 +39,7 @@ offset = clamp(cursor - screen/(2*level), 0, screen - screen/level)
Wind's older model integrated deltas into a smoothed centre and welded the pointer back to it: a
feedback loop, and the source of the transform wobble. A free transform session (not Inspect, not
locked) pins the mapper to the real cursor every tick (`reset(cursorPos)` then `update(0, 0, lvl)`)
and never welds. `cursorSensitivity` and `cursorSmoothing` do not apply there; the mapper clamps the
and never welds. `cursorSensitivity` does not apply there; the mapper clamps the
source rect exactly as the formula above does.

Render sessions keep delta integration plus the weld, because the render engine hides the real
Expand Down Expand Up @@ -131,6 +131,14 @@ slightly during zoom ramps, nearest keeps it pixelated and steady. Pure rules:
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.
- **Pan glide (#430, `panGlideMaxPx`, experimental, 0 = off).** A soft stop: when a mouse movement
stops, the POINTER eases on at the hand's speed and slows to rest within `panGlideMaxPx` SCREEN px
at any zoom (`src/pan_glide.h`); `panGlideMs` is the ease's time constant, shortened for a fast hand
so the cap holds. The view follows by DWM centring. Any hand movement, a mouse button, a game,
Inspect, a detached view or 1x ends it. Wind's own steps move the tick baseline (`lastSetVirtual`),
so the lock detector and the gain learner only ever see hand motion. A first, uncapped version
(momentum = speed x time) could throw the pointer across the screen and was replaced. It replaces
Pan smoothing (`cursorSmoothing`), which eased the view toward the pointer: lag, never momentum.
- **One centre during zoom.** DWM centres on its cursor point plus a learned hotspot offset that can
sit 1-2 desktop px off Wind's exact centre; a level write puts the view on Wind's centre, the next
cursor event back on DWM's (a 5-10 px shift at ~5x that snapped back when the zoom stopped). While
Expand Down
2 changes: 1 addition & 1 deletion docs/architecture/08-config-profiles.md
Original file line number Diff line number Diff line change
Expand Up @@ -173,7 +173,7 @@ Every key works in the ini whether or not Settings shows it. Keys hot-reload unl
| Keys | Meaning |
|---|---|
| `maxLevel` (12), `zoomInSpeed`, `zoomOutSpeed`, `zoomEaseOutMs`, `smoothZoom*` | Range and feel of the zoom |
| `cursorSensitivity` (1.0), `cursorSmoothing`, `panSpeed` (1.0) | Mouse and arrow-key pan speed and inertia |
| `cursorSensitivity` (1.0), `panSpeed` (1.0), `panGlideMaxPx` (0), `panGlideMs` (60) | Mouse and arrow-key pan speed; the pan glide's distance cap and ease (#430) |
| `mouseAlign` (0 centred, 1 within the edges), `mouseMarginPct` | Where the pointer sits while the view moves |
| `trackCaret` (1), `trackFocus` (0), `trackAlign`, `trackGlideMs` (200) | Follow the text caret and keyboard focus |
| `lockApps`, `warpLock` (0) | Games whose sessions pan from raw mouse motion; heuristics for unlisted games |
Expand Down
15 changes: 10 additions & 5 deletions src/config.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -210,7 +210,8 @@ Config ParseConfig(const std::string& text) {
else if (key == "dwmFlush") c.dwmFlush = ParseIntStrict(val);
else if (key == "diagnostics") c.diagnostics = ParseIntStrict(val);
else if (key == "cursorSensitivity") c.cursorSensitivity = ParseDoubleStrict(val);
else if (key == "cursorSmoothing") c.cursorSmoothing = ParseDoubleStrict(val);
else if (key == "panGlideMs") c.panGlideMs = ParseIntStrict(val);
else if (key == "panGlideMaxPx") c.panGlideMaxPx = ParseIntStrict(val);
else if (key == "cursorConstantSize") c.cursorConstantSize = ParseIntStrict(val);
else if (key == "cursorVisibility") c.cursorVisibility = val;
else if (key == "model") c.model = val;
Expand Down Expand Up @@ -295,7 +296,10 @@ Config ParseConfig(const std::string& text) {
c.smoothZoomAccel = clampd(c.smoothZoomAccel, 1.0, 8.0);
c.smoothZoomRamp = clampd(c.smoothZoomRamp, 0.1, 3.0);
c.cursorSensitivity = clampd(c.cursorSensitivity, 0.25, 4.0);
c.cursorSmoothing = clampd(c.cursorSmoothing, 0.0, 0.95);
if (c.panGlideMs < 10) c.panGlideMs = 10;
if (c.panGlideMs > 300) c.panGlideMs = 300;
if (c.panGlideMaxPx < 0) c.panGlideMaxPx = 0;
if (c.panGlideMaxPx > 400) c.panGlideMaxPx = 400;
c.sharpness = clampd(c.sharpness, 0.0, 1.0);
c.brightness = clampd(c.brightness, 0.5, 1.5);
c.colorWarmPct = (int)clampd(c.colorWarmPct, 0, 100);
Expand Down Expand Up @@ -513,9 +517,10 @@ std::string DefaultIniText() {
"; (DPI+accel) then scales by this (1.0=exact match); also scales locked-game panning.\n"
"; No effect in a free transform session (the pointer itself drives the view)\n"
"cursorSensitivity=1.0\n"
"; cursorSmoothing: light inertia on the pan (0=off, higher=smoother+laggier; 0.4 shipped: light);\n"
"; no effect in a free transform session\n"
"cursorSmoothing=0.4\n"
"; panGlideMaxPx: when a mouse movement stops the pointer eases on at most this many screen\n"
"; px before it rests (Transform engine, zoomed); 0=off. panGlideMs: the ease's time (ms)\n"
"panGlideMaxPx=0\n"
"panGlideMs=60\n"
"; cursorConstantSize: 0=the cursor grows with the zoom (default); 1=keep it at normal\n"
"; desktop size at every zoom (render engine only). Replaces cursorScaleWithZoom (ignored).\n"
"cursorConstantSize=0\n"
Expand Down
8 changes: 6 additions & 2 deletions src/config.h
Original file line number Diff line number Diff line change
Expand Up @@ -377,8 +377,12 @@ struct Config {
// is then scaled by this (1.0 = exact match, the default); it also scales the raw-input pan while
// a game has the cursor locked (relative-mouse mode).
double cursorSensitivity = 1.0;
double cursorSmoothing = 0.4; // light inertia on the pan: 0 = off, higher = smoother/laggier
// (0.4 shipped: light smoothing, less lag than 0.8)
// Pan glide (issue #430, src/pan_glide.h): when a mouse movement stops, the pointer eases on and
// comes to rest instead of halting dead. panGlideMaxPx caps how far, in SCREEN px at any zoom
// (0 = off, the default); panGlideMs is the ease's time constant (a fast hand eases more steeply
// to stay inside the cap). Hot. Replaces cursorSmoothing (ignored).
int panGlideMaxPx = 0;
int panGlideMs = 60;
// 0 (default) = the render engine's cursor grows with the zoom, matching the transform
// engine (DWM magnifies its sprite); 1 = opt-in constant desktop-size pointer. Replaces
// cursorScaleWithZoom (issue #253), which is IGNORED: the default template wrote it as an
Expand Down
62 changes: 9 additions & 53 deletions src/cursor_mapper.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -2,80 +2,36 @@
#include "transform.h"
#include <cmath>
namespace wind {
CursorMapper::CursorMapper(int screenW, int screenH, double smoothing, int tickHz)
: sw_(screenW), sh_(screenH), smoothing_(smoothing),
cx_(screenW / 2.0), cy_(screenH / 2.0), tx_(screenW / 2.0), ty_(screenH / 2.0) {
setTickRate(tickHz);
}

void CursorMapper::setTickRate(int tickHz) {
if (tickHz <= 0) tickHz = 144;
// The easing recurrence keeps a fraction (1 - alpha) of the gap each tick. The configured
// smoothing is that keep-fraction at the 144Hz baseline; keeping the SAME real-time decay at
// another tick rate means keep_hz = smoothing^(144/hz) (issue #223) - otherwise the felt
// inertia stretched by hz/144 (0.4 at 60Hz lagged like ~0.68 at 144Hz).
double keep = 0.0;
if (smoothing_ > 0.0) {
double s = smoothing_ > 0.95 ? 0.95 : smoothing_;
keep = std::pow(s, 144.0 / (double)tickHz);
}
nominalDtMs_ = 1000.0 / (double)tickHz;
alpha_ = 1.0 - keep;
if (alpha_ > 1.0) alpha_ = 1.0;
if (alpha_ < 0.05) alpha_ = 0.05; // never fully stall (keep responsiveness)
}
CursorMapper::CursorMapper(int screenW, int screenH)
: sw_(screenW), sh_(screenH), cx_(screenW / 2.0), cy_(screenH / 2.0) {}

void CursorMapper::reset(double centerX, double centerY) {
cx_ = tx_ = centerX; cy_ = ty_ = centerY;
cx_ = centerX; cy_ = centerY;
}

MapResult CursorMapper::update(int dx, int dy, double level) {
if (level < 1.0) level = 1.0;
// Apply the caller-resolved pixel delta at *desktop* speed (not divided by zoom): the focus
// reaches things at the same hand-speed whether at 2x or 8x, matching Windows Magnifier.
tx_ += dx;
ty_ += dy;
if (tx_ < 0) tx_ = 0; else if (tx_ > sw_) tx_ = sw_;
if (ty_ < 0) ty_ = 0; else if (ty_ > sh_) ty_ = sh_;
cx_ += dx;
cy_ += dy;
if (cx_ < 0) cx_ = 0; else if (cx_ > sw_) cx_ = sw_;
if (cy_ < 0) cy_ = 0; else if (cy_ > sh_) cy_ = sh_;
// Pan wall (issue #148): keep the SOURCE left edge at or under maxSrcX_ by bounding the
// center. Bounds the eased center too: during a zoom ramp at the right edge the wall
// moves inward with the level, and the rendered center must follow it the same tick.
// center. During a zoom ramp at the right edge the wall moves inward with the level, and the
// center follows it the same tick.
if (maxSrcX_ >= 0.0) {
double centerMax = maxSrcX_ + (sw_ / level) / 2.0;
if (centerMax > sw_) centerMax = sw_;
if (tx_ > centerMax) tx_ = centerMax;
if (cx_ > centerMax) cx_ = centerMax;
}
// Y wall (issue #191): identical shape - the 16-bit wrap is per-axis and Y was unguarded.
if (maxSrcY_ >= 0.0) {
double centerMaxY = maxSrcY_ + (sh_ / level) / 2.0;
if (centerMaxY > sh_) centerMaxY = sh_;
if (ty_ > centerMaxY) ty_ = centerMaxY;
if (cy_ > centerMaxY) cy_ = centerMaxY;
}

// Light inertia: ease the rendered center toward the target. Smooths jerk and the uneven
// per-frame delta steps; alpha = 1 means no smoothing (snaps to target).
//
// TIME-BASED, NOT TICK-BASED (2026-08-27). alpha_ is derived from a NOMINAL tick rate, which is
// right only while ticks are evenly spaced. On a VRR display they are not: the transform model
// paces on DwmFlush, and with G-Sync following a 73fps game the composite interval swings
// 6.9 -> 13.4 -> 25ms. A fixed per-tick keep-fraction then means the decay per unit TIME
// changes every tick, so a steady hand produces an unsteady lens - felt as judder that no
// amount of write-cadence tuning can remove. Re-deriving the keep-fraction from the MEASURED
// interval makes the inertia identical in real time whatever the refresh is doing.
double a = alpha_;
if (dtMs_ > 0.0 && smoothing_ > 0.0 && nominalDtMs_ > 0.0) {
const double keepNominal = 1.0 - alpha_;
if (keepNominal > 0.0) {
a = 1.0 - std::pow(keepNominal, dtMs_ / nominalDtMs_);
if (a > 1.0) a = 1.0;
if (a < 0.05) a = 0.05; // same floor as setTickRate: never fully stall
}
}
cx_ += (tx_ - cx_) * a;
cy_ += (ty_ - cy_) * a;

OffsetF o = ComputeOffsetF(cx_, cy_, level, sw_, sh_);
MapResult r;
r.srcLeft = o.x; r.srcTop = o.y;
Expand Down
27 changes: 6 additions & 21 deletions src/cursor_mapper.h
Original file line number Diff line number Diff line change
Expand Up @@ -16,16 +16,10 @@ struct MapResult {
// click through the transparent overlay lands exactly there.
class CursorMapper {
public:
// smoothing 0..~0.95: light inertia on the lens. 0 = none (rendered center snaps to the
// accumulated target); higher = the center eases toward the target over several frames,
// smoothing jerk and the uneven per-frame delta steps (costs a little lag).
// tickHz: the loop's tick rate. The smoothing value is defined at the 144Hz baseline (the
// rig it was tuned on, issue #223); the per-tick easing factor is re-derived so the felt
// inertia (the exponential's real-time constant) is the same at any refresh rate.
CursorMapper(int screenW, int screenH, double smoothing = 0.0, int tickHz = 144);
// Re-derive the easing factor for a new tick rate (monitor retarget / topology change),
// keeping the configured smoothing and all position state.
void setTickRate(int tickHz);
// The lens centre follows its target exactly. (The old cursorSmoothing eased it toward the
// target over several frames: lag, never momentum, so it was removed in #430. Momentum after a
// flick is the pan glide, src/pan_glide.h.)
CursorMapper(int screenW, int screenH);
void reset(double centerX, double centerY); // pin both target + rendered center
// Pan wall (issue #148): upper bound for the source rect's LEFT edge (desktop px), or a
// negative value for no bound. Transform GAME sessions set 32000/level each tick: the
Expand All @@ -41,20 +35,11 @@ class CursorMapper {
// dx/dy: the pixel delta to apply to the lens center this tick (already resolved by the
// caller - the OS cursor's own motion when free, or scaled raw input when a game locks it).
MapResult update(int dx, int dy, double level);
// Elapsed time since the previous update, ms. The easing below decays per UNIT TIME, so an
// uneven tick interval no longer changes the felt inertia. 0 = "assume the nominal tick rate"
// (what a fixed-refresh display gives, and what every unit test uses).
void setTickDeltaMs(double ms) { dtMs_ = ms > 0.0 ? ms : 0.0; }
double centerX() const { return cx_; } // rendered (smoothed) center
double centerX() const { return cx_; }
double centerY() const { return cy_; }
private:
int sw_, sh_;
double smoothing_; // configured smoothing (144Hz-baseline semantics)
double alpha_; // per-tick easing factor derived from smoothing_ + tick rate, clamped
double nominalDtMs_ = 1000.0 / 144.0; // the tick interval alpha_ was derived for
double dtMs_ = 0.0; // measured interval for THIS tick (0 = use the nominal)
double cx_, cy_; // rendered center (eased)
double tx_, ty_; // target center (delta-accumulated)
double cx_, cy_; // lens centre (delta-accumulated, clamped)
double maxSrcX_ = -1.0; // pan wall: max source-left (desktop px); <0 = unbounded
double maxSrcY_ = -1.0; // pan wall, Y axis (issue #191); <0 = unbounded
};
Expand Down
Loading