fix(terminal): touchpad scrolls 1:1 with the finger

xterm.js sends one wheel report per event whatever its size and damps a
touchpad's pixel deltas x0.3, so a swipe crawled. With mouse tracking on,
TerminalView now sends the SGR reports itself (lib/wheelReports.ts): one
per 5 rows of travel (what one report scrolls in a Claude pane), the
remainder carried between events, at least one per wheel notch, at the
pointer's cell with xterm's modifier bits.
This commit is contained in:
2026-11-02 16:06:43 +07:00
parent e0b8d5641e
commit 2d6c751fb2
5 changed files with 155 additions and 8 deletions
@@ -0,0 +1,42 @@
/**
* @file wheelReports.test.ts
* @description A touchpad scrolls 1:1 with the finger (one report per
* LINES_PER_REPORT rows, remainder carried), a wheel notch always sends one.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect } from "vitest";
import { LINES_PER_REPORT, wheelReports, type WheelState } from "../wheelReports";
const ROW = 20;
const px = (deltaY: number) => ({ deltaY, deltaMode: 0 });
describe("wheelReports", () => {
it("adds small touchpad steps up to one report per LINES_PER_REPORT rows", () => {
const s: WheelState = { partial: 0 };
let sent = 0;
// 30 steps of 10px = 300px = 15 rows.
for (let i = 0; i < 30; i++) sent += wheelReports(s, px(10), ROW, 40);
expect(sent).toBe(15 / LINES_PER_REPORT);
});
it("drops the carried remainder when the swipe turns around", () => {
const s: WheelState = { partial: 0 };
wheelReports(s, px(40), ROW, 40); // 2 rows down, nothing sent yet
let sent = 0;
for (let i = 0; i < 5; i++) sent += wheelReports(s, px(-20), ROW, 40); // 5 rows up
expect(sent).toBe(-1);
});
it("sends at least one report for a wheel notch, in either delta mode", () => {
expect(wheelReports({ partial: 0 }, px(100), ROW, 40)).toBe(1);
expect(wheelReports({ partial: 0 }, px(-100), ROW, 40)).toBe(-1);
expect(wheelReports({ partial: 0 }, { deltaY: 3, deltaMode: 1 }, ROW, 40)).toBe(1);
expect(wheelReports({ partial: 0 }, { deltaY: 1, deltaMode: 2 }, ROW, 40)).toBe(8);
});
it("sends nothing for a horizontal-only event or an unmeasured terminal", () => {
expect(wheelReports({ partial: 0 }, px(0), ROW, 40)).toBe(0);
expect(wheelReports({ partial: 0 }, px(10), 0, 40)).toBe(0);
});
});
+59
View File
@@ -0,0 +1,59 @@
/**
* @file wheelReports.ts — turns a browser wheel event into the number of SGR
* wheel reports a terminal pane gets, in proportion to how far the finger or
* the wheel moved, the way a native terminal does.
*
* xterm.js sends ONE report per wheel event whatever its size, and damps a
* touchpad's pixel deltas ×0.3 before it counts even that one — a touchpad
* swipe crawled. In our panes every report moves a fixed stretch (Claude's
* `CLAUDE_CODE_SCROLL_SPEED`, tmux's copy-mode `WHEEL_LINES`), so a report is
* sent per `LINES_PER_REPORT` rows of travel: a touchpad scrolls 1:1 with the
* finger, and a wheel notch always sends at least one.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
/** Rows one report scrolls in a Claude pane (server/lib/pty-run.js CLAUDE_SCROLL_SPEED). */
export const LINES_PER_REPORT = 5;
/** A pixel delta this large or larger is a wheel notch, not a touchpad step (xterm.js's own cut). */
const NOTCH_PX = 50;
const DOM_DELTA_PIXEL = 0;
const DOM_DELTA_LINE = 1;
const DOM_DELTA_PAGE = 2;
export interface WheelState {
/** Touchpad travel not yet sent, in rows (signed). */
partial: number;
}
/**
* Signed report count for `ev` — negative scrolls up. `rowPx` is one row's
* height on screen (CSS scale included), `rows` the terminal's height.
*/
export function wheelReports(
state: WheelState,
ev: Pick<WheelEvent, "deltaY" | "deltaMode">,
rowPx: number,
rows: number
): number {
if (!ev.deltaY || !(rowPx > 0)) return 0;
const sign = ev.deltaY < 0 ? -1 : 1;
if (ev.deltaMode === DOM_DELTA_PIXEL && Math.abs(ev.deltaY) < NOTCH_PX) {
// Touchpad: carry the remainder, so slow swipes still add up.
if (Math.sign(state.partial) === -sign) state.partial = 0; // turned around
state.partial += ev.deltaY / rowPx;
const n = Math.trunc(state.partial / LINES_PER_REPORT);
state.partial -= n * LINES_PER_REPORT;
return n;
}
// A notch (or a fast flick): at least one report, more for a long one.
state.partial = 0;
const lines =
ev.deltaMode === DOM_DELTA_LINE
? ev.deltaY
: ev.deltaMode === DOM_DELTA_PAGE
? ev.deltaY * rows
: ev.deltaY / rowPx;
return sign * Math.max(1, Math.round(Math.abs(lines) / LINES_PER_REPORT));
}