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:
@@ -86,6 +86,7 @@ import {
|
||||
sameFilesHere,
|
||||
} from "../../lib/promptAttach";
|
||||
import { attachImeInput } from "../../lib/imeInput";
|
||||
import { wheelReports, type WheelState } from "../../lib/wheelReports";
|
||||
import { nativePasteKey, nativePasteWorks, readNativePaste } from "../../lib/nativePaste";
|
||||
import { usePermissions } from "../AuthGate";
|
||||
import { TERMINAL_FONTS, readTerminalFont } from "../../lib/terminalFont";
|
||||
@@ -524,9 +525,32 @@ export const TerminalView = memo(function TerminalView({
|
||||
// wheel report itself — psmux scrolls, or hands it to the pane's app.
|
||||
// ponytail: at 1;1, which is all psmux reports to the app anyway.
|
||||
let ownWheel = false;
|
||||
// Tracked: the reports are this pane's own too — one per
|
||||
// LINES_PER_REPORT rows of travel (lib/wheelReports.ts), where xterm
|
||||
// sends one per event and damps a touchpad ×0.3. SGR, as tmux always
|
||||
// asks for it; at the pointer's cell, with xterm's modifier bits.
|
||||
const wheelState: WheelState = { partial: 0 };
|
||||
const sendWheel = (ev: WheelEvent) => {
|
||||
const screen = term.element?.querySelector(".xterm-screen");
|
||||
const box = screen?.getBoundingClientRect();
|
||||
if (!box || !box.height || !box.width) return;
|
||||
const n = wheelReports(wheelState, ev, box.height / term.rows, term.rows);
|
||||
const ws = wsRef.current;
|
||||
if (!n || !ws || ws.readyState !== WebSocket.OPEN) return;
|
||||
const clamp = (v: number, max: number) => Math.min(max, Math.max(1, v));
|
||||
const col = clamp(Math.floor(((ev.clientX - box.left) / box.width) * term.cols) + 1, term.cols);
|
||||
const row = clamp(Math.floor(((ev.clientY - box.top) / box.height) * term.rows) + 1, term.rows);
|
||||
const button = (n < 0 ? 64 : 65) | (ev.altKey ? 8 : 0) | (ev.ctrlKey ? 16 : 0);
|
||||
ws.send(`\x1b[<${button};${col};${row}M`.repeat(Math.abs(n)));
|
||||
};
|
||||
term.attachCustomWheelEventHandler((ev) => {
|
||||
const mode = term.modes.mouseTrackingMode;
|
||||
const tracked = mode !== "none" && mode !== "x10";
|
||||
// Shift+wheel stays xterm's (it reports nothing for it).
|
||||
if (tracked && !ev.shiftKey) {
|
||||
sendWheel(ev);
|
||||
return false;
|
||||
}
|
||||
if (!tracked && ownWheel) {
|
||||
if (ev.deltaY && wsRef.current?.readyState === WebSocket.OPEN) {
|
||||
wsRef.current.send(`\x1b[<${ev.deltaY < 0 ? 64 : 65};1;1M`);
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
screen,
|
||||
waitFor,
|
||||
} from "@testing-library/react";
|
||||
import { Terminal } from "@xterm/xterm";
|
||||
import { TerminalView, altArrowSeq, loadXterm } from "../TerminalView";
|
||||
import { pasteToLane, pasteToRun } from "../../../lib/panePaste";
|
||||
import { __resetNativePaste, nativePasteWorks } from "../../../lib/nativePaste";
|
||||
@@ -326,13 +327,33 @@ describe("TerminalView", () => {
|
||||
expect(wheelHandlerMock.mock.calls[0]![0]!(new Event("wheel"))).toBe(false);
|
||||
});
|
||||
|
||||
it("lets the wheel through once the pane's program tracks the mouse", () => {
|
||||
// tmux with `mouse on` sets this; the wheel must reach it as a real SGR
|
||||
// report, so returning false here would kill scrolling entirely.
|
||||
/** Gives the mocked terminal an on-screen box: 10px per column and per row. */
|
||||
const screenBox = () => {
|
||||
const term = vi.mocked(Terminal).mock.results[vi.mocked(Terminal).mock.results.length - 1]!.value as { element: HTMLElement };
|
||||
const screen = document.createElement("div");
|
||||
screen.className = "xterm-screen";
|
||||
screen.getBoundingClientRect = () =>
|
||||
({ left: 0, top: 0, width: termState.cols * 10, height: termState.rows * 10 }) as DOMRect;
|
||||
term.element.appendChild(screen);
|
||||
};
|
||||
|
||||
it("sends a tracked wheel itself, one report per LINES_PER_REPORT rows, at the pointer", () => {
|
||||
// tmux with `mouse on` sets this. xterm would send ONE report per event and
|
||||
// damp a touchpad ×0.3 — a swipe crawled — so the pane sends its own.
|
||||
termState.modes.mouseTrackingMode = "any";
|
||||
termState.buffer.active.type = "alternate";
|
||||
render(<TerminalView runId="agenthub-lane-1" wsBaseUrl="ws://localhost:4820" />);
|
||||
expect(wheelHandlerMock.mock.calls[0]![0]!(new Event("wheel"))).toBe(true);
|
||||
screenBox();
|
||||
const ws = MockWebSocket.instances[MockWebSocket.instances.length - 1]!;
|
||||
ws.sent.length = 0;
|
||||
const wheel = wheelHandlerMock.mock.calls[0]![0]!;
|
||||
// A notch of 6 rows: one report, at the cell under the pointer.
|
||||
expect(wheel(new WheelEvent("wheel", { deltaY: -60, clientX: 45, clientY: 72 }))).toBe(false);
|
||||
expect(ws.sent).toEqual(["\x1b[<64;5;8M"]);
|
||||
// A touchpad: 4 steps of 25px = 10 rows = two reports, sent as they add up.
|
||||
ws.sent.length = 0;
|
||||
for (let i = 0; i < 4; i++) wheel(new WheelEvent("wheel", { deltaY: 25, clientX: 5, clientY: 5 }));
|
||||
expect(ws.sent.join("")).toBe("\x1b[<65;1;1M".repeat(2));
|
||||
});
|
||||
|
||||
it("lets the wheel through on a normal buffer, where xterm scrolls scrollback", () => {
|
||||
@@ -361,10 +382,11 @@ describe("TerminalView", () => {
|
||||
expect(wheel(new WheelEvent("wheel", { deltaY: -100 }))).toBe(false);
|
||||
expect(wheel(new WheelEvent("wheel", { deltaY: 100 }))).toBe(false);
|
||||
expect(ws.sent).toEqual(["\x1b[<64;1;1M", "\x1b[<65;1;1M"]);
|
||||
// Tracked again (a tmux that does report): xterm sends it, never twice.
|
||||
// Tracked again (a tmux that does report): sent once, by the tracked path.
|
||||
termState.modes.mouseTrackingMode = "any";
|
||||
expect(wheel(new WheelEvent("wheel", { deltaY: -100 }))).toBe(true);
|
||||
expect(ws.sent).toHaveLength(2);
|
||||
screenBox();
|
||||
expect(wheel(new WheelEvent("wheel", { deltaY: -100 }))).toBe(false);
|
||||
expect(ws.sent).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("keeps Shift+Tab in the terminal instead of letting focus escape", () => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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));
|
||||
}
|
||||
Reference in New Issue
Block a user