@sanohiro/casty 0.5.0
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/LICENSE +21 -0
- package/README.ja.md +178 -0
- package/README.md +178 -0
- package/bin/casty +200 -0
- package/bin/casty.js +245 -0
- package/lib/bookmarks.js +32 -0
- package/lib/browser.js +305 -0
- package/lib/cdp.js +76 -0
- package/lib/chrome.js +155 -0
- package/lib/config.js +43 -0
- package/lib/hints.js +255 -0
- package/lib/input.js +545 -0
- package/lib/keys.js +51 -0
- package/lib/kitty.js +117 -0
- package/lib/urlbar.js +348 -0
- package/package.json +41 -0
package/lib/input.js
ADDED
|
@@ -0,0 +1,545 @@
|
|
|
1
|
+
// Input event handling
|
|
2
|
+
// Receives keyboard/mouse events via stdin raw mode and forwards them to Chrome via CDP
|
|
3
|
+
|
|
4
|
+
import { toKeyName } from './keys.js';
|
|
5
|
+
import { UrlBar } from './urlbar.js';
|
|
6
|
+
import { HintMode } from './hints.js';
|
|
7
|
+
|
|
8
|
+
// SGR 1006 mouse button codes
|
|
9
|
+
const MOUSE_BTN_LEFT = 0;
|
|
10
|
+
const MOUSE_BTN_MOTION = 35; // Motion without button (mode 1003)
|
|
11
|
+
const MOUSE_BTN_DRAG = 32;
|
|
12
|
+
const MOUSE_SCROLL_UP = 64;
|
|
13
|
+
const MOUSE_SCROLL_DOWN = 65;
|
|
14
|
+
const SCROLL_DELTA = 100; // px
|
|
15
|
+
|
|
16
|
+
// OSC 52 clipboard response timeout
|
|
17
|
+
const CLIPBOARD_TIMEOUT = 1000; // ms
|
|
18
|
+
|
|
19
|
+
// Status message display duration
|
|
20
|
+
const STATUS_DISPLAY_MS = 2000;
|
|
21
|
+
|
|
22
|
+
// Enable mouse events (SGR 1006 format)
|
|
23
|
+
// Mode 1003: any-event tracking (reports motion even without button press)
|
|
24
|
+
// This ensures Chrome always knows cursor position for proper hover/click handling
|
|
25
|
+
export function enableMouse() {
|
|
26
|
+
process.stdout.write('\x1b[?1000;1003;1006h');
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Disable mouse events
|
|
30
|
+
export function disableMouse() {
|
|
31
|
+
process.stdout.write('\x1b[?1000;1003;1006l');
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Convert terminal cell coordinates to pixel coordinates
|
|
35
|
+
// row=2 is the top of the browser area (row=1 is the URL bar)
|
|
36
|
+
function cellToPixel(col, row, cellWidth, cellHeight) {
|
|
37
|
+
return {
|
|
38
|
+
x: (col - 1) * cellWidth,
|
|
39
|
+
y: (row - 2) * cellHeight,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Send mouse event via CDP
|
|
44
|
+
async function dispatchMouse(client, type, x, y, button = 'left', clickCount = 0) {
|
|
45
|
+
await client.send('Input.dispatchMouseEvent', { type, x, y, button, clickCount });
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Send scroll event via CDP
|
|
49
|
+
async function dispatchScroll(client, x, y, deltaX, deltaY) {
|
|
50
|
+
await client.send('Input.dispatchMouseEvent', { type: 'mouseWheel', x, y, deltaX, deltaY });
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// CDP modifiers bitmask: Alt=1, Ctrl=2, Meta=4, Shift=8
|
|
54
|
+
function modifierBits({ alt = false, ctrl = false, meta = false, shift = false } = {}) {
|
|
55
|
+
return (alt ? 1 : 0) | (ctrl ? 2 : 0) | (meta ? 4 : 0) | (shift ? 8 : 0);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Send key event via CDP
|
|
59
|
+
async function dispatchKey(client, { key, code, keyCode, text, modifiers = 0 }) {
|
|
60
|
+
const base = { key, code, windowsVirtualKeyCode: keyCode, nativeVirtualKeyCode: keyCode, modifiers };
|
|
61
|
+
await client.send('Input.dispatchKeyEvent', { type: 'rawKeyDown', ...base });
|
|
62
|
+
if (text) {
|
|
63
|
+
await client.send('Input.dispatchKeyEvent', { type: 'char', text, key, code, modifiers });
|
|
64
|
+
}
|
|
65
|
+
await client.send('Input.dispatchKeyEvent', { type: 'keyUp', ...base });
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// ── Key mapping (terminal escape sequences → CDP key info) ──
|
|
69
|
+
|
|
70
|
+
const SPECIAL_KEYS = {
|
|
71
|
+
'\x1b[A': { key: 'ArrowUp', code: 'ArrowUp', keyCode: 38 },
|
|
72
|
+
'\x1b[B': { key: 'ArrowDown', code: 'ArrowDown', keyCode: 40 },
|
|
73
|
+
'\x1b[C': { key: 'ArrowRight', code: 'ArrowRight', keyCode: 39 },
|
|
74
|
+
'\x1b[D': { key: 'ArrowLeft', code: 'ArrowLeft', keyCode: 37 },
|
|
75
|
+
'\x1b[H': { key: 'Home', code: 'Home', keyCode: 36 },
|
|
76
|
+
'\x1b[F': { key: 'End', code: 'End', keyCode: 35 },
|
|
77
|
+
'\x1b[5~': { key: 'PageUp', code: 'PageUp', keyCode: 33 },
|
|
78
|
+
'\x1b[6~': { key: 'PageDown', code: 'PageDown', keyCode: 34 },
|
|
79
|
+
'\x1b[3~': { key: 'Delete', code: 'Delete', keyCode: 46 },
|
|
80
|
+
'\x7f': { key: 'Backspace', code: 'Backspace', keyCode: 8, text: '\x08' },
|
|
81
|
+
'\x08': { key: 'Backspace', code: 'Backspace', keyCode: 8, text: '\x08' },
|
|
82
|
+
'\r': { key: 'Enter', code: 'Enter', keyCode: 13, text: '\r' },
|
|
83
|
+
'\n': { key: 'Enter', code: 'Enter', keyCode: 13, text: '\r' },
|
|
84
|
+
'\t': { key: 'Tab', code: 'Tab', keyCode: 9, text: '\t' },
|
|
85
|
+
'\x1b': { key: 'Escape', code: 'Escape', keyCode: 27 },
|
|
86
|
+
' ': { key: ' ', code: 'Space', keyCode: 32, text: ' ' },
|
|
87
|
+
'\x1bOP': { key: 'F1', code: 'F1', keyCode: 112 },
|
|
88
|
+
'\x1bOQ': { key: 'F2', code: 'F2', keyCode: 113 },
|
|
89
|
+
'\x1bOR': { key: 'F3', code: 'F3', keyCode: 114 },
|
|
90
|
+
'\x1bOS': { key: 'F4', code: 'F4', keyCode: 115 },
|
|
91
|
+
'\x1b[15~': { key: 'F5', code: 'F5', keyCode: 116 },
|
|
92
|
+
'\x1b[17~': { key: 'F6', code: 'F6', keyCode: 117 },
|
|
93
|
+
'\x1b[18~': { key: 'F7', code: 'F7', keyCode: 118 },
|
|
94
|
+
'\x1b[19~': { key: 'F8', code: 'F8', keyCode: 119 },
|
|
95
|
+
'\x1b[20~': { key: 'F9', code: 'F9', keyCode: 120 },
|
|
96
|
+
'\x1b[21~': { key: 'F10', code: 'F10', keyCode: 121 },
|
|
97
|
+
'\x1b[23~': { key: 'F11', code: 'F11', keyCode: 122 },
|
|
98
|
+
'\x1b[24~': { key: 'F12', code: 'F12', keyCode: 123 },
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
// Ctrl+Key (0x01-0x1A) → exclude collisions with Backspace/Tab/Enter
|
|
102
|
+
const CTRL_KEYS = {};
|
|
103
|
+
const CTRL_EXCLUDE = new Set([2, 7, 8, 12]); // C, H(BS), I(Tab), M(Enter)
|
|
104
|
+
for (let i = 0; i < 26; i++) {
|
|
105
|
+
if (CTRL_EXCLUDE.has(i)) continue;
|
|
106
|
+
const char = String.fromCharCode(i + 1);
|
|
107
|
+
const letter = String.fromCharCode(i + 97);
|
|
108
|
+
const upper = letter.toUpperCase();
|
|
109
|
+
CTRL_KEYS[char] = {
|
|
110
|
+
key: letter,
|
|
111
|
+
code: `Key${upper}`,
|
|
112
|
+
keyCode: upper.charCodeAt(0),
|
|
113
|
+
modifiers: modifierBits({ ctrl: true }),
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Base key info for modifier+key combinations
|
|
118
|
+
const MOD_SUFFIX = {
|
|
119
|
+
'A': { key: 'ArrowUp', code: 'ArrowUp', keyCode: 38 },
|
|
120
|
+
'B': { key: 'ArrowDown', code: 'ArrowDown', keyCode: 40 },
|
|
121
|
+
'C': { key: 'ArrowRight', code: 'ArrowRight', keyCode: 39 },
|
|
122
|
+
'D': { key: 'ArrowLeft', code: 'ArrowLeft', keyCode: 37 },
|
|
123
|
+
'H': { key: 'Home', code: 'Home', keyCode: 36 },
|
|
124
|
+
'F': { key: 'End', code: 'End', keyCode: 35 },
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
const MOD_TILDE = {
|
|
128
|
+
'3': { key: 'Delete', code: 'Delete', keyCode: 46 },
|
|
129
|
+
'5': { key: 'PageUp', code: 'PageUp', keyCode: 33 },
|
|
130
|
+
'6': { key: 'PageDown', code: 'PageDown', keyCode: 34 },
|
|
131
|
+
'15': { key: 'F5', code: 'F5', keyCode: 116 },
|
|
132
|
+
'17': { key: 'F6', code: 'F6', keyCode: 117 },
|
|
133
|
+
'18': { key: 'F7', code: 'F7', keyCode: 118 },
|
|
134
|
+
'19': { key: 'F8', code: 'F8', keyCode: 119 },
|
|
135
|
+
'20': { key: 'F9', code: 'F9', keyCode: 120 },
|
|
136
|
+
'21': { key: 'F10', code: 'F10', keyCode: 121 },
|
|
137
|
+
'23': { key: 'F11', code: 'F11', keyCode: 122 },
|
|
138
|
+
'24': { key: 'F12', code: 'F12', keyCode: 123 },
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
// macOS Option+char → base char map (US keyboard layout)
|
|
142
|
+
const MAC_OPTION_MAP = {
|
|
143
|
+
'å': 'a', '∫': 'b', 'ç': 'c', '∂': 'd',
|
|
144
|
+
'ƒ': 'f', '©': 'g', '˙': 'h', '∆': 'j',
|
|
145
|
+
'˚': 'k', '¬': 'l', 'µ': 'm', 'ø': 'o',
|
|
146
|
+
'π': 'p', 'œ': 'q', '®': 'r', 'ß': 's', '†': 't',
|
|
147
|
+
'√': 'v', '∑': 'w', '≈': 'x', '¥': 'y', 'Ω': 'z',
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
function modBitsFromParam(p) {
|
|
151
|
+
const n = p - 1;
|
|
152
|
+
return modifierBits({ shift: !!(n & 1), alt: !!(n & 2), ctrl: !!(n & 4) });
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Pre-compiled regexes for parseInput (hot path)
|
|
156
|
+
const RE_MOD_ARROW = /^\x1b\[1;(\d+)([A-H])$/;
|
|
157
|
+
const RE_MOD_TILDE = /^\x1b\[(\d+);(\d+)~$/;
|
|
158
|
+
|
|
159
|
+
// Parse escape sequence into key info (with modifiers)
|
|
160
|
+
function parseInput(str) {
|
|
161
|
+
// Exact match for special keys
|
|
162
|
+
const special = SPECIAL_KEYS[str];
|
|
163
|
+
if (special) return { ...special, modifiers: 0 };
|
|
164
|
+
|
|
165
|
+
// Ctrl+Key (0x01-0x1A)
|
|
166
|
+
if (str.length === 1 && CTRL_KEYS[str]) return CTRL_KEYS[str];
|
|
167
|
+
|
|
168
|
+
// Modified arrows: ESC [ 1 ; <mod> <A-H>
|
|
169
|
+
let m = str.match(RE_MOD_ARROW);
|
|
170
|
+
if (m) {
|
|
171
|
+
const info = MOD_SUFFIX[m[2]];
|
|
172
|
+
if (info) return { ...info, modifiers: modBitsFromParam(parseInt(m[1], 10)) };
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Modified special keys: ESC [ <num> ; <mod> ~
|
|
176
|
+
m = str.match(RE_MOD_TILDE);
|
|
177
|
+
if (m) {
|
|
178
|
+
const info = MOD_TILDE[m[1]];
|
|
179
|
+
if (info) return { ...info, modifiers: modBitsFromParam(parseInt(m[2], 10)) };
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// Alt+char: ESC + single char (not ESC [ or ESC O sequences)
|
|
183
|
+
if (str.length === 2 && str[0] === '\x1b' && str[1] !== '[' && str[1] !== 'O') {
|
|
184
|
+
const ch = str[1];
|
|
185
|
+
return {
|
|
186
|
+
key: ch,
|
|
187
|
+
code: `Key${ch.toUpperCase()}`,
|
|
188
|
+
keyCode: ch.toUpperCase().charCodeAt(0),
|
|
189
|
+
modifiers: modifierBits({ alt: true }),
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// macOS Option+char: arrives as Unicode char → convert to alt+char
|
|
194
|
+
if (str.length === 1 && MAC_OPTION_MAP[str]) {
|
|
195
|
+
const ch = MAC_OPTION_MAP[str];
|
|
196
|
+
return {
|
|
197
|
+
key: ch,
|
|
198
|
+
code: `Key${ch.toUpperCase()}`,
|
|
199
|
+
keyCode: ch.toUpperCase().charCodeAt(0),
|
|
200
|
+
modifiers: modifierBits({ alt: true }),
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// Normal characters
|
|
205
|
+
if (!str.startsWith('\x1b') && str.length >= 1) return 'text';
|
|
206
|
+
|
|
207
|
+
return undefined;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// ── OSC 52 clipboard ──
|
|
211
|
+
|
|
212
|
+
// Write to clipboard via OSC 52
|
|
213
|
+
function clipboardWrite(text) {
|
|
214
|
+
const b64 = Buffer.from(text).toString('base64');
|
|
215
|
+
process.stdout.write(`\x1b]52;c;${b64}\x07`);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// Read from clipboard via OSC 52
|
|
219
|
+
function clipboardRead() {
|
|
220
|
+
const { promise, resolve } = Promise.withResolvers();
|
|
221
|
+
const timeout = setTimeout(() => {
|
|
222
|
+
process.stdin.removeListener('data', onData);
|
|
223
|
+
resolve(null);
|
|
224
|
+
}, CLIPBOARD_TIMEOUT);
|
|
225
|
+
|
|
226
|
+
let buf = '';
|
|
227
|
+
const onData = (data) => {
|
|
228
|
+
buf += data.toString();
|
|
229
|
+
// OSC 52 response: ESC ] 52 ; c ; <base64> ESC \ or BEL
|
|
230
|
+
const m = buf.match(/\x1b\]52;c;([A-Za-z0-9+/=]*?)(?:\x1b\\|\x07)/);
|
|
231
|
+
if (m) {
|
|
232
|
+
clearTimeout(timeout);
|
|
233
|
+
process.stdin.removeListener('data', onData);
|
|
234
|
+
resolve(Buffer.from(m[1], 'base64').toString());
|
|
235
|
+
}
|
|
236
|
+
};
|
|
237
|
+
process.stdin.on('data', onData);
|
|
238
|
+
|
|
239
|
+
// Request clipboard read
|
|
240
|
+
process.stdout.write('\x1b]52;c;?\x07');
|
|
241
|
+
return promise;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// Get selected text via CDP
|
|
245
|
+
async function getSelection(client) {
|
|
246
|
+
try {
|
|
247
|
+
const { result } = await client.send('Runtime.evaluate', {
|
|
248
|
+
expression: 'window.getSelection().toString()',
|
|
249
|
+
});
|
|
250
|
+
return result.value || '';
|
|
251
|
+
} catch { return ''; }
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// ── casty action execution ──
|
|
255
|
+
|
|
256
|
+
async function getHistoryEntry(client, delta) {
|
|
257
|
+
const { currentIndex, entries } = await client.send('Page.getNavigationHistory');
|
|
258
|
+
const idx = currentIndex + delta;
|
|
259
|
+
if (idx >= 0 && idx < entries.length) return { entryId: entries[idx].id };
|
|
260
|
+
return { entryId: entries[currentIndex].id };
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// ── Main ──
|
|
264
|
+
|
|
265
|
+
// Track currentUrl locally (replacement for page.url())
|
|
266
|
+
// bindings: { "ctrl+q": "quit", "alt+left": "back", "alt+l": "url_bar", ... }
|
|
267
|
+
export function startInputHandling(client, cellWidth, cellHeight, bindings, pauseRender, forceCapture) {
|
|
268
|
+
process.stdin.setRawMode(true);
|
|
269
|
+
process.stdin.resume();
|
|
270
|
+
|
|
271
|
+
// Mutable cell dimensions (updated on resize via updateCellSize)
|
|
272
|
+
let _cellW = cellWidth;
|
|
273
|
+
let _cellH = cellHeight;
|
|
274
|
+
|
|
275
|
+
const keyToAction = bindings;
|
|
276
|
+
const urlBar = new UrlBar();
|
|
277
|
+
const hintMode = new HintMode(forceCapture);
|
|
278
|
+
|
|
279
|
+
// Capture after user input — immediate + delayed for Chrome rendering lag
|
|
280
|
+
const INPUT_CAPTURE_DELAY = 150; // ms
|
|
281
|
+
let _captureTimer = null;
|
|
282
|
+
function captureAfterInput() {
|
|
283
|
+
forceCapture();
|
|
284
|
+
clearTimeout(_captureTimer);
|
|
285
|
+
_captureTimer = setTimeout(forceCapture, INPUT_CAPTURE_DELAY);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// Throttled mouse motion (mode 1003 generates events for every pixel)
|
|
289
|
+
let pendingMotion = null;
|
|
290
|
+
let _motionTimer = null;
|
|
291
|
+
const MOTION_INTERVAL = 16; // ms (~60fps)
|
|
292
|
+
function flushMotion() {
|
|
293
|
+
if (pendingMotion) {
|
|
294
|
+
const { x, y } = pendingMotion;
|
|
295
|
+
pendingMotion = null;
|
|
296
|
+
dispatchMouse(client, 'mouseMoved', x, y, 'none').catch(() => {});
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
_motionTimer = setInterval(flushMotion, MOTION_INTERVAL);
|
|
300
|
+
|
|
301
|
+
// Track current URL locally
|
|
302
|
+
let currentUrl = '';
|
|
303
|
+
|
|
304
|
+
// Build rawBindings for macOS Option key (macOS only)
|
|
305
|
+
// On Linux, ESC+char collides with Alt+char byte sequences
|
|
306
|
+
const rawBindings = {};
|
|
307
|
+
if (process.platform === 'darwin') {
|
|
308
|
+
const MAC_OPTION_ARROWS = { 'alt+left': '\x1bb', 'alt+right': '\x1bf' };
|
|
309
|
+
for (const [keyName, action] of Object.entries(bindings)) {
|
|
310
|
+
const m = keyName.match(/^alt\+([a-z])$/);
|
|
311
|
+
if (m) {
|
|
312
|
+
const optChar = Object.entries(MAC_OPTION_MAP).find(([, v]) => v === m[1]);
|
|
313
|
+
if (optChar) rawBindings[optChar[0]] = action;
|
|
314
|
+
}
|
|
315
|
+
if (MAC_OPTION_ARROWS[keyName]) {
|
|
316
|
+
rawBindings[MAC_OPTION_ARROWS[keyName]] = action;
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// Get initial URL from navigation history
|
|
322
|
+
urlBar.loading = true;
|
|
323
|
+
client.send('Page.getNavigationHistory').then(({ currentIndex, entries }) => {
|
|
324
|
+
if (entries[currentIndex]) {
|
|
325
|
+
urlBar.setUrl(entries[currentIndex].url);
|
|
326
|
+
}
|
|
327
|
+
}).catch(() => {});
|
|
328
|
+
|
|
329
|
+
// Update URL bar on page navigation (CDP events)
|
|
330
|
+
client.on('Page.frameNavigated', ({ frame }) => {
|
|
331
|
+
// Frame without parentId = main frame
|
|
332
|
+
if (!frame.parentId) {
|
|
333
|
+
currentUrl = frame.url;
|
|
334
|
+
urlBar.setUrl(currentUrl);
|
|
335
|
+
urlBar.loading = true;
|
|
336
|
+
}
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
// Loading complete
|
|
340
|
+
client.on('Page.loadEventFired', () => {
|
|
341
|
+
urlBar.loading = false;
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
// Download handling (CDP events)
|
|
345
|
+
client.on('Browser.downloadWillBegin', ({ suggestedFilename }) => {
|
|
346
|
+
urlBar.setStatus(`Downloading: ${suggestedFilename}`);
|
|
347
|
+
});
|
|
348
|
+
client.on('Browser.downloadProgress', ({ state }) => {
|
|
349
|
+
if (state === 'completed') {
|
|
350
|
+
urlBar.setStatus('Download complete');
|
|
351
|
+
setTimeout(() => urlBar.clearStatus(), 3000);
|
|
352
|
+
} else if (state === 'canceled') {
|
|
353
|
+
urlBar.setStatus('Download canceled');
|
|
354
|
+
setTimeout(() => urlBar.clearStatus(), 3000);
|
|
355
|
+
}
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
// Execute action
|
|
359
|
+
async function execAction(action) {
|
|
360
|
+
if (action === 'quit') { process.emit('SIGINT'); return true; }
|
|
361
|
+
if (action === 'url_bar') {
|
|
362
|
+
pauseRender();
|
|
363
|
+
const url = await urlBar.startEditing();
|
|
364
|
+
pauseRender(false);
|
|
365
|
+
if (url) {
|
|
366
|
+
urlBar.loading = true;
|
|
367
|
+
await client.send('Page.navigate', { url });
|
|
368
|
+
}
|
|
369
|
+
return true;
|
|
370
|
+
}
|
|
371
|
+
if (action === 'back') {
|
|
372
|
+
urlBar.loading = true;
|
|
373
|
+
await client.send('Page.navigateToHistoryEntry', await getHistoryEntry(client, -1));
|
|
374
|
+
return true;
|
|
375
|
+
}
|
|
376
|
+
if (action === 'forward') {
|
|
377
|
+
urlBar.loading = true;
|
|
378
|
+
await client.send('Page.navigateToHistoryEntry', await getHistoryEntry(client, +1));
|
|
379
|
+
return true;
|
|
380
|
+
}
|
|
381
|
+
if (action === 'copy') {
|
|
382
|
+
const text = await getSelection(client);
|
|
383
|
+
if (text) {
|
|
384
|
+
clipboardWrite(text);
|
|
385
|
+
urlBar.setStatus(`Copied: ${text.slice(0, 40)}${text.length > 40 ? '...' : ''}`);
|
|
386
|
+
setTimeout(() => urlBar.clearStatus(), STATUS_DISPLAY_MS);
|
|
387
|
+
}
|
|
388
|
+
return true;
|
|
389
|
+
}
|
|
390
|
+
if (action === 'paste') {
|
|
391
|
+
const text = await clipboardRead();
|
|
392
|
+
if (text) {
|
|
393
|
+
await client.send('Input.insertText', { text });
|
|
394
|
+
captureAfterInput();
|
|
395
|
+
}
|
|
396
|
+
return true;
|
|
397
|
+
}
|
|
398
|
+
if (action === 'hints') {
|
|
399
|
+
hintMode.start(client);
|
|
400
|
+
return true;
|
|
401
|
+
}
|
|
402
|
+
return false;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
// ESC prefix buffer: when ESC arrives alone, wait for subsequent chars.
|
|
406
|
+
// On Linux terminals, Alt+Key arrives as ESC + Key (2 bytes), but async
|
|
407
|
+
// handler delays can split them. 50ms is sufficient even over SSH without
|
|
408
|
+
// noticeable input lag.
|
|
409
|
+
// On macOS, rawBindings handle Option key directly, so this buffer
|
|
410
|
+
// is primarily relevant on Linux.
|
|
411
|
+
let escBuf = '';
|
|
412
|
+
let escTimer = null;
|
|
413
|
+
const ESC_TIMEOUT = 50; // ms
|
|
414
|
+
|
|
415
|
+
process.stdin.on('data', (data) => {
|
|
416
|
+
let str = data.toString();
|
|
417
|
+
|
|
418
|
+
if (escTimer) {
|
|
419
|
+
clearTimeout(escTimer);
|
|
420
|
+
escTimer = null;
|
|
421
|
+
str = escBuf + str;
|
|
422
|
+
escBuf = '';
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
// ESC arrived alone — wait for subsequent chars
|
|
426
|
+
if (str === '\x1b') {
|
|
427
|
+
escBuf = str;
|
|
428
|
+
escTimer = setTimeout(() => {
|
|
429
|
+
escTimer = null;
|
|
430
|
+
const buf = escBuf;
|
|
431
|
+
escBuf = '';
|
|
432
|
+
handleInput(buf).catch(e => console.error('casty: input error:', e.message));
|
|
433
|
+
}, ESC_TIMEOUT);
|
|
434
|
+
return;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
handleInput(str).catch(e => console.error('casty: input error:', e.message));
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
async function handleInput(str) {
|
|
441
|
+
// Hint mode active → route all input to hint mode
|
|
442
|
+
if (hintMode.active) {
|
|
443
|
+
await hintMode.handleInput(str);
|
|
444
|
+
return;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
// URL bar editing → handle copy/paste here, rest goes to URL bar
|
|
448
|
+
if (urlBar.editing) {
|
|
449
|
+
const r = parseInput(str);
|
|
450
|
+
const kn = r && r !== 'text' ? toKeyName(r) : null;
|
|
451
|
+
const act = kn ? keyToAction[kn] : rawBindings[str];
|
|
452
|
+
if (act === 'paste') {
|
|
453
|
+
const text = await clipboardRead();
|
|
454
|
+
if (text) urlBar.insertText(text);
|
|
455
|
+
return;
|
|
456
|
+
}
|
|
457
|
+
if (act === 'copy') {
|
|
458
|
+
if (urlBar.text) {
|
|
459
|
+
clipboardWrite(urlBar.text);
|
|
460
|
+
urlBar.setStatus('Copied');
|
|
461
|
+
setTimeout(() => urlBar.clearStatus(), 1500);
|
|
462
|
+
}
|
|
463
|
+
return;
|
|
464
|
+
}
|
|
465
|
+
urlBar.handleInput(str);
|
|
466
|
+
return;
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
// SGR 1006 mouse event: ESC [ < Cb ; Cx ; Cy M/m
|
|
470
|
+
// Cb: 0=left click, 32=drag, 64/65=scroll up/down
|
|
471
|
+
// M=press, m=release
|
|
472
|
+
const mouseRe = /\x1b\[<(\d+);(\d+);(\d+)([Mm])/g;
|
|
473
|
+
let match;
|
|
474
|
+
let hadMouse = false;
|
|
475
|
+
|
|
476
|
+
while ((match = mouseRe.exec(str)) !== null) {
|
|
477
|
+
hadMouse = true;
|
|
478
|
+
const cb = parseInt(match[1]);
|
|
479
|
+
const col = parseInt(match[2]);
|
|
480
|
+
const row = parseInt(match[3]);
|
|
481
|
+
const release = match[4] === 'm';
|
|
482
|
+
const { x, y } = cellToPixel(col, row, _cellW, _cellH);
|
|
483
|
+
|
|
484
|
+
|
|
485
|
+
// Click on line 1 → open address bar
|
|
486
|
+
if (row === 1 && cb === MOUSE_BTN_LEFT && !release) {
|
|
487
|
+
await execAction('url_bar');
|
|
488
|
+
return;
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
if (cb === MOUSE_SCROLL_UP) {
|
|
492
|
+
await dispatchScroll(client, x, y, 0, -SCROLL_DELTA);
|
|
493
|
+
} else if (cb === MOUSE_SCROLL_DOWN) {
|
|
494
|
+
await dispatchScroll(client, x, y, 0, SCROLL_DELTA);
|
|
495
|
+
} else if (cb === MOUSE_BTN_LEFT) {
|
|
496
|
+
if (release) {
|
|
497
|
+
await dispatchMouse(client, 'mouseReleased', x, y, 'left');
|
|
498
|
+
} else {
|
|
499
|
+
await dispatchMouse(client, 'mousePressed', x, y, 'left', 1);
|
|
500
|
+
}
|
|
501
|
+
} else if (cb === MOUSE_BTN_DRAG) {
|
|
502
|
+
await dispatchMouse(client, 'mouseMoved', x, y, 'left');
|
|
503
|
+
} else if (cb === MOUSE_BTN_MOTION) {
|
|
504
|
+
// Mode 1003: motion without button — throttled to avoid flooding CDP
|
|
505
|
+
pendingMotion = { x, y };
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
if (hadMouse) { captureAfterInput(); return; }
|
|
510
|
+
|
|
511
|
+
// Check raw input bindings directly (macOS Option+char)
|
|
512
|
+
if (rawBindings[str]) {
|
|
513
|
+
await execAction(rawBindings[str]);
|
|
514
|
+
return;
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
// Check bindings via parseInput
|
|
518
|
+
const result = parseInput(str);
|
|
519
|
+
if (result && result !== 'text') {
|
|
520
|
+
const keyName = toKeyName(result);
|
|
521
|
+
const action = keyToAction[keyName];
|
|
522
|
+
if (action && await execAction(action)) return;
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
// Ctrl+C fallback
|
|
526
|
+
if (str === '\x03') {
|
|
527
|
+
process.emit('SIGINT');
|
|
528
|
+
return;
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
// Not a casty action → pass through to Chrome
|
|
532
|
+
if (result === 'text') {
|
|
533
|
+
await client.send('Input.insertText', { text: str });
|
|
534
|
+
captureAfterInput();
|
|
535
|
+
} else if (result) {
|
|
536
|
+
await dispatchKey(client, result);
|
|
537
|
+
captureAfterInput();
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
// Allow updating cell dimensions after resize
|
|
542
|
+
urlBar.updateCellSize = (w, h) => { _cellW = w; _cellH = h; };
|
|
543
|
+
|
|
544
|
+
return urlBar;
|
|
545
|
+
}
|
package/lib/keys.js
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// Key binding configuration
|
|
2
|
+
// Customizable via ~/.casty/keys.json
|
|
3
|
+
// Only defines actions casty intercepts; everything else passes through to Chrome.
|
|
4
|
+
|
|
5
|
+
import { homedir } from 'node:os';
|
|
6
|
+
import { join } from 'node:path';
|
|
7
|
+
import { loadJsonFile } from './config.js';
|
|
8
|
+
|
|
9
|
+
const configPath = join(homedir(), '.casty', 'keys.json');
|
|
10
|
+
|
|
11
|
+
// Default key bindings
|
|
12
|
+
// Key names: "ctrl+c", "alt+left", "f5", "ctrl+shift+r", etc.
|
|
13
|
+
const DEFAULTS = {
|
|
14
|
+
'ctrl+q': 'quit',
|
|
15
|
+
'alt+left': 'back',
|
|
16
|
+
'alt+right': 'forward',
|
|
17
|
+
'alt+l': 'url_bar',
|
|
18
|
+
'alt+c': 'copy',
|
|
19
|
+
'ctrl+v': 'paste',
|
|
20
|
+
'alt+f': 'hints',
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
// Load bindings (falls back to defaults)
|
|
24
|
+
export function loadKeyBindings() {
|
|
25
|
+
return { ...DEFAULTS, ...loadJsonFile(configPath, {}) };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Build a human-readable key name from parsed input
|
|
29
|
+
// { key: 'ArrowLeft', modifiers: 1 } → "alt+left"
|
|
30
|
+
// { key: 'r', modifiers: 2 } → "ctrl+r"
|
|
31
|
+
// { key: 'F5', modifiers: 0 } → "f5"
|
|
32
|
+
export function toKeyName(info) {
|
|
33
|
+
const parts = [];
|
|
34
|
+
if (info.modifiers & 2) parts.push('ctrl');
|
|
35
|
+
if (info.modifiers & 1) parts.push('alt');
|
|
36
|
+
if (info.modifiers & 8) parts.push('shift');
|
|
37
|
+
if (info.modifiers & 4) parts.push('meta');
|
|
38
|
+
|
|
39
|
+
// Normalize key name
|
|
40
|
+
const k = info.key;
|
|
41
|
+
const normalized =
|
|
42
|
+
k === 'ArrowUp' ? 'up' :
|
|
43
|
+
k === 'ArrowDown' ? 'down' :
|
|
44
|
+
k === 'ArrowLeft' ? 'left' :
|
|
45
|
+
k === 'ArrowRight' ? 'right' :
|
|
46
|
+
k === ' ' ? 'space' :
|
|
47
|
+
k.toLowerCase();
|
|
48
|
+
|
|
49
|
+
parts.push(normalized);
|
|
50
|
+
return parts.join('+');
|
|
51
|
+
}
|