diff --git a/docs/architecture/07-cursor.md b/docs/architecture/07-cursor.md index 572b19e3..e34096d7 100644 --- a/docs/architecture/07-cursor.md +++ b/docs/architecture/07-cursor.md @@ -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 | @@ -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 @@ -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 diff --git a/docs/architecture/08-config-profiles.md b/docs/architecture/08-config-profiles.md index 4ee26b9d..9e0bf3ca 100644 --- a/docs/architecture/08-config-profiles.md +++ b/docs/architecture/08-config-profiles.md @@ -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 | diff --git a/src/config.cpp b/src/config.cpp index 7f523917..0442eef2 100644 --- a/src/config.cpp +++ b/src/config.cpp @@ -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; @@ -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); @@ -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" diff --git a/src/config.h b/src/config.h index c64c987f..b393c8fb 100644 --- a/src/config.h +++ b/src/config.h @@ -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 diff --git a/src/cursor_mapper.cpp b/src/cursor_mapper.cpp index 7ff3436d..b2fab58e 100644 --- a/src/cursor_mapper.cpp +++ b/src/cursor_mapper.cpp @@ -2,80 +2,36 @@ #include "transform.h" #include 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; diff --git a/src/cursor_mapper.h b/src/cursor_mapper.h index 2bbe35c3..c37b09cd 100644 --- a/src/cursor_mapper.h +++ b/src/cursor_mapper.h @@ -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 @@ -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 }; diff --git a/src/main.cpp b/src/main.cpp index 82a157d4..ff3a6dac 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -46,6 +46,7 @@ #include "cursor_decode.h" // edge mode measures the cursor body #include "focus_track.h" // tracking: caret/focus watcher thread #include "tray_host.h" // WindTray.exe owns the icon and menu (#291) +#include "pan_glide.h" // momentum after a flick (#430) #include "gain_learner.h" // learned pointer ballistics: locked pan at TRUE desktop speed #include "tray_ipc.h" // the status block shared with WindTray.exe #include "pointer_binds.h" // kWindInjectTag: tag our own injected clicks (#285) @@ -351,6 +352,7 @@ struct TickState { double lockedPanRemY = 0.0; bool renderStale = false; // the render overlay refused a retarget (multi-GPU) and still sits // on an old monitor: sessions use the transform engine + wind::PanGlide glide; // momentum after a flick (#430, src/pan_glide.h) GainLearner gainLearner; // free ticks teach it the real in->out ratio; // locked ticks replay it (gain_learner.h) bool inspectGame = false; // game-inspect (issue #144): foreground stolen from a mouselook @@ -423,7 +425,7 @@ struct TickState { TickState(IMagnifierModel* mdl, const MonitorTarget& m, const Config& c) : model(mdl), mon(m), cfg(c), zoom(1.0, c.maxLevel), - mapper(m.w, m.h, c.cursorSmoothing) {} + mapper(m.w, m.h) {} }; static TickState* g_tick = nullptr; @@ -1158,7 +1160,7 @@ static void RunTick(TickState& t) { t.zoom = ZoomController(1.0, nc.maxLevel); t.zoom.setLevel(keepLevel); double ocx = t.mapper.centerX(), ocy = t.mapper.centerY(); // preserve position - t.mapper = CursorMapper(t.mon.w, t.mon.h, nc.cursorSmoothing, t.hz); + t.mapper = CursorMapper(t.mon.w, t.mon.h); t.mapper.reset(ocx, ocy); } // core-relevant change guard (StripUiOnlyKeys) } @@ -1476,7 +1478,7 @@ static void RunTick(TickState& t) { int nhz = DetectRefreshHz(nt.device); // pace off the new monitor's refresh (#74) if (nhz > 0) t.hz = nhz; // Everything tuned in ticks follows the new tick rate (issue #223). - t.mapper = CursorMapper(nt.w, nt.h, t.cfg.cursorSmoothing, t.hz); + t.mapper = CursorMapper(nt.w, nt.h); t.detector.setTickRate(t.hz); } } @@ -1522,7 +1524,7 @@ static void RunTick(TickState& t) { if (curHz > 0 && curHz != t.hz) { wind::Log(wind::LogLevel::Info, "tick", "refresh rate %dHz -> %dHz", t.hz, curHz); t.hz = curHz; - t.mapper = CursorMapper(t.mon.w, t.mon.h, t.cfg.cursorSmoothing, t.hz); + t.mapper = CursorMapper(t.mon.w, t.mon.h); t.detector.setTickRate(t.hz); } } @@ -1844,8 +1846,8 @@ static void RunTick(TickState& t) { // // Pinning the mapper to the real cursor each tick reproduces native's formula exactly (the // mapper already clamps the source rect the same way) and leaves nothing to feed back. - // cursorSensitivity and cursorSmoothing do NOT apply here by construction: the pointer IS - // the input, so there is no delta to scale and no target to ease toward. + // cursorSensitivity does NOT apply here by construction: the pointer IS the input, so there + // is no delta to scale. // // Gated OFF wherever the OS cursor is not the truth: // - Inspect mode freezes the pointer and pans from raw mickeys, so reading it would pin @@ -1856,18 +1858,40 @@ static void RunTick(TickState& t) { dynamic_cast(t.model) != nullptr; // (A shell input panel needs nothing special: DWM draws its pointer above the panels and // keeps it centred, so the hand moves the pointer directly.) + // PAN GLIDE (issue #430, src/pan_glide.h): a soft stop, a few screen px at most. The pointer + // itself eases on and the view follows by DWM centring. curDx/curDy is this tick's HAND motion: during a + // glide the previous tick ended with the baseline on Wind's own step, so any motion here is + // the hand, which (like a button or leaving a plain zoomed desktop pan) ends the glide. + { + const bool btn = ((GetAsyncKeyState(VK_LBUTTON) | GetAsyncKeyState(VK_RBUTTON) | + GetAsyncKeyState(VK_MBUTTON)) & 0x8000) != 0; + const bool glideOk = t.cfg.panGlideMaxPx > 0 && freeCursor && lvl > 1.001 && !fsCover && + !t.viewDetached && !btn; + if (t.glide.active && (!glideOk || curDx != 0 || curDy != 0)) wind::PanGlideCancel(t.glide); + if (!t.glide.active) + wind::PanGlideObserve(t.glide, curDx, curDy, dt, cur.x, cur.y, t.cfg.panGlideMs / 1000.0, + t.cfg.panGlideMaxPx / lvl, glideOk); // the cap is in screen px + double gx = 0.0, gy = 0.0; + if (t.glide.active && wind::PanGlideStep(t.glide, dt, gx, gy)) { + POINT np{ (LONG)std::lround(gx), (LONG)std::lround(gy) }; + const LONG l = t.mon.x, tp = t.mon.y, r = t.mon.x + t.mon.w - 1, b = t.mon.y + t.mon.h - 1; + if (np.x < l || np.x > r || np.y < tp || np.y > b) { // reached the edge: stop there + np.x = (std::min)((std::max)(np.x, l), r); + np.y = (std::min)((std::max)(np.y, tp), b); + wind::PanGlideCancel(t.glide); + } + if (np.x != cur.x || np.y != cur.y) { + SetCursorPos(np.x, np.y); + cur = np; // the tick's baseline ends here, so the next tick reads hand motion only + } + } + } if (freeCursor) { POINT cp; if (GetCursorPos(&cp)) { t.mapper.reset(double(cp.x - t.mon.x), double(cp.y - t.mon.y)); } } - // Feed the MEASURED tick interval so the lens easing decays per unit time, not per tick. - // The transform model paces on DwmFlush, so on a VRR display this interval swings with - // whatever the game is doing (6.9 -> 13.4 -> 25ms with G-Sync following a 73fps game) and a - // fixed per-tick keep-fraction turns a steady hand into an unsteady lens. Clamped: after a - // real stall we want the lens to catch up, but a 500ms gap should not snap it. - t.mapper.setTickDeltaMs(dt > 0.05 ? 50.0 : dt * 1000.0); MapResult r = t.mapper.update(freeCursor ? 0 : dx, freeCursor ? 0 : dy, lvl); // --- Tracking (issue #276): caret / focus own the view; the pointer is never moved. --- // fsCover (read once above, see the "Foreground facts for this tick" comment) is the @@ -3292,10 +3316,9 @@ int WINAPI wWinMain(HINSTANCE hInst, HINSTANCE, PWSTR, int) { // Paces the idle/1x loop and the vsync=0 path; while zoomed, DwmFlush/vsync pace instead. ts.hz = DetectRefreshHz(); if (ts.hz <= 0) ts.hz = 60; // query failed at startup: assume the safe common case - // Everything tuned in ticks (lock-detector streaks/windows, cursor smoothing inertia) - // derives from the detected rate too, so a tick stays the same real-time span (issue #223). + // Everything tuned in ticks (lock-detector streaks/windows) derives from the detected rate + // too, so a tick stays the same real-time span (issue #223). ts.detector.setTickRate(ts.hz); - ts.mapper.setTickRate(ts.hz); int pacedHz = ts.hz; // hz the timer interval below is computed for LARGE_INTEGER due; due.QuadPart = -(10000000LL / pacedHz); long long timerDeadlineQ = 0; // absolute QPC target of the next timer-paced tick (0 = unset) diff --git a/src/pan_glide.h b/src/pan_glide.h new file mode 100644 index 00000000..f6379735 --- /dev/null +++ b/src/pan_glide.h @@ -0,0 +1,81 @@ +#pragma once +// Pan glide (issue #430) - PURE, no , tests/test_pan_glide.cpp. +// +// A soft stop: when the hand stops a mouse movement, the POINTER eases on in the same direction for +// a few pixels instead of halting dead (exponential decay, like the zoom's release glide). It starts +// at exactly the hand's speed and only slows down, and it never travels further than a cap set in +// SCREEN pixels (field 2026-10-10: uncapped momentum could throw the pointer across the screen; what +// is wanted is an ease, "some pixels"). A faster hand eases out more steeply inside the same cap. +// The view follows by DWM centring exactly as it follows the hand, so nothing jumps when the hand +// takes over again, and every click lands where the pointer is seen. +// +// The caller feeds one tick of HAND motion at a time (never Wind's own glide steps), and cancels an +// active glide the moment the hand moves, a button is pressed, or the session stops being a plain +// zoomed desktop pan (game, Inspect, a detached view, 1x). +#include +namespace wind { + +struct PanGlide { + double vx = 0.0, vy = 0.0; // hand velocity estimate (desktop px/s), only while moving + bool movedLast = false; // the previous tick had hand motion + bool active = false; // a glide is running + double t = 0.0; // seconds since it started + double ox = 0.0, oy = 0.0; // where the hand stopped + double v0x = 0.0, v0y = 0.0; // velocity it started with + double tau = 0.0; // decay time constant (s) +}; + +inline constexpr double kGlideVelTauS = 0.03; // velocity estimate: the last ~30 ms of motion +inline constexpr double kGlideMinStartSpeed = 40.0; // px/s: below this the hand was resting anyway +inline constexpr double kGlideStopDist = 0.5; // desktop px left to travel: the glide is over + +// One tick of hand motion (dx, dy desktop px over dt s), with the pointer now at (curX, curY). +// Starts a glide (returns true) on the first still tick after motion when the session allows it. +// tauS is the ease's time constant; maxDist (desktop px, > 0) caps how far it travels: a hand fast +// enough to exceed it gets a shorter time constant instead, so the start speed is always the hand's. +// A still tick clears the estimate, so every movement is measured fresh. +inline bool PanGlideObserve(PanGlide& g, double dx, double dy, double dt, double curX, double curY, + double tauS, double maxDist, bool eligible) { + if (dt <= 0.0) dt = 1.0 / 144.0; + const bool moved = dx != 0.0 || dy != 0.0; + if (moved) { + const double k = 1.0 - std::exp(-dt / kGlideVelTauS); + g.vx += (dx / dt - g.vx) * k; + g.vy += (dy / dt - g.vy) * k; + } + bool started = false; + if (!moved && g.movedLast && eligible && tauS > 0.0 && maxDist > 0.0 && + std::hypot(g.vx, g.vy) >= kGlideMinStartSpeed) { + g.active = true; + g.t = 0.0; + g.ox = curX; g.oy = curY; + g.v0x = g.vx; g.v0y = g.vy; + const double speed = std::hypot(g.vx, g.vy); + g.tau = (maxDist > 0.0 && speed * tauS > maxDist) ? maxDist / speed : tauS; + started = true; + } + if (!moved) { g.vx = 0.0; g.vy = 0.0; } + g.movedLast = moved; + return started; +} + +// Advance a running glide by dt: (x, y) is where the pointer belongs now. Ends the glide once less +// than kGlideStopDist is left to travel (the position returned on that tick is still valid). +inline bool PanGlideStep(PanGlide& g, double dt, double& x, double& y) { + if (!g.active) return false; + if (dt <= 0.0) dt = 1.0 / 144.0; + g.t += dt; + const double decay = std::exp(-g.t / g.tau); + x = g.ox + g.v0x * g.tau * (1.0 - decay); + y = g.oy + g.v0y * g.tau * (1.0 - decay); + if (std::hypot(g.v0x, g.v0y) * g.tau * decay < kGlideStopDist) g.active = false; + return true; +} + +inline void PanGlideCancel(PanGlide& g) { + g.active = false; + g.vx = g.vy = 0.0; + g.movedLast = false; +} + +} // namespace wind diff --git a/src/tray_app/flyout_model.h b/src/tray_app/flyout_model.h index e622c326..4814024e 100644 --- a/src/tray_app/flyout_model.h +++ b/src/tray_app/flyout_model.h @@ -187,7 +187,7 @@ inline Hit HitTest(const Geometry& g, int x, int y) { // ---------------------------------------------------------------- items -enum class ValueFmt { Percent, TimesInt, Times2, Dec2, Millis }; +enum class ValueFmt { Percent, TimesInt, Times2, Millis, Pixels }; struct SliderSpec { const char* key; @@ -207,7 +207,7 @@ inline const SliderSpec* SliderSpecs(int* count) { { "zoomInSpeed", L"Zoom-in speed", 0.25, 4, 1, ValueFmt::Times2, "zin", 0.05 }, { "zoomOutSpeed", L"Zoom-out speed", 0.25, 4, 1, ValueFmt::Times2, "zout", 0.05 }, { "panSpeed", L"Arrow key speed", 0.25, 4, 1, ValueFmt::Times2, "pan", 0.05 }, - { "cursorSmoothing",L"Pan smoothing", 0, 0.95, 0.4, ValueFmt::Dec2, "smooth", 0.05 }, + { "panGlideMaxPx", L"Pan glide", 0, 200, 0, ValueFmt::Pixels, "smooth", 5 }, { "zoomEaseOutMs", L"Release glide", 0, 300, 45, ValueFmt::Millis, "glide", 5 }, }; if (count) *count = (int)(sizeof(k) / sizeof(k[0])); @@ -272,8 +272,8 @@ inline std::wstring FormatSliderValue(const SliderSpec& s, double v) { case ValueFmt::Percent: std::snprintf(b, sizeof b, "%d%%", (int)std::lround(v)); break; case ValueFmt::TimesInt: std::snprintf(b, sizeof b, "%dx", (int)std::lround(v)); break; case ValueFmt::Times2: std::snprintf(b, sizeof b, "%.2fx", v); break; - case ValueFmt::Dec2: std::snprintf(b, sizeof b, "%.2f", v); break; case ValueFmt::Millis: std::snprintf(b, sizeof b, "%d ms", (int)std::lround(v)); break; + case ValueFmt::Pixels: std::snprintf(b, sizeof b, "%d px", (int)std::lround(v)); break; } return Widen(b); } @@ -468,7 +468,7 @@ inline double StepSlider(const SliderSpec& s, double cur, int dir, bool big) { inline std::string FormatIniValue(const SliderSpec& s, double v) { char b[32]; switch (s.fmt) { - case ValueFmt::Percent: case ValueFmt::TimesInt: case ValueFmt::Millis: + case ValueFmt::Percent: case ValueFmt::TimesInt: case ValueFmt::Millis: case ValueFmt::Pixels: std::snprintf(b, sizeof b, "%d", (int)std::lround(v)); break; default: std::snprintf(b, sizeof b, "%.2f", v); break; } diff --git a/src/tray_items.cpp b/src/tray_items.cpp index ca591b43..1efa3368 100644 --- a/src/tray_items.cpp +++ b/src/tray_items.cpp @@ -3,7 +3,7 @@ namespace wind { const std::vector& EligibleSliders() { static const std::vector k = {"colorWarmPct", "colorDimPct", "maxLevel", "zoomInSpeed", - "zoomOutSpeed", "panSpeed", "cursorSmoothing", "zoomEaseOutMs"}; + "zoomOutSpeed", "panSpeed", "panGlideMaxPx", "zoomEaseOutMs"}; return k; } const std::vector& EligibleToggles() { diff --git a/src/version.h b/src/version.h index 50963081..cc32ed7b 100644 --- a/src/version.h +++ b/src/version.h @@ -3,8 +3,8 @@ #pragma once #define WIND_VER_MAJOR 0 -#define WIND_VER_MINOR 25 +#define WIND_VER_MINOR 26 #define WIND_VER_PATCH 0 // String form for logs/snapshot/UI. Keep in sync with the numeric parts above. -#define WIND_VERSION_STR "0.25.0" +#define WIND_VERSION_STR "0.26.0" diff --git a/tests/test_config.cpp b/tests/test_config.cpp index da17f351..f944bb3f 100644 --- a/tests/test_config.cpp +++ b/tests/test_config.cpp @@ -57,7 +57,8 @@ TEST_CASE("renderer knobs have sane defaults") { CHECK(c.cursorConstantSize == 0); // cursor grows with the zoom, like transform (issue #253) CHECK(c.bilinear == 1); CHECK(c.sharpness == doctest::Approx(0.0)); // off by default - CHECK(c.cursorSmoothing == doctest::Approx(0.4)); + CHECK(c.panGlideMaxPx == 0); + CHECK(c.panGlideMs == 60); CHECK(c.zorderBand == 0); // shipped unbanded (issue #162): band 16 sat UNDER the // Snipping Tool overlay, which cost the cursor entirely CHECK(c.brightness == doctest::Approx(1.0)); @@ -139,9 +140,9 @@ TEST_CASE("hdrTonemap can be disabled") { Config c = ParseConfig("hdrTonemap=0\n"); CHECK(c.hdrTonemap == 0); } -TEST_CASE("parses cursorSmoothing, z-order band, brightness") { - Config c = ParseConfig("cursorSmoothing=0.7\nzorderBand=16\nbrightness=0.85\n"); - CHECK(c.cursorSmoothing == doctest::Approx(0.7)); +TEST_CASE("parses panGlideMs, z-order band, brightness; the old cursorSmoothing is ignored") { + Config c = ParseConfig("panGlideMs=150\ncursorSmoothing=0.7\nzorderBand=16\nbrightness=0.85\n"); + CHECK(c.panGlideMs == 150); CHECK(c.zorderBand == 16); CHECK(c.brightness == doctest::Approx(0.85)); } @@ -177,13 +178,14 @@ TEST_CASE("numeric fields are clamped to documented ranges") { CHECK(ParseConfig("smoothZoomAccel=0\n").smoothZoomAccel == doctest::Approx(1.0)); CHECK(ParseConfig("smoothZoomRamp=-1\n").smoothZoomRamp == doctest::Approx(0.1)); CHECK(ParseConfig("cursorSensitivity=10\n").cursorSensitivity == doctest::Approx(4.0)); - CHECK(ParseConfig("cursorSmoothing=5\n").cursorSmoothing == doctest::Approx(0.95)); - CHECK(ParseConfig("cursorSmoothing=-1\n").cursorSmoothing == doctest::Approx(0.0)); + CHECK(ParseConfig("panGlideMs=5000\n").panGlideMs == 300); + CHECK(ParseConfig("panGlideMs=-1\n").panGlideMs == 10); + CHECK(ParseConfig("panGlideMaxPx=9999\n").panGlideMaxPx == 400); CHECK(ParseConfig("sharpness=9\n").sharpness == doctest::Approx(1.0)); CHECK(ParseConfig("brightness=0\n").brightness == doctest::Approx(0.5)); // In-range values pass through untouched. CHECK(ParseConfig("maxLevel=8\n").maxLevel == doctest::Approx(8.0)); - CHECK(ParseConfig("cursorSmoothing=0.4\n").cursorSmoothing == doctest::Approx(0.4)); + CHECK(ParseConfig("panGlideMs=150\n").panGlideMs == 150); } TEST_CASE("multiMonitor can be set") { CHECK(ParseConfig("multiMonitor=0\n").multiMonitor == 0); @@ -458,7 +460,8 @@ TEST_CASE("the first-run ini template parses to the struct defaults (issue #274) CHECK(t.dwmFlush == d.dwmFlush); CHECK(t.diagnostics == d.diagnostics); CHECK(t.cursorSensitivity == doctest::Approx(d.cursorSensitivity)); - CHECK(t.cursorSmoothing == doctest::Approx(d.cursorSmoothing)); + CHECK(t.panGlideMs == d.panGlideMs); + CHECK(t.panGlideMaxPx == d.panGlideMaxPx); CHECK(t.cursorConstantSize == d.cursorConstantSize); CHECK(t.cursorVisibility == d.cursorVisibility); CHECK(t.bilinear == d.bilinear); diff --git a/tests/test_cursor_mapper.cpp b/tests/test_cursor_mapper.cpp index 94d520d2..8156c56c 100644 --- a/tests/test_cursor_mapper.cpp +++ b/tests/test_cursor_mapper.cpp @@ -59,37 +59,6 @@ TEST_CASE("update integrates the pixel delta directly; zoom level does NOT chang CHECK(b.centerX() == doctest::Approx(1000.0)); } -TEST_CASE("smoothing eases the rendered center toward the target (light inertia)") { - CursorMapper m(1920, 1080, 0.5); // alpha = 0.5 - m.reset(960, 540); - m.update(40, 0, 2.0); // target 1000; rendered = 960 + (1000-960)*0.5 = 980 - CHECK(m.centerX() == doctest::Approx(980.0)); - m.update(0, 0, 2.0); // target still 1000; rendered = 980 + (1000-980)*0.5 = 990 - CHECK(m.centerX() == doctest::Approx(990.0)); -} - -TEST_CASE("smoothing 0 snaps instantly (no inertia)") { - CursorMapper m(1920, 1080, 0.0); - m.reset(960, 540); - m.update(40, 0, 2.0); - CHECK(m.centerX() == doctest::Approx(1000.0)); // straight to target -} - -TEST_CASE("smoothing clamps: max smoothing still advances ~5%/tick (never fully stalls)") { - // alpha = 1 - smoothing, floored at 0.05 so the lens always makes progress. - CursorMapper m(1920, 1080, 1.0); // smoothing 1.0 -> alpha clamped to 0.05 - m.reset(960, 540); - m.update(40, 0, 2.0); // 960 + (1000-960)*0.05 = 962 - CHECK(m.centerX() == doctest::Approx(962.0)); -} - -TEST_CASE("smoothing out-of-range (negative) clamps to snap (alpha 1.0)") { - CursorMapper m(1920, 1080, -1.0); // 1 - (-1) = 2 -> alpha clamped to 1.0 - m.reset(960, 540); - m.update(40, 0, 2.0); - CHECK(m.centerX() == doctest::Approx(1000.0)); // snaps to target -} - TEST_CASE("reset overrides the accumulated center") { CursorMapper m(1920, 1080); m.reset(100, 100); @@ -98,40 +67,3 @@ TEST_CASE("reset overrides the accumulated center") { CHECK(m.centerX() == doctest::Approx(800.0)); CHECK(m.centerY() == doctest::Approx(400.0)); } - -// --- time-based easing under a variable refresh rate ------------------------------------ -// On a VRR display the tick interval is not fixed (the transform model paces on DwmFlush, and -// G-Sync follows the game), so a per-TICK keep-fraction changes the real-time decay every tick -// and a steady hand produces an unsteady lens. The easing must decay per unit TIME. -TEST_CASE("easing decays by elapsed time, not by tick count") { - const double nominal = 1000.0 / 144.0; - - // One tick of 2x the nominal interval must land where TWO nominal ticks land: with - // smoothing 0.5 the pair goes 550 -> 575 -> 587.5, and the single double-length tick keeps - // 0.5^2 = 0.25 of the gap, landing on 587.5 directly. - CursorMapper twoTicks(1000, 1000, 0.5, 144); - twoTicks.reset(500.0, 500.0); - twoTicks.update(100, 0, 1.0); // move the target - twoTicks.setTickDeltaMs(nominal); - twoTicks.update(0, 0, 1.0); // two nominal ticks... - twoTicks.setTickDeltaMs(nominal); - twoTicks.update(0, 0, 1.0); - const double after2 = twoTicks.centerX(); - - CursorMapper oneLong(1000, 1000, 0.5, 144); - oneLong.reset(500.0, 500.0); - oneLong.update(100, 0, 1.0); - oneLong.setTickDeltaMs(nominal * 2.0); - const double afterLong = oneLong.update(0, 0, 1.0).centerX; - - CHECK(afterLong == doctest::Approx(after2).epsilon(0.02)); -} - -TEST_CASE("a zero delta falls back to the nominal tick rate") { - CursorMapper a(1000, 1000, 0.5, 144), b(1000, 1000, 0.5, 144); - a.reset(500.0, 500.0); b.reset(500.0, 500.0); - a.update(100, 0, 1.0); b.update(100, 0, 1.0); - a.setTickDeltaMs(0.0); // "unknown" - behave exactly as before - b.setTickDeltaMs(1000.0 / 144.0); - CHECK(a.update(0, 0, 1.0).centerX == doctest::Approx(b.update(0, 0, 1.0).centerX)); -} diff --git a/tests/test_flyout.cpp b/tests/test_flyout.cpp index 22e1117a..a54b9985 100644 --- a/tests/test_flyout.cpp +++ b/tests/test_flyout.cpp @@ -107,7 +107,7 @@ TEST_CASE("slider value text") { CHECK(fmt("maxLevel", 12) == L"12x"); CHECK(fmt("zoomInSpeed", 1) == L"1.00x"); CHECK(fmt("panSpeed", 0.25) == L"0.25x"); - CHECK(fmt("cursorSmoothing", 0.4) == L"0.40"); + CHECK(fmt("panGlideMaxPx", 40) == L"40 px"); CHECK(fmt("zoomEaseOutMs", 45) == L"45 ms"); } diff --git a/tests/test_flyout_interact.cpp b/tests/test_flyout_interact.cpp index fef80439..af6f35cf 100644 --- a/tests/test_flyout_interact.cpp +++ b/tests/test_flyout_interact.cpp @@ -83,12 +83,12 @@ TEST_CASE("slider value formatting for the ini: whole numbers or two decimals") CHECK(FormatIniValue(*FindSliderSpec("zoomEaseOutMs"), 45.0) == "45"); CHECK(FormatIniValue(*FindSliderSpec("zoomInSpeed"), 1.05) == "1.05"); CHECK(FormatIniValue(*FindSliderSpec("zoomInSpeed"), 1.0) == "1.00"); - CHECK(FormatIniValue(*FindSliderSpec("cursorSmoothing"), 0.4) == "0.40"); + CHECK(FormatIniValue(*FindSliderSpec("panGlideMaxPx"), 40) == "40"); } TEST_CASE("slider value round trip: what is written is what the flyout reads back and shows") { for (const char* key : { "colorWarmPct", "colorDimPct", "maxLevel", "zoomInSpeed", "zoomOutSpeed", - "panSpeed", "cursorSmoothing", "zoomEaseOutMs" }) { + "panSpeed", "panGlideMaxPx", "zoomEaseOutMs" }) { const SliderSpec& s = *FindSliderSpec(key); for (int i = 0; i <= 20; ++i) { const double v = SnapSlider(s, s.min + (s.max - s.min) * i / 20.0); diff --git a/tests/test_free_cursor.cpp b/tests/test_free_cursor.cpp index daef149c..933cde46 100644 --- a/tests/test_free_cursor.cpp +++ b/tests/test_free_cursor.cpp @@ -38,7 +38,7 @@ static MapResult FreeCursorMap(CursorMapper& m, double curX, double curY, double } TEST_CASE("free cursor reproduces native Magnifier's measured source rect at 8x") { - CursorMapper m(3840, 2160, 0.0); + CursorMapper m(3840, 2160); for (const NativeSample& s : kNative8x) { MapResult r = FreeCursorMap(m, s.curX, s.curY, 8.0); CHECK(std::llround(r.srcLeft) == s.offX); @@ -46,28 +46,12 @@ TEST_CASE("free cursor reproduces native Magnifier's measured source rect at 8x" } } -TEST_CASE("cursorSmoothing cannot introduce lag in free-cursor mode") { - // THE point of the change. The welded model eased the rendered centre toward a delta-integrated - // target, so the view trailed the hand - the "inertia" complaint - and the weld then dragged the - // pointer toward that trailing centre, closing a feedback loop (issue #169, and the wobble). - // reset() pins target AND rendered centre together, so a shipped smoothing of 0.4 is inert here. - CursorMapper eased(3840, 2160, 0.4); - CursorMapper none(3840, 2160, 0.0); - for (const NativeSample& s : kNative8x) { - MapResult a = FreeCursorMap(eased, s.curX, s.curY, 8.0); - MapResult b = FreeCursorMap(none, s.curX, s.curY, 8.0); - CHECK(a.srcLeft == doctest::Approx(b.srcLeft)); - CHECK(a.srcTop == doctest::Approx(b.srcTop)); - CHECK(std::llround(a.srcLeft) == s.offX); - } -} - TEST_CASE("free cursor is stateless: history cannot change where the view lands") { // Native's view position is a pure function of the CURRENT cursor position. Ours must be too, // or the wobble has somewhere to hide. Reaching one point by wildly different routes must give // bit-identical geometry. - CursorMapper direct(3840, 2160, 0.4); - CursorMapper wandered(3840, 2160, 0.4); + CursorMapper direct(3840, 2160); + CursorMapper wandered(3840, 2160); for (int i = 0; i < 200; ++i) { // drag it all over first FreeCursorMap(wandered, (i * 137) % 3840, (i * 91) % 2160, 3.0 + (i % 9)); } @@ -82,7 +66,7 @@ TEST_CASE("free cursor is stateless: history cannot change where the view lands" TEST_CASE("the pointer lands screen-centre except where the view clamps") { // Exactly the behaviour asked for: "centered in the middle of the zoom window following it, // unless it gets to the edges". - CursorMapper m(3840, 2160, 0.0); + CursorMapper m(3840, 2160); static const double kLevels[] = { 2.0, 4.0, 8.0, 20.0 }; for (int li = 0; li < 4; ++li) { const double level = kLevels[li]; @@ -103,7 +87,7 @@ TEST_CASE("free cursor matches native's formula across levels, not just the samp // offset = clamp(cursor - screen/(2*level), 0, screen - screen/level), the fit confirmed at // 92/99 exact against the real magnifier (the misses were samples caught mid-ease). const double W = 3840, H = 2160; - CursorMapper m(3840, 2160, 0.25); + CursorMapper m(3840, 2160); for (double level = 1.5; level <= 20.0; level += 0.37) { for (int cx = 0; cx <= 3840; cx += 137) { for (int cy = 0; cy <= 2160; cy += 331) { @@ -124,7 +108,7 @@ TEST_CASE("free cursor matches native's formula across levels, not just the samp TEST_CASE("the source rect always stays inside the desktop") { // The clamp is load-bearing for more than looks: sampling outside the desktop texture is the // issue #148 driver-reset class. - CursorMapper m(3840, 2160, 0.0); + CursorMapper m(3840, 2160); // Out-of-desktop cursor values included deliberately: a multi-monitor origin offset or a // stale sample can hand us one, and the clamp must survive it. static const int kX[] = { -5000, -1, 0, 1920, 3839, 3840, 9000 }; diff --git a/tests/test_mag_transform.cpp b/tests/test_mag_transform.cpp index 6ce5c734..99d0791e 100644 --- a/tests/test_mag_transform.cpp +++ b/tests/test_mag_transform.cpp @@ -8,7 +8,7 @@ TEST_CASE("CursorMapper pan wall: source left never exceeds the bound (issue #14 // Transform game sessions set maxSourceLeft = 32000/level each tick: the driver resets // when the far-right strip is magnified above ~9.3x. Pan hard right at 12x and assert the // source rect's left edge stays at/under the wall while Y (never overflowing) reaches max. - CursorMapper m(3840, 2160, 0.0); + CursorMapper m(3840, 2160); m.reset(1920.0, 1080.0); const double level = 12.0; m.setMaxSourceLeft(32000.0 / level); @@ -27,7 +27,7 @@ TEST_CASE("CursorMapper pan wall Y: source top never exceeds the bound (issue #1 // The shipped wall was X-only: |srcY*level| overflows the SAME 16-bit plane field, and the // bottom strip above ~16.2x on 2160 was reachable-lethal. Grind toward the bottom-right at // 20x with BOTH walls set and assert both axes hold the driver-safe bound. - CursorMapper m(3840, 2160, 0.0); + CursorMapper m(3840, 2160); m.reset(1920.0, 1080.0); const double level = 20.0; m.setMaxSourceLeft(32000.0 / level); @@ -45,7 +45,7 @@ TEST_CASE("CursorMapper pan wall Y: source top never exceeds the bound (issue #1 } TEST_CASE("CursorMapper pan wall Y: unset by default (desktop/render sessions unrestricted)") { - CursorMapper m(3840, 2160, 0.0); + CursorMapper m(3840, 2160); m.reset(1920.0, 1080.0); MapResult r{}; for (int i = 0; i < 600; ++i) r = m.update(0, 12, 18.0); diff --git a/tests/test_pan_glide.cpp b/tests/test_pan_glide.cpp new file mode 100644 index 00000000..4480cb29 --- /dev/null +++ b/tests/test_pan_glide.cpp @@ -0,0 +1,76 @@ +#include "doctest.h" +#include "../src/pan_glide.h" +#include + +using namespace wind; + +static const double kTick = 1.0 / 144.0; + +// A flick: n ticks at (dxPerTick, dyPerTick), then one still tick. Returns whether a glide started. +static bool Flick(PanGlide& g, int n, double dxPerTick, double dyPerTick, double tau, double cap = 1e9, bool ok = true) { + double x = 1000, y = 1000; + for (int i = 0; i < n; ++i) { x += dxPerTick; y += dyPerTick; PanGlideObserve(g, dxPerTick, dyPerTick, kTick, x, y, tau, cap, ok); } + return PanGlideObserve(g, 0, 0, kTick, x, y, tau, cap, ok); +} + +TEST_CASE("a stop after movement starts a glide in its direction; a resting hand does not (#430)") { + PanGlide fast; + CHECK(Flick(fast, 20, 20.0, 0.0, 0.15)); // 2880 px/s + CHECK(fast.active); + CHECK(fast.v0x > 2000.0); + PanGlide normal; + CHECK(Flick(normal, 20, 1.0, 0.0, 0.15)); // 144 px/s: an ordinary movement eases too + PanGlide creep; + CHECK_FALSE(Flick(creep, 20, 0.2, 0.0, 0.15)); // ~29 px/s: the hand was resting anyway +} + +TEST_CASE("the cap bounds the distance at any speed and keeps the hand's start speed") { + const double ticks[] = { 2.0, 20.0, 80.0 }; + for (double perTick : ticks) { + PanGlide g; + REQUIRE(Flick(g, 20, perTick, 0.0, 0.15, 10.0)); // cap 10 desktop px + const double v0 = g.v0x; + double x = 0, y = 0; + while (PanGlideStep(g, kTick, x, y)) { if (!g.active) break; } + CHECK(x - g.ox <= 10.0 + 1e-6); + CHECK(v0 > perTick * 144.0 * 0.6); // started at (about) the hand's speed + } +} + +TEST_CASE("no glide when it is off or the session does not allow it") { + PanGlide off; + CHECK_FALSE(Flick(off, 20, 20.0, 0.0, 0.0)); // panGlideMs = 0 + PanGlide game; + CHECK_FALSE(Flick(game, 20, 20.0, 0.0, 0.15, 1e9, false)); + PanGlide nocap; + CHECK_FALSE(Flick(nocap, 20, 20.0, 0.0, 0.15, 0.0)); // panGlideMaxPx = 0 +} + +TEST_CASE("the glide decelerates smoothly, travels about v*tau, and ends") { + PanGlide g; + REQUIRE(Flick(g, 20, 20.0, 10.0, 0.15)); + double x = 0, y = 0, px = g.ox, py = g.oy, prevStep = 1e9; + int ticks = 0; + while (PanGlideStep(g, kTick, x, y) && ticks < 1000) { + const double step = std::hypot(x - px, y - py); + CHECK(step <= prevStep + 1e-9); // never speeds up + CHECK((x - px) >= 0.0); CHECK((y - py) >= 0.0); // keeps its direction + prevStep = step; px = x; py = y; ++ticks; + if (!g.active) break; + } + CHECK_FALSE(g.active); + const double travelled = std::hypot(x - g.ox, y - g.oy); + const double expected = std::hypot(g.v0x, g.v0y) * 0.15; + CHECK(travelled > expected * 0.9); + CHECK(travelled <= expected + 1e-6); +} + +TEST_CASE("cancel stops a glide and forgets the flick") { + PanGlide g; + REQUIRE(Flick(g, 20, 20.0, 0.0, 0.15)); + PanGlideCancel(g); + CHECK_FALSE(g.active); + double x, y; + CHECK_FALSE(PanGlideStep(g, kTick, x, y)); + CHECK_FALSE(PanGlideObserve(g, 0, 0, kTick, 0, 0, 0.15, 1e9, true)); // no stale motion restarts it +} diff --git a/tests/test_tray_items.cpp b/tests/test_tray_items.cpp index b67f3196..15985570 100644 --- a/tests/test_tray_items.cpp +++ b/tests/test_tray_items.cpp @@ -28,7 +28,7 @@ TEST_CASE("tray layout: an explicit trayPerf=0 and an empty toggle list still hi TEST_CASE("eligible lists match the owner's choice") { CHECK(EligibleSliders() == V{"colorWarmPct", "colorDimPct", "maxLevel", "zoomInSpeed", - "zoomOutSpeed", "panSpeed", "cursorSmoothing", "zoomEaseOutMs"}); + "zoomOutSpeed", "panSpeed", "panGlideMaxPx", "zoomEaseOutMs"}); CHECK(EligibleToggles() == V{"trackCaret", "trackFocus", "keepEdges", "engine"}); } @@ -36,7 +36,7 @@ TEST_CASE("tray layout round trip keeps order and enabled state") { TrayLayout l = ParseTrayLayout({}); // Move panSpeed to the front, enable it, disable warmth; enable two toggles in a custom order. l.sliders = {{"panSpeed", true}, {"colorDimPct", true}, {"colorWarmPct", false}, {"maxLevel", true}, - {"zoomInSpeed", false}, {"zoomOutSpeed", false}, {"cursorSmoothing", false}, + {"zoomInSpeed", false}, {"zoomOutSpeed", false}, {"panGlideMaxPx", false}, {"zoomEaseOutMs", false}}; l.toggles = {{"keepEdges", true}, {"trackCaret", false}, {"trackFocus", true}, {"engine", false}}; l.perf = true; @@ -81,7 +81,7 @@ TEST_CASE("unknown keys are dropped and missing eligible items appended off") { TrayLayout l = ParseTrayLayout(v); CHECK(Keys(l.sliders, false) == V{"zoomInSpeed", "panSpeed", "colorWarmPct", "colorDimPct", "maxLevel", "zoomOutSpeed", - "cursorSmoothing", "zoomEaseOutMs"}); + "panGlideMaxPx", "zoomEaseOutMs"}); CHECK(Keys(l.sliders, true) == V{"panSpeed", "colorWarmPct"}); CHECK(Keys(l.toggles, false) == V{"trackFocus", "trackCaret", "keepEdges", "engine"}); CHECK(Keys(l.toggles, true) == V{"trackFocus"}); diff --git a/ui/src/settings-schema.js b/ui/src/settings-schema.js index a316835b..26151cb5 100644 --- a/ui/src/settings-schema.js +++ b/ui/src/settings-schema.js @@ -102,7 +102,8 @@ export const groups = [ { caption: 'Speed', rows: [ { key: 'panSpeed', type: 'slider', label: 'Arrow key speed', keywords: ['pan', 'pan speed', 'arrows', 'move', 'scroll', 'keyboard', 'how fast', 'velocity', 'shift view', 'rate'], desc: 'How fast the arrow keys move the view.', min: 0.25, max: 4, step: 0.05, def: 1.0, unit: 'times' }, { key: 'cursorSensitivity', type: 'slider', label: 'Mouse speed', keywords: ['sensitivity', 'pointer speed', 'cursor speed', 'dpi', 'pan', 'follow', 'tracking speed', 'how fast', 'velocity', 'mouse sensitivity', 'rate'], desc: 'How fast the view moves with the mouse in the Render engine, in Inspect mode and in mouse-locked games. With Transform the view otherwise follows the pointer directly.', min: 0.25, max: 4, step: 0.05, def: 1.0, unit: 'times' }, - { key: 'cursorSmoothing', type: 'slider', label: 'Pan smoothing', keywords: ['inertia', 'smooth', 'smoothing', 'glide', 'momentum', 'lag', 'delay', 'damping', 'gentle', 'filter', 'mouse'], desc: 'Gentle inertia when the view pans in the Render engine, in Inspect mode and in mouse-locked games. With Transform the view otherwise follows the pointer directly.', min: 0, max: 0.95, step: 0.05, def: 0.4 }, + { key: 'panGlideMaxPx', type: 'slider', label: 'Pan glide', tag: 'Experimental', keywords: ['inertia', 'momentum', 'glide', 'coast', 'ease', 'ease out', 'soft stop', 'stop', 'slide', 'smooth', 'smoothing', 'mouse', 'pan', 'drift', 'deceleration'], desc: 'When you stop moving the mouse, the pointer eases on this far before it rests instead of stopping dead. Transform engine.', min: 0, max: 200, step: 5, def: 0, unit: 'px' }, + { key: 'panGlideMs', type: 'slider', adv: true, label: 'Pan glide ease', keywords: ['inertia', 'glide', 'ease', 'softness', 'time', 'duration', 'milliseconds', 'smooth', 'deceleration'], desc: 'How gently the pan glide slows down. A fast movement eases more steeply to stay within the distance.', min: 10, max: 300, step: 10, def: 60, unit: 'ms', showIf: { key: 'panGlideMaxPx', ne: '0' } }, ] }, { caption: 'Pointer', rows: [ { key: 'mouseAlign', type: 'select', label: 'Pointer position', keywords: ['mouse position', 'mouse', 'cursor', 'pointer', 'where', 'centred', 'centered', 'center', 'centre', 'middle', 'edge', 'edges', 'within the edges', 'align', 'alignment', 'lock to center', 'follow mode', 'placement'], desc: 'Where the mouse pointer sits while the view moves.', options: ['0', '1'], optionLabels: { '0': 'Centred', '1': 'Within the edges' }, def: '0' }, diff --git a/ui/src/tray/trayModel.js b/ui/src/tray/trayModel.js index 5be91999..d5a9925e 100644 --- a/ui/src/tray/trayModel.js +++ b/ui/src/tray/trayModel.js @@ -13,7 +13,7 @@ export const SLIDERS = [ { key: 'zoomInSpeed', icon: 'zin', name: 'Zoom-in speed', desc: 'How quickly the view magnifies.' }, { key: 'zoomOutSpeed', icon: 'zout', name: 'Zoom-out speed', desc: 'How quickly the view returns.' }, { key: 'panSpeed', icon: 'pan', name: 'Arrow key speed', desc: 'How fast the view pans.' }, - { key: 'cursorSmoothing', icon: 'smooth', name: 'Pan smoothing', desc: 'Gentle inertia while panning.' }, + { key: 'panGlideMaxPx', icon: 'smooth', name: 'Pan glide', desc: 'How far the pointer eases on when you stop.' }, { key: 'zoomEaseOutMs', icon: 'glide', name: 'Release glide', desc: 'How softly the zoom coasts to a stop.' }, ]; export const TOGGLES = [ diff --git a/ui/tests/schema.spec.js b/ui/tests/schema.spec.js index 04a1b95a..a23d195d 100644 --- a/ui/tests/schema.spec.js +++ b/ui/tests/schema.spec.js @@ -10,7 +10,7 @@ const OLD_KEYS = [ '__zoomIn', '__zoomOut', '__zoomWheel', '__pan', // was four rows (Pan left/right/up/down); their ini keys live in OLD_BIND_KEYS 'noSwallowApps', 'maxLevel', 'zoomInSpeed', 'zoomOutSpeed', 'panSpeed', 'smoothZoomAccel', 'smoothZoomRamp', 'zoomEaseOutMs', 'txSamplingMode', '__hideCursor', '__cursorLock', 'lockApps', - 'cursorSensitivity', 'cursorSmoothing', 'trackCaret', 'trackFocus', 'trackAlign', 'mouseAlign', + 'cursorSensitivity', 'panGlideMaxPx', 'panGlideMs', 'trackCaret', 'trackFocus', 'trackAlign', 'mouseAlign', 'mouseMarginPct', 'colorWarmPct', 'colorDimPct', 'model', 'engineGame', 'engineAcrylic', 'engineDesktop', 'engineOther', 'renderExclude', 'diagnostics', '__about', ]; @@ -57,7 +57,7 @@ test('a captioned section has two or more rows, and the advanced rows are the on if (base.length) expect(base.length, g.id + ' / ' + c.caption + ' (advanced off)').toBeGreaterThanOrEqual(2); } expect(allRows.filter((r) => r.adv).map((r) => r.key).sort()).toEqual( - ['engineAcrylic', 'engineDesktop', 'engineGame', 'engineOther', 'lockApps', 'model', 'noSwallowApps', 'smoothZoomAccel', 'smoothZoomRamp'].sort()); + ['engineAcrylic', 'engineDesktop', 'engineGame', 'engineOther', 'lockApps', 'model', 'noSwallowApps', 'panGlideMs', 'smoothZoomAccel', 'smoothZoomRamp'].sort()); // Troubleshooting is never advanced, and the extra keys carry their switch key. for (const k of ['diagnostics', '__diagnostics', '__openIni']) expect(allRows.find((r) => r.key === k).adv).toBeUndefined(); expect(Object.fromEntries(allRows.filter((r) => r.onKey).map((r) => [r.key, r.onKey]))).toEqual( diff --git a/ui/tests/search.spec.js b/ui/tests/search.spec.js index b42e995f..21b53a36 100644 --- a/ui/tests/search.spec.js +++ b/ui/tests/search.spec.js @@ -98,7 +98,7 @@ test('typo tolerance: distance 1 for 4-7 letters, 2 for 8+, prefixes, substrings expect(keys('brigthness')[0]).toBe('colorDimPct'); // 10 letters expect(keys('brihtness')[0]).toBe('colorDimPct'); // dropped letter expect(keys('inertai')).toEqual(expect.arrayContaining(['zoomEaseOutMs'])); - expect(keys('smothing')).toContain('cursorSmoothing'); + expect(keys('smothing')).toContain('panGlideMaxPx'); expect(keys('sensitivty')).toContain('cursorSensitivity'); // keyword, 10 letters, one missing expect(keys('nite light')).toEqual(expect.arrayContaining(['colorWarmPct'])); expect(keys('nite light')[0]).toBe('colorWarmPct'); diff --git a/ui/tests/settings.spec.js b/ui/tests/settings.spec.js index 121dd516..8ea146be 100644 --- a/ui/tests/settings.spec.js +++ b/ui/tests/settings.spec.js @@ -122,7 +122,7 @@ test('Show advanced settings reveals the advanced rows inline, with no marker, a for (const k of ['model', 'engineGame', 'smoothZoomAccel', 'smoothZoomRamp']) await expect(key(page, k)).toHaveCount(0); await go(page, 'view'); await expect(key(page, 'lockApps')).toHaveCount(0); - await expect(key(page, 'cursorSmoothing')).toBeVisible(); // Pan smoothing is a plain row (#423) + await expect(key(page, 'panGlideMaxPx')).toBeVisible(); // Pan glide is a plain row (#430) await expect(key(page, 'mouseAlign')).toBeVisible(); // Troubleshooting is always there; the switch itself is a global key, so it never raises the capsule. await go(page, 'prefs'); diff --git a/ui/tests/tray.spec.js b/ui/tests/tray.spec.js index 6eea9ee8..a679f026 100644 --- a/ui/tests/tray.spec.js +++ b/ui/tests/tray.spec.js @@ -51,7 +51,7 @@ test('model: round trip keeps order and enabled, drops unknown keys, appends mis const v = { traySliders: 'zoomInSpeed,colorWarmPct,bogus', traySliderOrder: 'panSpeed,zoomInSpeed,nope,colorWarmPct', trayToggles: 'keepEdges', trayToggleOrder: 'keepEdges,trackCaret' }; const s = parseList(v, 'sliders'); - expect(s.map((i) => i.key)).toEqual(['panSpeed', 'zoomInSpeed', 'colorWarmPct', 'colorDimPct', 'maxLevel', 'zoomOutSpeed', 'cursorSmoothing', 'zoomEaseOutMs']); + expect(s.map((i) => i.key)).toEqual(['panSpeed', 'zoomInSpeed', 'colorWarmPct', 'colorDimPct', 'maxLevel', 'zoomOutSpeed', 'panGlideMaxPx', 'zoomEaseOutMs']); expect(s.filter((i) => i.on).map((i) => i.key)).toEqual(['zoomInSpeed', 'colorWarmPct']); const w = serialiseList('sliders', s); expect(w.traySliders).toBe('zoomInSpeed,colorWarmPct'); @@ -85,7 +85,7 @@ test('tab: below the sidebar divider, banner, performance card and both lists', await expect(page.getByRole('switch', { name: 'Performance in the tray' })).toBeChecked(); // on by default (#329) await expect(page.locator('#cap-sliders')).toHaveText('Sliders'); await expect(page.locator('#cap-toggles')).toHaveText('Toggles'); - expect(await names(page, 'sliders')).toEqual(['Warmth', 'Brightness', 'Max zoom', 'Zoom-in speed', 'Zoom-out speed', 'Arrow key speed', 'Pan smoothing', 'Release glide']); + expect(await names(page, 'sliders')).toEqual(['Warmth', 'Brightness', 'Max zoom', 'Zoom-in speed', 'Zoom-out speed', 'Arrow key speed', 'Pan glide', 'Release glide']); expect(await names(page, 'toggles')).toEqual(['Follow the text cursor', 'Follow keyboard focus', 'Keep cursor centred', 'Engine']); await expect(page.locator('[data-cnt="sliders"]')).toHaveText('2 of 4'); await expect(page.locator('[data-cnt="toggles"]')).toHaveText('4 on'); // every toggle on by default (#329) @@ -116,7 +116,7 @@ test('check and uncheck write the enabled list and the full order', async ({ pag await chk(page, 'sliders', 'Arrow key speed').click(); let v = await live(page); expect(v.traySliders).toBe('colorWarmPct,colorDimPct,panSpeed'); - expect(v.traySliderOrder).toBe('colorWarmPct,colorDimPct,maxLevel,zoomInSpeed,zoomOutSpeed,panSpeed,cursorSmoothing,zoomEaseOutMs'); + expect(v.traySliderOrder).toBe('colorWarmPct,colorDimPct,maxLevel,zoomInSpeed,zoomOutSpeed,panSpeed,panGlideMaxPx,zoomEaseOutMs'); await expect(page.locator('[data-cnt="sliders"]')).toHaveText('3 of 4'); await chk(page, 'sliders', 'Warmth').click(); v = await live(page);