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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions docs/architecture/05-transform-engine.md
Original file line number Diff line number Diff line change
Expand Up @@ -249,6 +249,19 @@ 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): 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. 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
#426), so `zoomEaseOutMs` is not applied while `txSamplingMode=1`; the zoom stops on release.
Expand Down
11 changes: 9 additions & 2 deletions src/config.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -289,21 +291,26 @@ 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);
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);
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);
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);
Expand Down
8 changes: 8 additions & 0 deletions src/config.h
Original file line number Diff line number Diff line change
Expand Up @@ -305,6 +305,14 @@ 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. 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)
int mpoGuardLiftWall = 1; // no pan walls / write clamp / ghost while the session is plane-free (hot)
Expand Down
36 changes: 27 additions & 9 deletions src/transform_model.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -542,31 +543,48 @@ 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
// it, or the zoom jumps to a neighbouring clean level after it stopped (#375).
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).
ladderReq_ = level;
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);
Expand Down
1 change: 1 addition & 0 deletions src/transform_model.h
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion src/tray_app/flyout_model.h
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
Expand Down
4 changes: 2 additions & 2 deletions src/version.h
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

#define WIND_VER_MAJOR 0
#define WIND_VER_MINOR 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"
27 changes: 27 additions & 0 deletions src/zoom_ladder.h
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,33 @@ 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, 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;
}

// 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
Expand Down
5 changes: 3 additions & 2 deletions tests/test_config.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand Down Expand Up @@ -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
}
Expand Down
21 changes: 21 additions & 0 deletions tests/test_zoom_ladder.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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));
}
2 changes: 1 addition & 1 deletion ui/src/settings-schema.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
2 changes: 1 addition & 1 deletion ui/tests/schema.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});
Expand Down
Loading