coneko-term 0.0.0-stage → 0.1.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.
@@ -0,0 +1,2020 @@
1
+ import { WasmTerminal } from "./wasm/coneko_term_wasm.js";
2
+ import { getMemory } from "./memory.js";
3
+ import { SelectionModel, } from "./selection.js";
4
+ import { CompositionHelper, KeyHandler, detectPlatform, } from "./input.js";
5
+ import { bracketTextForPaste, prepareTextForTerminal } from "./keyboard.js";
6
+ import { CanvasRenderer, Palette, Scrollbar, SCROLLBAR_WIDTH, proposeDimensions, } from "./renderer.js";
7
+ /** セル 1 つが使う `u32` の数。`RowView.cells` の並びは README を参照。 */
8
+ export const CELL_STRIDE = 7;
9
+ /** セルの字(並びの 0 番目)にこのビットが立っていたら、残りのビットが `RowView.cluster` の番号。 */
10
+ export const CLUSTER_FLAG = 0x8000_0000;
11
+ /** wasm が `\0` で区切って渡す記録を読む。並びは Rust 側の `program_status_fields` を参照。 */
12
+ function programStatusOf(fields) {
13
+ const [removed, id, state, kind, progress, app, title, msg] = fields.split("\0");
14
+ if (removed === "1")
15
+ return { id };
16
+ const status = { id, state: state };
17
+ if (kind)
18
+ status.kind = kind;
19
+ if (progress)
20
+ status.progress = Number(progress);
21
+ if (app)
22
+ status.app = app;
23
+ if (title !== "")
24
+ status.title = title;
25
+ if (msg !== "")
26
+ status.msg = msg;
27
+ return { id, status };
28
+ }
29
+ // wasm 側の場所の番号(0 が主のクリップボード、1 が primary selection)。
30
+ function clipboardLocationOf(code) {
31
+ return code === 1 ? "primary" : "clipboard";
32
+ }
33
+ function operationBits(ops) {
34
+ return (ops.copy ? 1 : 0) | (ops.move ? 2 : 0);
35
+ }
36
+ function operationOf(code) {
37
+ return code === 1 ? "copy" : code === 2 ? "move" : null;
38
+ }
39
+ const DROP_REQUEST_KINDS = { data: 0, entry: 1, dirEntry: 2, dirClose: 3 };
40
+ function dropRequestParts(r) {
41
+ switch (r.type) {
42
+ case "data":
43
+ return [DROP_REQUEST_KINDS.data, r.index, 0];
44
+ case "entry":
45
+ return [DROP_REQUEST_KINDS.entry, r.index, r.subIndex];
46
+ case "dirEntry":
47
+ return [DROP_REQUEST_KINDS.dirEntry, r.handle, r.index];
48
+ case "dirClose":
49
+ return [DROP_REQUEST_KINDS.dirClose, r.handle, 0];
50
+ }
51
+ }
52
+ // wasm の `dnd_ints` / `dnd_strings` / `dnd_blob` を、イベントにする。並びは wasm 側の説明のとおり。
53
+ function dndEventOf(ev) {
54
+ const ints = ev.dnd_ints;
55
+ const strings = ev.dnd_strings;
56
+ switch (ev.dnd_kind) {
57
+ case 0:
58
+ return { type: "dropEnabled", mimes: strings };
59
+ case 1:
60
+ return { type: "dropDisabled" };
61
+ case 2:
62
+ return {
63
+ type: "dropResponse",
64
+ operation: operationOf(ints[0]),
65
+ mimes: ints[1] ? strings : null,
66
+ };
67
+ case 3: {
68
+ const [kind, a, b] = ints;
69
+ const request = kind === 0
70
+ ? { type: "data", index: a }
71
+ : kind === 1
72
+ ? { type: "entry", index: a, subIndex: b }
73
+ : kind === 2
74
+ ? { type: "dirEntry", handle: a, index: b }
75
+ : kind === 3
76
+ ? { type: "dirClose", handle: a }
77
+ : undefined;
78
+ return request && { type: "dropRequest", request };
79
+ }
80
+ case 4:
81
+ return { type: "dropFinished", operation: operationOf(ints[0]) };
82
+ case 5:
83
+ return {
84
+ type: "dragEnabled",
85
+ machineId: ints[0] ? (strings[0] ?? null) : null,
86
+ };
87
+ case 6:
88
+ return { type: "dragDisabled" };
89
+ case 7: {
90
+ const [ops, image, nMimes, nImages] = ints;
91
+ const hasMachine = ints[4 + nMimes + nImages * 4] !== 0;
92
+ const data = [];
93
+ for (let i = 0; i < nMimes; i++) {
94
+ data.push(ints[4 + i] ? ev.dnd_blob(i) : null);
95
+ }
96
+ const images = [];
97
+ for (let i = 0; i < nImages; i++) {
98
+ const at = 4 + nMimes + i * 4;
99
+ images.push({
100
+ format: ["rgb", "rgba", "png", "text"][ints[at]] ?? "png",
101
+ width: ints[at + 1],
102
+ height: ints[at + 2],
103
+ opacity: ints[at + 3],
104
+ data: ev.dnd_blob(nMimes + i),
105
+ });
106
+ }
107
+ return {
108
+ type: "dragStart",
109
+ operations: { copy: (ops & 1) !== 0, move: (ops & 2) !== 0 },
110
+ mimes: strings.slice(0, nMimes),
111
+ data,
112
+ images,
113
+ image: image < 0 ? null : image,
114
+ machineId: hasMachine ? (strings[nMimes] ?? null) : null,
115
+ };
116
+ }
117
+ case 8:
118
+ return { type: "dragImage", index: ints[0] < 0 ? null : ints[0] };
119
+ case 9:
120
+ return { type: "dragData", index: ints[0], data: ev.dnd_blob(0) };
121
+ case 10:
122
+ return {
123
+ type: "dragDataError",
124
+ index: ints[0],
125
+ error: strings[0] ?? "EUNKNOWN",
126
+ description: strings[1] ?? "",
127
+ };
128
+ case 11:
129
+ return {
130
+ type: "dragEntry",
131
+ index: ints[0],
132
+ subIndex: ints[1],
133
+ parent: ints[2],
134
+ form: ["file", "symlink", "directory"][ints[3]] ?? "file",
135
+ handle: ints[4],
136
+ data: ev.dnd_blob(0),
137
+ };
138
+ case 12:
139
+ return { type: "dragCanceled" };
140
+ case 13:
141
+ return {
142
+ type: "dragAborted",
143
+ error: strings[0] ?? "EUNKNOWN",
144
+ description: strings[1] ?? "",
145
+ };
146
+ default:
147
+ return undefined;
148
+ }
149
+ }
150
+ const FILE_TYPES = ["regular", "directory", "symlink", "link"];
151
+ function fileTransferEventOf(ev) {
152
+ const id = ev.ft_id;
153
+ switch (ev.ft_action) {
154
+ case 0:
155
+ case 1: {
156
+ const q = ev.ft_quiet;
157
+ const size = ev.ft_size;
158
+ return {
159
+ type: ev.ft_action === 0 ? "send" : "receive",
160
+ id,
161
+ bypass: ev.ft_bypass ?? null,
162
+ quiet: q === 1 ? 1 : q === 2 ? 2 : 0,
163
+ paths: size ?? null,
164
+ };
165
+ }
166
+ case 2:
167
+ return {
168
+ type: "file",
169
+ id,
170
+ fileId: ev.ft_file_id,
171
+ name: ev.ft_name,
172
+ fileType: FILE_TYPES[ev.ft_file_type] ?? "regular",
173
+ compression: ev.ft_compression === 1 ? "zlib" : "none",
174
+ transmission: ev.ft_transmission === 1 ? "rsync" : "simple",
175
+ mtime: ev.ft_mtime ?? null,
176
+ permissions: ev.ft_permissions ?? null,
177
+ size: ev.ft_size ?? null,
178
+ parent: ev.ft_parent ?? null,
179
+ };
180
+ case 3:
181
+ case 4:
182
+ return {
183
+ type: "data",
184
+ id,
185
+ fileId: ev.ft_file_id,
186
+ data: ev.ft_data(),
187
+ end: ev.ft_action === 4,
188
+ };
189
+ case 5:
190
+ return { type: "finish", id };
191
+ case 6:
192
+ return { type: "cancel", id };
193
+ default:
194
+ return undefined;
195
+ }
196
+ }
197
+ const ANSI_NAMES = [
198
+ "black",
199
+ "red",
200
+ "green",
201
+ "yellow",
202
+ "blue",
203
+ "magenta",
204
+ "cyan",
205
+ "white",
206
+ "brightBlack",
207
+ "brightRed",
208
+ "brightGreen",
209
+ "brightYellow",
210
+ "brightBlue",
211
+ "brightMagenta",
212
+ "brightCyan",
213
+ "brightWhite",
214
+ ];
215
+ const DEFAULT_FONT_FAMILY = "monospace";
216
+ const DEFAULT_FONT_SIZE = 15;
217
+ // xterm.js 6.0.0 の既定のテーマ(Rust の本体の既定と同じ)
218
+ const DEFAULT_THEME = {
219
+ foreground: "#ffffff",
220
+ background: "#000000",
221
+ cursor: "#ffffff",
222
+ black: "#2e3436",
223
+ red: "#cc0000",
224
+ green: "#4e9a06",
225
+ yellow: "#c4a000",
226
+ blue: "#3465a4",
227
+ magenta: "#75507b",
228
+ cyan: "#06989a",
229
+ white: "#d3d7cf",
230
+ brightBlack: "#555753",
231
+ brightRed: "#ef2929",
232
+ brightGreen: "#8ae234",
233
+ brightYellow: "#fce94f",
234
+ brightBlue: "#729fcf",
235
+ brightMagenta: "#ad7fa8",
236
+ brightCyan: "#34e2e2",
237
+ brightWhite: "#eeeeec",
238
+ };
239
+ /** xterm.js 6.0.0 の選択範囲の既定の色(白の 30%)。 */
240
+ const DEFAULT_SELECTION = "#ffffff4d";
241
+ /** xterm.js 6.0.0 の `wordSeparator` の既定(Rust の本体の既定と同じ)。 */
242
+ const DEFAULT_WORD_SEPARATOR = " ()[]{}',\"`";
243
+ function parseSelectionColor(value) {
244
+ const v = value.trim();
245
+ if (!/^#(?:[0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/i.test(v)) {
246
+ throw new Error(`coneko-term: unsupported selectionBackground "${value}" (use #rgb, #rgba, #rrggbb or #rrggbbaa)`);
247
+ }
248
+ return v;
249
+ }
250
+ function parseColor(value) {
251
+ const m = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i.exec(value.trim());
252
+ if (!m) {
253
+ throw new Error(`coneko-term: unsupported color "${value}" (use #rgb or #rrggbb)`);
254
+ }
255
+ let h = m[1];
256
+ if (h.length === 3)
257
+ h = [...h].map((c) => c + c).join("");
258
+ return [
259
+ parseInt(h.slice(0, 2), 16),
260
+ parseInt(h.slice(2, 4), 16),
261
+ parseInt(h.slice(4, 6), 16),
262
+ ];
263
+ }
264
+ /** 1 回に処理する最大のバイト数。 */
265
+ const SLICE_BYTES = 64 * 1024;
266
+ /** 1 回の処理で使う時間(ミリ秒)。これを超えたらブラウザーに制御を返す。 */
267
+ const SLICE_MS = 8;
268
+ const sleep0 = () => new Promise((r) => setTimeout(r, 0));
269
+ /**
270
+ * ターミナル。VT のバイト列を `write` で受け、画面の中身を持つ。DOM を使わないので
271
+ * Node.js でも動く(`open` を呼ぶまで DOM に触らない)。`open` すると Canvas 2D で描く。
272
+ *
273
+ * 使う前に `init()` を呼ぶこと。
274
+ */
275
+ export class Terminal {
276
+ options;
277
+ // 注: `syncColors` が、プログラムが OSC で変えた文字・背景・カーソルの色もここへ書く(「いまの色」)。
278
+ resolvedTheme = DEFAULT_THEME;
279
+ renderer;
280
+ scrollbar;
281
+ focused = false;
282
+ /** 画面の一番下から何行さかのぼって見ているか。0 なら一番下。 */
283
+ viewOffset = 0;
284
+ lastScrollback = 0;
285
+ scrollListeners = new Set();
286
+ cellSizeListeners = new Set();
287
+ wasm;
288
+ selectionColor = DEFAULT_SELECTION;
289
+ /** `theme.selectionBackground` で決めた選択範囲の色(プログラムが OSC 21 で変える前の色)。 */
290
+ configuredSelection = DEFAULT_SELECTION;
291
+ /** プログラムが OSC で変えた 256 色の表と、カーソルの下の字の色(変えていなければ `undefined`)。`open` の前の変更も、ここに残って最初の描画に入る。 */
292
+ colorTable;
293
+ cursorTextColor;
294
+ selection;
295
+ selectionListeners = new Set();
296
+ lastSelectionKey = "";
297
+ lastTrimmed = 0;
298
+ lastAlt = false;
299
+ /** 押してから離すまで、マウスのイベントを受け持つもの。 */
300
+ mouseOwner;
301
+ reportButton = 0;
302
+ wheelPartial = 0;
303
+ /** `dispose` のあとに触ると、分かりやすい例外にする。 */
304
+ get core() {
305
+ if (this.disposed)
306
+ throw new Error("coneko-term: terminal is disposed");
307
+ return this.wasm;
308
+ }
309
+ queue = [];
310
+ pumping = false;
311
+ disposed = false;
312
+ dataListeners = new Set();
313
+ titleListeners = new Set();
314
+ bellListeners = new Set();
315
+ notificationListeners = new Set();
316
+ notificationCloseListeners = new Set();
317
+ notificationQueryListeners = new Set();
318
+ notificationAliveListeners = new Set();
319
+ clipboardWriteListeners = new Set();
320
+ clipboardReadListeners = new Set();
321
+ dndListeners = new Set();
322
+ fileTransferListeners = new Set();
323
+ programStatusListeners = new Set();
324
+ oscHandlers = new Map();
325
+ element;
326
+ cleanups = [];
327
+ textarea;
328
+ composition;
329
+ customKeyHandler;
330
+ decoder = new TextDecoder();
331
+ encoder = new TextEncoder();
332
+ constructor(options = {}) {
333
+ getMemory();
334
+ this.options = { ...options };
335
+ const cols = Math.max(1, options.cols ?? 80);
336
+ const rows = Math.max(1, options.rows ?? 24);
337
+ this.wasm = new WasmTerminal(rows, cols, options.scrollback ?? 1000);
338
+ this.core.set_cursor_blink(options.cursorBlink ?? false);
339
+ this.applyTheme(options.theme ?? {});
340
+ this.selection = new SelectionModel(this.selectionHost());
341
+ this.lastAlt = this.core.is_alt_screen();
342
+ this.lastTrimmed = this.core.scrollback_trimmed();
343
+ }
344
+ selectionHost() {
345
+ // eslint-disable-next-line @typescript-eslint/no-this-alias
346
+ const t = this;
347
+ return {
348
+ get cols() {
349
+ return t.cols;
350
+ },
351
+ totalRows: () => t.scrollbackLength + t.rows,
352
+ trimmed: () => t.core.scrollback_trimmed(),
353
+ wordRange: (row, col) => Array.from(t.core.word_range(row, col, t.wordSeparator)),
354
+ lineRange: (row) => Array.from(t.core.line_range(row)),
355
+ };
356
+ }
357
+ get wordSeparator() {
358
+ return this.options.wordSeparator ?? DEFAULT_WORD_SEPARATOR;
359
+ }
360
+ applyTheme(theme) {
361
+ this.selectionColor =
362
+ theme.selectionBackground === undefined
363
+ ? DEFAULT_SELECTION
364
+ : parseSelectionColor(theme.selectionBackground);
365
+ this.configuredSelection = this.selectionColor;
366
+ const t = { ...DEFAULT_THEME, ...stripUndefined(theme) };
367
+ this.resolvedTheme = t;
368
+ const ansi = new Uint8Array(48);
369
+ ANSI_NAMES.forEach((name, i) => ansi.set(parseColor(t[name]), i * 3));
370
+ this.core.set_theme(Uint8Array.from(parseColor(t.foreground)), Uint8Array.from(parseColor(t.background)), Uint8Array.from(parseColor(t.cursor)), ansi);
371
+ // テーマの設定は描画の色にもう入っているので、「色が変わった」印は消す
372
+ this.core.colors_changed();
373
+ }
374
+ get fontFamily() {
375
+ return this.options.fontFamily ?? DEFAULT_FONT_FAMILY;
376
+ }
377
+ get fontSize() {
378
+ return this.options.fontSize ?? DEFAULT_FONT_SIZE;
379
+ }
380
+ /** 列の数。 */
381
+ get cols() {
382
+ return this.core.cols();
383
+ }
384
+ /** 行の数。 */
385
+ get rows() {
386
+ return this.core.rows();
387
+ }
388
+ /** スクロールバックに入っている行の数。 */
389
+ get scrollbackLength() {
390
+ return this.core.scrollback_len();
391
+ }
392
+ /** カーソルの状態。 */
393
+ get cursor() {
394
+ const [x, y, visible, blinking, shape] = this.core.cursor();
395
+ return {
396
+ x,
397
+ y,
398
+ visible: visible === 1,
399
+ blinking: blinking === 1,
400
+ shape: ["block", "underline", "bar"][shape],
401
+ };
402
+ }
403
+ /**
404
+ * 追加のカーソル(kitty の multiple cursors protocol)。上の行から、左の列の順。
405
+ * `shape` は、主のカーソルに従うものも、いまの主のカーソルの形にしてある。
406
+ */
407
+ get extraCursors() {
408
+ const flat = this.core.extra_cursors();
409
+ const main = this.cursor.shape;
410
+ const shapes = ["block", "underline", "bar"];
411
+ const out = [];
412
+ for (let i = 0; i + 2 < flat.length; i += 3) {
413
+ out.push({ x: flat[i], y: flat[i + 1], shape: shapes[flat[i + 2]] ?? main });
414
+ }
415
+ return out;
416
+ }
417
+ /** 追加のカーソルの `[x, y, 形]` の並び(描画が使う。形は 3 が主のカーソルに従う)。 */
418
+ readExtraCursors() {
419
+ return this.core.extra_cursors();
420
+ }
421
+ /** 追加のカーソルの色の語 `[字の色, カーソルの色]`(描画が使う。上位 8 ビットが 0 主のカーソルと同じ、1 パレット、2 RGB、3 反転)。 */
422
+ readExtraCursorColors() {
423
+ return this.core.extra_cursor_colors();
424
+ }
425
+ /** 代替画面にいるか。 */
426
+ get isAlternateScreen() {
427
+ return this.core.is_alt_screen();
428
+ }
429
+ /** bracketed paste(2004)が有効か。`paste` が使う。 */
430
+ get bracketedPaste() {
431
+ return this.core.bracketed_paste();
432
+ }
433
+ /**
434
+ * kitty keyboard protocol のいまのフラグ(アプリが `CSI > u` などで決める)。
435
+ * ビットは 1 disambiguate、2 report event types、4 report alternate keys、
436
+ * 8 report all keys as escape codes、16 report associated text。
437
+ */
438
+ get keyboardFlags() {
439
+ return this.core.keyboard_flags();
440
+ }
441
+ /** アプリケーションカーソルキー(DECCKM)か。矢印・Home・End の送り方が変わる。 */
442
+ get applicationCursorKeys() {
443
+ return this.core.application_cursor_keys();
444
+ }
445
+ /**
446
+ * データを書く。文字列は UTF-8 にする。大きなデータは小分けにして処理し、間でブラウザーに
447
+ * 制御を返す。処理は呼んだ順に行い、このデータを処理し終えたら `done` を呼ぶ。
448
+ * `write` から戻った時点では、まだ画面に反映されていない。
449
+ */
450
+ write(data, done) {
451
+ if (this.disposed)
452
+ return;
453
+ // 非同期に処理するので、呼ぶ側がバッファーを使い回しても壊れないようにコピーする
454
+ const bytes = typeof data === "string" ? this.encoder.encode(data) : data.slice();
455
+ this.queue.push({ bytes, offset: 0, done });
456
+ if (!this.pumping) {
457
+ this.pumping = true;
458
+ queueMicrotask(() => void this.pump());
459
+ }
460
+ }
461
+ async pump() {
462
+ try {
463
+ let sliceStart = performance.now();
464
+ while (!this.disposed && this.queue.length > 0) {
465
+ const job = this.queue[0];
466
+ const end = Math.min(job.offset + SLICE_BYTES, job.bytes.length);
467
+ this.core.write(job.bytes.subarray(job.offset, end));
468
+ job.offset = end;
469
+ this.syncViewport();
470
+ this.syncSelection();
471
+ this.afterChange();
472
+ // 聞き手や done の例外で残りの処理を止めない。投げ直して報告する
473
+ this.guard(() => this.flush());
474
+ if (this.disposed)
475
+ break;
476
+ if (job.offset >= job.bytes.length) {
477
+ this.queue.shift();
478
+ if (job.done)
479
+ this.guard(job.done);
480
+ }
481
+ if (performance.now() - sliceStart >= SLICE_MS && this.queue.length > 0) {
482
+ await sleep0();
483
+ sliceStart = performance.now();
484
+ }
485
+ }
486
+ }
487
+ finally {
488
+ this.pumping = false;
489
+ }
490
+ }
491
+ guard(f) {
492
+ try {
493
+ f();
494
+ }
495
+ catch (e) {
496
+ queueMicrotask(() => {
497
+ throw e;
498
+ });
499
+ }
500
+ }
501
+ /**
502
+ * プログラムが OSC 4・10・11・12・21 などで色を変えていたら、描画の色の表を作り直す。
503
+ * 選択範囲の背景は、字が見えるように設定済みの色と同じ透明度(`4d`)にする。
504
+ */
505
+ syncColors() {
506
+ if (!this.core.colors_changed())
507
+ return;
508
+ const b = this.core.colors();
509
+ const hex = (o) => `#${[b[o], b[o + 1], b[o + 2]].map((n) => n.toString(16).padStart(2, "0")).join("")}`;
510
+ const t = this.resolvedTheme;
511
+ t.foreground = hex(0);
512
+ t.background = hex(3);
513
+ t.cursor = hex(6);
514
+ const ansi = [];
515
+ for (let i = 0; i < 256; i++)
516
+ ansi.push(hex(21 + i * 3));
517
+ this.selectionColor =
518
+ b[13] === 1 ? `${hex(14)}4d` : this.configuredSelection;
519
+ this.colorTable = ansi;
520
+ this.cursorTextColor = b[9] === 1 ? hex(10) : undefined;
521
+ if (this.element)
522
+ this.element.style.background = t.background;
523
+ this.renderer?.setOptions(this.renderOptions());
524
+ }
525
+ /** PTY へ送るバイト列とイベントを、聞き手に渡す。 */
526
+ flush() {
527
+ for (;;) {
528
+ const ev = this.core.next_event();
529
+ if (!ev)
530
+ break;
531
+ try {
532
+ const data = ev.data;
533
+ switch (ev.kind) {
534
+ case 0:
535
+ for (const l of [...this.bellListeners])
536
+ l();
537
+ break;
538
+ case 1: {
539
+ const title = this.decoder.decode(data);
540
+ for (const l of [...this.titleListeners])
541
+ l(title);
542
+ break;
543
+ }
544
+ case 4: {
545
+ if (this.notificationListeners.size === 0)
546
+ break;
547
+ const urgency = ev.notification_urgency;
548
+ const actions = ev.notification_actions;
549
+ const timeout = ev.notification_timeout;
550
+ const n = {
551
+ id: ev.notification_id,
552
+ title: ev.notification_title,
553
+ body: ev.notification_body,
554
+ urgency: urgency < 0 ? undefined : urgency,
555
+ actions: { focus: (actions & 1) !== 0, report: (actions & 2) !== 0 },
556
+ when: ["always", "unfocused", "invisible"][ev.notification_when] ?? "always",
557
+ reportClose: ev.notification_report_close,
558
+ appName: ev.notification_app_name,
559
+ types: ev.notification_types,
560
+ timeout: timeout === -2 ? undefined : timeout,
561
+ sound: ev.notification_sound,
562
+ iconNames: ev.notification_icon_names,
563
+ iconCacheId: ev.notification_icon_cache_id,
564
+ buttons: ev.notification_buttons,
565
+ };
566
+ for (const l of [...this.notificationListeners])
567
+ l(n);
568
+ break;
569
+ }
570
+ case 5:
571
+ case 6:
572
+ case 7: {
573
+ const id = this.decoder.decode(data);
574
+ const set = ev.kind === 5
575
+ ? this.notificationCloseListeners
576
+ : ev.kind === 6
577
+ ? this.notificationQueryListeners
578
+ : this.notificationAliveListeners;
579
+ for (const l of [...set])
580
+ l(id);
581
+ break;
582
+ }
583
+ case 8: {
584
+ const id = this.decoder.decode(data);
585
+ if (this.clipboardWriteListeners.size === 0) {
586
+ // 聞き手がいなければ許さない。
587
+ this.core.clipboard_write_reply(id, "EPERM");
588
+ break;
589
+ }
590
+ const location = clipboardLocationOf(ev.clipboard_location);
591
+ const items = ev.clipboard_mimes.map((mime, i) => ({
592
+ mime,
593
+ data: ev.clipboard_item_data(i),
594
+ }));
595
+ for (const l of [...this.clipboardWriteListeners]) {
596
+ l({ id, location, items });
597
+ }
598
+ break;
599
+ }
600
+ case 9: {
601
+ const id = this.decoder.decode(data);
602
+ if (this.clipboardReadListeners.size === 0) {
603
+ // 聞き手がいなければ許さない。
604
+ this.core.clipboard_read_reply_error(id, "EPERM");
605
+ break;
606
+ }
607
+ const request = {
608
+ id,
609
+ location: clipboardLocationOf(ev.clipboard_location),
610
+ list: ev.clipboard_list,
611
+ mimes: ev.clipboard_mimes,
612
+ };
613
+ for (const l of [...this.clipboardReadListeners])
614
+ l(request);
615
+ break;
616
+ }
617
+ case 10: {
618
+ const e = dndEventOf(ev);
619
+ if (!e)
620
+ break;
621
+ if (this.dndListeners.size === 0) {
622
+ // 聞き手がいなければ許さない。
623
+ if (e.type === "dropRequest") {
624
+ if (e.request.type !== "dirClose") {
625
+ const [kind, a, b] = dropRequestParts(e.request);
626
+ this.core.drop_error_reply(kind, a, b, "EPERM", "");
627
+ }
628
+ }
629
+ else if (e.type === "dragStart") {
630
+ this.core.drag_error("EPERM", "");
631
+ }
632
+ break;
633
+ }
634
+ for (const l of [...this.dndListeners])
635
+ l(e);
636
+ break;
637
+ }
638
+ case 11: {
639
+ const e = fileTransferEventOf(ev);
640
+ if (!e)
641
+ break;
642
+ if (this.fileTransferListeners.size === 0) {
643
+ // 聞き手がいなければ許さない。取り消しには、取り消したと答える。
644
+ if (e.type === "send" || e.type === "receive") {
645
+ this.core.file_transfer_refuse(e.id);
646
+ }
647
+ else if (e.type === "cancel") {
648
+ this.core.file_transfer_canceled(e.id);
649
+ }
650
+ break;
651
+ }
652
+ for (const l of [...this.fileTransferListeners])
653
+ l(e);
654
+ break;
655
+ }
656
+ case 12: {
657
+ const change = programStatusOf(this.decoder.decode(data));
658
+ for (const l of [...this.programStatusListeners])
659
+ l(change);
660
+ break;
661
+ }
662
+ case 3: {
663
+ const text = this.decoder.decode(data);
664
+ for (const l of [...(this.oscHandlers.get(ev.ident) ?? [])])
665
+ l(text);
666
+ break;
667
+ }
668
+ }
669
+ }
670
+ finally {
671
+ ev.free();
672
+ }
673
+ }
674
+ const out = this.core.take_output();
675
+ if (out.length > 0) {
676
+ for (const l of [...this.dataListeners])
677
+ l(out);
678
+ }
679
+ this.syncColors();
680
+ }
681
+ /**
682
+ * キーボードやマウスなどの入力を、返事と同じ出口(`onData`)から出す。
683
+ * 文字列は UTF-8 にする。
684
+ */
685
+ input(data) {
686
+ if (this.disposed)
687
+ return;
688
+ this.core.queue_output(typeof data === "string" ? this.encoder.encode(data) : data);
689
+ this.flush();
690
+ }
691
+ /**
692
+ * ユーザーの入力(キー・変換の確定・貼り付け)を出口から出す。`scrollOnUserInput` が false でなく、
693
+ * さかのぼって見ていたら、一番下へ戻す。
694
+ */
695
+ sendUserInput(data) {
696
+ if (this.disposed || data.length === 0)
697
+ return;
698
+ if (this.options.scrollOnUserInput !== false)
699
+ this.scrollToBottom();
700
+ this.input(data);
701
+ }
702
+ /**
703
+ * 貼り付ける。改行は `\r` にそろえ(xterm.js と同じ)、bracketed paste(2004)が有効なら
704
+ * `ESC [200~` と `ESC [201~` で囲んで、出口から出す。`paste` イベントも、これを呼ぶ。
705
+ */
706
+ paste(text) {
707
+ if (this.disposed)
708
+ return;
709
+ this.sendUserInput(bracketTextForPaste(prepareTextForTerminal(text), this.bracketedPaste));
710
+ if (this.textarea)
711
+ this.textarea.value = "";
712
+ }
713
+ /**
714
+ * キーのイベントを、ターミナルより先に見るハンドラー(xterm.js の `attachCustomKeyEventHandler`)。
715
+ * `false` を返したキーは、ターミナルが処理しない。外すには `undefined`。
716
+ */
717
+ attachCustomKeyEventHandler(handler) {
718
+ this.customKeyHandler = handler;
719
+ }
720
+ /**
721
+ * PTY へ送るバイト列の出口。問い合わせへの返事(DSR など)と、`input` に渡したものが出る。
722
+ * 渡す `Uint8Array` は聞き手のもの(コピー済み)。
723
+ */
724
+ onData(listener) {
725
+ return addListener(this.dataListeners, listener);
726
+ }
727
+ /** 題名が変わった(OSC 0、OSC 2)。 */
728
+ onTitleChange(listener) {
729
+ return addListener(this.titleListeners, listener);
730
+ }
731
+ /** BEL が来た。 */
732
+ onBell(listener) {
733
+ return addListener(this.bellListeners, listener);
734
+ }
735
+ /**
736
+ * 通知(kitty の desktop notifications、OSC 99)がそろった。分けて送られた通知は、
737
+ * そろった時点で 1 回だけ呼ぶ。OS の通知を出すのは呼ぶ側。聞き手がいなければ何もしない。
738
+ */
739
+ onNotification(listener) {
740
+ return addListener(this.notificationListeners, listener);
741
+ }
742
+ /**
743
+ * プログラムが自分の状態(kitty の Program Status Protocol、OSC 7501)を報告し、記録が変わった、または
744
+ * 消えた。何をどう見せるかは呼ぶ側が決める。消えるのは、`state=clear`、RIS、新しいシェルのプロンプト
745
+ * (OSC 133 の `A`。working・blocked・idle)、窓がフォーカスを得たこととキーを押したこと(done・error)、
746
+ * 記録の数の上限(256)のとき。プロセスの終了は端末の外のことなので、`programStatusProcessExited` で知らせる。
747
+ */
748
+ onProgramStatus(listener) {
749
+ return addListener(this.programStatusListeners, listener);
750
+ }
751
+ /** いま持っている状態の記録。更新が古い順。 */
752
+ get programStatus() {
753
+ const text = this.core.program_status_records();
754
+ if (text === "")
755
+ return [];
756
+ return text
757
+ .split("\u0001")
758
+ .map((r) => programStatusOf(r).status)
759
+ .filter((s) => s !== undefined);
760
+ }
761
+ /** 端末につないだプロセスが終わったことを知らせる。動いている・待っている状態の記録(working・blocked・idle)を消す。 */
762
+ programStatusProcessExited() {
763
+ this.core.program_status_drop_running();
764
+ this.flush();
765
+ }
766
+ /** アプリケーションが通知を閉じるよう求めた(`p=close`)。`id` は `i`。 */
767
+ onNotificationClose(listener) {
768
+ return addListener(this.notificationCloseListeners, listener);
769
+ }
770
+ /**
771
+ * アプリケーションが対応を尋ねた(`p=?`)。`replyNotificationQuery` で答える。
772
+ * `id` は `i`(無ければ空文字列)。
773
+ */
774
+ onNotificationQuery(listener) {
775
+ return addListener(this.notificationQueryListeners, listener);
776
+ }
777
+ /**
778
+ * アプリケーションが生きている通知を尋ねた(`p=alive`)。`replyNotificationAlive` で答える。
779
+ */
780
+ onNotificationAlive(listener) {
781
+ return addListener(this.notificationAliveListeners, listener);
782
+ }
783
+ /**
784
+ * 通知が押されたことをアプリケーションへ返す(`onData` から出る)。通知の `actions.report` が
785
+ * 真のときだけ呼ぶ。`button` はボタンを押したときの番号(1 始まり)。`id` が空なら `i=0` で送る(仕様)。
786
+ */
787
+ notificationActivated(id, button) {
788
+ if (this.disposed)
789
+ return;
790
+ this.core.notification_activated(id, button && button > 0 ? button : 0);
791
+ this.flush();
792
+ }
793
+ /** 通知が閉じられたことを返す。通知の `reportClose` が真のときだけ呼ぶ。 */
794
+ notificationClosed(id) {
795
+ if (this.disposed)
796
+ return;
797
+ this.core.notification_closed(id);
798
+ this.flush();
799
+ }
800
+ /** `p=?` の問い合わせ(`onNotificationQuery`)に答える。 */
801
+ replyNotificationQuery(id, support = {}) {
802
+ if (this.disposed)
803
+ return;
804
+ this.core.notification_query_reply(id, support.actions ?? ["focus", "report"], support.when ?? ["always", "unfocused", "invisible"], support.payloads ?? ["title", "body", "?", "close", "alive"], Uint8Array.from(support.urgencies ?? [0, 1, 2]), support.closeReport ?? true, support.timeout ?? false, support.sounds ?? [], support.types ?? []);
805
+ this.flush();
806
+ }
807
+ /** `p=alive` の問い合わせ(`onNotificationAlive`)に答える。`ids` はまだ生きている通知の `i`。 */
808
+ replyNotificationAlive(id, ids) {
809
+ if (this.disposed)
810
+ return;
811
+ this.core.notification_alive_reply(id, ids);
812
+ this.flush();
813
+ }
814
+ /**
815
+ * クリップボードへの書き込み(kitty の clipboard protocol、OSC 5522)がそろった。分けて
816
+ * 送られたデータは、そろった時点で 1 回だけ呼ぶ。OS のクリップボードに書くかどうかは呼ぶ側が決め、
817
+ * どちらにしても `replyClipboardWrite` で答える。聞き手がいなければ、`EPERM` で答える。
818
+ */
819
+ onClipboardWrite(listener) {
820
+ return addListener(this.clipboardWriteListeners, listener);
821
+ }
822
+ /**
823
+ * クリップボードの読み取りの頼み(OSC 5522 の `type=read`)が来た。読ませるかどうかは呼ぶ側が決め、
824
+ * `replyClipboardReadTypes`・`replyClipboardReadData`・`replyClipboardReadError` のどれかで答える。
825
+ * 聞き手がいなければ、`EPERM`(許されない)で答える。
826
+ */
827
+ onClipboardRead(listener) {
828
+ return addListener(this.clipboardReadListeners, listener);
829
+ }
830
+ /** 書き込み(`onClipboardWrite`)に答える。`status` の既定は成功(`DONE`)。 */
831
+ replyClipboardWrite(id, status = "DONE") {
832
+ if (this.disposed)
833
+ return;
834
+ this.core.clipboard_write_reply(id, status);
835
+ this.flush();
836
+ }
837
+ /** 種類の一覧の頼み(`list` が真)に答える。`mimes` が空なら、データの無い OK と DONE だけを返す。 */
838
+ replyClipboardReadTypes(id, mimes) {
839
+ if (this.disposed)
840
+ return;
841
+ this.core.clipboard_read_reply_types(id, mimes);
842
+ this.flush();
843
+ }
844
+ /**
845
+ * データの頼みに答える。`items` は、頼まれた種類のうち渡せるもの。空なら、データの無い OK と DONE だけを返す。
846
+ * 4096 バイトごとのパケットに分けて送る。
847
+ */
848
+ replyClipboardReadData(id, items) {
849
+ if (this.disposed)
850
+ return;
851
+ for (const item of items)
852
+ this.core.clipboard_reply_item(item.mime, item.data);
853
+ this.core.clipboard_read_reply_data(id);
854
+ this.flush();
855
+ }
856
+ /** 読み取りの頼みを断る・失敗を知らせる。読ませないなら `EPERM`。 */
857
+ replyClipboardReadError(id, error) {
858
+ if (this.disposed)
859
+ return;
860
+ this.core.clipboard_read_reply_error(id, error);
861
+ this.flush();
862
+ }
863
+ /**
864
+ * kitty の drag and drop protocol(OSC 72)のイベントを受ける。種類は `DndEvent` を見ること。
865
+ * OS のドラッグとドロップにつなぐのは呼ぶ側の仕事。聞き手がいないとき、ドロップされたデータの
866
+ * 頼みには `EPERM` で、ドラッグを始める求めにも `EPERM` で答える。
867
+ */
868
+ onDnd(listener) {
869
+ return addListener(this.dndListeners, listener);
870
+ }
871
+ /**
872
+ * kitty の file transfer protocol(OSC 5113)の、クライアントからの命令を受ける。種類は
873
+ * `FileTransferEvent` を見ること。許すかどうかと、OS のファイルの読み書きは呼ぶ側の仕事で、
874
+ * `replyFileTransfer*`・`refuseFileTransfer` で答える。聞き手がいないとき、`send` と `receive` には
875
+ * `EPERM`(断る)で、`cancel` には `CANCELED` で答える。
876
+ */
877
+ onFileTransfer(listener) {
878
+ return addListener(this.fileTransferListeners, listener);
879
+ }
880
+ /**
881
+ * 成功の知らせ(`status=OK`)。セッションの始まり(`send`・`receive`)を許すときは `id` だけ。
882
+ * ディレクトリの受け付けと、`end_data` のあとの完了は `fileId`(完了には `size` に書いたバイト数)。
883
+ * 受け取りの一覧を並べ終えたら、`name` にホームディレクトリを付ける。`q` に従って省くことがある。
884
+ */
885
+ replyFileTransferOk(id, opts = {}) {
886
+ if (this.disposed)
887
+ return;
888
+ this.core.file_transfer_ok(id, opts.fileId ?? "", opts.size, opts.name);
889
+ this.flush();
890
+ }
891
+ /** ファイルを受け付けた(`status=STARTED`)。`rsync` は、署名を送れるとき。 */
892
+ replyFileTransferStarted(id, fileId, rsync = false) {
893
+ if (this.disposed)
894
+ return;
895
+ this.core.file_transfer_started(id, fileId, rsync);
896
+ this.flush();
897
+ }
898
+ /** データを書いた(`status=PROGRESS`)。`written` はここまでに書いたバイト数。 */
899
+ replyFileTransferProgress(id, fileId, written) {
900
+ if (this.disposed)
901
+ return;
902
+ this.core.file_transfer_progress(id, fileId, written);
903
+ this.flush();
904
+ }
905
+ /** エラーで答える。`fileId` を省くと、セッション全体のエラー。 */
906
+ replyFileTransferError(id, error, opts = {}) {
907
+ if (this.disposed)
908
+ return;
909
+ this.core.file_transfer_error(id, opts.fileId ?? "", error, opts.message ?? "");
910
+ this.flush();
911
+ }
912
+ /** セッションの始まりを断る(`EPERM`)。 */
913
+ refuseFileTransfer(id) {
914
+ if (this.disposed)
915
+ return;
916
+ this.core.file_transfer_refuse(id);
917
+ this.flush();
918
+ }
919
+ /** 取り消し(`cancel`)に答える(`status=CANCELED`)。 */
920
+ replyFileTransferCanceled(id) {
921
+ if (this.disposed)
922
+ return;
923
+ this.core.file_transfer_canceled(id);
924
+ this.flush();
925
+ }
926
+ /** 受け取りの一覧に項目を足す。全部並べたら `replyFileTransferOk(id, { name: ホーム })` で終える。 */
927
+ replyFileTransferListing(id, entry) {
928
+ if (this.disposed)
929
+ return;
930
+ this.core.file_transfer_listing(id, entry.requestId, entry.entryId, entry.name, Math.max(0, FILE_TYPES.indexOf(entry.fileType)), entry.mtime, entry.permissions, entry.size, entry.parent, entry.data);
931
+ this.flush();
932
+ }
933
+ /**
934
+ * 受け取るファイルのデータを送る。4096 バイトごとのパケットに分ける。`end` が真なら最後を
935
+ * `end_data` にする(`data` が空でも送る)。圧縮や rsync の差分は、作ったバイト列をそのまま渡す。
936
+ */
937
+ replyFileTransferData(id, fileId, data, end = false) {
938
+ if (this.disposed)
939
+ return;
940
+ this.core.file_transfer_data(id, fileId, data, end);
941
+ this.flush();
942
+ }
943
+ /** プログラムがドロップを受け付けると言っているか。偽の間、`dropMove` などは何もしない。 */
944
+ get dropAccepting() {
945
+ return this.core.drop_accepting();
946
+ }
947
+ /** プログラムが受け付けると言った MIME の種類(空でもよい)。 */
948
+ get dropAcceptedMimes() {
949
+ return this.core.drop_accepted_mimes();
950
+ }
951
+ /**
952
+ * プログラムが渡した機械の ID(ドロップ側)。自分の機械の ID と比べて別の機械なら、
953
+ * `text/uri-list` の答えに `remote: true` を付ける。知らない版の ID は別の機械として扱う。
954
+ */
955
+ get dropMachineId() {
956
+ return this.core.drop_machine_id() ?? null;
957
+ }
958
+ /**
959
+ * ドラッグが窓の上を動いた。`mimes` はドラッグされているものの MIME の種類。MIME の一覧は、
960
+ * 最初の移動と変わったときだけ PTY へ付ける。受け付けていないとき・位置が負のときは何もせず、false を返す。
961
+ */
962
+ dropMove(location, mimes, allowed = { copy: true, move: true }) {
963
+ if (this.disposed)
964
+ return false;
965
+ const ok = this.core.drop_move(location.x, location.y, location.pixelX, location.pixelY, mimes, operationBits(allowed));
966
+ this.flush();
967
+ return ok;
968
+ }
969
+ /** ドラッグが窓から出た(位置が -1 の移動を送る)。窓の上に無かったときは何もせず、false を返す。 */
970
+ dropLeave() {
971
+ if (this.disposed)
972
+ return false;
973
+ const ok = this.core.drop_leave();
974
+ this.flush();
975
+ return ok;
976
+ }
977
+ /**
978
+ * ドロップされた。`mimes` は落とされたものの MIME の種類で、空ではいけない。`fromThisWindow` は、
979
+ * ドラッグがこの窓から始まったか(真なら、遠くの機械向けのファイルの読み取りを `EPERM` で断る)。
980
+ */
981
+ dropDrop(location, mimes, allowed = { copy: true, move: true }, fromThisWindow = false) {
982
+ if (this.disposed)
983
+ return false;
984
+ const ok = this.core.drop_drop(location.x, location.y, location.pixelX, location.pixelY, mimes, operationBits(allowed), fromThisWindow);
985
+ this.flush();
986
+ return ok;
987
+ }
988
+ /**
989
+ * `dropRequest` に、データで答える。データは全部を一度に渡す。4096 バイトのパケットに分け、
990
+ * 終わりの空のパケットまで送る。
991
+ */
992
+ replyDropData(request, form, data) {
993
+ if (this.disposed)
994
+ return;
995
+ const [kind, a, b] = dropRequestParts(request);
996
+ const code = form.kind === "mime"
997
+ ? 0
998
+ : form.kind === "file"
999
+ ? 1
1000
+ : form.kind === "symlink"
1001
+ ? 2
1002
+ : 3;
1003
+ this.core.drop_data_reply(kind, a, b, code, form.kind === "directory" ? form.handle : 0, form.kind === "mime" && form.remote === true, data);
1004
+ this.flush();
1005
+ }
1006
+ /** `dropRequest` に、エラーで答える。`error` は POSIX の記号名。 */
1007
+ replyDropError(request, error, description = "") {
1008
+ if (this.disposed)
1009
+ return;
1010
+ const [kind, a, b] = dropRequestParts(request);
1011
+ this.core.drop_error_reply(kind, a, b, error, description);
1012
+ this.flush();
1013
+ }
1014
+ /** プログラムがドラッグを始められると言っているか。 */
1015
+ get dragOffersEnabled() {
1016
+ return this.core.drag_offers_enabled();
1017
+ }
1018
+ /** プログラムが渡した機械の ID(ドラッグ側)。 */
1019
+ get dragMachineId() {
1020
+ return this.core.drag_machine_id() ?? null;
1021
+ }
1022
+ /**
1023
+ * ドラッグの身振りを見つけた。プログラムへ位置を送り、続く申し出を 1 回だけ受ける。申し出ていない
1024
+ * とき・ドラッグの途中のときは何もせず、false を返す。
1025
+ */
1026
+ dragGesture(location) {
1027
+ if (this.disposed)
1028
+ return false;
1029
+ const ok = this.core.drag_gesture(location.x, location.y, location.pixelX, location.pixelY);
1030
+ this.flush();
1031
+ return ok;
1032
+ }
1033
+ /** `dragStart` に答える: OS のドラッグが始まった。 */
1034
+ dragStarted() {
1035
+ if (this.disposed)
1036
+ return false;
1037
+ const ok = this.core.drag_started();
1038
+ this.flush();
1039
+ return ok;
1040
+ }
1041
+ /**
1042
+ * `dragStart` に失敗で答える、またはドラッグを止める。すでにユーザーが取り消していたら
1043
+ * `EPERM`、画像が大きすぎれば `EFBIG`。ドラッグの状態は捨てる。
1044
+ */
1045
+ dragError(error, description = "") {
1046
+ if (this.disposed)
1047
+ return false;
1048
+ const ok = this.core.drag_error(error, description);
1049
+ this.flush();
1050
+ return ok;
1051
+ }
1052
+ /** ドラッグが受け入れられた。`index` は、相手が欲しがりそうな MIME の種類(0 始まり)。 */
1053
+ dragAccepted(index) {
1054
+ if (this.disposed)
1055
+ return false;
1056
+ const ok = this.core.drag_accepted(index);
1057
+ this.flush();
1058
+ return ok;
1059
+ }
1060
+ /** 相手が行いそうな操作が変わった。null は受け付けない。 */
1061
+ dragAction(operation) {
1062
+ if (this.disposed)
1063
+ return false;
1064
+ const ok = this.core.drag_action(operation === "copy" ? 1 : operation === "move" ? 2 : 0);
1065
+ this.flush();
1066
+ return ok;
1067
+ }
1068
+ /** ドラッグが相手に落とされた。このあと `requestDragData` で、データを頼む。 */
1069
+ dragDropped() {
1070
+ if (this.disposed)
1071
+ return false;
1072
+ const ok = this.core.drag_dropped();
1073
+ this.flush();
1074
+ return ok;
1075
+ }
1076
+ /** ドラッグが終わった。`canceled` が真ならユーザーが取り消した。ドラッグの状態は捨てる。 */
1077
+ dragFinished(canceled = false) {
1078
+ if (this.disposed)
1079
+ return false;
1080
+ const ok = this.core.drag_finished(canceled);
1081
+ this.flush();
1082
+ return ok;
1083
+ }
1084
+ /** MIME の種類 `index`(0 始まり)のデータをプログラムに頼む。答えは `dragData` か `dragDataError`。 */
1085
+ requestDragData(index) {
1086
+ if (this.disposed)
1087
+ return false;
1088
+ const ok = this.core.drag_data_request(index);
1089
+ this.flush();
1090
+ return ok;
1091
+ }
1092
+ /** 遠くの機械へのドラッグで、`text/uri-list` の `index` 番目(1 始まり)の項目を頼む。答えは `dragEntry`。 */
1093
+ requestDragEntry(index) {
1094
+ if (this.disposed)
1095
+ return false;
1096
+ const ok = this.core.drag_entry_request(index);
1097
+ this.flush();
1098
+ return ok;
1099
+ }
1100
+ /**
1101
+ * OSC の番号 `ident` のハンドラーを登録する(xterm.js の `parser.registerOscHandler` に当たる)。
1102
+ * `data` は `OSC ident ;` のあとの文字列(UTF-8)。登録した番号は、ターミナルの内蔵の処理の
1103
+ * 代わりにハンドラーが受ける。同じ番号に複数登録できる。
1104
+ */
1105
+ registerOscHandler(ident, handler) {
1106
+ let set = this.oscHandlers.get(ident);
1107
+ if (!set) {
1108
+ set = new Set();
1109
+ this.oscHandlers.set(ident, set);
1110
+ this.core.register_osc(ident);
1111
+ }
1112
+ set.add(handler);
1113
+ return {
1114
+ dispose: () => {
1115
+ const s = this.oscHandlers.get(ident);
1116
+ if (!s?.delete(handler) || s.size > 0)
1117
+ return;
1118
+ this.oscHandlers.delete(ident);
1119
+ if (!this.disposed)
1120
+ this.core.unregister_osc(ident);
1121
+ },
1122
+ };
1123
+ }
1124
+ /** 初期状態に戻す。書き込み待ちのデータは捨てる(`done` は呼ばない)。 */
1125
+ reset() {
1126
+ this.queue.length = 0;
1127
+ this.core.reset();
1128
+ this.selection.clear();
1129
+ this.syncSelection();
1130
+ this.viewOffset = 0;
1131
+ this.lastScrollback = this.core.scrollback_len();
1132
+ this.afterChange();
1133
+ }
1134
+ /** 大きさを変える。 */
1135
+ resize(cols, rows) {
1136
+ this.core.resize(Math.max(1, rows), Math.max(1, cols));
1137
+ // 折り返しの組み直しで行の位置が変わるので、選択は消す
1138
+ this.selection.clear();
1139
+ this.syncSelection();
1140
+ this.lastScrollback = this.core.scrollback_len();
1141
+ this.viewOffset = Math.min(this.viewOffset, this.lastScrollback);
1142
+ this.renderer?.resize();
1143
+ this.afterChange();
1144
+ }
1145
+ /**
1146
+ * 欄(`open` に渡した要素)の大きさに合わせて列と行の数を決め、大きさを変える
1147
+ * (xterm.js の FitAddon の `fit()` と同じ式。スクロールバーの幅を引く)。
1148
+ * 返すのは変えたあとの大きさ。`open` の前や、欄が見えていない(大きさが 0)ときや、
1149
+ * フォントの大きさがまだ測れていないときは、何もせず、いまの大きさを返す。
1150
+ */
1151
+ fit() {
1152
+ const dims = this.proposeDimensions();
1153
+ if (dims && (dims.cols !== this.cols || dims.rows !== this.rows)) {
1154
+ this.resize(dims.cols, dims.rows);
1155
+ }
1156
+ return { cols: this.cols, rows: this.rows };
1157
+ }
1158
+ /** `fit()` が使う列と行の数を、変えずに出す。 */
1159
+ proposeDimensions() {
1160
+ const host = this.host;
1161
+ const cell = this.renderer?.cellSize;
1162
+ if (!host || !cell)
1163
+ return undefined;
1164
+ const style = host.ownerDocument.defaultView?.getComputedStyle(host);
1165
+ const px = (v) => Number.parseFloat(v ?? "") || 0;
1166
+ const width = host.clientWidth - px(style?.paddingLeft) - px(style?.paddingRight);
1167
+ const height = host.clientHeight - px(style?.paddingTop) - px(style?.paddingBottom);
1168
+ return proposeDimensions({
1169
+ width,
1170
+ height,
1171
+ cellWidth: cell.width,
1172
+ cellHeight: cell.height,
1173
+ scrollbarWidth: this.options.scrollback === 0 ? 0 : SCROLLBAR_WIDTH,
1174
+ });
1175
+ }
1176
+ /** フォーカスがあるか(`setFocused` か、要素のフォーカスで決まる)。 */
1177
+ get isFocused() {
1178
+ return this.focused;
1179
+ }
1180
+ /** ウィンドウの代わりに、フォーカスを得た・失ったことを知らせる。 */
1181
+ setFocused(focused) {
1182
+ this.focused = focused;
1183
+ this.core.set_focus(focused);
1184
+ this.flush();
1185
+ this.renderer?.touch();
1186
+ }
1187
+ /**
1188
+ * 欄の要素に付ける。canvas・スクロールバー・見えない `textarea`(フォーカスを受ける)を持つ要素を作り、
1189
+ * Canvas 2D で描き始める。字の幅と高さは、フォントを読み終えてから測り直す。
1190
+ * 欄の大きさに合わせるには `fit()` を呼ぶ。
1191
+ */
1192
+ open(host) {
1193
+ if (this.element)
1194
+ throw new Error("coneko-term: already opened");
1195
+ const doc = host.ownerDocument;
1196
+ const el = doc.createElement("div");
1197
+ el.className = "coneko-term";
1198
+ Object.assign(el.style, {
1199
+ position: "relative",
1200
+ width: "100%",
1201
+ height: "100%",
1202
+ overflow: "hidden",
1203
+ outline: "none",
1204
+ background: this.resolvedTheme.background,
1205
+ });
1206
+ const canvas = doc.createElement("canvas");
1207
+ canvas.className = "coneko-term-screen";
1208
+ Object.assign(canvas.style, { position: "absolute", left: "0", top: "0" });
1209
+ el.appendChild(canvas);
1210
+ this.cleanups.push(() => el.remove());
1211
+ host.appendChild(el);
1212
+ this.element = el;
1213
+ this.host = host;
1214
+ this.syncPointerShape();
1215
+ this.scrollbar = new Scrollbar(el, {
1216
+ total: () => this.scrollbackLength + this.rows,
1217
+ rows: () => this.rows,
1218
+ viewportY: () => this.viewportY,
1219
+ scrollToLine: (n) => this.scrollToLine(n),
1220
+ scrollLines: (n) => this.scrollLines(n),
1221
+ });
1222
+ this.cleanups.push(() => this.scrollbar?.dispose());
1223
+ const renderer = new CanvasRenderer(canvas, this, this.renderOptions());
1224
+ this.renderer = renderer;
1225
+ this.syncCellSize();
1226
+ renderer.onCellSizeChange((size) => {
1227
+ this.syncCellSize();
1228
+ for (const l of [...this.cellSizeListeners])
1229
+ l(size);
1230
+ });
1231
+ this.cleanups.push(() => renderer.dispose());
1232
+ this.cleanups.push(this.bindPointer(canvas), this.bindWheel(el, canvas), this.bindCopy(el));
1233
+ this.setupInput(doc, el);
1234
+ this.afterChange();
1235
+ this.cleanups.push(this.watchFonts(doc, renderer));
1236
+ this.cleanups.push(this.watchDevicePixelRatio(doc, renderer));
1237
+ }
1238
+ /**
1239
+ * 見えない `textarea` を置いて、キー・変換・貼り付け・フォーカスを受ける。
1240
+ * 変換中の字は、カーソルの位置に重ねて見せる。
1241
+ */
1242
+ setupInput(doc, el) {
1243
+ const textarea = createInputTextarea(doc);
1244
+ const { element: viewEl, view } = createCompositionView(doc, this.resolvedTheme.background, this.resolvedTheme.foreground);
1245
+ el.append(textarea, viewEl);
1246
+ const composition = new CompositionHelper(textarea, view, (data) => this.sendUserInput(data), () => this.compositionAnchor(), () => ({ family: this.fontFamily, size: this.fontSize }));
1247
+ this.composition = composition;
1248
+ this.textarea = textarea;
1249
+ const keys = this.createKeyHandler(textarea, composition);
1250
+ this.bindInputEvents(textarea, el, keys, composition);
1251
+ }
1252
+ /** 変換中の字を出す位置。さかのぼって見ていて、カーソルが見えないときは `undefined`。 */
1253
+ compositionAnchor() {
1254
+ const cell = this.renderer?.cellSize;
1255
+ if (!cell || this.viewportOffset !== 0)
1256
+ return undefined;
1257
+ const c = this.cursor;
1258
+ return {
1259
+ left: Math.min(c.x, this.cols - 1) * cell.width,
1260
+ top: c.y * cell.height,
1261
+ cellHeight: cell.height,
1262
+ };
1263
+ }
1264
+ createKeyHandler(textarea, composition) {
1265
+ const platform = detectPlatform();
1266
+ return new KeyHandler({
1267
+ send: (data) => this.sendUserInput(data),
1268
+ applicationCursorKeys: () => this.applicationCursorKeys,
1269
+ keyboardFlags: () => this.keyboardFlags,
1270
+ scrollPages: (n) => this.scrollLines(n * (this.rows - 1)),
1271
+ scrollToBottom: () => {
1272
+ if (this.options.scrollOnUserInput !== false)
1273
+ this.scrollToBottom();
1274
+ },
1275
+ isMac: platform.isMac,
1276
+ isWindows: platform.isWindows,
1277
+ macOptionIsMeta: this.options.macOptionIsMeta ?? false,
1278
+ customKeyHandler: () => this.customKeyHandler,
1279
+ }, textarea, composition);
1280
+ }
1281
+ bindInputEvents(textarea, el, keys, composition) {
1282
+ const on = (target, type, f, capture = false) => {
1283
+ const h = f;
1284
+ target.addEventListener(type, h, capture);
1285
+ this.cleanups.push(() => target.removeEventListener(type, h, capture));
1286
+ };
1287
+ on(textarea, "keydown", (e) => {
1288
+ // 修飾キーだけでは、利用者が操作したとは数えない
1289
+ if (!["Shift", "Control", "Alt", "Meta"].includes(e.key)) {
1290
+ this.core.program_status_key_pressed();
1291
+ this.flush();
1292
+ }
1293
+ void keys.keyDown(e);
1294
+ }, true);
1295
+ on(textarea, "keyup", (e) => keys.keyUp(e), true);
1296
+ on(textarea, "keypress", (e) => void keys.keyPress(e), true);
1297
+ on(textarea, "input", (e) => void keys.input(e), true);
1298
+ on(textarea, "compositionstart", () => composition.compositionstart());
1299
+ on(textarea, "compositionupdate", (e) => composition.compositionupdate(e.data));
1300
+ on(textarea, "compositionend", () => composition.compositionend());
1301
+ const onPaste = (e) => {
1302
+ e.stopPropagation();
1303
+ e.preventDefault();
1304
+ const text = e.clipboardData?.getData("text/plain");
1305
+ if (text)
1306
+ this.paste(text);
1307
+ };
1308
+ on(textarea, "paste", onPaste);
1309
+ on(el, "paste", onPaste);
1310
+ on(textarea, "focus", () => this.setFocused(true));
1311
+ on(textarea, "blur", () => {
1312
+ keys.reset();
1313
+ textarea.value = "";
1314
+ this.setFocused(false);
1315
+ });
1316
+ // 欄を押したら、フォーカスは `textarea` へ(押した先に動かさない)。マウスの報告や選択は #10
1317
+ on(el, "mousedown", (e) => {
1318
+ if (e.target !== textarea)
1319
+ e.preventDefault();
1320
+ textarea.focus();
1321
+ });
1322
+ }
1323
+ /** フォントを読み終えてから測り直す(読む前は代わりのフォントの大きさになる)。戻り値は後始末。 */
1324
+ watchFonts(doc, renderer) {
1325
+ const fonts = doc.fonts;
1326
+ if (!fonts)
1327
+ return () => { };
1328
+ const remeasure = () => {
1329
+ if (!this.disposed)
1330
+ renderer.measure();
1331
+ };
1332
+ const spec = `${this.fontSize}px ${this.fontFamily}`;
1333
+ void fonts.load(spec).then(remeasure, () => { });
1334
+ void fonts.ready.then(remeasure, () => { });
1335
+ fonts.addEventListener("loadingdone", remeasure);
1336
+ return () => fonts.removeEventListener("loadingdone", remeasure);
1337
+ }
1338
+ /** `devicePixelRatio` が変わったら(別のディスプレイへ動かした、ズームした)測り直す。戻り値は後始末。 */
1339
+ watchDevicePixelRatio(doc, renderer) {
1340
+ const win = doc.defaultView;
1341
+ if (!win?.matchMedia)
1342
+ return () => { };
1343
+ let mq;
1344
+ const watch = () => {
1345
+ mq?.removeEventListener("change", onChange);
1346
+ mq = win.matchMedia(`(resolution: ${win.devicePixelRatio}dppx)`);
1347
+ mq.addEventListener("change", onChange);
1348
+ };
1349
+ const onChange = () => {
1350
+ watch();
1351
+ if (!this.disposed)
1352
+ renderer.measure();
1353
+ };
1354
+ watch();
1355
+ return () => mq?.removeEventListener("change", onChange);
1356
+ }
1357
+ renderOptions() {
1358
+ const t = this.resolvedTheme;
1359
+ return {
1360
+ fontFamily: this.fontFamily,
1361
+ fontSize: this.fontSize,
1362
+ lineHeight: this.options.lineHeight ?? 1,
1363
+ palette: new Palette(this.colorTable ?? ANSI_NAMES.map((n) => t[n]), t.foreground, t.background, t.cursor, this.selectionColor, this.cursorTextColor ?? t.background),
1364
+ };
1365
+ }
1366
+ /** セルの大きさ(CSS ピクセル)。`open` の前は `undefined`。 */
1367
+ get cellSize() {
1368
+ return this.renderer?.cellSize;
1369
+ }
1370
+ /** セルの大きさが変わった(フォントを読み終えた、`devicePixelRatio` が変わった)。`fit()` を呼び直す目印に使う。 */
1371
+ onCellSizeChange(listener) {
1372
+ return addListener(this.cellSizeListeners, listener);
1373
+ }
1374
+ // ---- 表示位置(viewport)
1375
+ /** 実際に使う、さかのぼっている行数。代替画面では常に 0。 */
1376
+ get viewportOffset() {
1377
+ return this.core.is_alt_screen() ? 0 : this.viewOffset;
1378
+ }
1379
+ /** 一番上に見えている行の番号。0 がスクロールバックの一番古い行で、一番下を見ているときは `scrollbackLength`。 */
1380
+ get viewportY() {
1381
+ return this.scrollbackLength - this.viewportOffset;
1382
+ }
1383
+ /** 一番下を見ているか(新しい出力に付いていく状態)。 */
1384
+ get isAtBottom() {
1385
+ return this.viewportOffset === 0;
1386
+ }
1387
+ /** `n` 行ぶん動かす。負の数で上(古い方)、正の数で下。 */
1388
+ scrollLines(n) {
1389
+ this.setViewOffset(this.viewOffset - Math.trunc(n));
1390
+ }
1391
+ /** 一番上に見える行を `line`(0 がスクロールバックの一番古い行)にする。 */
1392
+ scrollToLine(line) {
1393
+ this.setViewOffset(this.scrollbackLength - Math.trunc(line));
1394
+ }
1395
+ /** 一番古い行へ。 */
1396
+ scrollToTop() {
1397
+ this.setViewOffset(this.scrollbackLength);
1398
+ }
1399
+ /** 一番下へ。 */
1400
+ scrollToBottom() {
1401
+ this.setViewOffset(0);
1402
+ }
1403
+ /** 表示位置が変わった。引数は新しい `viewportY`。 */
1404
+ onScroll(listener) {
1405
+ return addListener(this.scrollListeners, listener);
1406
+ }
1407
+ setViewOffset(offset) {
1408
+ if (this.disposed)
1409
+ return;
1410
+ const before = this.viewportY;
1411
+ this.viewOffset = Math.max(0, Math.min(offset, this.scrollbackLength));
1412
+ if (this.viewportY === before)
1413
+ return;
1414
+ this.renderer?.invalidate();
1415
+ this.afterChange();
1416
+ for (const l of [...this.scrollListeners])
1417
+ this.guard(() => l(this.viewportY));
1418
+ }
1419
+ /** 書き込みでスクロールバックが増えたとき、さかのぼって見ているなら同じ内容を見続ける。 */
1420
+ syncViewport() {
1421
+ const len = this.core.scrollback_len();
1422
+ const grown = len - this.lastScrollback;
1423
+ this.lastScrollback = len;
1424
+ if (this.viewOffset > 0 && grown > 0)
1425
+ this.viewOffset += grown;
1426
+ this.viewOffset = Math.min(this.viewOffset, len);
1427
+ }
1428
+ /** 描画とスクロールバーに、画面が変わったことを知らせる。 */
1429
+ afterChange() {
1430
+ this.syncPointerShape();
1431
+ const r = this.renderer;
1432
+ if (!r)
1433
+ return;
1434
+ r.touch();
1435
+ this.composition?.updateCompositionElements(true);
1436
+ const cell = r.cellSize;
1437
+ this.scrollbar?.update(this.rows * cell.height, !this.core.is_alt_screen());
1438
+ }
1439
+ /**
1440
+ * プログラムが OSC 22(kitty の mouse pointer shapes)で決めた、ポインターの形。CSS の `cursor` の値で、
1441
+ * 決まっていなければ `undefined`。マウスの報告が有効かどうかには関わらない。
1442
+ * `open` した要素の `cursor` には、これが自動で反映される。
1443
+ */
1444
+ get pointerShape() {
1445
+ return this.core.pointer_shape() ?? undefined;
1446
+ }
1447
+ syncPointerShape() {
1448
+ const el = this.element;
1449
+ if (!el || this.disposed)
1450
+ return;
1451
+ const shape = this.pointerShape ?? "";
1452
+ if (el.style.cursor !== shape)
1453
+ el.style.cursor = shape;
1454
+ }
1455
+ /** 画面の `y` 行目(表示位置を考えた、いま見えている行)のセル。 */
1456
+ readViewportRow(y) {
1457
+ const i = this.viewportY + y;
1458
+ const sb = this.scrollbackLength;
1459
+ return i < sb ? this.readScrollbackRow(i) : this.readRow(i - sb);
1460
+ }
1461
+ /** 欄の要素。 */
1462
+ host;
1463
+ /** `open` で作った要素。`open` の前は `undefined`。 */
1464
+ get hostElement() {
1465
+ return this.element;
1466
+ }
1467
+ /** 見えない `textarea` にフォーカスを当てる。`open` の前は何もしない。 */
1468
+ focus() {
1469
+ this.textarea?.focus();
1470
+ }
1471
+ /** フォーカスを外す。`open` の前は何もしない。 */
1472
+ blur() {
1473
+ this.textarea?.blur();
1474
+ }
1475
+ /** セルの画素の大きさをコアに教える(`c`・`r` が無い画像の置く大きさを決めるのに使う)。 */
1476
+ syncCellSize() {
1477
+ const cell = this.renderer?.deviceCellSize;
1478
+ if (cell)
1479
+ this.core.set_cell_size(cell.width, cell.height);
1480
+ }
1481
+ /** 画像の配置の並び(描画が使う)。`PLACEMENT_STRIDE` 個ずつ。 */
1482
+ readPlacements() {
1483
+ return this.core.placements();
1484
+ }
1485
+ /** 持っている画像の `[ID, 通し番号]` の並び(描画が使う)。 */
1486
+ imageSerials() {
1487
+ return this.core.image_serials();
1488
+ }
1489
+ /** 画像の情報 `[通し番号, 形式, 圧縮, 幅, 高さ]`(描画が使う)。画像が無ければ空。 */
1490
+ imageInfo(id) {
1491
+ return this.core.image_info(id);
1492
+ }
1493
+ /** 画像のバイト列(描画が使う)。 */
1494
+ imageData(id) {
1495
+ return this.core.image_data(id);
1496
+ }
1497
+ /** 画像のアニメーションの状態 `[コマの数, いまのコマ, 再生の状態(1 止まる、2 待ちながら回す、3 回す), いまのコマの表示時間(ミリ秒), 操作の数]`(描画が使う)。画像が無ければ空。 */
1498
+ animationInfo(id) {
1499
+ return this.core.animation_info(id);
1500
+ }
1501
+ /** いまのコマの表示時間が過ぎたと知らせ、次のコマへ進める(描画が使う)。進んだら `true`。 */
1502
+ animationStep(id) {
1503
+ return this.core.animation_step(id);
1504
+ }
1505
+ /** コマを作る `index` 番目の操作(描画が使う)。並びは `WasmTerminal.frame_op` の説明を参照。 */
1506
+ frameOp(id, index) {
1507
+ return this.core.frame_op(id, index);
1508
+ }
1509
+ /** `frameOp` の「データを置く」操作のバイト列(描画が使う)。 */
1510
+ frameOpData(id, index) {
1511
+ return this.core.frame_op_data(id, index);
1512
+ }
1513
+ /** 変わった行(見えている画面の行番号)を返し、「変わった」の印を消す。 */
1514
+ takeDirtyRows() {
1515
+ return Array.from(this.core.take_dirty_rows());
1516
+ }
1517
+ /** 見えている画面の `y` 行目のセル。 */
1518
+ readRow(y) {
1519
+ return this.view(this.core.read_row(y));
1520
+ }
1521
+ /** スクロールバックの `i` 行目(0 が一番古い)のセル。 */
1522
+ readScrollbackRow(i) {
1523
+ return this.view(this.core.read_scrollback_row(i));
1524
+ }
1525
+ /** 見えている画面の `y` 行目の字(空のセルは空白)。末尾の空白は残す。 */
1526
+ getRowText(y) {
1527
+ const row = this.readRow(y);
1528
+ let s = "";
1529
+ for (let x = 0; x < row.length; x++) {
1530
+ const cp = row.cells[x * CELL_STRIDE];
1531
+ const width = row.cells[x * CELL_STRIDE + 1];
1532
+ if (width === 0)
1533
+ continue;
1534
+ const mark = row.cells[x * CELL_STRIDE + 6];
1535
+ if (mark & 0xffff) {
1536
+ // 四角の左上だけが字を持つ
1537
+ if (mark >>> 16 === 0)
1538
+ s += row.sized((mark & 0xffff) - 1).text;
1539
+ continue;
1540
+ }
1541
+ if (cp === 0)
1542
+ s += " ";
1543
+ else if (cp & CLUSTER_FLAG)
1544
+ s += row.cluster(cp & ~CLUSTER_FLAG);
1545
+ else
1546
+ s += String.fromCodePoint(cp);
1547
+ }
1548
+ return s;
1549
+ }
1550
+ view(length) {
1551
+ const core = this.core;
1552
+ const wrapped = core.row_wrapped();
1553
+ const cells = new Uint32Array(getMemory().buffer, core.cells_ptr(), length * CELL_STRIDE);
1554
+ return {
1555
+ length,
1556
+ cells,
1557
+ wrapped,
1558
+ cluster: (i) => core.cluster(i),
1559
+ sized: (i) => {
1560
+ const [cols, scale, numerator, denominator, vertical, horizontal] = core.sized_info(i);
1561
+ return {
1562
+ text: core.sized_text(i),
1563
+ cols,
1564
+ scale,
1565
+ numerator,
1566
+ denominator,
1567
+ vertical,
1568
+ horizontal,
1569
+ };
1570
+ },
1571
+ };
1572
+ }
1573
+ // ---- 選択
1574
+ /** 選択範囲が変わった(広げた、消えた、書き込みで行がずれた)。 */
1575
+ onSelectionChange(listener) {
1576
+ return addListener(this.selectionListeners, listener);
1577
+ }
1578
+ /** 選択があるか。 */
1579
+ hasSelection() {
1580
+ return this.selection.range() !== undefined;
1581
+ }
1582
+ /**
1583
+ * 選択範囲。位置は、スクロールバックの一番古い行を 0 とした行と列で、`end` は含まない
1584
+ * (代替画面では画面の 0 行目が 0)。選択が無ければ `undefined`。
1585
+ */
1586
+ getSelectionRange() {
1587
+ return this.selection.range();
1588
+ }
1589
+ /**
1590
+ * 選択した字。折り返しでつながった行は改行を入れずにつなぎ、行末の空のセルは捨てる。
1591
+ * 選択が無ければ空文字列。
1592
+ */
1593
+ getSelection() {
1594
+ const r = this.selection.range();
1595
+ if (!r)
1596
+ return "";
1597
+ return this.core.selection_text(r.start.row, r.start.col, r.end.row, r.end.col);
1598
+ }
1599
+ /** 全部(スクロールバックも)を選ぶ。 */
1600
+ selectAll() {
1601
+ this.selection.selectAll();
1602
+ this.syncSelection();
1603
+ }
1604
+ /** 選択を消す。 */
1605
+ clearSelection() {
1606
+ this.selection.clear();
1607
+ this.syncSelection();
1608
+ }
1609
+ /**
1610
+ * 選択した字をクリップボードのデータに入れる(`copy` イベントの `clipboardData` を渡す)。
1611
+ * 選択が無ければ何もせず `false`。`open` した要素は、自分で `copy` イベントを受けて
1612
+ * これを呼ぶ(xterm.js と同じ)ので、ふつうは呼ばなくてよい。
1613
+ */
1614
+ copySelection(data) {
1615
+ if (!this.hasSelection())
1616
+ return false;
1617
+ data.setData("text/plain", this.getSelection());
1618
+ return true;
1619
+ }
1620
+ /** 選択範囲が変わっていたら、描き直して、聞き手に知らせる。書き込みのあとにも呼ぶ。 */
1621
+ syncSelection() {
1622
+ if (this.disposed)
1623
+ return;
1624
+ const trimmed = this.core.scrollback_trimmed();
1625
+ const alt = this.core.is_alt_screen();
1626
+ // 主画面と代替画面は別の行の並びなので、切り替わったら選択は消す
1627
+ if (alt !== this.lastAlt)
1628
+ this.selection.clear();
1629
+ this.lastAlt = alt;
1630
+ const moved = trimmed !== this.lastTrimmed;
1631
+ this.lastTrimmed = trimmed;
1632
+ const r = this.selection.range();
1633
+ const key = r
1634
+ ? `${r.start.row}:${r.start.col}-${r.end.row}:${r.end.col}`
1635
+ : "";
1636
+ if (key === this.lastSelectionKey && !(moved && r))
1637
+ return;
1638
+ this.lastSelectionKey = key;
1639
+ this.renderer?.invalidate();
1640
+ for (const l of [...this.selectionListeners])
1641
+ this.guard(l);
1642
+ }
1643
+ /** 表示位置を考えた、いま見えている `y` 行目の選択範囲 `[開始の列, 終わりの列)`(描画が使う)。 */
1644
+ selectionSpan(y) {
1645
+ const r = this.selection.range();
1646
+ if (!r)
1647
+ return undefined;
1648
+ const row = this.viewportY + y;
1649
+ if (row < r.start.row || row > r.end.row)
1650
+ return undefined;
1651
+ const c0 = row === r.start.row ? r.start.col : 0;
1652
+ const c1 = row === r.end.row ? r.end.col : this.cols;
1653
+ return c1 > c0 ? [c0, c1] : undefined;
1654
+ }
1655
+ // ---- マウス
1656
+ /** マウスの報告が有効か(アプリが `CSI ? 1000 h` などで頼んだ)。 */
1657
+ get mouseReporting() {
1658
+ return this.core.mouse_reporting();
1659
+ }
1660
+ /**
1661
+ * ポインターのイベントを渡す。`open` した要素は、自分で DOM のイベントを受けて、これを呼ぶ。
1662
+ *
1663
+ * - マウスの報告が有効なら、押した・離した・動かした(ボタンを押したまま、または何も押さずに。
1664
+ * モードによる)を PTY へ送るバイト列にして、`onData` の出口から出す。Shift を押して押した
1665
+ * ときは報告せず、選択になる(xterm.js と同じ)。
1666
+ * - そうでなければ、左ボタンのドラッグで範囲、ダブルクリックで単語、トリプルクリックで行を選ぶ。
1667
+ * Shift を押してクリックすると、いまの選択を広げる。
1668
+ *
1669
+ * 押してから離すまでは、押したときに決めた側が受け持つ。受け持った(ブラウザーの既定の動きを
1670
+ * 止めてよい)ときは `true`。
1671
+ */
1672
+ handleMouse(ev) {
1673
+ if (this.disposed)
1674
+ return false;
1675
+ const col = clamp(Math.trunc(ev.col), 0, this.cols - 1);
1676
+ const row = clamp(Math.trunc(ev.row), 0, this.rows - 1);
1677
+ const shift = ev.shiftKey === true;
1678
+ const report = (button, action) => this.core.report_mouse(button, action, col, row, Math.max(0, Math.floor(ev.x ?? 0)), Math.max(0, Math.floor(ev.y ?? 0)), (shift ? 1 : 0) | (ev.altKey ? 2 : 0) | (ev.ctrlKey ? 4 : 0));
1679
+ const flushReport = (sent) => {
1680
+ if (sent)
1681
+ this.flush();
1682
+ };
1683
+ if (ev.type === "down") {
1684
+ if (this.mouseOwner)
1685
+ return true;
1686
+ if (this.core.mouse_reporting() && !shift) {
1687
+ const button = domButton(ev.button);
1688
+ this.mouseOwner = "report";
1689
+ this.reportButton = button;
1690
+ flushReport(report(button, 0));
1691
+ return true;
1692
+ }
1693
+ if (ev.button !== 0)
1694
+ return false;
1695
+ this.mouseOwner = "select";
1696
+ const at = { row: this.viewportY + row, col };
1697
+ if (shift && this.selection.range()) {
1698
+ this.selection.extendSelection(at);
1699
+ }
1700
+ else {
1701
+ const n = ((Math.max(1, ev.clickCount ?? 1) - 1) % 3) + 1;
1702
+ this.selection.start(n === 1 ? "cell" : n === 2 ? "word" : "line", at);
1703
+ }
1704
+ this.syncSelection();
1705
+ return true;
1706
+ }
1707
+ if (ev.type === "leave") {
1708
+ // 押している間は、窓の外へ出てもボタンの報告が続くので、出たことは報告しない
1709
+ if (!this.mouseOwner)
1710
+ flushReport(this.core.report_mouse_leave());
1711
+ return false;
1712
+ }
1713
+ if (ev.type === "move") {
1714
+ if (this.mouseOwner === "report") {
1715
+ flushReport(report(this.reportButton, 2));
1716
+ return true;
1717
+ }
1718
+ if (this.mouseOwner === "select") {
1719
+ this.selection.extend({ row: this.viewportY + row, col });
1720
+ this.syncSelection();
1721
+ return true;
1722
+ }
1723
+ if (this.core.mouse_reporting() && !shift) {
1724
+ flushReport(report(3, 2));
1725
+ return true;
1726
+ }
1727
+ return false;
1728
+ }
1729
+ // up
1730
+ const owner = this.mouseOwner;
1731
+ if (!owner)
1732
+ return false;
1733
+ this.mouseOwner = undefined;
1734
+ if (owner === "report")
1735
+ flushReport(report(this.reportButton, 1));
1736
+ return true;
1737
+ }
1738
+ /**
1739
+ * ホイールのイベントを渡す。`open` した要素は、自分で DOM のイベントを受けて、これを呼ぶ。
1740
+ *
1741
+ * - マウスの報告が有効なら、ホイールとして報告する(1 回の `wheel` に 1 つ)。
1742
+ * - 代替画面でマウスの報告が無効なら、動かした行の数だけ上下の矢印キーを送る
1743
+ * (アプリケーションカーソルキーなら `ESC O A`、そうでなければ `ESC [ A`)。
1744
+ * - 通常の画面なら、表示位置を動かす。
1745
+ *
1746
+ * 縦に回さないとき(`deltaY` が 0 か Shift つき)は何もしない。ブラウザーの既定の動きを
1747
+ * 止めてよいときは `true`。
1748
+ */
1749
+ handleWheel(ev) {
1750
+ if (this.disposed || ev.deltaY === 0)
1751
+ return false;
1752
+ const up = ev.deltaY < 0;
1753
+ if (this.core.mouse_reporting()) {
1754
+ const col = clamp(Math.trunc(ev.col ?? 0), 0, this.cols - 1);
1755
+ const row = clamp(Math.trunc(ev.row ?? 0), 0, this.rows - 1);
1756
+ const sent = this.core.report_mouse(up ? 6 : 7, 0, col, row, Math.max(0, Math.floor(ev.x ?? 0)), Math.max(0, Math.floor(ev.y ?? 0)), (ev.shiftKey ? 1 : 0) | (ev.altKey ? 2 : 0) | (ev.ctrlKey ? 4 : 0));
1757
+ if (sent)
1758
+ this.flush();
1759
+ return true;
1760
+ }
1761
+ const lines = this.wheelLines(ev);
1762
+ if (this.core.is_alt_screen()) {
1763
+ if (lines === 0)
1764
+ return false;
1765
+ const key = up ? "A" : "B";
1766
+ const seq = (this.core.application_cursor_keys() ? "\x1bO" : "\x1b[") + key;
1767
+ this.input(seq.repeat(Math.abs(lines)));
1768
+ return true;
1769
+ }
1770
+ if (this.scrollbackLength === 0)
1771
+ return false;
1772
+ if (lines !== 0)
1773
+ this.scrollLines(lines);
1774
+ return true;
1775
+ }
1776
+ /** ホイールが動かす行の数(上が負)。Shift つきは 0。xterm.js の `getLinesScrolled` と同じ式。 */
1777
+ wheelLines(ev) {
1778
+ if (ev.shiftKey)
1779
+ return 0;
1780
+ let amount = ev.deltaY;
1781
+ if (ev.deltaMode === 0) {
1782
+ // ピクセル: 行の高さで割り、端数は次に回す
1783
+ const h = this.renderer?.cellSize.height || 1;
1784
+ this.wheelPartial += amount / h;
1785
+ amount = Math.trunc(this.wheelPartial);
1786
+ this.wheelPartial -= amount;
1787
+ }
1788
+ else if (ev.deltaMode === 2) {
1789
+ amount *= this.rows;
1790
+ }
1791
+ return Math.trunc(amount);
1792
+ }
1793
+ /** DOM のマウスのイベントを `MouseInput` にする。 */
1794
+ pointerInput(canvas, e, type) {
1795
+ const rect = canvas.getBoundingClientRect();
1796
+ const cell = this.renderer?.cellSize ?? { width: 1, height: 1 };
1797
+ const x = e.clientX - rect.left;
1798
+ const y = e.clientY - rect.top;
1799
+ return {
1800
+ type,
1801
+ button: e.button,
1802
+ col: Math.floor(x / cell.width),
1803
+ row: Math.floor(y / cell.height),
1804
+ x,
1805
+ y,
1806
+ shiftKey: e.shiftKey,
1807
+ altKey: e.altKey,
1808
+ ctrlKey: e.ctrlKey,
1809
+ clickCount: e.detail,
1810
+ outside: y < 0 ? -1 : y >= this.rows * cell.height ? 1 : 0,
1811
+ };
1812
+ }
1813
+ /** 選択中に画面の外へ出たとき、その向きにスクロールし続ける。`move` を渡し続け、`stop` で止める。 */
1814
+ autoScroller(canvas) {
1815
+ let timer;
1816
+ let last;
1817
+ const stop = () => {
1818
+ if (timer !== undefined)
1819
+ clearInterval(timer);
1820
+ timer = undefined;
1821
+ };
1822
+ return {
1823
+ stop,
1824
+ move: (e) => {
1825
+ last = e;
1826
+ const outside = this.pointerInput(canvas, e, "move").outside;
1827
+ if (this.mouseOwner !== "select" || outside === 0)
1828
+ return stop();
1829
+ timer ??= setInterval(() => {
1830
+ const i = last && this.pointerInput(canvas, last, "move");
1831
+ if (!i || this.mouseOwner !== "select" || i.outside === 0)
1832
+ return stop();
1833
+ this.scrollLines(i.outside);
1834
+ this.handleMouse(i);
1835
+ }, 50);
1836
+ },
1837
+ };
1838
+ }
1839
+ /** 押す・動かす・離すを受ける。戻り値は後始末。 */
1840
+ bindPointer(canvas) {
1841
+ const doc = canvas.ownerDocument;
1842
+ const auto = this.autoScroller(canvas);
1843
+ const onMove = (e) => {
1844
+ this.handleMouse(this.pointerInput(canvas, e, "move"));
1845
+ auto.move(e);
1846
+ };
1847
+ const onUp = (e) => {
1848
+ auto.stop();
1849
+ doc.removeEventListener("mousemove", onMove);
1850
+ doc.removeEventListener("mouseup", onUp);
1851
+ this.handleMouse(this.pointerInput(canvas, e, "up"));
1852
+ };
1853
+ const onDown = (e) => {
1854
+ if (this.handleMouse(this.pointerInput(canvas, e, "down"))) {
1855
+ // ブラウザーの既定の選択やドラッグを止め、代わりにフォーカスを当てる
1856
+ e.preventDefault();
1857
+ this.focus();
1858
+ doc.addEventListener("mousemove", onMove);
1859
+ doc.addEventListener("mouseup", onUp);
1860
+ }
1861
+ };
1862
+ // ボタンを押していない動きは、canvas の上のものだけ報告する
1863
+ const onHover = (e) => {
1864
+ if (!this.mouseOwner)
1865
+ this.handleMouse(this.pointerInput(canvas, e, "move"));
1866
+ };
1867
+ canvas.addEventListener("mousedown", onDown);
1868
+ canvas.addEventListener("mousemove", onHover);
1869
+ // 窓から出たことは、kitty の拡張(ピクセル単位の SGR)で報告する
1870
+ const onLeave = (e) => {
1871
+ this.handleMouse(this.pointerInput(canvas, e, "leave"));
1872
+ };
1873
+ canvas.addEventListener("mouseleave", onLeave);
1874
+ return () => {
1875
+ auto.stop();
1876
+ canvas.removeEventListener("mousedown", onDown);
1877
+ canvas.removeEventListener("mousemove", onHover);
1878
+ canvas.removeEventListener("mouseleave", onLeave);
1879
+ doc.removeEventListener("mousemove", onMove);
1880
+ doc.removeEventListener("mouseup", onUp);
1881
+ };
1882
+ }
1883
+ /** ホイールを受ける。戻り値は後始末。 */
1884
+ bindWheel(el, canvas) {
1885
+ const onWheel = (e) => {
1886
+ const i = this.pointerInput(canvas, e, "move");
1887
+ const consumed = this.handleWheel({
1888
+ deltaY: e.deltaY,
1889
+ deltaMode: e.deltaMode,
1890
+ col: i.col,
1891
+ row: i.row,
1892
+ x: i.x,
1893
+ y: i.y,
1894
+ shiftKey: e.shiftKey,
1895
+ altKey: e.altKey,
1896
+ ctrlKey: e.ctrlKey,
1897
+ });
1898
+ if (consumed)
1899
+ e.preventDefault();
1900
+ };
1901
+ el.addEventListener("wheel", onWheel, { passive: false });
1902
+ return () => el.removeEventListener("wheel", onWheel);
1903
+ }
1904
+ /**
1905
+ * `copy` を受ける。ブラウザーは `copy` を、編集できる要素か選択の始点か `body` に送り、
1906
+ * 欄の要素には送らないことがあるので、document で受けて、フォーカスが欄の中にあるときだけ扱う
1907
+ * (別の入力欄でのコピーは邪魔しない)。戻り値は後始末。
1908
+ */
1909
+ bindCopy(el) {
1910
+ const doc = el.ownerDocument;
1911
+ const onCopy = (e) => {
1912
+ const active = doc.activeElement;
1913
+ if (!active || !el.contains(active))
1914
+ return;
1915
+ if (e.clipboardData && this.copySelection(e.clipboardData)) {
1916
+ e.preventDefault();
1917
+ }
1918
+ };
1919
+ doc.addEventListener("copy", onCopy);
1920
+ return () => doc.removeEventListener("copy", onCopy);
1921
+ }
1922
+ /** 後始末をする。書き込み待ちのデータは捨てる(`done` は呼ばない)。 */
1923
+ dispose() {
1924
+ if (this.disposed)
1925
+ return;
1926
+ this.disposed = true;
1927
+ this.queue.length = 0;
1928
+ for (const c of this.cleanups)
1929
+ c();
1930
+ this.cleanups.length = 0;
1931
+ this.element = undefined;
1932
+ this.textarea = undefined;
1933
+ this.composition = undefined;
1934
+ this.host = undefined;
1935
+ this.renderer = undefined;
1936
+ this.scrollbar = undefined;
1937
+ this.scrollListeners.clear();
1938
+ this.selectionListeners.clear();
1939
+ this.cellSizeListeners.clear();
1940
+ this.dataListeners.clear();
1941
+ this.titleListeners.clear();
1942
+ this.bellListeners.clear();
1943
+ this.notificationListeners.clear();
1944
+ this.notificationCloseListeners.clear();
1945
+ this.notificationQueryListeners.clear();
1946
+ this.notificationAliveListeners.clear();
1947
+ this.dndListeners.clear();
1948
+ this.fileTransferListeners.clear();
1949
+ this.oscHandlers.clear();
1950
+ this.wasm.free();
1951
+ }
1952
+ }
1953
+ function clamp(n, lo, hi) {
1954
+ return Math.max(lo, Math.min(hi, n));
1955
+ }
1956
+ /** DOM の `MouseEvent.button` を、WASM のボタンの番号にする。 */
1957
+ function domButton(b) {
1958
+ return b === 3 ? 4 : b === 4 ? 5 : b >= 0 && b <= 2 ? b : 3;
1959
+ }
1960
+ function addListener(set, listener) {
1961
+ set.add(listener);
1962
+ return { dispose: () => void set.delete(listener) };
1963
+ }
1964
+ function stripUndefined(o) {
1965
+ return Object.fromEntries(Object.entries(o).filter(([, v]) => v !== undefined));
1966
+ }
1967
+ /** キーと変換を受ける、見えない `textarea`。 */
1968
+ function createInputTextarea(doc) {
1969
+ const textarea = doc.createElement("textarea");
1970
+ textarea.className = "coneko-term-input";
1971
+ textarea.setAttribute("aria-label", "Terminal input");
1972
+ textarea.setAttribute("autocorrect", "off");
1973
+ textarea.setAttribute("autocapitalize", "off");
1974
+ textarea.setAttribute("autocomplete", "off");
1975
+ textarea.setAttribute("spellcheck", "false");
1976
+ textarea.setAttribute("wrap", "off");
1977
+ textarea.tabIndex = 0;
1978
+ Object.assign(textarea.style, {
1979
+ position: "absolute",
1980
+ left: "-9999em",
1981
+ top: "0",
1982
+ width: "0",
1983
+ height: "0",
1984
+ padding: "0",
1985
+ border: "0",
1986
+ margin: "0",
1987
+ opacity: "0",
1988
+ zIndex: "-5",
1989
+ whiteSpace: "nowrap",
1990
+ overflow: "hidden",
1991
+ resize: "none",
1992
+ });
1993
+ return textarea;
1994
+ }
1995
+ /** 変換中の字を見せる要素と、`CompositionHelper` が使う見え方。 */
1996
+ function createCompositionView(doc, background, color) {
1997
+ const element = doc.createElement("div");
1998
+ element.className = "coneko-term-composition";
1999
+ Object.assign(element.style, {
2000
+ display: "none",
2001
+ position: "absolute",
2002
+ whiteSpace: "nowrap",
2003
+ zIndex: "1",
2004
+ background,
2005
+ color,
2006
+ });
2007
+ const view = {
2008
+ get textContent() {
2009
+ return element.textContent;
2010
+ },
2011
+ set textContent(v) {
2012
+ element.textContent = v;
2013
+ },
2014
+ style: element.style,
2015
+ getBoundingClientRect: () => element.getBoundingClientRect(),
2016
+ show: () => void (element.style.display = "inline-block"),
2017
+ hide: () => void (element.style.display = "none"),
2018
+ };
2019
+ return { element, view };
2020
+ }