From f262b8cb69abf041b726407a6eb0ade15dc2dc74 Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Fri, 7 Aug 2026 13:36:27 +0200 Subject: [PATCH] feat(terminal): gentler glide start and fractional wheel accumulation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two smoothness refinements on the local wheel path: the drain factor drops from 35% to 22% per frame, so the first frame of a notch takes a smaller step and the glide lasts longer; and local scrolling accumulates FRACTIONAL lines (_wheelScrollLinesFloat) instead of rounding every event, so a slow macOS trackpad drag no longer snaps a whole line per tiny delta (the old ±1 fallback made slow drags scroll faster than the finger). Sub-line residuals stay pending until further input crosses a whole line. Forwarded SGR ticks keep the rounded integer path. Probe: a 20-line notch now glides through 14 positions to an exact landing; the 9-check scroll matrix still passes. Co-Authored-By: Claude Fable 5 --- src/web/public/terminal-ui.js | 51 ++++++++++++++++++++++------------- 1 file changed, 33 insertions(+), 18 deletions(-) diff --git a/src/web/public/terminal-ui.js b/src/web/public/terminal-ui.js index 71c673f2..99065d03 100644 --- a/src/web/public/terminal-ui.js +++ b/src/web/public/terminal-ui.js @@ -451,11 +451,14 @@ Object.assign(CodemanApp.prototype, { if (this.terminal?.buffer?.active?.type === 'alternate') return; ev.preventDefault(); ev.stopPropagation(); - const lines = this._wheelScrollLines(ev); if (this._shouldForwardWheelToApp(ev)) { - this._forwardScrollToApp(ev.clientX, ev.clientY, lines); + this._forwardScrollToApp(ev.clientX, ev.clientY, this._wheelScrollLines(ev)); return; } + // Local scrolling accumulates FRACTIONAL lines: a macOS trackpad emits + // a stream of tiny pixel deltas, and rounding each one to a whole line + // (the ±1 fallback) made slow drags scroll faster than the finger. + const lines = this._wheelScrollLinesFloat(ev); this._noteTerminalUserScroll(lines); this._smoothScrollBy(lines); }, @@ -2081,11 +2084,15 @@ Object.assign(CodemanApp.prototype, { * wheel handler owns local scrolling (xterm's own smooth scroller is * bypassed, see the listener comment), so without this every notch was an * instant multi-line jump. Wheel deltas accumulate into a pending line - * count and drain ~35% per animation frame (minimum one line, so it always - * terminates); more notches mid-glide just deepen the pending count, which - * reads as natural acceleration. Direction reversals cancel arithmetically. - * The pending amount is dropped when the active session changes mid-glide — - * leftover momentum must never scroll the tab the user just switched to. + * count (fractional — see _wheelScrollLinesFloat) and drain ~22% per + * animation frame with a one-line floor, so a single notch starts with a + * gentle step and glides to an exact landing; more notches mid-glide deepen + * the pending count, which reads as natural acceleration. A sub-line + * residual stays pending until further input pushes it past a whole line + * (that is what makes slow trackpad drags track the finger). Direction + * reversals cancel arithmetically. The pending amount is dropped when the + * active session changes mid-glide — leftover momentum must never scroll + * the tab the user just switched to. */ _smoothScrollBy(lines) { if (!lines) return; @@ -2100,12 +2107,13 @@ Object.assign(CodemanApp.prototype, { this._smoothScrollPending = 0; return; } - const move = - pending > 0 ? Math.max(1, Math.floor(pending * 0.35)) : Math.min(-1, Math.ceil(pending * 0.35)); + if (Math.abs(pending) < 1) return; // sub-line residual: wait for more input + const eased = pending * 0.22; + const move = pending > 0 ? Math.max(1, Math.floor(eased)) : Math.min(-1, Math.ceil(eased)); this._smoothScrollPending = pending - move; this.terminal.scrollLines(move); this._maybeLoadMoreHistoryOnScroll(move); - if (this._smoothScrollPending) this._smoothScrollFrame = requestAnimationFrame(step); + if (Math.abs(this._smoothScrollPending) >= 1) this._smoothScrollFrame = requestAnimationFrame(step); }; this._smoothScrollFrame = requestAnimationFrame(step); }, @@ -2937,15 +2945,22 @@ Object.assign(CodemanApp.prototype, { // the ±1 fallback — one line per notch, versus 4-5 for Chrome's ~110px. In // Claude mode the same value also capped the forwarded SGR report at one tick. _wheelScrollLines(ev) { + const lines = this._wheelScrollLinesFloat(ev); + if (!lines) return 0; // pure horizontal swipe: don't fall through to -1 + return Math.round(lines) || (lines > 0 ? 1 : -1); + }, + + /** Unrounded variant for the smooth local-scroll path, which accumulates + * sub-line fractions across events instead of forcing every tiny trackpad + * delta to a whole ±1 line. Same unit handling and Shift-axis trap. */ + _wheelScrollLinesFloat(ev) { const delta = ev.shiftKey && Math.abs(ev.deltaX) > Math.abs(ev.deltaY) ? ev.deltaX : ev.deltaY; - if (!delta) return 0; // pure horizontal swipe: don't fall through to -1 - const lines = - ev.deltaMode === 1 // DOM_DELTA_LINE (Firefox mouse wheel) - ? delta - : ev.deltaMode === 2 // DOM_DELTA_PAGE - ? delta * (this.terminal?.rows || 24) - : delta / 25; // DOM_DELTA_PIXEL (Chrome/WebKit, and every trackpad) - return Math.round(lines) || (delta > 0 ? 1 : -1); + if (!delta) return 0; + return ev.deltaMode === 1 // DOM_DELTA_LINE (Firefox mouse wheel) + ? delta + : ev.deltaMode === 2 // DOM_DELTA_PAGE + ? delta * (this.terminal?.rows || 24) + : delta / 25; // DOM_DELTA_PIXEL (Chrome/WebKit, and every trackpad) }, _shouldForwardWheelToApp(ev) {