From f7841fa3e38952c35d4c379131aa522f536dbc7f Mon Sep 17 00:00:00 2001 From: Max <112043822+Maxaubert@users.noreply.github.com> Date: Sat, 10 Oct 2026 20:25:11 +0200 Subject: [PATCH 1/2] feat(zoom): full release glide with the high resolution cursor; 0.28.0 The glide was cut short (#375) and then turned off (#427) because its slow tail shook and showed the image doubled. That was the ladder stepping back below a request it had overtaken, fixed in #438. Measured 2026-10-10 (speed 2.7, 115 ms): the glide runs 490-550 ms instead of 240-260 ms, no backward step, no jump, pointer steady (max 5 px). EaseOutShouldStop is gone and the Release glide setting is live again with the high resolution cursor. Closes #439 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011zPmivSAQeivGUdBjTsaMu --- docs/architecture/05-transform-engine.md | 10 ++++++--- src/main.cpp | 28 +++++------------------- src/transform_model.cpp | 1 - src/version.h | 6 ++--- src/zoom_ladder.h | 10 --------- tests/test_zoom_ladder.cpp | 7 ------ ui/src/settings-schema.js | 3 +-- ui/tests/settings.spec.js | 9 +++----- 8 files changed, 19 insertions(+), 55 deletions(-) diff --git a/docs/architecture/05-transform-engine.md b/docs/architecture/05-transform-engine.md index d4fdbb7..586dd3d 100644 --- a/docs/architecture/05-transform-engine.md +++ b/docs/architecture/05-transform-engine.md @@ -262,9 +262,13 @@ DWM magnifies with nearest neighbour unless something calls `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. +- Release glide at both sampling modes (#439, measured 2026-10-10). The high resolution cursor + had its glide cut short (#375) and then turned off (#427) because the slow tail shook and showed + the image doubled; that was the ladder stepping back below a request it had overtaken (#429, + `LadderDir`). With that fixed the full glide (zoomInSpeed 2.7, 115 ms) runs 480-550 ms instead + of 240-260 ms with no backward step and the pointer steady (max 5 px). Capping the snap at the + glide's own motion per frame (`stepRel`) eases perfectly but shook 11-13 px, so the tail keeps + the ladder and lands in 0.5-0.9 % hops above ~10x. - Nearest while the level moves and smooth at rest was tried: steady, but the switch from pixel to smooth is plainly visible, so it was rejected (2026-10-07). The older "swap shifted the image 1-2 px" verdict predates the working setter and is void. diff --git a/src/main.cpp b/src/main.cpp index 8a4c88e..49e2bbe 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -31,7 +31,6 @@ #include "transform_model.h" #include "native_cursor.h" // WantDwmCentring, LockApplies (issue #369) #include "mpo_guard.h" // WantMpoGuard, GuardedColorMatrix (issue #369) -#include "zoom_ladder.h" // EaseOutShouldStop (issue #369) #include "hitch_record.h" // hitch recorder (#361) #include "tick_span.h" #include "input_router.h" @@ -1295,10 +1294,11 @@ static void RunTick(TickState& t) { // root-caused elsewhere - issue #148 - so the user's configured speed applies everywhere.) t.zoom.setProfile(t.cfg.zoomInSpeed, t.cfg.zoomOutSpeed, t.cfg.smoothZoom != 0, t.cfg.smoothZoomAccel, t.cfg.smoothZoomRamp); - // No release glide with the high resolution cursor (#427): its ladder can only cut a glide short - // (EaseOutShouldStop) or let the slow tail cross DWM's rounding steps, which shook the image and - // showed it doubled (field 2026-10-10, closed #426). The zoom stops on release instead. - t.zoom.setEaseOut(t.cfg.txSamplingMode == 1 ? 0.0 : t.cfg.zoomEaseOutMs / 1000.0); + // The release glide runs in full at both sampling modes (#439). With the high resolution cursor it + // was cut short (#375) and then turned off (#427): its slow tail shook and showed the image doubled. + // That was the ladder stepping back below a request it had overtaken (#429, LadderDir); measured + // 2026-10-10, the full glide runs twice as long with the pointer steady and no backward step. + t.zoom.setEaseOut(t.cfg.zoomEaseOutMs / 1000.0); // Quick-zoom trigger. Modifier mode (quickZoomHotkeyMode==0): hold the configured modifier // (Ctrl/Alt/Shift; "None" = off) and tap a zoom key. While the modifier is held it toggles quick // zoom (below) instead of hold-zooming, so suppress the hold-zoom direction (the toggle snaps the @@ -1321,24 +1321,6 @@ static void RunTick(TickState& t) { // freeze holds it at ~1.01 until the hold expires. const bool quiesceFreeze = QuiesceHoldActive(t) && t.zoom.level() > 1.0; if (!quiesceFreeze) t.zoom.tick(dt < kMaxZoomDt ? dt : kMaxZoomDt); - // SMOOTH-ZOOM RELEASE (issue #369, src/zoom_ladder.h EaseOutShouldStop). With smooth sampling and - // the zoom ladder, a slow zoom must cross DWM's whole-pixel rounding steps, each an image jump of - // about the level in px; snapping the slow tail hopped, holding it froze then caught up (field). - // So after a release the user's ease-out runs (snapped like a held zoom) until it moves less per - // frame than clean levels are apart, then the zoom stops on the level on screen. - { - auto& zs = g_input.state(); - const bool held = zs.inHeld.load() || zs.outHeld.load() || g_input.anyBoundKeyPressed(); - // AFTER the controller ticked: before it, level() still equals last frame's level, so the - // "still moving" test never fired and the ease-out always ran its slow tail (#375). - if (!held && !t.zoom.hasTarget() && t.cfg.txSamplingMode == 1 && t.cfg.txSmoothLadder != 0 && - t.zoom.level() != t.prevLvl && wind::EaseOutShouldStop(t.zoom.level(), t.prevLvl)) { - if (auto* tmStop = dynamic_cast(t.model)) { - const double shown = tmStop->writtenLevel(); - if (shown > 1.001) { t.zoom.setLevel(shown); t.zoom.stopGlide(); } - } - } - } // Recenter on a recenterVk key press (rising edge). bool recenter = false; bool recenterDown = comboHeld(t.cfg.recenterVk, t.cfg.recenterMods); // mods since #307 diff --git a/src/transform_model.cpp b/src/transform_model.cpp index 96ca836..d5f3ead 100644 --- a/src/transform_model.cpp +++ b/src/transform_model.cpp @@ -571,7 +571,6 @@ void TransformModel::present(const MapResult& r, double level, const Config& cfg 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; diff --git a/src/version.h b/src/version.h index 1ba9f14..b5bf7e5 100644 --- a/src/version.h +++ b/src/version.h @@ -3,8 +3,8 @@ #pragma once #define WIND_VER_MAJOR 0 -#define WIND_VER_MINOR 27 -#define WIND_VER_PATCH 1 +#define WIND_VER_MINOR 28 +#define WIND_VER_PATCH 0 // String form for logs/snapshot/UI. Keep in sync with the numeric parts above. -#define WIND_VERSION_STR "0.27.1" +#define WIND_VERSION_STR "0.28.0" diff --git a/src/zoom_ladder.h b/src/zoom_ladder.h index 761b887..c05131e 100644 --- a/src/zoom_ladder.h +++ b/src/zoom_ladder.h @@ -142,14 +142,4 @@ inline bool RampStepHeld(double request, double onScreen, double minRel, double 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 -// the glide (nearly all of it) survives; only the barely-moving tail is cut. -inline bool EaseOutShouldStop(double level, double prevLevel) { - if (level <= 1.001 || prevLevel <= 1.001) return false; - const double step = std::fabs(level - prevLevel) / level; - return step < 0.6 * SnapWindow(level); -} - } // namespace wind diff --git a/tests/test_zoom_ladder.cpp b/tests/test_zoom_ladder.cpp index 3e37c20..9242a37 100644 --- a/tests/test_zoom_ladder.cpp +++ b/tests/test_zoom_ladder.cpp @@ -77,13 +77,6 @@ TEST_CASE("a slow (easing) zoom is barely snapped: the snap never exceeds the fr } } -TEST_CASE("the ease-out runs while it moves faster than the clean-level spacing, then stops") { - CHECK(EaseOutShouldStop(5.0, 5.0 / 1.02) == false); // 2 % per frame: still gliding - CHECK(EaseOutShouldStop(5.0, 5.0 / 1.001) == true); // 0.1 % per frame: the tail, stop - 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); diff --git a/ui/src/settings-schema.js b/ui/src/settings-schema.js index 77bb14a..3481d54 100644 --- a/ui/src/settings-schema.js +++ b/ui/src/settings-schema.js @@ -66,8 +66,7 @@ export const groups = [ { 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', - offIf: { key: 'txSamplingMode', eq: '1' } }, + { 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' }, ] }, { caption: 'Easing', rows: [ // Smooth zoom is always on (core default 1); its shape sliders are advanced. diff --git a/ui/tests/settings.spec.js b/ui/tests/settings.spec.js index 969afdc..a2dac0a 100644 --- a/ui/tests/settings.spec.js +++ b/ui/tests/settings.spec.js @@ -185,17 +185,14 @@ test('View: the edge margin shows only when the pointer is kept within the edges await expect(key(page, 'mouseMarginPct')).toBeVisible(); }); -test('High resolution cursor is tagged Experimental and turns the release glide off (#427)', async ({ page }) => { +test('High resolution cursor is tagged Experimental and keeps the release glide (#439)', async ({ page }) => { await page.goto('/'); await go(page, 'view'); await expect(key(page, 'txSamplingMode').locator('.tag')).toHaveText('Experimental'); - await go(page, 'zoom'); - await expect(key(page, 'zoomEaseOutMs')).not.toHaveClass(/disabled/); - await go(page, 'view'); await key(page, 'txSamplingMode').getByRole('switch').check({ force: true }); await go(page, 'zoom'); - await expect(key(page, 'zoomEaseOutMs')).toHaveClass(/disabled/); - await expect(key(page, 'zoomEaseOutMs').locator('input[type=range]')).toBeDisabled(); + await expect(key(page, 'zoomEaseOutMs')).not.toHaveClass(/disabled/); + await expect(key(page, 'zoomEaseOutMs').locator('input[type=range]')).toBeEnabled(); }); test('Typing and focus: caret on, focus off by default; a toggle writes at once', async ({ page }) => { From 7c100dc2d14d695bc2e46167626cf5f5360b95c7 Mon Sep 17 00:00:00 2001 From: Max <112043822+Maxaubert@users.noreply.github.com> Date: Sat, 10 Oct 2026 20:45:00 +0200 Subject: [PATCH 2/2] fix(zoom): high resolution glide stops just before its slow tail Field: the full tail lands in ladder hops, which read as shake. The glide now stops on the level on screen once it moves less per frame than clean levels are apart (EaseOutShouldStop, restored): 240-260 ms of glide at speed 2.7 / 115 ms, no backward step, pointer steady. 0.27.1 had none. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011zPmivSAQeivGUdBjTsaMu --- docs/architecture/05-transform-engine.md | 13 ++++++----- src/main.cpp | 28 ++++++++++++++++++++---- src/transform_model.cpp | 1 + src/zoom_ladder.h | 10 +++++++++ tests/test_zoom_ladder.cpp | 7 ++++++ 5 files changed, 49 insertions(+), 10 deletions(-) diff --git a/docs/architecture/05-transform-engine.md b/docs/architecture/05-transform-engine.md index 586dd3d..9904d26 100644 --- a/docs/architecture/05-transform-engine.md +++ b/docs/architecture/05-transform-engine.md @@ -263,12 +263,13 @@ DWM magnifies with nearest neighbour unless something calls Report: `Documents\Claude\research\wind\2026-10-10-high-zoom-shake.md`. - Release glide at both sampling modes (#439, measured 2026-10-10). The high resolution cursor - had its glide cut short (#375) and then turned off (#427) because the slow tail shook and showed - the image doubled; that was the ladder stepping back below a request it had overtaken (#429, - `LadderDir`). With that fixed the full glide (zoomInSpeed 2.7, 115 ms) runs 480-550 ms instead - of 240-260 ms with no backward step and the pointer steady (max 5 px). Capping the snap at the - glide's own motion per frame (`stepRel`) eases perfectly but shook 11-13 px, so the tail keeps - the ladder and lands in 0.5-0.9 % hops above ~10x. + had its glide turned off (#427) because the slow tail shook and showed the image doubled; the + doubling was the ladder stepping back below a request it had overtaken (#429, `LadderDir`). The + glide is back and stops just before its slow tail (`EaseOutShouldStop`, on the level on screen): + 240-260 ms at zoomInSpeed 2.7 / 115 ms, no backward step, pointer steady. The full tail runs + 480-550 ms but lands in 0.5-0.9 % ladder hops, which the field read as shake; snapping it to + the exact glide level (`stepRel` capped at the glide's own motion) eases perfectly but shook + 11-13 px. Both rejected. - Nearest while the level moves and smooth at rest was tried: steady, but the switch from pixel to smooth is plainly visible, so it was rejected (2026-10-07). The older "swap shifted the image 1-2 px" verdict predates the working setter and is void. diff --git a/src/main.cpp b/src/main.cpp index 49e2bbe..90a0067 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -31,6 +31,7 @@ #include "transform_model.h" #include "native_cursor.h" // WantDwmCentring, LockApplies (issue #369) #include "mpo_guard.h" // WantMpoGuard, GuardedColorMatrix (issue #369) +#include "zoom_ladder.h" // EaseOutShouldStop (issue #369) #include "hitch_record.h" // hitch recorder (#361) #include "tick_span.h" #include "input_router.h" @@ -1294,10 +1295,11 @@ static void RunTick(TickState& t) { // root-caused elsewhere - issue #148 - so the user's configured speed applies everywhere.) t.zoom.setProfile(t.cfg.zoomInSpeed, t.cfg.zoomOutSpeed, t.cfg.smoothZoom != 0, t.cfg.smoothZoomAccel, t.cfg.smoothZoomRamp); - // The release glide runs in full at both sampling modes (#439). With the high resolution cursor it - // was cut short (#375) and then turned off (#427): its slow tail shook and showed the image doubled. - // That was the ladder stepping back below a request it had overtaken (#429, LadderDir); measured - // 2026-10-10, the full glide runs twice as long with the pointer steady and no backward step. + // The release glide runs at both sampling modes (#439). With the high resolution cursor it stops + // just before its slow tail (EaseOutShouldStop below): the full tail lands in ladder hops, which + // the field reads as shake (2026-10-10). Turned off in #427 because the tail also showed the image + // doubled; that was the ladder stepping back below a request it had overtaken (#429, LadderDir). + // Measured with the cut (speed 2.7, 115 ms): 240-260 ms of glide, no backward step, pointer steady. t.zoom.setEaseOut(t.cfg.zoomEaseOutMs / 1000.0); // Quick-zoom trigger. Modifier mode (quickZoomHotkeyMode==0): hold the configured modifier // (Ctrl/Alt/Shift; "None" = off) and tap a zoom key. While the modifier is held it toggles quick @@ -1321,6 +1323,24 @@ static void RunTick(TickState& t) { // freeze holds it at ~1.01 until the hold expires. const bool quiesceFreeze = QuiesceHoldActive(t) && t.zoom.level() > 1.0; if (!quiesceFreeze) t.zoom.tick(dt < kMaxZoomDt ? dt : kMaxZoomDt); + // SMOOTH-ZOOM RELEASE (issue #369, src/zoom_ladder.h EaseOutShouldStop). With smooth sampling and + // the zoom ladder, a slow zoom must cross DWM's whole-pixel rounding steps, each an image jump of + // about the level in px; snapping the slow tail hopped, holding it froze then caught up (field). + // So after a release the user's ease-out runs (snapped like a held zoom) until it moves less per + // frame than clean levels are apart, then the zoom stops on the level on screen. + { + auto& zs = g_input.state(); + const bool held = zs.inHeld.load() || zs.outHeld.load() || g_input.anyBoundKeyPressed(); + // AFTER the controller ticked: before it, level() still equals last frame's level, so the + // "still moving" test never fired and the ease-out always ran its slow tail (#375). + if (!held && !t.zoom.hasTarget() && t.cfg.txSamplingMode == 1 && t.cfg.txSmoothLadder != 0 && + t.zoom.level() != t.prevLvl && wind::EaseOutShouldStop(t.zoom.level(), t.prevLvl)) { + if (auto* tmStop = dynamic_cast(t.model)) { + const double shown = tmStop->writtenLevel(); + if (shown > 1.001) { t.zoom.setLevel(shown); t.zoom.stopGlide(); } + } + } + } // Recenter on a recenterVk key press (rising edge). bool recenter = false; bool recenterDown = comboHeld(t.cfg.recenterVk, t.cfg.recenterMods); // mods since #307 diff --git a/src/transform_model.cpp b/src/transform_model.cpp index d5f3ead..96ca836 100644 --- a/src/transform_model.cpp +++ b/src/transform_model.cpp @@ -571,6 +571,7 @@ void TransformModel::present(const MapResult& r, double level, const Config& cfg 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; diff --git a/src/zoom_ladder.h b/src/zoom_ladder.h index c05131e..761b887 100644 --- a/src/zoom_ladder.h +++ b/src/zoom_ladder.h @@ -142,4 +142,14 @@ inline bool RampStepHeld(double request, double onScreen, double minRel, double 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 +// the glide (nearly all of it) survives; only the barely-moving tail is cut. +inline bool EaseOutShouldStop(double level, double prevLevel) { + if (level <= 1.001 || prevLevel <= 1.001) return false; + const double step = std::fabs(level - prevLevel) / level; + return step < 0.6 * SnapWindow(level); +} + } // namespace wind diff --git a/tests/test_zoom_ladder.cpp b/tests/test_zoom_ladder.cpp index 9242a37..3e37c20 100644 --- a/tests/test_zoom_ladder.cpp +++ b/tests/test_zoom_ladder.cpp @@ -77,6 +77,13 @@ TEST_CASE("a slow (easing) zoom is barely snapped: the snap never exceeds the fr } } +TEST_CASE("the ease-out runs while it moves faster than the clean-level spacing, then stops") { + CHECK(EaseOutShouldStop(5.0, 5.0 / 1.02) == false); // 2 % per frame: still gliding + CHECK(EaseOutShouldStop(5.0, 5.0 / 1.001) == true); // 0.1 % per frame: the tail, stop + 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);