jaitus 0.3.0 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/tui/mouse.js CHANGED
@@ -1,125 +1,150 @@
1
- // ─── Mouse ──────────────────────────────────────────────────────────────────────
2
- //
3
- // Ink has no mouse support, so this is the whole of it: turn the terminal's reporting
4
- // on, recognise the sequences when they arrive through the ordinary key handler, and
5
- // turn it off again on the way out.
6
- //
7
- // Two things make that safe to do:
8
- //
9
- // • **SGR extended mode (1006) or nothing.** The original X10 encoding packs the
10
- // coordinates into single bytes offset by 32, so it cannot report a click past
11
- // column 223 — on a wide terminal the right-hand half of the screen silently stops
12
- // responding. Every terminal worth enabling this for has supported 1006 for a
13
- // decade; the ones that do not simply get no mouse, which is the honest outcome.
14
- // • **The sequences arrive as ordinary input.** Ink's key parser does not recognise
15
- // them, so it hands them through as a plain string with the leading escape stripped.
16
- // That is lucky rather than designed, and it is why the guard below runs before
17
- // anything can insert them into the composer — without it, scrolling the wheel types
18
- // `[<64;10;5M` into your prompt.
19
- //
20
- // Enabling mouse reporting takes over the terminal's own text selection. That is a real
21
- // cost, not a detail: people select and copy out of a terminal constantly. Hold Shift to
22
- // get the native behaviour back in every terminal that supports this, and `/mouse off`
23
- // turns it off for good.
24
-
25
- // Button-event tracking (1002) rather than any-event (1003): 1003 reports every pixel of
26
- // pointer motion, which on a busy screen is thousands of writes a second for information
27
- // nothing here uses.
28
- const ON = '\x1b[?1000h\x1b[?1002h\x1b[?1006h';
29
- const OFF = '\x1b[?1006l\x1b[?1002l\x1b[?1000l';
30
-
31
- // Ink strips the leading escape, so both spellings have to match.
32
- const SGR = /^\x1b?\[<(\d+);(\d+);(\d+)([Mm])$/;
33
-
34
- // The X10 encoding: `CSI M` followed by three raw bytes. A terminal that does not
35
- // understand the 1006 request still honours 1000 and reports in this form — and those
36
- // bytes are unprintable garbage that would otherwise be typed into the composer. It is
37
- // recognised only to be swallowed: its coordinates top out at column 223, which makes
38
- // it useless for anything but ignoring.
39
- const X10 = /^\x1b?\[M[\s\S]{3}$/;
40
-
41
- /** True once, per process: never write the disable sequence for something never enabled. */
42
- let enabled = false;
43
-
44
- export function enableMouse(stream = process.stdout) {
45
- if (enabled || !stream?.isTTY) return false;
46
- try { stream.write(ON); enabled = true; } catch { /* not a real terminal */ }
47
- return enabled;
48
- }
49
-
50
- export function disableMouse(stream = process.stdout) {
51
- if (!enabled) return;
52
- try { stream.write(OFF); } catch { /* going away anyway */ }
53
- enabled = false;
54
- }
55
-
56
- export const mouseEnabled = () => enabled;
57
-
58
- /**
59
- * One input string → a mouse event, or null.
60
- *
61
- * The button field is a bitfield: the low two bits are which button, bit 5 (32) means
62
- * the pointer was moving with a button held, and bit 6 (64) means the wheel. Shift, Alt
63
- * and Ctrl ride in bits 2–4 and are reported so a Shift-click can mean something
64
- * different from a click.
65
- */
66
- export function parseMouse(input) {
67
- const m = SGR.exec(String(input ?? ''));
68
- if (!m) return null;
69
- const code = Number(m[1]);
70
- const x = Number(m[2]);
71
- const y = Number(m[3]);
72
- const pressed = m[4] === 'M';
73
-
74
- const wheel = (code & 64) !== 0;
75
- const motion = (code & 32) !== 0;
76
- const button = code & 3;
77
-
78
- return {
79
- x,
80
- y,
81
- shift: (code & 4) !== 0,
82
- meta: (code & 8) !== 0,
83
- ctrl: (code & 16) !== 0,
84
- // A wheel "button" of 0 is up and 1 is down; 2 and 3 are horizontal tilt, which is
85
- // reported so a future horizontal scroll has somewhere to go rather than being
86
- // silently read as a vertical one.
87
- kind: wheel
88
- ? (button === 0 ? 'wheelUp' : button === 1 ? 'wheelDown' : button === 2 ? 'wheelLeft' : 'wheelRight')
89
- : motion ? 'drag'
90
- : pressed ? 'down' : 'up',
91
- button: wheel ? null : button === 0 ? 'left' : button === 1 ? 'middle' : button === 2 ? 'right' : null,
92
- };
93
- }
94
-
95
- /** Does this input look like a mouse report we should swallow rather than type? */
96
- export const isMouse = (input) => {
97
- const s = String(input ?? '');
98
- return SGR.test(s) || X10.test(s);
99
- };
100
-
101
- /**
102
- * A hit map for one frame.
103
- *
104
- * The layout is computed in one place, so the regions can be computed there too rather
105
- * than measured afterwards. Rows and columns are 1-based to match what the terminal
106
- * reports, and later registrations win — which is what makes an overlay's buttons
107
- * reachable while the header underneath it is not.
108
- */
109
- export function createHitMap() {
110
- let regions = [];
111
- return {
112
- reset() { regions = []; },
113
- add(region) { if (region && region.action) regions.push(region); },
114
- /** The topmost region containing this cell, or null. */
115
- at(x, y) {
116
- for (let i = regions.length - 1; i >= 0; i--) {
117
- const r = regions[i];
118
- if (x >= r.x1 && x <= r.x2 && y >= r.y1 && y <= r.y2) return r;
119
- }
120
- return null;
121
- },
122
- get size() { return regions.length; },
123
- all() { return regions.slice(); },
124
- };
125
- }
1
+ // ─── Mouse ──────────────────────────────────────────────────────────────────────
2
+ //
3
+ // Ink has no mouse support, so this is the whole of it: turn the terminal's reporting
4
+ // on, recognise the sequences when they arrive through the ordinary key handler, and
5
+ // turn it off again on the way out.
6
+ //
7
+ // Two things make that safe to do:
8
+ //
9
+ // • **SGR extended mode (1006) or nothing.** The original X10 encoding packs the
10
+ // coordinates into single bytes offset by 32, so it cannot report a click past
11
+ // column 223 — on a wide terminal the right-hand half of the screen silently stops
12
+ // responding. Every terminal worth enabling this for has supported 1006 for a
13
+ // decade; the ones that do not simply get no mouse, which is the honest outcome.
14
+ // • **The sequences arrive as ordinary input.** Ink's key parser does not recognise
15
+ // them, so it hands them through as a plain string with the leading escape stripped.
16
+ // That is lucky rather than designed, and it is why the guard below runs before
17
+ // anything can insert them into the composer — without it, scrolling the wheel types
18
+ // `[<64;10;5M` into your prompt.
19
+ //
20
+ // Enabling mouse reporting takes over the terminal's own text selection. That is a real
21
+ // cost, not a detail: people select and copy out of a terminal constantly. Hold Shift to
22
+ // get the native behaviour back in every terminal that supports this, and `/mouse off`
23
+ // turns it off for good.
24
+
25
+ // Button-event tracking (1002) rather than any-event (1003): 1003 reports every pixel of
26
+ // pointer motion, which on a busy screen is thousands of writes a second for information
27
+ // nothing here uses.
28
+ const ON = '\x1b[?1000h\x1b[?1002h\x1b[?1006h';
29
+ const OFF = '\x1b[?1006l\x1b[?1002l\x1b[?1000l';
30
+
31
+ // Ink strips the leading escape, so both spellings have to match.
32
+ const SGR = /^\x1b?\[<(\d+);(\d+);(\d+)([Mm])$/;
33
+
34
+ // The X10 encoding: `CSI M` followed by three raw bytes, each offset by 32. A terminal
35
+ // that does not understand the 1006 request still honours 1000 and reports in this
36
+ // form. It is decoded rather than swallowed: its coordinates stop at column 223, but
37
+ // below that it is a perfectly good report, and swallowing it meant a terminal without
38
+ // 1006 had no selection at all AND no native one, because reporting was still on.
39
+ const X10 = /^\x1b?\[M([\s\S])([\s\S])([\s\S])$/;
40
+
41
+ // A report the input layer delivered in pieces. Ink holds an unfinished escape sequence
42
+ // for only 20ms before passing it through as typed text, so a report split across two
43
+ // reads — a busy machine, SSH, a fast trackpad drag — arrived as two halves: the drag
44
+ // was lost and the second half was typed into the prompt as `[<32;15;13M`.
45
+ const SGR_FRAGMENT = /^\x1b?\[<[\d;]*$/;
46
+ const X10_FRAGMENT = /^\x1b?\[M[\s\S]{0,2}$/;
47
+
48
+ /** True once, per process: never write the disable sequence for something never enabled. */
49
+ let enabled = false;
50
+
51
+ export function enableMouse(stream = process.stdout) {
52
+ if (enabled || !stream?.isTTY) return false;
53
+ try { stream.write(ON); enabled = true; } catch { /* not a real terminal */ }
54
+ return enabled;
55
+ }
56
+
57
+ export function disableMouse(stream = process.stdout) {
58
+ if (!enabled) return;
59
+ try { stream.write(OFF); } catch { /* going away anyway */ }
60
+ enabled = false;
61
+ }
62
+
63
+ export const mouseEnabled = () => enabled;
64
+
65
+ /**
66
+ * One input string → a mouse event, or null.
67
+ *
68
+ * The button field is a bitfield: the low two bits are which button, bit 5 (32) means
69
+ * the pointer was moving with a button held, and bit 6 (64) means the wheel. Shift, Alt
70
+ * and Ctrl ride in bits 2–4 and are reported so a Shift-click can mean something
71
+ * different from a click.
72
+ */
73
+ export function parseMouse(input) {
74
+ const s = String(input ?? '');
75
+ let code, x, y, pressed;
76
+ const m = SGR.exec(s);
77
+ if (m) {
78
+ code = Number(m[1]);
79
+ x = Number(m[2]);
80
+ y = Number(m[3]);
81
+ pressed = m[4] === 'M';
82
+ } else {
83
+ const o = X10.exec(s);
84
+ if (!o) return null;
85
+ code = o[1].charCodeAt(0) - 32;
86
+ x = o[2].charCodeAt(0) - 32;
87
+ y = o[3].charCodeAt(0) - 32;
88
+ // X10 has no release suffix: a release is button 3, and it does not say which.
89
+ pressed = (code & 3) !== 3 || (code & 64) !== 0;
90
+ if (x < 1 || y < 1) return null;
91
+ }
92
+
93
+ const wheel = (code & 64) !== 0;
94
+ const motion = (code & 32) !== 0;
95
+ const button = code & 3;
96
+
97
+ return {
98
+ x,
99
+ y,
100
+ shift: (code & 4) !== 0,
101
+ meta: (code & 8) !== 0,
102
+ ctrl: (code & 16) !== 0,
103
+ // A wheel "button" of 0 is up and 1 is down; 2 and 3 are horizontal tilt, which is
104
+ // reported so a future horizontal scroll has somewhere to go rather than being
105
+ // silently read as a vertical one.
106
+ kind: wheel
107
+ ? (button === 0 ? 'wheelUp' : button === 1 ? 'wheelDown' : button === 2 ? 'wheelLeft' : 'wheelRight')
108
+ : motion ? 'drag'
109
+ : pressed ? 'down' : 'up',
110
+ button: wheel ? null : button === 0 ? 'left' : button === 1 ? 'middle' : button === 2 ? 'right' : null,
111
+ };
112
+ }
113
+
114
+ /** The start of a mouse report whose remainder has not arrived yet. */
115
+ export const isMouseFragment = (input) => {
116
+ const s = String(input ?? '');
117
+ return (SGR_FRAGMENT.test(s) || X10_FRAGMENT.test(s)) && !SGR.test(s) && !X10.test(s);
118
+ };
119
+
120
+ /** Does this input look like a mouse report we should swallow rather than type? */
121
+ export const isMouse = (input) => {
122
+ const s = String(input ?? '');
123
+ return SGR.test(s) || X10.test(s);
124
+ };
125
+
126
+ /**
127
+ * A hit map for one frame.
128
+ *
129
+ * The layout is computed in one place, so the regions can be computed there too rather
130
+ * than measured afterwards. Rows and columns are 1-based to match what the terminal
131
+ * reports, and later registrations win — which is what makes an overlay's buttons
132
+ * reachable while the header underneath it is not.
133
+ */
134
+ export function createHitMap() {
135
+ let regions = [];
136
+ return {
137
+ reset() { regions = []; },
138
+ add(region) { if (region && region.action) regions.push(region); },
139
+ /** The topmost region containing this cell, or null. */
140
+ at(x, y) {
141
+ for (let i = regions.length - 1; i >= 0; i--) {
142
+ const r = regions[i];
143
+ if (x >= r.x1 && x <= r.x2 && y >= r.y1 && y <= r.y2) return r;
144
+ }
145
+ return null;
146
+ },
147
+ get size() { return regions.length; },
148
+ all() { return regions.slice(); },
149
+ };
150
+ }