mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-24 10:12:44 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
96020ab773 |
@@ -50,9 +50,9 @@ import {
|
||||
import {
|
||||
TERMINAL_GESTURE_LONG_PRESS_MS,
|
||||
TERMINAL_GESTURE_TAP_TOLERANCE_PX,
|
||||
advanceTerminalScrollGesture,
|
||||
classifyTerminalGestureIntent,
|
||||
resolveTerminalGestureReleaseAction,
|
||||
routeTerminalGestureRelease,
|
||||
routeTerminalScrollGesture,
|
||||
type TerminalGestureIntent,
|
||||
} from "../terminal/native-renderer/terminal-selection-gesture";
|
||||
import { resolveNativeTerminalMountAction } from "../terminal/native-renderer/terminal-mount-policy";
|
||||
@@ -95,6 +95,7 @@ interface NativeTerminalPanState {
|
||||
didNavigate: boolean;
|
||||
movedBeyondTapTolerance: boolean;
|
||||
intent: TerminalGestureIntent | null;
|
||||
origin: { x: number; y: number };
|
||||
}
|
||||
|
||||
interface NativeTerminalSelectionPress {
|
||||
@@ -214,6 +215,7 @@ function NativeTerminalEmulator({
|
||||
didNavigate: false,
|
||||
movedBeyondTapTolerance: false,
|
||||
intent: null,
|
||||
origin: { x: 0, y: 0 },
|
||||
});
|
||||
const inputModeTrackerRef = useRef(new TerminalInputModeTracker());
|
||||
const lastInputModeStateRef = useRef<TerminalInputModeState>({
|
||||
@@ -775,31 +777,33 @@ function NativeTerminalEmulator({
|
||||
);
|
||||
|
||||
const handlePanResponderScroll = useCallback(
|
||||
(currentDy: number) => {
|
||||
const cellHeight = metricsRef.current?.cellHeight;
|
||||
if (!cellHeight || cellHeight <= 0) {
|
||||
(gesture: PanResponderGestureState) => {
|
||||
const metrics = metricsRef.current;
|
||||
const viewport = resolveSelectionViewport();
|
||||
if (!metrics || metrics.cellHeight <= 0 || !viewport) {
|
||||
return;
|
||||
}
|
||||
const panState = panStateRef.current;
|
||||
const update = advanceTerminalScrollGesture({
|
||||
const { state, route } = routeTerminalScrollGesture({
|
||||
state: panState,
|
||||
currentDy,
|
||||
cellHeight,
|
||||
currentDy: gesture.dy,
|
||||
point: { x: panState.origin.x + gesture.dx, y: panState.origin.y + gesture.dy },
|
||||
metrics,
|
||||
viewport,
|
||||
inputMode: inputModeTrackerRef.current,
|
||||
});
|
||||
panState.lastDy = update.state.lastDy;
|
||||
panState.rowRemainder = update.state.rowRemainder;
|
||||
panState.didScroll = update.state.didScroll;
|
||||
const { rowDelta } = update;
|
||||
if (rowDelta === 0) {
|
||||
panState.lastDy = state.lastDy;
|
||||
panState.rowRemainder = state.rowRemainder;
|
||||
panState.didScroll = state.didScroll;
|
||||
if (route.kind === "scrollback") {
|
||||
applyScroll(route.direction, route.rows);
|
||||
return;
|
||||
}
|
||||
if (rowDelta > 0) {
|
||||
applyScroll("up", rowDelta);
|
||||
return;
|
||||
if (route.kind === "input") {
|
||||
callbacksRef.current.onInput?.(route.data);
|
||||
}
|
||||
applyScroll("down", Math.abs(rowDelta));
|
||||
},
|
||||
[applyScroll],
|
||||
[applyScroll, resolveSelectionViewport],
|
||||
);
|
||||
|
||||
const panResponder = useMemo(
|
||||
@@ -828,6 +832,10 @@ function NativeTerminalEmulator({
|
||||
return intent === "scroll" || intent === "select";
|
||||
},
|
||||
onPanResponderGrant: (event) => {
|
||||
const point = {
|
||||
x: event.nativeEvent.locationX,
|
||||
y: event.nativeEvent.locationY,
|
||||
};
|
||||
panStateRef.current = {
|
||||
lastDy: 0,
|
||||
rowRemainder: 0,
|
||||
@@ -835,10 +843,7 @@ function NativeTerminalEmulator({
|
||||
didNavigate: false,
|
||||
movedBeyondTapTolerance: false,
|
||||
intent: null,
|
||||
};
|
||||
const point = {
|
||||
x: event.nativeEvent.locationX,
|
||||
y: event.nativeEvent.locationY,
|
||||
origin: point,
|
||||
};
|
||||
const pressStatus = selectionRangeRef.current ? "selecting" : "pressing";
|
||||
selectionPressRef.current = {
|
||||
@@ -875,7 +880,7 @@ function NativeTerminalEmulator({
|
||||
}
|
||||
|
||||
if (panState.intent === "scroll") {
|
||||
handlePanResponderScroll(gesture.dy);
|
||||
handlePanResponderScroll(gesture);
|
||||
return;
|
||||
}
|
||||
if (panState.didNavigate) {
|
||||
@@ -903,7 +908,7 @@ function NativeTerminalEmulator({
|
||||
|
||||
if (intent === "scroll") {
|
||||
selectionPressRef.current = null;
|
||||
handlePanResponderScroll(gesture.dy);
|
||||
handlePanResponderScroll(gesture);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -932,8 +937,9 @@ function NativeTerminalEmulator({
|
||||
didNavigate: false,
|
||||
movedBeyondTapTolerance: false,
|
||||
intent: null,
|
||||
origin: { x: 0, y: 0 },
|
||||
};
|
||||
const releaseAction = resolveTerminalGestureReleaseAction({
|
||||
const { action: releaseAction, mouseInput } = routeTerminalGestureRelease({
|
||||
status: selectionPress?.status ?? "idle",
|
||||
startedWithSelection: selectionPress?.startedWithSelection,
|
||||
didScroll,
|
||||
@@ -942,11 +948,18 @@ function NativeTerminalEmulator({
|
||||
pressDurationMs: selectionPress ? Date.now() - selectionPress.startedAt : 0,
|
||||
longPressMs: TERMINAL_GESTURE_LONG_PRESS_MS,
|
||||
scrollMode: terminalScreenRef.current?.scroll.mode ?? "following",
|
||||
point: { x: selectionPress?.startX ?? 0, y: selectionPress?.startY ?? 0 },
|
||||
metrics: metricsRef.current,
|
||||
viewport: resolveSelectionViewport(),
|
||||
inputMode: inputModeTrackerRef.current,
|
||||
});
|
||||
if (releaseAction === "select" && selectionPress) {
|
||||
beginSelectionAtPoint({ x: selectionPress.startX, y: selectionPress.startY });
|
||||
return;
|
||||
}
|
||||
if (mouseInput !== null) {
|
||||
callbacksRef.current.onInput?.(mouseInput);
|
||||
}
|
||||
if (releaseAction === "focus" || releaseAction === "clear") {
|
||||
focusTerminalFromTap();
|
||||
}
|
||||
@@ -961,6 +974,7 @@ function NativeTerminalEmulator({
|
||||
didNavigate: false,
|
||||
movedBeyondTapTolerance: false,
|
||||
intent: null,
|
||||
origin: { x: 0, y: 0 },
|
||||
};
|
||||
},
|
||||
}),
|
||||
@@ -969,6 +983,7 @@ function NativeTerminalEmulator({
|
||||
clearSelectionLongPressTimeout,
|
||||
focusTerminalFromTap,
|
||||
handlePanResponderScroll,
|
||||
resolveSelectionViewport,
|
||||
updateSelectionAtPoint,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { TerminalInputModeTracker } from "@getpaseo/protocol/terminal-input-mode";
|
||||
|
||||
import {
|
||||
TERMINAL_GESTURE_LONG_PRESS_MS,
|
||||
@@ -7,8 +8,64 @@ import {
|
||||
advanceTerminalScrollGesture,
|
||||
classifyTerminalGestureIntent,
|
||||
resolveTerminalGestureReleaseAction,
|
||||
routeTerminalGestureRelease,
|
||||
routeTerminalScrollGesture,
|
||||
type TerminalGestureReleaseRouteInput,
|
||||
type TerminalScrollGestureRoute,
|
||||
} from "./terminal-selection-gesture";
|
||||
|
||||
const PI_FULLSCREEN_MOUSE_ENABLE =
|
||||
"\x1b[?1049h\x1b[?1000h\x1b[?1002h\x1b[?1003h\x1b[?1004h\x1b[?1006h";
|
||||
const PI_FULLSCREEN_MOUSE_DISABLE = "\x1b[?1006l\x1b[?1004l\x1b[?1003l\x1b[?1002l\x1b[?1000l";
|
||||
|
||||
function trackerFromOutput(output: string): TerminalInputModeTracker {
|
||||
const tracker = new TerminalInputModeTracker();
|
||||
tracker.feed(output);
|
||||
return tracker;
|
||||
}
|
||||
|
||||
function dragRoutes(input: {
|
||||
inputMode: TerminalInputModeTracker;
|
||||
dys: number[];
|
||||
point?: { x: number; y: number };
|
||||
}): TerminalScrollGestureRoute[] {
|
||||
let state = { lastDy: 0, rowRemainder: 0, didScroll: false };
|
||||
const routes: TerminalScrollGestureRoute[] = [];
|
||||
for (const currentDy of input.dys) {
|
||||
const update = routeTerminalScrollGesture({
|
||||
state,
|
||||
currentDy,
|
||||
point: input.point ?? { x: 25, y: 50 },
|
||||
metrics: { cellWidth: 8, cellHeight: 14 },
|
||||
viewport: { rows: 30, cols: 40 },
|
||||
inputMode: input.inputMode,
|
||||
});
|
||||
state = update.state;
|
||||
routes.push(update.route);
|
||||
}
|
||||
return routes;
|
||||
}
|
||||
|
||||
function tapRelease(
|
||||
overrides: Partial<TerminalGestureReleaseRouteInput> = {},
|
||||
): ReturnType<typeof routeTerminalGestureRelease> {
|
||||
return routeTerminalGestureRelease({
|
||||
status: "pressing",
|
||||
startedWithSelection: false,
|
||||
didScroll: false,
|
||||
didNavigate: false,
|
||||
movedBeyondTapTolerance: false,
|
||||
pressDurationMs: 80,
|
||||
longPressMs: TERMINAL_GESTURE_LONG_PRESS_MS,
|
||||
scrollMode: "following",
|
||||
point: { x: 25, y: 50 },
|
||||
metrics: { cellWidth: 8, cellHeight: 14 },
|
||||
viewport: { rows: 30, cols: 40 },
|
||||
inputMode: trackerFromOutput(PI_FULLSCREEN_MOUSE_ENABLE),
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
describe("native terminal gesture policy", () => {
|
||||
describe("advanceTerminalScrollGesture", () => {
|
||||
it("keeps consuming a slow drag after scroll intent arms below one row", () => {
|
||||
@@ -351,4 +408,143 @@ describe("native terminal gesture policy", () => {
|
||||
).toEqual("none");
|
||||
});
|
||||
});
|
||||
describe("routeTerminalScrollGesture", () => {
|
||||
it("sends SGR wheel reports at the touched cell while a fullscreen app tracks the mouse", () => {
|
||||
const inputMode = trackerFromOutput(PI_FULLSCREEN_MOUSE_ENABLE);
|
||||
|
||||
expect(dragRoutes({ inputMode, dys: [13, 28, 42, 14] })).toEqual([
|
||||
{ kind: "none" },
|
||||
{ kind: "input", data: "\x1b[<64;4;4M\x1b[<64;4;4M" },
|
||||
{ kind: "input", data: "\x1b[<64;4;4M" },
|
||||
{ kind: "input", data: "\x1b[<65;4;4M\x1b[<65;4;4M" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps ordinary shell drags in local scrollback", () => {
|
||||
const inputMode = trackerFromOutput("\x1b[?2004h$ ls\r\n");
|
||||
|
||||
expect(dragRoutes({ inputMode, dys: [13, 28, 14] })).toEqual([
|
||||
{ kind: "none" },
|
||||
{ kind: "scrollback", direction: "up", rows: 2 },
|
||||
{ kind: "scrollback", direction: "down", rows: 1 },
|
||||
]);
|
||||
});
|
||||
|
||||
it("returns drags to local scrollback after the app disables mouse tracking", () => {
|
||||
const inputMode = trackerFromOutput(PI_FULLSCREEN_MOUSE_ENABLE);
|
||||
inputMode.feed(PI_FULLSCREEN_MOUSE_DISABLE);
|
||||
|
||||
expect(dragRoutes({ inputMode, dys: [28] })).toEqual([
|
||||
{ kind: "scrollback", direction: "up", rows: 2 },
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps forwarding wheel input after a reconnect replays the daemon preamble", () => {
|
||||
const daemon = trackerFromOutput(PI_FULLSCREEN_MOUSE_ENABLE);
|
||||
const client = trackerFromOutput(PI_FULLSCREEN_MOUSE_ENABLE);
|
||||
client.reset();
|
||||
client.feed(daemon.getPreamble());
|
||||
|
||||
expect(dragRoutes({ inputMode: client, dys: [14] })).toEqual([
|
||||
{ kind: "input", data: "\x1b[<64;4;4M" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps pixel reports integral at fractional cell boundaries", () => {
|
||||
const inputMode = trackerFromOutput("\x1b[?1003h\x1b[?1016h");
|
||||
const { route } = routeTerminalScrollGesture({
|
||||
state: { lastDy: 0, rowRemainder: 0, didScroll: false },
|
||||
currentDy: 15,
|
||||
point: { x: 999, y: 999 },
|
||||
metrics: { cellWidth: 8.25, cellHeight: 14.25 },
|
||||
viewport: { rows: 3, cols: 3 },
|
||||
inputMode,
|
||||
});
|
||||
expect(route).toEqual({ kind: "input", data: "\x1b[<64;25;43M" });
|
||||
});
|
||||
|
||||
it("clamps wheel reports to the visible grid", () => {
|
||||
const inputMode = trackerFromOutput(PI_FULLSCREEN_MOUSE_ENABLE);
|
||||
|
||||
expect(dragRoutes({ inputMode, dys: [-14], point: { x: 999, y: -5 } })).toEqual([
|
||||
{ kind: "input", data: "\x1b[<65;40;1M" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("routeTerminalGestureRelease", () => {
|
||||
it("clicks the tapped cell and still focuses while a fullscreen app tracks the mouse", () => {
|
||||
expect(tapRelease()).toEqual({
|
||||
action: "focus",
|
||||
mouseInput: "\x1b[<0;4;4M\x1b[<0;4;4m",
|
||||
});
|
||||
});
|
||||
|
||||
it("only focuses on an ordinary shell tap", () => {
|
||||
expect(tapRelease({ inputMode: trackerFromOutput("\x1b[?2004h$ ls\r\n") })).toEqual({
|
||||
action: "focus",
|
||||
mouseInput: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("sends only the press for X10 tracking", () => {
|
||||
expect(tapRelease({ inputMode: trackerFromOutput("\x1b[?9h") })).toEqual({
|
||||
action: "focus",
|
||||
mouseInput: "\x1b[M $$",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps a long press as local selection without clicking", () => {
|
||||
expect(tapRelease({ pressDurationMs: TERMINAL_GESTURE_LONG_PRESS_MS + 50 })).toEqual({
|
||||
action: "select",
|
||||
mouseInput: null,
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
["moved beyond tap tolerance", { movedBeyondTapTolerance: true }],
|
||||
["scrolled", { didScroll: true }],
|
||||
])("never clicks after a drag that %s", (_name, overrides) => {
|
||||
expect(tapRelease(overrides)).toEqual({ action: "none", mouseInput: null });
|
||||
});
|
||||
|
||||
it("clears an existing selection without clicking", () => {
|
||||
expect(tapRelease({ status: "selecting", startedWithSelection: true })).toEqual({
|
||||
action: "clear",
|
||||
mouseInput: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("does not send live mouse input from a scrolled-back viewport", () => {
|
||||
expect(tapRelease({ scrollMode: "scrolled" })).toEqual({
|
||||
action: "focus",
|
||||
mouseInput: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("does not click before the grid is measured", () => {
|
||||
expect(tapRelease({ metrics: null, viewport: null })).toEqual({
|
||||
action: "focus",
|
||||
mouseInput: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("clamps clicks to the visible grid", () => {
|
||||
expect(tapRelease({ point: { x: 999, y: -5 } })).toEqual({
|
||||
action: "focus",
|
||||
mouseInput: "\x1b[<0;40;1M\x1b[<0;40;1m",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps pixel clicks integral at fractional cell boundaries", () => {
|
||||
expect(
|
||||
tapRelease({
|
||||
inputMode: trackerFromOutput("\x1b[?1000h\x1b[?1016h"),
|
||||
point: { x: 999, y: 999 },
|
||||
metrics: { cellWidth: 8.25, cellHeight: 14.25 },
|
||||
viewport: { rows: 3, cols: 3 },
|
||||
}),
|
||||
).toEqual({ action: "focus", mouseInput: "\x1b[<0;25;43M\x1b[<0;25;43m" });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
import type {
|
||||
TerminalInputModeTracker,
|
||||
TerminalMousePosition,
|
||||
} from "@getpaseo/protocol/terminal-input-mode";
|
||||
import type { TerminalGridCellMetrics } from "./terminal-grid-metrics";
|
||||
|
||||
export type TerminalGestureStatus = "idle" | "pressing" | "selecting";
|
||||
export type TerminalGestureIntent = "tap" | "pending" | "select" | "scroll";
|
||||
export type TerminalGestureScrollMode = "following" | "scrolled";
|
||||
@@ -33,8 +39,39 @@ export interface TerminalScrollGestureUpdate {
|
||||
rowDelta: number;
|
||||
}
|
||||
|
||||
export type TerminalScrollGestureRoute =
|
||||
| { kind: "none" }
|
||||
| { kind: "scrollback"; direction: "up" | "down"; rows: number }
|
||||
| { kind: "input"; data: string };
|
||||
|
||||
export interface TerminalScrollGestureRouteInput {
|
||||
state: TerminalScrollGestureState;
|
||||
currentDy: number;
|
||||
point: { x: number; y: number };
|
||||
metrics: TerminalGridCellMetrics;
|
||||
viewport: { rows: number; cols: number };
|
||||
inputMode: Pick<TerminalInputModeTracker, "encodeWheel">;
|
||||
}
|
||||
|
||||
export interface TerminalScrollGestureRouteUpdate {
|
||||
state: TerminalScrollGestureState;
|
||||
route: TerminalScrollGestureRoute;
|
||||
}
|
||||
|
||||
export type TerminalGestureAction = "none" | "focus" | "select" | "clear";
|
||||
|
||||
export interface TerminalGestureReleaseRouteInput extends TerminalGestureReleaseInput {
|
||||
point: { x: number; y: number };
|
||||
metrics: TerminalGridCellMetrics | null;
|
||||
viewport: { rows: number; cols: number } | null;
|
||||
inputMode: Pick<TerminalInputModeTracker, "encodeLeftClick">;
|
||||
}
|
||||
|
||||
export interface TerminalGestureReleaseRoute {
|
||||
action: TerminalGestureAction;
|
||||
mouseInput: string | null;
|
||||
}
|
||||
|
||||
export const TERMINAL_GESTURE_TAP_TOLERANCE_PX = 8;
|
||||
export const TERMINAL_GESTURE_LONG_PRESS_MS = 450;
|
||||
export const TERMINAL_GESTURE_VERTICAL_SCROLL_THRESHOLD_PX = 12;
|
||||
@@ -78,6 +115,60 @@ export function advanceTerminalScrollGesture(input: {
|
||||
};
|
||||
}
|
||||
|
||||
function clamp(value: number, min: number, max: number): number {
|
||||
return Math.min(max, Math.max(min, value));
|
||||
}
|
||||
|
||||
/** Clamps a touch point to the visible grid; pixel reports stay integral at fractional cell sizes. */
|
||||
function resolveTerminalMousePosition(input: {
|
||||
point: { x: number; y: number };
|
||||
metrics: TerminalGridCellMetrics;
|
||||
viewport: { rows: number; cols: number };
|
||||
}): TerminalMousePosition {
|
||||
const { cellWidth, cellHeight } = input.metrics;
|
||||
const pixelX = clamp(
|
||||
Math.floor(input.point.x),
|
||||
0,
|
||||
Math.ceil(input.viewport.cols * cellWidth) - 1,
|
||||
);
|
||||
const pixelY = clamp(
|
||||
Math.floor(input.point.y),
|
||||
0,
|
||||
Math.ceil(input.viewport.rows * cellHeight) - 1,
|
||||
);
|
||||
return {
|
||||
col: Math.floor(pixelX / cellWidth) + 1,
|
||||
row: Math.floor(pixelY / cellHeight) + 1,
|
||||
pixelX: pixelX + 1,
|
||||
pixelY: pixelY + 1,
|
||||
};
|
||||
}
|
||||
|
||||
/** A drag scrolls local scrollback unless the foreground app asked for wheel reports. */
|
||||
export function routeTerminalScrollGesture(
|
||||
input: TerminalScrollGestureRouteInput,
|
||||
): TerminalScrollGestureRouteUpdate {
|
||||
const { state, rowDelta } = advanceTerminalScrollGesture({
|
||||
state: input.state,
|
||||
currentDy: input.currentDy,
|
||||
cellHeight: input.metrics.cellHeight,
|
||||
});
|
||||
if (rowDelta === 0) {
|
||||
return { state, route: { kind: "none" } };
|
||||
}
|
||||
|
||||
const direction = rowDelta > 0 ? "up" : "down";
|
||||
const rows = Math.abs(rowDelta);
|
||||
const report = input.inputMode.encodeWheel({
|
||||
direction,
|
||||
...resolveTerminalMousePosition(input),
|
||||
});
|
||||
if (report === null) {
|
||||
return { state, route: { kind: "scrollback", direction, rows } };
|
||||
}
|
||||
return { state, route: { kind: "input", data: report.repeat(rows) } };
|
||||
}
|
||||
|
||||
export function resolveTerminalGestureReleaseAction(
|
||||
input: TerminalGestureReleaseInput,
|
||||
): TerminalGestureAction {
|
||||
@@ -106,3 +197,19 @@ export function resolveTerminalGestureReleaseAction(
|
||||
|
||||
return "focus";
|
||||
}
|
||||
|
||||
/**
|
||||
* Only a short tap on the live screen clicks; long press, drags and selection clears stay local.
|
||||
* The caller sends the click before focusing, so the keyboard resize cannot move the target cell.
|
||||
*/
|
||||
export function routeTerminalGestureRelease(
|
||||
input: TerminalGestureReleaseRouteInput,
|
||||
): TerminalGestureReleaseRoute {
|
||||
const action = resolveTerminalGestureReleaseAction(input);
|
||||
const { metrics, viewport } = input;
|
||||
if (action !== "focus" || input.scrollMode !== "following" || !metrics || !viewport) {
|
||||
return { action, mouseInput: null };
|
||||
}
|
||||
const position = resolveTerminalMousePosition({ point: input.point, metrics, viewport });
|
||||
return { action, mouseInput: input.inputMode.encodeLeftClick(position) };
|
||||
}
|
||||
|
||||
@@ -118,6 +118,111 @@ describe("TerminalInputModeTracker", () => {
|
||||
expect(tracker.getPreamble()).toBe("");
|
||||
});
|
||||
|
||||
const PI_FULLSCREEN_MOUSE_ENABLE =
|
||||
"\x1b[?1049h\x1b[?1000h\x1b[?1002h\x1b[?1003h\x1b[?1004h\x1b[?1006h";
|
||||
const PI_FULLSCREEN_MOUSE_DISABLE = "\x1b[?1006l\x1b[?1004l\x1b[?1003l\x1b[?1002l\x1b[?1000l";
|
||||
const wheelUpAt3x4 = { direction: "up" as const, col: 3, row: 4, pixelX: 30, pixelY: 80 };
|
||||
|
||||
it("encodes SGR wheel reports while a fullscreen app tracks the mouse", () => {
|
||||
const tracker = new TerminalInputModeTracker();
|
||||
expect(tracker.encodeWheel(wheelUpAt3x4)).toBeNull();
|
||||
|
||||
expect(tracker.feed(PI_FULLSCREEN_MOUSE_ENABLE).changed).toBe(true);
|
||||
|
||||
expect(tracker.encodeWheel(wheelUpAt3x4)).toBe("\x1b[<64;3;4M");
|
||||
expect(tracker.encodeWheel({ ...wheelUpAt3x4, direction: "down" })).toBe("\x1b[<65;3;4M");
|
||||
});
|
||||
|
||||
it("stops encoding wheel reports once the app disables mouse tracking", () => {
|
||||
const tracker = new TerminalInputModeTracker();
|
||||
tracker.feed(PI_FULLSCREEN_MOUSE_ENABLE);
|
||||
|
||||
expect(tracker.feed(PI_FULLSCREEN_MOUSE_DISABLE).changed).toBe(true);
|
||||
|
||||
expect(tracker.encodeWheel(wheelUpAt3x4)).toBeNull();
|
||||
expect(tracker.getPreamble()).toBe("");
|
||||
});
|
||||
|
||||
it("replays mouse tracking so a reconnected client keeps forwarding wheel input", () => {
|
||||
const daemon = new TerminalInputModeTracker();
|
||||
daemon.feed(PI_FULLSCREEN_MOUSE_ENABLE);
|
||||
const client = new TerminalInputModeTracker();
|
||||
client.feed(PI_FULLSCREEN_MOUSE_ENABLE);
|
||||
|
||||
client.reset();
|
||||
expect(client.encodeWheel(wheelUpAt3x4)).toBeNull();
|
||||
expect(daemon.getPreamble()).toBe("\x1b[?1003h\x1b[?1006h");
|
||||
client.feed(daemon.getPreamble());
|
||||
|
||||
expect(client.encodeWheel(wheelUpAt3x4)).toBe("\x1b[<64;3;4M");
|
||||
});
|
||||
|
||||
it("encodes SGR pixel wheel reports with pixel coordinates", () => {
|
||||
const tracker = new TerminalInputModeTracker();
|
||||
tracker.feed("\x1b[?1000h\x1b[?1016h");
|
||||
|
||||
expect(tracker.encodeWheel(wheelUpAt3x4)).toBe("\x1b[<64;30;80M");
|
||||
expect(tracker.getPreamble()).toBe("\x1b[?1000h\x1b[?1016h");
|
||||
});
|
||||
|
||||
it("encodes default wheel reports only while every byte stays ASCII", () => {
|
||||
const tracker = new TerminalInputModeTracker();
|
||||
tracker.feed("\x1b[?1000h");
|
||||
|
||||
expect(tracker.encodeWheel(wheelUpAt3x4)).toBe("\x1b[M`#$");
|
||||
expect(tracker.encodeWheel({ ...wheelUpAt3x4, col: 96 })).toBeNull();
|
||||
expect(tracker.encodeWheel({ ...wheelUpAt3x4, row: 96 })).toBeNull();
|
||||
});
|
||||
|
||||
it("does not encode wheel reports for X10 press-only tracking", () => {
|
||||
const tracker = new TerminalInputModeTracker();
|
||||
tracker.feed("\x1b[?9h\x1b[?1006h");
|
||||
|
||||
expect(tracker.encodeWheel(wheelUpAt3x4)).toBeNull();
|
||||
});
|
||||
|
||||
const tapAt3x4 = { col: 3, row: 4, pixelX: 30, pixelY: 80 };
|
||||
|
||||
it.each([
|
||||
["no tracking", "", null],
|
||||
["SGR press/release", PI_FULLSCREEN_MOUSE_ENABLE, "\x1b[<0;3;4M\x1b[<0;3;4m"],
|
||||
["SGR pixel press/release", "\x1b[?1000h\x1b[?1016h", "\x1b[<0;30;80M\x1b[<0;30;80m"],
|
||||
["default press and button-3 release", "\x1b[?1000h", "\x1b[M #$\x1b[M##$"],
|
||||
["X10 default press only", "\x1b[?9h", "\x1b[M #$"],
|
||||
["X10 SGR press only", "\x1b[?9h\x1b[?1006h", "\x1b[<0;3;4M"],
|
||||
[
|
||||
"tracking disabled again",
|
||||
`${PI_FULLSCREEN_MOUSE_ENABLE}${PI_FULLSCREEN_MOUSE_DISABLE}`,
|
||||
null,
|
||||
],
|
||||
])("encodes a left click with %s", (_name, output, expected) => {
|
||||
const tracker = new TerminalInputModeTracker();
|
||||
tracker.feed(output);
|
||||
|
||||
expect(tracker.encodeLeftClick(tapAt3x4)).toBe(expected);
|
||||
});
|
||||
|
||||
it("does not encode a default left click that leaves ASCII", () => {
|
||||
const tracker = new TerminalInputModeTracker();
|
||||
tracker.feed("\x1b[?1000h");
|
||||
|
||||
expect(tracker.encodeLeftClick({ ...tapAt3x4, col: 96 })).toBeNull();
|
||||
});
|
||||
|
||||
it.each([["\x1bc"], ["\x1b", "c"]])(
|
||||
"clears every tracked mode on a full reset split as %j",
|
||||
(...chunks) => {
|
||||
const tracker = new TerminalInputModeTracker();
|
||||
tracker.feed(`\x1b[>7u\x1b[?2004h${PI_FULLSCREEN_MOUSE_ENABLE}`);
|
||||
|
||||
const results = chunks.map((chunk) => tracker.feed(chunk));
|
||||
|
||||
expect(results.at(-1)).toEqual({ changed: true, responses: [] });
|
||||
expect(tracker.getPreamble()).toBe("");
|
||||
expect(tracker.encodeWheel(wheelUpAt3x4)).toBeNull();
|
||||
},
|
||||
);
|
||||
|
||||
it("ignores encoded key input sequences", () => {
|
||||
const tracker = new TerminalInputModeTracker();
|
||||
|
||||
|
||||
@@ -10,6 +10,17 @@ export interface TerminalInputModeState {
|
||||
bracketedPaste?: boolean;
|
||||
}
|
||||
|
||||
export interface TerminalMousePosition {
|
||||
col: number;
|
||||
row: number;
|
||||
pixelX: number;
|
||||
pixelY: number;
|
||||
}
|
||||
|
||||
export interface TerminalWheelInput extends TerminalMousePosition {
|
||||
direction: "up" | "down";
|
||||
}
|
||||
|
||||
export const DEFAULT_TERMINAL_INPUT_MODE_STATE: TerminalInputModeState = {
|
||||
kittyKeyboardFlags: 0,
|
||||
win32InputMode: false,
|
||||
@@ -21,8 +32,20 @@ const ESC = String.fromCharCode(0x1b);
|
||||
const APPLICATION_CURSOR_KEYS_MODE = 1;
|
||||
const WIN32_INPUT_MODE = 9001;
|
||||
const BRACKETED_PASTE_MODE = 2004;
|
||||
const CSI_INPUT_MODE_SEQUENCE = new RegExp(
|
||||
`${ESC}\\[(?:([<>=?]?)([0-9;]*)u|\\?([0-9;]*)([hl]))`,
|
||||
const X10_MOUSE_TRACKING_MODE = 9;
|
||||
const MOUSE_TRACKING_MODES = new Set([X10_MOUSE_TRACKING_MODE, 1000, 1002, 1003]);
|
||||
const SGR_MOUSE_ENCODING_MODE = 1006;
|
||||
const SGR_PIXELS_MOUSE_ENCODING_MODE = 1016;
|
||||
const MOUSE_ENCODING_MODES = new Set([SGR_MOUSE_ENCODING_MODE, SGR_PIXELS_MOUSE_ENCODING_MODE]);
|
||||
const LEFT_BUTTON = 0;
|
||||
const DEFAULT_RELEASE_BUTTON = 3;
|
||||
const WHEEL_UP_BUTTON = 64;
|
||||
const WHEEL_DOWN_BUTTON = 65;
|
||||
const DEFAULT_MOUSE_ENCODING_OFFSET = 32;
|
||||
const MAX_ASCII_CODE = 127;
|
||||
const FULL_RESET = `${ESC}c`;
|
||||
const INPUT_MODE_SEQUENCE = new RegExp(
|
||||
`${ESC}(?:c|\\[(?:([<>=?]?)([0-9;]*)u|\\?([0-9;]*)([hl])))`,
|
||||
"g",
|
||||
);
|
||||
const INCOMPLETE_CSI_INPUT_MODE_SEQUENCE = new RegExp(`${ESC}\\[[<>=?]?[0-9;]*$`);
|
||||
@@ -74,6 +97,8 @@ export class TerminalInputModeTracker {
|
||||
private win32InputMode = false;
|
||||
private applicationCursorKeys = false;
|
||||
private bracketedPaste = false;
|
||||
private mouseTrackingMode = 0;
|
||||
private mouseEncodingMode = 0;
|
||||
private readonly kittyKeyboardStack: number[] = [];
|
||||
private pending = "";
|
||||
|
||||
@@ -89,13 +114,19 @@ export class TerminalInputModeTracker {
|
||||
const responses: string[] = [];
|
||||
let consumedUntil = 0;
|
||||
|
||||
CSI_INPUT_MODE_SEQUENCE.lastIndex = 0;
|
||||
INPUT_MODE_SEQUENCE.lastIndex = 0;
|
||||
for (;;) {
|
||||
const match = CSI_INPUT_MODE_SEQUENCE.exec(text);
|
||||
const match = INPUT_MODE_SEQUENCE.exec(text);
|
||||
if (!match) {
|
||||
break;
|
||||
}
|
||||
consumedUntil = CSI_INPUT_MODE_SEQUENCE.lastIndex;
|
||||
consumedUntil = INPUT_MODE_SEQUENCE.lastIndex;
|
||||
|
||||
if (match[0] === FULL_RESET) {
|
||||
changed = this.getPreamble().length > 0 || changed;
|
||||
this.reset();
|
||||
continue;
|
||||
}
|
||||
|
||||
if (match[4]) {
|
||||
changed = this.applyPrivateModeSequence(match[3] ?? "", match[4]) || changed;
|
||||
@@ -108,10 +139,10 @@ export class TerminalInputModeTracker {
|
||||
}
|
||||
|
||||
const tail = text.slice(consumedUntil);
|
||||
const pendingStart = tail.lastIndexOf(`${ESC}[`);
|
||||
const pendingStart = tail.lastIndexOf(ESC);
|
||||
if (pendingStart >= 0) {
|
||||
const pending = tail.slice(pendingStart);
|
||||
if (INCOMPLETE_CSI_INPUT_MODE_SEQUENCE.test(pending)) {
|
||||
if (pending === ESC || INCOMPLETE_CSI_INPUT_MODE_SEQUENCE.test(pending)) {
|
||||
this.pending = pending;
|
||||
}
|
||||
}
|
||||
@@ -124,6 +155,8 @@ export class TerminalInputModeTracker {
|
||||
this.win32InputMode = false;
|
||||
this.applicationCursorKeys = false;
|
||||
this.bracketedPaste = false;
|
||||
this.mouseTrackingMode = 0;
|
||||
this.mouseEncodingMode = 0;
|
||||
this.kittyKeyboardStack.length = 0;
|
||||
this.pending = "";
|
||||
}
|
||||
@@ -159,9 +192,64 @@ export class TerminalInputModeTracker {
|
||||
if (this.bracketedPaste) {
|
||||
parts.push("\x1b[?2004h");
|
||||
}
|
||||
// The alternate screen is never replayed here: re-entering it would clear the restored screen.
|
||||
if (this.mouseTrackingMode > 0) {
|
||||
parts.push(`\x1b[?${this.mouseTrackingMode}h`);
|
||||
}
|
||||
if (this.mouseEncodingMode > 0) {
|
||||
parts.push(`\x1b[?${this.mouseEncodingMode}h`);
|
||||
}
|
||||
return parts.join("");
|
||||
}
|
||||
|
||||
/** Returns the wheel report the foreground app requested, or null when it does not track the wheel. */
|
||||
encodeWheel(input: TerminalWheelInput): string | null {
|
||||
const isWheelReported =
|
||||
this.mouseTrackingMode > 0 && this.mouseTrackingMode !== X10_MOUSE_TRACKING_MODE;
|
||||
if (!isWheelReported) {
|
||||
return null;
|
||||
}
|
||||
const button = input.direction === "up" ? WHEEL_UP_BUTTON : WHEEL_DOWN_BUTTON;
|
||||
return this.encodeMouseReport(button, input, "M");
|
||||
}
|
||||
|
||||
/** Returns a left press and release, press only under X10, or null when the app does not track the mouse. */
|
||||
encodeLeftClick(position: TerminalMousePosition): string | null {
|
||||
if (this.mouseTrackingMode === 0) {
|
||||
return null;
|
||||
}
|
||||
const press = this.encodeMouseReport(LEFT_BUTTON, position, "M");
|
||||
if (press === null || this.mouseTrackingMode === X10_MOUSE_TRACKING_MODE) {
|
||||
return press;
|
||||
}
|
||||
const isSgr = this.mouseEncodingMode !== 0;
|
||||
const release = isSgr
|
||||
? this.encodeMouseReport(LEFT_BUTTON, position, "m")
|
||||
: this.encodeMouseReport(DEFAULT_RELEASE_BUTTON, position, "M");
|
||||
return release === null ? null : `${press}${release}`;
|
||||
}
|
||||
|
||||
private encodeMouseReport(
|
||||
button: number,
|
||||
position: TerminalMousePosition,
|
||||
sgrFinal: "M" | "m",
|
||||
): string | null {
|
||||
if (this.mouseEncodingMode === SGR_PIXELS_MOUSE_ENCODING_MODE) {
|
||||
return `\x1b[<${button};${position.pixelX};${position.pixelY}${sgrFinal}`;
|
||||
}
|
||||
if (this.mouseEncodingMode === SGR_MOUSE_ENCODING_MODE) {
|
||||
return `\x1b[<${button};${position.col};${position.row}${sgrFinal}`;
|
||||
}
|
||||
// Input travels as UTF-8 text, so a default-encoded byte above ASCII would be re-encoded.
|
||||
const codes = [button, position.col, position.row].map(
|
||||
(value) => value + DEFAULT_MOUSE_ENCODING_OFFSET,
|
||||
);
|
||||
if (codes.some((code) => code > MAX_ASCII_CODE)) {
|
||||
return null;
|
||||
}
|
||||
return `\x1b[M${String.fromCharCode(...codes)}`;
|
||||
}
|
||||
|
||||
private applyKittyKeyboardSequence(
|
||||
prefix: string,
|
||||
params: string,
|
||||
@@ -205,6 +293,18 @@ export class TerminalInputModeTracker {
|
||||
const modes = parsePrivateModeParams(params);
|
||||
let changed = false;
|
||||
|
||||
for (const mode of modes) {
|
||||
const next = final === "h" ? mode : 0;
|
||||
if (MOUSE_TRACKING_MODES.has(mode)) {
|
||||
changed = this.mouseTrackingMode !== next || changed;
|
||||
this.mouseTrackingMode = next;
|
||||
}
|
||||
if (MOUSE_ENCODING_MODES.has(mode)) {
|
||||
changed = this.mouseEncodingMode !== next || changed;
|
||||
this.mouseEncodingMode = next;
|
||||
}
|
||||
}
|
||||
|
||||
if (modes.has(WIN32_INPUT_MODE)) {
|
||||
const previous = this.win32InputMode;
|
||||
this.win32InputMode = final === "h";
|
||||
|
||||
Reference in New Issue
Block a user