From 2e97a46ec49ef92fa0c6dbbe1da25a72e36d6844 Mon Sep 17 00:00:00 2001 From: Max <112043822+Maxaubert@users.noreply.github.com> Date: Sat, 10 Oct 2026 09:58:04 +0200 Subject: [PATCH 1/3] fix(transform): no single-frame jumps at high zoom with the high resolution cursor; 0.27.1 While DWM centres, every level write can show one frame off DWM's centre by the fraction of its learned offset times the zoom (10-25 px at 25-50x). Smooth sampling only: - the ladder takes the ramp direction from the requests (LadderDir): it used to snap back below a request it had overtaken, so 19 % of the writes in a slow zoom stepped backwards; - above 12x the level moves in steps of at least 2 % (txRampMinStep=20, txRampMinFrom=12, RampStepHeld); a zoom that stops at the maximum lands on it exactly. Slow 1x-50x zoom: 10-16 jumps per zoom before, none in 8 runs after; fast zoom 0 in 3 runs. Closes #429 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011zPmivSAQeivGUdBjTsaMu --- docs/architecture/05-transform-engine.md | 8 ++++++ src/config.cpp | 5 ++++ src/config.h | 7 +++++ src/transform_model.cpp | 36 ++++++++++++++++++------ src/transform_model.h | 1 + src/version.h | 4 +-- src/zoom_ladder.h | 26 +++++++++++++++++ tests/test_zoom_ladder.cpp | 21 ++++++++++++++ 8 files changed, 97 insertions(+), 11 deletions(-) diff --git a/docs/architecture/05-transform-engine.md b/docs/architecture/05-transform-engine.md index c6f1097..0b5bcf9 100644 --- a/docs/architecture/05-transform-engine.md +++ b/docs/architecture/05-transform-engine.md @@ -249,6 +249,14 @@ DWM magnifies with nearest neighbour unless something calls slow held zoom shows small image jumps (predicted 3.6-4.2 px p95 at 12-20x). Capped, rate-keeping and stricter snaps were all simulated against the trace and the ladder's own model: each traded the shake for uneven speed or lurches, so the ladder stays as it is. +- High-zoom jumps (#429, measured 2026-10-10): while DWM centres, a level write can show one frame + off DWM's centre by the fraction of its learned offset times the zoom (10-25 px at 25-50x). Two + changes, smooth sampling only: the ladder takes the ramp direction from the requests (`LadderDir`; + it used to snap back below a request it had overtaken, 19 % of the writes in a slow zoom), and + above 12x the level moves in steps of at least 2 % (`txRampMinStep=20`, `txRampMinFrom=12`, + `RampStepHeld`); a zoom that stops at the maximum lands on it exactly. Slow 1x-50x zoom: 10-16 + jumps per zoom before, none in 8 runs after. Report: + `Documents\Claude\research\wind\2026-10-10-high-zoom-shake.md`. - No release glide with the high resolution cursor (#427): the ladder could only cut a glide short or let its slow tail cross rounding steps (which shook the image and showed it doubled, closed #426), so `zoomEaseOutMs` is not applied while `txSamplingMode=1`; the zoom stops on release. diff --git a/src/config.cpp b/src/config.cpp index 4f93503..8b06bb8 100644 --- a/src/config.cpp +++ b/src/config.cpp @@ -258,6 +258,8 @@ Config ParseConfig(const std::string& text) { else if (key == "txWarmHz") c.txWarmHz = ParseIntStrict(val); else if (key == "mpoNearestGuard") c.mpoNearestGuard = ParseIntStrict(val); else if (key == "txSmoothLadder") c.txSmoothLadder = ParseIntStrict(val); + else if (key == "txRampMinStep") c.txRampMinStep = ParseIntStrict(val); + else if (key == "txRampMinFrom") c.txRampMinFrom = ParseDoubleStrict(val); else if (key == "mpoGuardTest") c.mpoGuardTest = ParseIntStrict(val); else if (key == "mpoGuard") c.mpoGuard = ParseIntStrict(val); else if (key == "mpoGuardLiftWall") c.mpoGuardLiftWall = ParseIntStrict(val); @@ -297,6 +299,9 @@ Config ParseConfig(const std::string& text) { c.smoothZoomRamp = clampd(c.smoothZoomRamp, 0.1, 3.0); c.cursorSensitivity = clampd(c.cursorSensitivity, 0.25, 4.0); if (c.panGlideMaxPx < 0) c.panGlideMaxPx = 0; + if (c.txRampMinStep < 0) c.txRampMinStep = 0; + if (c.txRampMinStep > 100) c.txRampMinStep = 100; + if (c.txRampMinFrom < 1.0) c.txRampMinFrom = 1.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); diff --git a/src/config.h b/src/config.h index 2758f69..68ce8f8 100644 --- a/src/config.h +++ b/src/config.h @@ -305,6 +305,13 @@ struct Config { // levels where DWM's per-frame scratch rounding predicts under 1 px of movement (the zoom shake: // 11 px p95 -> 0.7 px measured at 10-25x). 0 = off (hot). int txSmoothLadder = 1; + // Smooth high-zoom steps (issue #429, RampStepHeld in src/zoom_ladder.h): with smooth sampling + // and DWM centring, above txRampMinFrom the level only changes in steps of at least + // txRampMinStep per mille. Every level write can show one frame off DWM's centre by up to a + // source pixel times the zoom (10-25 px at 25-50x); measured per slow 1x-50x zoom: 10-16 such + // frames at 0, 1-2 at 10-15, none at 20 or 30 (5 runs each). 0 = off (hot). + int txRampMinStep = 20; + double txRampMinFrom = 12.0; int mpoGuardTest = 0; // diagnostic: apply the MPO guard effect even on an MPO-off boot (hot) int mpoGuard = 1; // diagnostic: 0 = never apply the guard effect (A/B on an MPO boot; hot) int mpoGuardLiftWall = 1; // no pan walls / write clamp / ghost while the session is plane-free (hot) diff --git a/src/transform_model.cpp b/src/transform_model.cpp index d2c18d2..96ca836 100644 --- a/src/transform_model.cpp +++ b/src/transform_model.cpp @@ -517,6 +517,7 @@ void TransformModel::present(const MapResult& r, double level, const Config& cfg // pattern, small continuous ones the cheap one.) double applyLevel = level; const bool rampStopped = (level == lastRequestedLevel_); // the controller stopped requesting new levels + const double prevRequestedLevel = lastRequestedLevel_; lastRequestedLevel_ = level; // txMaxStepPct: rate-limit the APPLIED level change per tick. Each change makes DWM re-scale // its cached surfaces and that cost grows with the level, so an unclamped fast ramp demands @@ -542,7 +543,12 @@ void TransformModel::present(const MapResult& r, double level, const Config& cfg // that never ended, which held the input-transform publish forever (hover dead zones). const double preLadderLevel = applyLevel; if (cfg.txSmoothLadder != 0 && cfg.txSamplingMode == 1 && applyLevel > 1.001) { - if (applyLevel == level && level == ladderReq_ && ladderOut_ > 0.0) { + if (rampStopped && applyLevel == level && level >= cfg.maxLevel - 1e-6) { + // Stopped at the maximum: land on it exactly. A level at rest does not shake, and the + // coarser steps above (RampStepHeld) could otherwise leave a clean level just under it. + ladderReq_ = level; + ladderOut_ = level; + } else if (applyLevel == level && level == ladderReq_ && ladderOut_ > 0.0) { applyLevel = ladderOut_; } else if (lastLevel_ > 1.001 && std::fabs(applyLevel - lastLevel_) <= applyLevel * 1e-9) { // The request IS the level on screen (RunTick stopped the ease-out there): never re-snap @@ -550,7 +556,7 @@ void TransformModel::present(const MapResult& r, double level, const Config& cfg ladderReq_ = level; ladderOut_ = lastLevel_; applyLevel = lastLevel_; - } else if (rampStopped && applyLevel == level && lastLevel_ > 1.001 && + } else if (rampStopped && !rampStepHeld_ && applyLevel == level && lastLevel_ > 1.001 && std::fabs(lastLevel_ - level) <= level * (SnapWindow(level) + 1e-5)) { // The zoom just stopped: keep the level already on screen rather than re-snapping, so // releasing the key never nudges the zoom in or out (field 2026-10-07). @@ -558,15 +564,27 @@ void TransformModel::present(const MapResult& r, double level, const Config& cfg ladderOut_ = lastLevel_; applyLevel = lastLevel_; } else { - const int dir = applyLevel > lastLevel_ ? 1 : (applyLevel < lastLevel_ ? -1 : 0); - // (The slow tail of an ease-out never reaches here: RunTick stops the glide first.) - const double snapped = SnapSmoothLevel(applyLevel, r.centerX, r.centerY, mon_.w, mon_.h, - lastLevel_ > 1.0 ? lastLevel_ : 0.0, dir); - ladderReq_ = level; - ladderOut_ = snapped; - applyLevel = snapped; + // Direction from the requests, not from the level on screen (LadderDir, #429). + const int dir = LadderDir(level, prevRequestedLevel, lastLevel_); + if (LadderHoldsLevel(dir, applyLevel, lastLevel_)) { + ladderReq_ = level; + ladderOut_ = lastLevel_; + applyLevel = lastLevel_; + } else { + // (The slow tail of an ease-out never reaches here: RunTick stops the glide first.) + const double snapped = SnapSmoothLevel(applyLevel, r.centerX, r.centerY, mon_.w, mon_.h, + lastLevel_ > 1.0 ? lastLevel_ : 0.0, dir); + ladderReq_ = level; + ladderOut_ = snapped; + applyLevel = snapped; + } } } + // Smooth high-zoom steps (#429, RampStepHeld): while DWM centres, step the level less often. + // A stopped request after a held step lands exactly, not on the ladder's "keep what is shown". + rampStepHeld_ = cfg.txSamplingMode == 1 && dwmCentreOn_ && + RampStepHeld(applyLevel, lastLevel_, cfg.txRampMinStep / 1000.0, cfg.txRampMinFrom, rampStopped); + if (rampStepHeld_) applyLevel = lastLevel_; double srcL = r.srcLeft, srcT = r.srcTop; if (applyLevel != level) { OffsetF o = ComputeOffsetF(r.centerX, r.centerY, applyLevel, mon_.w, mon_.h); diff --git a/src/transform_model.h b/src/transform_model.h index 60c4521..3915dd7 100644 --- a/src/transform_model.h +++ b/src/transform_model.h @@ -107,6 +107,7 @@ class TransformModel : public IMagnifierModel { int lastOffX_ = 0, lastOffY_ = 0, lastTxX_ = 0, lastTxY_ = 0; // last applied transform double lastLevel_ = 0.0; double lastRequestedLevel_ = 0.0; + bool rampStepHeld_ = false; // the last tick held the level for RampStepHeld (#429) double sessionMaxLevel_ = 0.0; // logged at teardown: scripted-run engagement proof unsigned long long lastChangeMs_ = 0; // when the transform last REALLY changed unsigned long long lastWarmMs_ = 0; // when the last warm pulse CLOSED (issue #246) diff --git a/src/version.h b/src/version.h index 77acf73..1ba9f14 100644 --- a/src/version.h +++ b/src/version.h @@ -4,7 +4,7 @@ #define WIND_VER_MAJOR 0 #define WIND_VER_MINOR 27 -#define WIND_VER_PATCH 0 +#define WIND_VER_PATCH 1 // String form for logs/snapshot/UI. Keep in sync with the numeric parts above. -#define WIND_VERSION_STR "0.27.0" +#define WIND_VERSION_STR "0.27.1" diff --git a/src/zoom_ladder.h b/src/zoom_ladder.h index 17a4fae..91e1800 100644 --- a/src/zoom_ladder.h +++ b/src/zoom_ladder.h @@ -115,6 +115,32 @@ inline double SnapSmoothLevel(double want, double centreX, double centreY, int w return want; } +// RAMP DIRECTION COMES FROM THE REQUESTS (#429, measured 2026-10-10). The ladder may snap a level +// ahead of the request; judged against the level on screen, the next tick's request then looked like +// a zoom the other way and the ladder snapped back below it: 19 % of the writes in a slow 1x-50x zoom +// stepped backwards, and every needless write while DWM centres can show one off-centre frame. +// The direction is the request's own trend; a request still behind the level on screen in that +// direction keeps the level on screen (no write) until it catches up. +inline int LadderDir(double request, double prevRequest, double onScreen) { + if (prevRequest > 1.0 && request != prevRequest) return request > prevRequest ? 1 : -1; + return request > onScreen ? 1 : (request < onScreen ? -1 : 0); +} +inline bool LadderHoldsLevel(int dir, double request, double onScreen) { + if (onScreen <= 1.001) return false; + return (dir > 0 && request <= onScreen) || (dir < 0 && request >= onScreen); +} + +// SMOOTH HIGH-ZOOM STEPS (#429, measured 2026-10-10). While DWM centres, every level write can show +// one frame on Wind's centre instead of DWM's: off by up to a source pixel times the zoom, 10-25 px +// at 25-50x. Fewer writes, fewer such frames: per slow 1x-50x zoom 10-16 of them at every tick, +// 1-2 with 1-1.5 % steps, none with 2 % or 3 % (5 runs each). So above `fromLevel` the level only +// moves once the request is at least `minRel` away from the level on screen; a fast zoom moves more +// than that per tick anyway. A request that has stopped always lands (the zoom reaches its target). +inline bool RampStepHeld(double request, double onScreen, double minRel, double fromLevel, bool stopped) { + if (stopped || minRel <= 0.0 || onScreen <= fromLevel || request == onScreen) return false; + return std::fabs(request - onScreen) < onScreen * minRel; +} + // TRUNCATED EASE-OUT (field 2026-10-07). After a release the user's ease-out runs, snapped to // clean levels like a held zoom; once it moves less per frame than clean levels are apart, the // ladder could only hop or jump, so the zoom stops there on the level on screen. The fast part of diff --git a/tests/test_zoom_ladder.cpp b/tests/test_zoom_ladder.cpp index 1cb49af..3e37c20 100644 --- a/tests/test_zoom_ladder.cpp +++ b/tests/test_zoom_ladder.cpp @@ -83,3 +83,24 @@ TEST_CASE("the ease-out runs while it moves faster than the clean-level spacing, CHECK(EaseOutShouldStop(25.0, 25.0 / 1.004) == true); // 0.4 % at 25x (window 1.2 %) CHECK(EaseOutShouldStop(1.0, 1.0) == false); } + +TEST_CASE("ramp direction comes from the requests, so a snap ahead never steps back (#429)") { + // The ladder showed 16.81 while the request was 16.70 on its way up: still zooming in. + CHECK(LadderDir(16.70, 16.66, 16.81) == 1); + CHECK(LadderHoldsLevel(1, 16.70, 16.81)); // hold 16.81 until the request passes it + CHECK_FALSE(LadderHoldsLevel(1, 16.90, 16.81)); + CHECK(LadderDir(16.70, 16.75, 16.60) == -1); // zooming out + CHECK(LadderHoldsLevel(-1, 16.70, 16.60)); + CHECK(LadderDir(5.0, 1.0, 1.0) == 1); // first tick of a session: judged on screen + CHECK_FALSE(LadderHoldsLevel(1, 1.5, 1.0)); // nothing on screen yet +} + +TEST_CASE("smooth high-zoom steps: small changes wait above the threshold, a stopped request lands (#429)") { + CHECK(RampStepHeld(30.3, 30.0, 0.02, 12.0, false)); // 1 %: wait + CHECK_FALSE(RampStepHeld(30.7, 30.0, 0.02, 12.0, false)); // 2.3 %: step + CHECK(RampStepHeld(29.7, 30.0, 0.02, 12.0, false)); // zooming out too + CHECK_FALSE(RampStepHeld(10.1, 10.0, 0.02, 12.0, false)); // below 12x: every tick + CHECK_FALSE(RampStepHeld(50.0, 49.55, 0.02, 12.0, true)); // the zoom stopped: reach 50 + CHECK_FALSE(RampStepHeld(30.3, 30.0, 0.0, 12.0, false)); // off + CHECK_FALSE(RampStepHeld(30.0, 30.0, 0.02, 12.0, false)); +} From 6e5e7c04e74aa90ab60c453e5b0926ae0f278288 Mon Sep 17 00:00:00 2001 From: Max <112043822+Maxaubert@users.noreply.github.com> Date: Sat, 10 Oct 2026 19:21:46 +0200 Subject: [PATCH 2/3] fix(transform): keep fine zoom steps; the 2 % step hold ships off Field test: 2 % steps made the top of a slow zoom visibly coarse. The remaining jumps are inside DWM's smooth path (about 1 % of level changes, half a source pixel; with or without the nudge, and DWM's decompiled centre formula matches Wind's write exactly), so only fewer level changes remove them. Fine steps win: txRampMinStep defaults to 0. The ladder direction fix stays (jumps 10-16 -> 1-5 per slow 1x-50x zoom), and a zoom that stops at the maximum still lands on it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011zPmivSAQeivGUdBjTsaMu --- docs/architecture/05-transform-engine.md | 17 ++++++++++------- src/config.h | 9 +++++---- src/zoom_ladder.h | 13 +++++++------ 3 files changed, 22 insertions(+), 17 deletions(-) diff --git a/docs/architecture/05-transform-engine.md b/docs/architecture/05-transform-engine.md index 0b5bcf9..2688190 100644 --- a/docs/architecture/05-transform-engine.md +++ b/docs/architecture/05-transform-engine.md @@ -249,13 +249,16 @@ DWM magnifies with nearest neighbour unless something calls slow held zoom shows small image jumps (predicted 3.6-4.2 px p95 at 12-20x). Capped, rate-keeping and stricter snaps were all simulated against the trace and the ladder's own model: each traded the shake for uneven speed or lurches, so the ladder stays as it is. -- High-zoom jumps (#429, measured 2026-10-10): while DWM centres, a level write can show one frame - off DWM's centre by the fraction of its learned offset times the zoom (10-25 px at 25-50x). Two - changes, smooth sampling only: the ladder takes the ramp direction from the requests (`LadderDir`; - it used to snap back below a request it had overtaken, 19 % of the writes in a slow zoom), and - above 12x the level moves in steps of at least 2 % (`txRampMinStep=20`, `txRampMinFrom=12`, - `RampStepHeld`); a zoom that stops at the maximum lands on it exactly. Slow 1x-50x zoom: 10-16 - jumps per zoom before, none in 8 runs after. Report: +- High-zoom jumps (#429, measured 2026-10-10): about 1 % of level changes show one frame half a + source pixel off (0.44-0.50 x the zoom: 10 px at 20x, 20 px at 40x). It is inside DWM's smooth + path: it happens with or without the nudge, and the ladder predicts 0 px for those levels + (DWM's centre-mode formula, decompiled: `(int)((P - (W/z)/2) * z + 0.5)` on the integer cursor + point, is exactly what Wind writes). The ladder used to take the ramp direction from the level + on screen and snapped back below a request it had overtaken (19 % of the writes in a slow zoom); + `LadderDir` takes it from the requests, which cut the jumps from 10-16 to 1-5 per slow 1x-50x + zoom. Changing the level less often removes the rest (`txRampMinStep`, none at 2 % steps), but + 2 % steps made the top of a slow zoom visibly coarse in the field, so it ships off. A zoom that + stops at the maximum lands on it exactly. Report: `Documents\Claude\research\wind\2026-10-10-high-zoom-shake.md`. - No release glide with the high resolution cursor (#427): the ladder could only cut a glide short or let its slow tail cross rounding steps (which shook the image and showed it doubled, closed diff --git a/src/config.h b/src/config.h index 68ce8f8..709a3e5 100644 --- a/src/config.h +++ b/src/config.h @@ -307,10 +307,11 @@ struct Config { int txSmoothLadder = 1; // Smooth high-zoom steps (issue #429, RampStepHeld in src/zoom_ladder.h): with smooth sampling // and DWM centring, above txRampMinFrom the level only changes in steps of at least - // txRampMinStep per mille. Every level write can show one frame off DWM's centre by up to a - // source pixel times the zoom (10-25 px at 25-50x); measured per slow 1x-50x zoom: 10-16 such - // frames at 0, 1-2 at 10-15, none at 20 or 30 (5 runs each). 0 = off (hot). - int txRampMinStep = 20; + // txRampMinStep per mille. About 1 % of level changes show one frame half a source pixel off + // (inside DWM, with or without the nudge), so fewer changes mean fewer jumps: per slow 1x-50x + // zoom about 3-5 at 0, 1-2 at 10-15, none at 20. Field 2026-10-10: 20 made the top of a slow + // zoom move in visibly coarse steps, rejected; fine steps win. 0 = off (DEFAULT, hot). + int txRampMinStep = 0; double txRampMinFrom = 12.0; int mpoGuardTest = 0; // diagnostic: apply the MPO guard effect even on an MPO-off boot (hot) int mpoGuard = 1; // diagnostic: 0 = never apply the guard effect (A/B on an MPO boot; hot) diff --git a/src/zoom_ladder.h b/src/zoom_ladder.h index 91e1800..761b887 100644 --- a/src/zoom_ladder.h +++ b/src/zoom_ladder.h @@ -130,12 +130,13 @@ inline bool LadderHoldsLevel(int dir, double request, double onScreen) { return (dir > 0 && request <= onScreen) || (dir < 0 && request >= onScreen); } -// SMOOTH HIGH-ZOOM STEPS (#429, measured 2026-10-10). While DWM centres, every level write can show -// one frame on Wind's centre instead of DWM's: off by up to a source pixel times the zoom, 10-25 px -// at 25-50x. Fewer writes, fewer such frames: per slow 1x-50x zoom 10-16 of them at every tick, -// 1-2 with 1-1.5 % steps, none with 2 % or 3 % (5 runs each). So above `fromLevel` the level only -// moves once the request is at least `minRel` away from the level on screen; a fast zoom moves more -// than that per tick anyway. A request that has stopped always lands (the zoom reaches its target). +// SMOOTH HIGH-ZOOM STEPS (#429, measured 2026-10-10, OFF by default). About 1 % of level changes +// show one frame half a source pixel off (0.44-0.50 x the zoom: 10 px at 20x, 20 px at 40x), inside +// DWM's smooth path: with or without the nudge, and the ladder predicts 0 px for those levels. Fewer +// changes, fewer such frames: per slow 1x-50x zoom 3-5 at every tick, 1-2 with 1-1.5 % steps, none +// with 2 %; but 2 % steps made the top of a slow zoom visibly coarse, so the default is every tick. +// When on, above `fromLevel` the level only moves once the request is at least `minRel` away from +// the level on screen. A request that has stopped always lands (the zoom reaches its target). inline bool RampStepHeld(double request, double onScreen, double minRel, double fromLevel, bool stopped) { if (stopped || minRel <= 0.0 || onScreen <= fromLevel || request == onScreen) return false; return std::fabs(request - onScreen) < onScreen * minRel; From a75393d02b45d4c083b31eed1a850c877bf0c03c Mon Sep 17 00:00:00 2001 From: Max <112043822+Maxaubert@users.noreply.github.com> Date: Sat, 10 Oct 2026 19:33:29 +0200 Subject: [PATCH 3/3] fix(zoom): cap max zoom at 30x Above 30x the remaining smooth-path jumps reach 15-25 px and the pointer runs off the screen, and they cannot be removed without coarse steps. maxLevel and quickZoomDefault clamp to 30; the settings and tray sliders end at 30x. Slow 1x-30x zoom: 0, 0 and 4 jumps (max 15 px) in 3 runs. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011zPmivSAQeivGUdBjTsaMu --- docs/architecture/05-transform-engine.md | 4 +++- src/config.cpp | 6 ++++-- src/tray_app/flyout_model.h | 2 +- tests/test_config.cpp | 5 +++-- ui/src/settings-schema.js | 2 +- ui/tests/schema.spec.js | 2 +- 6 files changed, 13 insertions(+), 8 deletions(-) diff --git a/docs/architecture/05-transform-engine.md b/docs/architecture/05-transform-engine.md index 2688190..d4fdbb7 100644 --- a/docs/architecture/05-transform-engine.md +++ b/docs/architecture/05-transform-engine.md @@ -258,7 +258,9 @@ DWM magnifies with nearest neighbour unless something calls `LadderDir` takes it from the requests, which cut the jumps from 10-16 to 1-5 per slow 1x-50x zoom. Changing the level less often removes the rest (`txRampMinStep`, none at 2 % steps), but 2 % steps made the top of a slow zoom visibly coarse in the field, so it ships off. A zoom that - stops at the maximum lands on it exactly. Report: + stops at the maximum lands on it exactly. Max zoom is capped at 30x (`maxLevel`, + `quickZoomDefault`): above it the jumps reach 15-25 px and the pointer runs off the screen. + Report: `Documents\Claude\research\wind\2026-10-10-high-zoom-shake.md`. - No release glide with the high resolution cursor (#427): the ladder could only cut a glide short or let its slow tail cross rounding steps (which shook the image and showed it doubled, closed diff --git a/src/config.cpp b/src/config.cpp index 8b06bb8..255a2da 100644 --- a/src/config.cpp +++ b/src/config.cpp @@ -291,7 +291,9 @@ Config ParseConfig(const std::string& text) { // config UI sliders / the struct-comment docs. c.trackMarginPct = (int)clampd(c.trackMarginPct, 0, 40); c.mouseMarginPct = (int)clampd(c.mouseMarginPct, 0, 40); - c.maxLevel = clampd(c.maxLevel, 1.0, 50.0); // must be >= the 1.0 min zoom level + // 30x cap (#429): above it the smooth path's half-texel jumps reach 15-25 px and the pointer + // runs off the screen; must be >= the 1.0 min zoom level. + c.maxLevel = clampd(c.maxLevel, 1.0, 30.0); c.zoomInSpeed = clampd(c.zoomInSpeed, 0.25, 4.0); c.zoomOutSpeed = clampd(c.zoomOutSpeed, 0.25, 4.0); c.panSpeed = clampd(c.panSpeed, 0.25, 4.0); @@ -308,7 +310,7 @@ Config ParseConfig(const std::string& text) { c.colorWarmPct = (int)clampd(c.colorWarmPct, 0, 100); c.colorDimPct = (int)clampd(c.colorDimPct, 1, 100); c.trayPinned = (int)clampd(c.trayPinned, 0, 1); - c.quickZoomDefault = clampd(c.quickZoomDefault, 1.0, 50.0); + c.quickZoomDefault = clampd(c.quickZoomDefault, 1.0, 30.0); if (c.outlineThickness < 1) c.outlineThickness = 1; if (c.outlineThickness > 40) c.outlineThickness = 40; c.outlineLowZoomMax = clampd(c.outlineLowZoomMax, 1.0, 50.0); diff --git a/src/tray_app/flyout_model.h b/src/tray_app/flyout_model.h index 4814024..55d0fb4 100644 --- a/src/tray_app/flyout_model.h +++ b/src/tray_app/flyout_model.h @@ -203,7 +203,7 @@ inline const SliderSpec* SliderSpecs(int* count) { static const SliderSpec k[] = { { "colorWarmPct", L"Warmth", 0, 100, 0, ValueFmt::Percent, "warm", 5 }, { "colorDimPct", L"Brightness", 1, 100, 100, ValueFmt::Percent, "bright", 1 }, - { "maxLevel", L"Max zoom", 2, 50, 12, ValueFmt::TimesInt, "maxz", 1 }, + { "maxLevel", L"Max zoom", 2, 30, 12, ValueFmt::TimesInt, "maxz", 1 }, { "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 }, diff --git a/tests/test_config.cpp b/tests/test_config.cpp index f75c7af..ddc7274 100644 --- a/tests/test_config.cpp +++ b/tests/test_config.cpp @@ -170,7 +170,8 @@ TEST_CASE("numeric fields are clamped to documented ranges") { // maxLevel < 1 would invert ZoomController's clamp and disable zoom; must clamp up to 1.0. CHECK(ParseConfig("maxLevel=0\n").maxLevel == doctest::Approx(1.0)); CHECK(ParseConfig("maxLevel=-5\n").maxLevel == doctest::Approx(1.0)); - CHECK(ParseConfig("maxLevel=999\n").maxLevel == doctest::Approx(50.0)); // capped + CHECK(ParseConfig("maxLevel=999\n").maxLevel == doctest::Approx(30.0)); // capped (#429) + CHECK(ParseConfig("maxLevel=50\n").maxLevel == doctest::Approx(30.0)); // Speeds, accel, ramp, sensitivity, smoothing, sharpness, brightness clamp to their ranges. CHECK(ParseConfig("zoomInSpeed=0\n").zoomInSpeed == doctest::Approx(0.25)); CHECK(ParseConfig("zoomOutSpeed=99\n").zoomOutSpeed == doctest::Approx(4.0)); @@ -257,7 +258,7 @@ TEST_CASE("quick-zoom config parses and clamps") { CHECK(off.quickZoomModifier == "None"); Config hi = ParseConfig("quickZoomDefault=99\n"); - CHECK(hi.quickZoomDefault == doctest::Approx(50.0)); // clamped to max + CHECK(hi.quickZoomDefault == doctest::Approx(30.0)); // clamped to max Config lo = ParseConfig("quickZoomDefault=0.1\n"); CHECK(lo.quickZoomDefault == doctest::Approx(1.0)); // clamped to min } diff --git a/ui/src/settings-schema.js b/ui/src/settings-schema.js index bba1f8c..77bb14a 100644 --- a/ui/src/settings-schema.js +++ b/ui/src/settings-schema.js @@ -63,7 +63,7 @@ export const groups = [ desc: 'How far and how fast to zoom', cards: [ { caption: 'Level and speed', rows: [ - { key: 'maxLevel', type: 'slider', label: 'Max zoom', keywords: ['maximum', 'limit', 'highest', 'magnification', 'level', 'cap', 'zoom level', 'factor', 'times', 'upper'], desc: 'The highest zoom level', min: 2, max: 50, step: 1, def: 12.0, unit: 'times' }, + { key: 'maxLevel', type: 'slider', label: 'Max zoom', keywords: ['maximum', 'limit', 'highest', 'magnification', 'level', 'cap', 'zoom level', 'factor', 'times', 'upper'], desc: 'The highest zoom level', min: 2, max: 30, step: 1, def: 12.0, unit: 'times' }, { key: 'zoomInSpeed', type: 'slider', label: 'Zoom-in speed', keywords: ['faster', 'slower', 'rate', 'magnify speed', 'zoom rate', 'acceleration', 'velocity', 'how fast'], desc: 'How fast the view zooms in', min: 0.25, max: 4, step: 0.05, def: 1.0, unit: 'times' }, { key: 'zoomOutSpeed', type: 'slider', label: 'Zoom-out speed', keywords: ['faster', 'slower', 'rate', 'zoom rate', 'velocity', 'how fast'], desc: 'How fast the view zooms out', min: 0.25, max: 4, step: 0.05, def: 1.0, unit: 'times' }, { key: 'zoomEaseOutMs', type: 'slider', label: 'Release glide', keywords: ['inertia', 'coast', 'momentum', 'ease out', 'easing', 'smooth stop', 'slow down', 'deceleration', 'stop', 'let go', 'release', 'delay', 'milliseconds'], desc: 'How long the zoom coasts after you let go', min: 0, max: 300, step: 5, def: 45, unit: 'ms', diff --git a/ui/tests/schema.spec.js b/ui/tests/schema.spec.js index f2a6603..9e8e5b3 100644 --- a/ui/tests/schema.spec.js +++ b/ui/tests/schema.spec.js @@ -149,7 +149,7 @@ test('hotkeys page: one box per binding, "or" between them, and the old keybind await page.goto('/controls.html?group=zoom'); const sl = ctl(page, 'maxLevel').locator('input[type=range]'); await expect(sl).toHaveAttribute('aria-valuetext', '12 times'); - expect(parseFloat(await css(sl, '--pct'))).toBeCloseTo(20.83, 1); + expect(parseFloat(await css(sl, '--pct'))).toBeCloseTo(35.71, 1); // 12x on the 2-30x range (#429) await sl.fill('30'); await expect(ctl(page, 'maxLevel').locator('.val')).toHaveText('30x'); });