From dcb4cda67e235457ef5707499bd2974864ea58cb Mon Sep 17 00:00:00 2001 From: Max <112043822+Maxaubert@users.noreply.github.com> Date: Wed, 7 Oct 2026 21:28:36 +0200 Subject: [PATCH] fix(cursor): the smooth-zoom ease-out cut-off actually fires (#375) The cut-off ran before the zoom controller ticked, when level() still equalled last frame's level, so 'still moving' was never true and every ease-out ran its slow tail, where DWM's half-pixel origin rounding flipped the image back and forth (measured: pointer jumps of 17-33 px at the end of a zoom with a 150-300 ms ease-out). It now runs right after the tick. Also: a request equal to the level on screen is never re-snapped. Measured after: end-of-zoom jumps 1.4-5 px at ease 0/150/300, 2-6x and up to ~20x, the same as during the zoom. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011zPmivSAQeivGUdBjTsaMu --- src/main.cpp | 4 +++- src/transform_model.cpp | 6 ++++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/src/main.cpp b/src/main.cpp index a7b5557f..b5f84979 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -1359,6 +1359,7 @@ static void RunTick(TickState& t) { // smooth ramp, merely delayed. Gated on level > 1.0 so the session still enters and the // 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). @@ -1367,6 +1368,8 @@ static void RunTick(TickState& t) { { 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)) { @@ -1376,7 +1379,6 @@ static void RunTick(TickState& t) { } t.prevZoomHeld = held; } - if (!quiesceFreeze) t.zoom.tick(dt < kMaxZoomDt ? dt : kMaxZoomDt); // 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 40063f7d..7af7bbff 100644 --- a/src/transform_model.cpp +++ b/src/transform_model.cpp @@ -649,6 +649,12 @@ void TransformModel::present(const MapResult& r, double level, const Config& cfg if (cfg.txSmoothLadder != 0 && cfg.txSamplingMode == 1 && applyLevel > 1.001) { 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 && std::fabs(lastLevel_ - level) <= level * (SnapWindow(level) + 1e-5)) { // The zoom just stopped: keep the level already on screen rather than re-snapping, so