12. 鼠标解析与滚动接线
正文能用滚轮、PageUp/PageDown 翻看,靠的是 mouse.ts 解析 SGR 鼠标序列,再由 HomeScreenView.tsx 把这些事件接到滚动原子上。这一篇把这条链路讲完。
开关鼠标上报:转义序列
终端默认不上报鼠标事件,需要主动开启:
export const ENABLE_SGR_MOUSE_TRACKING = '\u001B[?1000h\u001B[?1006h';
export const DISABLE_SGR_MOUSE_TRACKING = '\u001B[?1006l\u001B[?1000l';
?1000开启基本鼠标按键上报。?1006开启 SGR 扩展模式,让坐标和按钮以可读的十进制数字编码(而不是受限的单字节),支持大终端。- 开启用
h结尾、关闭用l结尾,关闭时顺序相反,干净地还原终端状态。
解析 SGR 序列
SGR 鼠标事件长这样:ESC [ < 按钮码 ; 列 ; 行 M(或以 m 结尾表示松开)。用一个正则匹配:
const SGR_MOUSE_INPUT_PATTERN = /^(?:\u001B)?\[<(?<buttonCode>\d+);\d+;\d+(?<eventType>[mM])$/;
const WHEEL_BUTTON_OFFSET = 64;
const WHEEL_BUTTON_COUNT = 4;
export function isMouseInput(input: string): boolean {
return SGR_MOUSE_INPUT_PATTERN.test(input);
}
isMouseInput 只判断“是不是鼠标序列”。第 9 篇的输入框就是用它把鼠标事件挡在 prompt 文本之外——开头的 ESC 用 (?:\u001B)? 设为可选,是因为不同终端/解析层有时会把 ESC 先吃掉,留下以 [< 开头的部分。
解析滚轮方向:parseMouseWheelInput
export function parseMouseWheelInput(input: string): MouseWheelDirection | null {
const match = SGR_MOUSE_INPUT_PATTERN.exec(input);
if (match?.groups === undefined || match.groups.eventType !== 'M') {
return null;
}
const buttonCode = Number.parseInt(match.groups.buttonCode, 10);
if (buttonCode < WHEEL_BUTTON_OFFSET) {
return null;
}
// SGR mouse encodes wheel events starting at button 64; modulo strips any
// modifier bits while keeping 0/1 as vertical wheel up/down.
const wheelButton = (buttonCode - WHEEL_BUTTON_OFFSET) % WHEEL_BUTTON_COUNT;
if (wheelButton === 0) {
return 'up';
}
if (wheelButton === 1) {
return 'down';
}
return null;
}
逐步解码:
- 只认
M(按下/触发)事件,m(松开)忽略。 - 滚轮按钮码从 64(
WHEEL_BUTTON_OFFSET)起,小于 64 的是普通点击/拖拽,直接null。 - 减掉偏移后对 4(
WHEEL_BUTTON_COUNT)取模,剥掉 Shift/Ctrl 等修饰位(它们叠加在更高位上),只留下低位:0= 向上滚、1= 向下滚,其余(水平滚轮等)返回null。
返回一个干净的 'up' | 'down' | null,调用方完全不用碰转义码细节。
滚动接线:HomeScreenView
进出时开关鼠标上报
useEffect(() => {
if (!stdout.isTTY) {
return;
}
stdout.write(ENABLE_SGR_MOUSE_TRACKING);
return () => {
stdout.write(DISABLE_SGR_MOUSE_TRACKING);
};
}, [stdout]);
挂载时往 stdout 写开启序列,卸载时写关闭序列还原终端。只在 stdout.isTTY 时做——管道、测试渲染器等非 TTY 场景跳过,免得把转义码污染进输出。
把事件派发到滚动原子
const MOUSE_WHEEL_SCROLL_ROWS = 3;
useInput((input, key) => {
const wheelDirection = parseMouseWheelInput(input);
if (wheelDirection !== null) {
scrollBodyByRows(wheelDirection === 'up' ? MOUSE_WHEEL_SCROLL_ROWS : -MOUSE_WHEEL_SCROLL_ROWS);
return;
}
if (key.pageUp) {
scrollBodyByRows(Math.max(1, layout.bodyRows - 2));
return;
}
if (key.pageDown) {
scrollBodyByRows(-Math.max(1, layout.bodyRows - 2));
return;
}
if (key.end) {
scrollBodyByRows(Number.NEGATIVE_INFINITY);
}
});
四种导航,全部汇聚到第 3 篇的 scrollBodyByRows(它内部会夹住范围):
- 滚轮:每次 3 行(
MOUSE_WHEEL_SCROLL_ROWS),上滚为正、下滚为负。回忆第 3 篇:正偏移是“往回看历史”,负偏移是“回到最新”。 - PageUp/PageDown:一次翻“可见行数 - 2”行(留 2 行重叠,保持上下文连续),至少 1 行。
- End:传
-∞,被scrollBodyByRows的Math.max(0, ...)夹成偏移 0,也就是一键回到底部最新内容。
注意这里的 scrollBodyByRows 是第 3 篇 scrollBodyByRowsAtom 的 setter(useSetAtom),而方向解析复用 mouse.ts,正文是否真的能滚由 BodyPane 按 maxScrollOffset 决定(第 7 篇)。整条链路就此闭合:终端发 SGR 序列 → mouse.ts 解析 → HomeScreenView 派发 → 滚动原子夹范围 → BodyPane 渲染对应窗口。
终端 resize
最后补一句尺寸自适应。第 1 篇 App.tsx 用 Ink 的 useWindowSize() 拿实时 columns/rows,终端一 resize 就重渲染,createHomeScreenConfig 产出新的 columns/rows,第 4 篇的 layoutAtom、bottomSpacerRowsAtom、composerTopAtom 全部重算。于是无论怎么拉伸终端窗口,cwd、输入框、状态栏都稳稳吸在底部,正文与输入框各自按新尺寸重新换行——这就是 tui/AGENTS.md 要求的“任意 shell 窗口尺寸下底部 chrome 都吸底”。
下一篇做 U2 总结。