anvilsign in

collin/strudel-claude

main / node-tui / mouse.ts
1// Mouse support.
2//
3// Ink has none — it reads keys and nothing else — so this turns tracking on at
4// the terminal and parses the reports itself.
5//
6// Two escape sequences do the enabling. `?1000h` asks for button press/release
7// reports; `?1006h` asks for them in SGR form. The second one matters: the
8// original X10 encoding packs coordinates into single bytes offset by 32, which
9// silently breaks past column 223 — a width any real terminal exceeds. SGR mode
10// reports decimal coordinates instead and has no such ceiling.
11//
12// Both must be turned back off on exit. A terminal left in tracking mode after
13// the program dies emits escape sequences into the user's shell every time they
14// move the mouse, which looks like the shell is broken.
15import { useEffect, useRef } from 'react';
16import { useStdin, useStdout } from 'ink';
17
18const ENABLE = '\x1b[?1000h\x1b[?1006h';
19const DISABLE = '\x1b[?1006l\x1b[?1000l';
20
21export type MouseButton = 'left' | 'middle' | 'right' | 'wheel-up' | 'wheel-down';
22
23export interface MouseEvent {
24 button: MouseButton;
25 /** Zero-based, converted from the terminal's one-based coordinates. */
26 column: number;
27 row: number;
28 pressed: boolean;
29}
30
31// SGR reports look like `ESC [ < button ; column ; row (M|m)`, where M is a
32// press and m a release.
33//
34// The leading ESC is optional here, and that is not defensiveness — Ink's key
35// parser consumes the ESC before handing the rest to useInput, so the very same
36// report reaches this module as `\x1b[<0;9;6M` on raw stdin and as `[<0;9;6M`
37// through the keymap. Both have to be recognised: the first to be acted on, the
38// second to be discarded before it gets typed into the buffer.
39const SGR = /(?:\x1b)?\[<(\d+);(\d+);(\d+)([Mm])/g;
40
41/** True if a chunk contains a mouse report — used to keep them out of the keymap. */
42export function isMouseSequence(input: string): boolean {
43 return /(?:\x1b)?\[<\d+;\d+;\d+[Mm]/.test(input);
44}
45
46function decodeButton(code: number): MouseButton | null {
47 // Bit 6 marks wheel events; the low bits then say which direction.
48 if (code & 64) return (code & 1) === 0 ? 'wheel-up' : 'wheel-down';
49 // Bits 2-5 are modifier keys (shift/meta/ctrl) and drag state, not the button.
50 switch (code & 3) {
51 case 0: return 'left';
52 case 1: return 'middle';
53 case 2: return 'right';
54 default: return null; // 3 is "released, button unknown"
55 }
56}
57
58/** Parse every mouse report in a chunk. Non-mouse bytes are ignored. */
59export function parseMouse(chunk: string): MouseEvent[] {
60 const events: MouseEvent[] = [];
61 SGR.lastIndex = 0;
62 let match: RegExpExecArray | null;
63 while ((match = SGR.exec(chunk)) !== null) {
64 const button = decodeButton(Number(match[1]));
65 if (!button) continue;
66 events.push({
67 button,
68 column: Number(match[2]) - 1,
69 row: Number(match[3]) - 1,
70 pressed: match[4] === 'M',
71 });
72 }
73 return events;
74}
75
76/**
77 * Enable mouse reporting for as long as the component is mounted, and call
78 * `onEvent` for each report.
79 *
80 * Only presses are delivered — releases would double every click, and nothing
81 * here needs drag.
82 */
83export function useMouse(onEvent: (event: MouseEvent) => void): void {
84 const { stdin, isRawModeSupported } = useStdin();
85 const { stdout } = useStdout();
86
87 // The handler goes through a ref so the effect's dependencies stay stable.
88 // Its natural dependencies — the layout, the song list — are rebuilt on every
89 // render, and this component re-renders 25 times a second to animate. With
90 // `onEvent` in the dependency array the effect would tear down and re-arm at
91 // that rate: writing the enable/disable sequences continuously, and piling up
92 // process 'exit' listeners until Node started warning about a leak. Reading
93 // the latest handler from a ref means the terminal is configured exactly once.
94 const handler = useRef(onEvent);
95 handler.current = onEvent;
96
97 useEffect(() => {
98 if (!isRawModeSupported) return;
99
100 stdout.write(ENABLE);
101 const listener = (data: Buffer | string): void => {
102 for (const event of parseMouse(data.toString('utf8'))) {
103 if (event.pressed) handler.current(event);
104 }
105 };
106 stdin.on('data', listener);
107
108 // Also disarm if the process is killed outright — an unmount handler alone
109 // doesn't run on SIGINT/SIGTERM, and that's exactly when a terminal gets
110 // left in tracking mode.
111 const restore = (): void => void stdout.write(DISABLE);
112 process.on('exit', restore);
113
114 return () => {
115 stdin.off('data', listener);
116 process.off('exit', restore);
117 stdout.write(DISABLE);
118 };
119 }, [stdin, stdout, isRawModeSupported]);
120}