From d583e173d23b2acd59c9ed2ec2ef32212a4a47da Mon Sep 17 00:00:00 2001 From: tiennm99 Date: Fri, 23 Oct 2026 03:37:38 +0700 Subject: [PATCH] fix(ui): resize on Ctrl+wheel only after 100 px of gesture travel A new gesture, including one started by a change of direction, used to send a full step at once, so a few px of reversed jitter in a touchpad pinch shrank and grew the bubble back and forth. A new gesture now starts from its own travel and steps only once that reaches 100 px. --- tests/js/layout.test.js | 29 ++++++++++++++++++++--------- ui/layout.js | 12 +++++++----- 2 files changed, 27 insertions(+), 14 deletions(-) diff --git a/tests/js/layout.test.js b/tests/js/layout.test.js index c2233d6..32b1d21 100644 --- a/tests/js/layout.test.js +++ b/tests/js/layout.test.js @@ -310,9 +310,10 @@ describe('wheelSteps', () => { assert.deepEqual(run([[-100, PIXEL, 0], [-100, PIXEL, 50], [-100, PIXEL, 100]]), [1, 1, 1]); }); - it('gives one step for a single notch reported as few px', () => { - assert.deepEqual(run([[-40, PIXEL, 0]]), [1]); - assert.deepEqual(run([[-40, PIXEL, 0], [-40, PIXEL, 1000]]), [1, 1]); + it('waits for 100 px of travel before a step', () => { + assert.deepEqual(run([[-40, PIXEL, 0]]), [0]); + assert.deepEqual(run([[-40, PIXEL, 0], [-40, PIXEL, 10], [-40, PIXEL, 20]]), [0, 0, 1]); + assert.deepEqual(run([[-40, PIXEL, 0], [-40, PIXEL, 1000]]), [0, 0]); }); it('turns a stream of small touchpad deltas into one step per 100 px', () => { @@ -320,18 +321,28 @@ describe('wheelSteps', () => { Array.from({ length: 30 }, (_, i) => [-10, PIXEL, i * 10]) ); const steps = run(events); - // The first event starts the gesture with a step; the other 290 px add two. assert.equal(steps.reduce((a, b) => a + b, 0), 3); assert.ok(steps.every((s) => s === 0 || s === 1)); }); it('starts a new gesture after an idle pause', () => { - assert.deepEqual(run([[-60, PIXEL, 0], [-60, PIXEL, WHEEL_IDLE_MS + 1]]), [1, 1]); - assert.deepEqual(run([[-60, PIXEL, 0], [-60, PIXEL, WHEEL_IDLE_MS]]), [1, 0]); + assert.deepEqual(run([[-60, PIXEL, 0], [-60, PIXEL, WHEEL_IDLE_MS + 1]]), [0, 0]); + assert.deepEqual(run([[-60, PIXEL, 0], [-60, PIXEL, WHEEL_IDLE_MS]]), [0, 1]); }); it('starts a new gesture when the direction flips', () => { - assert.deepEqual(run([[-150, PIXEL, 0], [10, PIXEL, 10]]), [1, -1]); + assert.deepEqual(run([[-150, PIXEL, 0], [10, PIXEL, 10]]), [1, 0]); + assert.deepEqual(run([[-150, PIXEL, 0], [60, PIXEL, 10], [60, PIXEL, 20]]), [1, 0, -1]); + }); + + it('ignores a few px of reversed jitter inside a gesture', () => { + assert.deepEqual( + run([[-10, PIXEL, 0], [-10, PIXEL, 10], [2, PIXEL, 20], [-10, PIXEL, 30], [1, PIXEL, 40], [-10, PIXEL, 50]]), + [0, 0, 0, 0, 0, 0], + ); + // After a whole notch leaves no travel, a reverse twitch and the next + // forward delta still wait for 100 px. + assert.deepEqual(run([[-100, PIXEL, 0], [2, PIXEL, 10], [-10, PIXEL, 20]]), [1, 0, 0]); }); it('sends several steps for one large delta', () => { @@ -339,9 +350,9 @@ describe('wheelSteps', () => { }); it('converts line and page deltas to px', () => { - // Three lines are 48 px: the gesture's first step, then one per 100 px. + // Three lines are 48 px, so the third event crosses 100 px. const lines = run([[-3, LINE, 0], [-3, LINE, 10], [-3, LINE, 20], [-3, LINE, 30]]); - assert.deepEqual(lines, [1, 0, 0, 1]); + assert.deepEqual(lines, [0, 0, 1, 0]); assert.deepEqual(run([[-1, PAGE, 0]]), [8]); }); diff --git a/ui/layout.js b/ui/layout.js index 46509cc..d475ae3 100644 --- a/ui/layout.js +++ b/ui/layout.js @@ -374,9 +374,10 @@ export const WHEEL_IDLE = Object.freeze({ acc: 0, lastMs: -Infinity }); /** * Turn one Ctrl+wheel event into resize steps. Travel accumulates in px, so * a touchpad's stream of small deltas resizes gradually instead of one step - * per event. The first event of a gesture always gives at least one step, so - * a single mouse notch resizes however few px the platform reports for it. - * A pause of `WHEEL_IDLE_MS` or a change of direction starts a new gesture. + * per event: a step is sent each time the gesture's travel reaches + * `WHEEL_STEP_PX`. A pause of `WHEEL_IDLE_MS` or a change of direction starts + * a new gesture from zero, so a few px of reversed jitter inside a touchpad + * stream never resizes the other way. * @param {WheelState} state State from the previous event, or `WHEEL_IDLE`. * @param {number} deltaY `WheelEvent.deltaY`; negative scrolls up and grows. * @param {number} deltaMode `WheelEvent.deltaMode`. @@ -389,7 +390,8 @@ export function wheelSteps(state, deltaY, deltaMode, nowMs) { return { state, steps: 0 }; } const fresh = nowMs - state.lastMs > WHEEL_IDLE_MS || Math.sign(state.acc) === -Math.sign(px); - const acc = fresh ? Math.sign(px) * Math.max(Math.abs(px), WHEEL_STEP_PX) : state.acc + px; - const steps = Math.trunc(acc / WHEEL_STEP_PX); + const acc = fresh ? px : state.acc + px; + // `|| 0` turns the -0 of a small reverse delta into a plain 0. + const steps = Math.trunc(acc / WHEEL_STEP_PX) || 0; return { state: { acc: acc - steps * WHEEL_STEP_PX, lastMs: nowMs }, steps }; }