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,870 @@
1
+ import { CELL_STRIDE, CLUSTER_FLAG, } from "./terminal.js";
2
+ import { ImageCache, Z_UNDER_BACKGROUND, browserImageDecoder, parsePlacements, } from "./images.js";
3
+ /** スクロールバーの幅(CSS ピクセル)。xterm.js 6.0.0 と同じ。 */
4
+ export const SCROLLBAR_WIDTH = 14;
5
+ /** カーソルが点滅する間隔(ミリ秒)。xterm.js 6.0.0 と同じ。 */
6
+ const BLINK_MS = 600;
7
+ /**
8
+ * 欄の大きさから列と行の数を出す。xterm.js 6.0.0 の FitAddon(`proposeDimensions`)と同じ式:
9
+ * 幅からスクロールバーの幅を引いてセルの幅で割り、切り捨てる。列は 2 以上、行は 1 以上。
10
+ * セルの大きさが分からない(0 以下)、または欄が見えていない(幅か高さが 0 以下)ときは
11
+ * `undefined`。
12
+ */
13
+ export function proposeDimensions(input) {
14
+ const { width, height, cellWidth, cellHeight, scrollbarWidth } = input;
15
+ if (!(cellWidth > 0) || !(cellHeight > 0))
16
+ return undefined;
17
+ if (!(width > 0) || !(height > 0))
18
+ return undefined;
19
+ return {
20
+ cols: Math.max(2, Math.floor((width - scrollbarWidth) / cellWidth)),
21
+ rows: Math.max(1, Math.floor(height / cellHeight)),
22
+ };
23
+ }
24
+ /** 256 色のうち、16 から 255 の色(6x6x6 の立方体と 24 段階の灰色)。 */
25
+ function extendedPaletteColor(index) {
26
+ let r, g, b;
27
+ if (index < 232) {
28
+ const i = index - 16;
29
+ const level = (n) => (n === 0 ? 0 : 55 + n * 40);
30
+ r = level(Math.floor(i / 36));
31
+ g = level(Math.floor(i / 6) % 6);
32
+ b = level(i % 6);
33
+ }
34
+ else {
35
+ r = g = b = 8 + (index - 232) * 10;
36
+ }
37
+ const hex = (n) => n.toString(16).padStart(2, "0");
38
+ return `#${hex(r)}${hex(g)}${hex(b)}`;
39
+ }
40
+ /** 色の語(`RowView.cells` の色の並び)から作る、描画に使う色の表。 */
41
+ export class Palette {
42
+ foreground;
43
+ background;
44
+ cursor;
45
+ selection;
46
+ cursorText;
47
+ colors;
48
+ /** `ansi` は先頭の 16 色か、256 色すべて。 */
49
+ constructor(ansi, foreground, background, cursor,
50
+ /** 選択範囲の色。半透明でよい(CSS の色)。 */
51
+ selection = "#ffffff4d",
52
+ /** ブロックカーソルの下の字の色。省略したら背景の色。 */
53
+ cursorText = background) {
54
+ this.foreground = foreground;
55
+ this.background = background;
56
+ this.cursor = cursor;
57
+ this.selection = selection;
58
+ this.cursorText = cursorText;
59
+ this.colors = [...ansi];
60
+ for (let i = ansi.length; i < 256; i++)
61
+ this.colors.push(extendedPaletteColor(i));
62
+ }
63
+ /** パレットの `index` 番の色。 */
64
+ get(index) {
65
+ return this.colors[index] ?? this.foreground;
66
+ }
67
+ /**
68
+ * 色の語を CSS の色にする。上位 8 ビットが種類(0 既定、1 パレット、2 RGB)。
69
+ * `bold` のときは、パレットの 0 から 7 を明るい色(8 から 15)にする(xterm.js の既定)。
70
+ */
71
+ resolve(word, isBackground, bold = false) {
72
+ const kind = word >>> 24;
73
+ const value = word & 0xff_ffff;
74
+ if (kind === 1)
75
+ return this.get(bold && value < 8 ? value + 8 : value);
76
+ if (kind === 2) {
77
+ return `#${value.toString(16).padStart(6, "0")}`;
78
+ }
79
+ return isBackground ? this.background : this.foreground;
80
+ }
81
+ }
82
+ /** ブラウザーの `devicePixelRatio` と `requestAnimationFrame` を使う、既定の `RenderEnv`。 */
83
+ export const browserEnv = {
84
+ devicePixelRatio: () => globalThis.devicePixelRatio || 1,
85
+ requestFrame: (cb) => requestAnimationFrame(cb),
86
+ cancelFrame: (id) => cancelAnimationFrame(id),
87
+ };
88
+ /**
89
+ * Canvas 2D でターミナルの画面を描く。
90
+ *
91
+ * - 座標はすべてデバイスピクセルの整数で扱い、canvas の実際の大きさを
92
+ * `devicePixelRatio` 倍にする。にじまない。
93
+ * - 描くのは `requestAnimationFrame` でまとめる。変わった行(と、カーソルが動いた行)だけを描く。
94
+ * 表示位置が動いたり、大きさ・フォント・色が変わったときは全部を描く。
95
+ */
96
+ export class CanvasRenderer {
97
+ canvas;
98
+ source;
99
+ options;
100
+ env;
101
+ ctx;
102
+ dpr = 1;
103
+ /** デバイスピクセルでのセルの大きさ。 */
104
+ cellW = 1;
105
+ cellH = 1;
106
+ /** セルの上端から、ベースラインまでの距離(デバイスピクセル)。 */
107
+ baseline = 1;
108
+ descent = 0;
109
+ frame;
110
+ needsFull = true;
111
+ extraRows = new Set();
112
+ lastCursor;
113
+ /** いま描く追加のカーソル。行ごと。 */
114
+ extra = new Map();
115
+ /** 追加のカーソルの色の語 `[字の色, カーソルの色]`。 */
116
+ extraColors = [0, 0];
117
+ /** 直前の描画で追加のカーソルがあった行。消えたときに描き直す。 */
118
+ lastExtraRows = new Set();
119
+ blinkOn = true;
120
+ blinkTimer;
121
+ disposed = false;
122
+ cellSizeListeners = new Set();
123
+ /** 直前の描画で、スクロールバックを見ていたか。 */
124
+ lastOffset = 0;
125
+ imageCache;
126
+ /** 今のフレームで描く画像(読み解き済みのもの)。層ごと、`z` と ID の順。 */
127
+ layers = { under: [], below: [], above: [] };
128
+ /** 直前のフレームで描いた画像の印。変わったら全部を描き直す。 */
129
+ lastImageKey = "";
130
+ /** 直前のフレームで、回している画像があったか(あれば次のフレームも描く)。 */
131
+ animating = false;
132
+ constructor(canvas, source, options, env = browserEnv) {
133
+ this.canvas = canvas;
134
+ this.source = source;
135
+ this.options = options;
136
+ this.env = env;
137
+ const ctx = canvas.getContext("2d", { alpha: false });
138
+ if (!ctx)
139
+ throw new Error("coneko-term: canvas 2d context is unavailable");
140
+ this.ctx = ctx;
141
+ this.measure();
142
+ }
143
+ /** セルの大きさ(CSS ピクセル)。 */
144
+ get cellSize() {
145
+ return { width: this.cellW / this.dpr, height: this.cellH / this.dpr };
146
+ }
147
+ /** デバイスピクセルでのセルの大きさ。 */
148
+ get deviceCellSize() {
149
+ return { width: this.cellW, height: this.cellH };
150
+ }
151
+ /** セルの大きさが変わった(フォントの読み込みや `devicePixelRatio` の変化のあと)。 */
152
+ onCellSizeChange(listener) {
153
+ this.cellSizeListeners.add(listener);
154
+ return { dispose: () => void this.cellSizeListeners.delete(listener) };
155
+ }
156
+ /** 設定を入れ替えて測り直す。 */
157
+ setOptions(options) {
158
+ this.options = options;
159
+ this.measure();
160
+ }
161
+ font(bold, italic, scale = 1) {
162
+ const { fontFamily, fontSize } = this.options;
163
+ return `${italic ? "italic " : ""}${bold ? "bold " : ""}${fontSize * this.dpr * scale}px ${fontFamily}`;
164
+ }
165
+ /**
166
+ * 字の幅と高さを測る。フォントを読み終える前に測ると代わりのフォントの値になるので、
167
+ * 読み終えたら呼び直す(`Terminal.open` がそうする)。
168
+ */
169
+ measure() {
170
+ const ctx = this.ctx;
171
+ this.dpr = this.env.devicePixelRatio();
172
+ const size = this.options.fontSize * this.dpr;
173
+ ctx.font = this.font(false, false);
174
+ ctx.textBaseline = "alphabetic";
175
+ const sample = ctx.measureText("W".repeat(32));
176
+ const m = ctx.measureText("Mg");
177
+ const width = Math.max(1, Math.round(sample.width / 32) || Math.round(size * 0.6));
178
+ const ascent = m.fontBoundingBoxAscent ?? m.actualBoundingBoxAscent ?? size * 0.8;
179
+ const descent = m.fontBoundingBoxDescent ?? m.actualBoundingBoxDescent ?? size * 0.2;
180
+ const charH = Math.max(1, Math.ceil(ascent + descent));
181
+ const height = Math.max(1, Math.floor(charH * this.options.lineHeight));
182
+ const changed = width !== this.cellW || height !== this.cellH;
183
+ this.cellW = width;
184
+ this.cellH = height;
185
+ this.descent = Math.ceil(descent);
186
+ // 字の高さより行が高いぶんは、上下に半分ずつ余白にする
187
+ this.baseline = Math.floor((height - charH) / 2) + Math.ceil(ascent);
188
+ this.applySize();
189
+ this.needsFull = true;
190
+ this.requestRender();
191
+ if (changed) {
192
+ const size = this.cellSize;
193
+ for (const l of [...this.cellSizeListeners])
194
+ l(size);
195
+ }
196
+ }
197
+ /** 列と行の数に合わせて canvas の大きさを決める。 */
198
+ applySize() {
199
+ const c = this.canvas;
200
+ const w = this.source.cols * this.cellW;
201
+ const h = this.source.rows * this.cellH;
202
+ if (c.width !== w)
203
+ c.width = w;
204
+ if (c.height !== h)
205
+ c.height = h;
206
+ c.style.width = `${w / this.dpr}px`;
207
+ c.style.height = `${h / this.dpr}px`;
208
+ // canvas の大きさを変えると、2D の状態(font など)が初期に戻る
209
+ this.ctx.textBaseline = "alphabetic";
210
+ }
211
+ /** 列と行の数が変わった。 */
212
+ resize() {
213
+ this.applySize();
214
+ this.needsFull = true;
215
+ this.requestRender();
216
+ }
217
+ /** 全部を描き直す。 */
218
+ invalidate() {
219
+ this.needsFull = true;
220
+ this.requestRender();
221
+ }
222
+ /** 行 `ys`(見えている画面の行番号)を次のフレームで描き直す。 */
223
+ invalidateRows(ys) {
224
+ for (const y of ys)
225
+ this.extraRows.add(y);
226
+ this.requestRender();
227
+ }
228
+ /** 次のフレームで描く。何回呼んでも 1 回にまとめる。 */
229
+ requestRender() {
230
+ if (this.frame !== undefined || this.disposed)
231
+ return;
232
+ this.frame = this.env.requestFrame(() => {
233
+ this.frame = undefined;
234
+ this.render();
235
+ });
236
+ }
237
+ /** 画面に何か起きた(書き込みなど)。カーソルの点滅を最初に戻して描く。 */
238
+ touch() {
239
+ this.blinkOn = true;
240
+ if (this.blinkTimer !== undefined)
241
+ this.restartBlink();
242
+ this.requestRender();
243
+ }
244
+ restartBlink() {
245
+ this.stopBlink();
246
+ this.blinkTimer = setInterval(() => {
247
+ this.blinkOn = !this.blinkOn;
248
+ this.requestRender();
249
+ }, BLINK_MS);
250
+ }
251
+ stopBlink() {
252
+ if (this.blinkTimer !== undefined)
253
+ clearInterval(this.blinkTimer);
254
+ this.blinkTimer = undefined;
255
+ }
256
+ updateBlink() {
257
+ const { cursor, isFocused: focused } = this.source;
258
+ // 追加のカーソルは、主のカーソルが見えなくても、同じ点滅に従う
259
+ const want = cursor.blinking && (cursor.visible || this.extra.size > 0) && focused;
260
+ if (want && this.blinkTimer === undefined)
261
+ this.restartBlink();
262
+ if (!want && this.blinkTimer !== undefined) {
263
+ this.stopBlink();
264
+ this.blinkOn = true;
265
+ }
266
+ }
267
+ /** いま描く。通常は `requestRender` で足りる。 */
268
+ render() {
269
+ if (this.disposed)
270
+ return;
271
+ const src = this.source;
272
+ const dirty = src.takeDirtyRows();
273
+ const offset = src.viewportOffset;
274
+ this.collectExtraCursors(offset);
275
+ this.updateBlink();
276
+ const cursor = src.cursor;
277
+ const showCursor = this.cursorShown(offset);
278
+ const rows = new Set();
279
+ let full = this.needsFull || offset !== this.lastOffset;
280
+ // 画像の置き場所や中身が変わったら、全部を描き直す
281
+ this.animating = false;
282
+ if (this.collectImages(offset))
283
+ full = true;
284
+ // 過去の行を見ているときは、書き込みでずれる行を全部描き直す
285
+ if (offset > 0 && dirty.length > 0)
286
+ full = true;
287
+ if (!full) {
288
+ for (const y of dirty)
289
+ rows.add(y);
290
+ for (const y of this.extraRows)
291
+ rows.add(y);
292
+ if (this.lastCursor)
293
+ rows.add(this.lastCursor.y);
294
+ if (showCursor || this.lastCursor)
295
+ rows.add(cursor.y);
296
+ // 追加のカーソルは、点滅と主の形に従うので、いる行を毎回描く
297
+ for (const y of this.extra.keys())
298
+ rows.add(y);
299
+ for (const y of this.lastExtraRows)
300
+ rows.add(y);
301
+ }
302
+ this.extraRows.clear();
303
+ this.needsFull = false;
304
+ this.lastOffset = offset;
305
+ this.lastCursor = showCursor ? { x: cursor.x, y: cursor.y } : undefined;
306
+ this.lastExtraRows = new Set(this.extra.keys());
307
+ if (full) {
308
+ for (let y = 0; y < src.rows; y++)
309
+ this.drawRow(y, offset);
310
+ }
311
+ else {
312
+ for (const y of [...rows].sort((a, b) => a - b)) {
313
+ if (y < src.rows)
314
+ this.drawRow(y, offset);
315
+ }
316
+ }
317
+ // アニメーションを回している間は、止まるまで毎フレーム時刻を確かめる
318
+ if (this.animating)
319
+ this.requestRender();
320
+ }
321
+ /**
322
+ * 見えている画像の配置を集めて層に分ける。前のフレームから変わったら `true`。
323
+ * 読み解きが終わっていない画像は、終わるまで描かない(終わったら描き直す)。
324
+ */
325
+ collectImages(offset) {
326
+ const src = this.source;
327
+ const flat = src.readPlacements?.();
328
+ const layers = { under: [], below: [], above: [] };
329
+ let key = "";
330
+ if (flat && flat.length > 0) {
331
+ const cache = (this.imageCache ??= new ImageCache(src, this.env.imageDecoder ?? browserImageDecoder, () => this.invalidate()));
332
+ cache.sync();
333
+ const placed = parsePlacements(flat)
334
+ .filter((p) => p.row + offset < src.rows && p.row + offset + p.rows > 0)
335
+ .sort((a, b) => a.z - b.z || a.imageId - b.imageId);
336
+ const ticked = new Set();
337
+ const now = (this.env.now ?? (() => performance.now()))();
338
+ for (const p of placed) {
339
+ if (!ticked.has(p.imageId)) {
340
+ ticked.add(p.imageId);
341
+ if (cache.tick(p.imageId, now))
342
+ this.animating = true;
343
+ }
344
+ const image = cache.get(p.imageId);
345
+ if (!image)
346
+ continue;
347
+ const layer = p.z < Z_UNDER_BACKGROUND ? layers.under : p.z < 0 ? layers.below : layers.above;
348
+ layer.push({ p, image });
349
+ key += `${p.imageId}:${p.row}:${p.col}:${p.srcX}:${p.srcY}:${p.srcW}:${p.srcH}:${p.offsetX}:${p.offsetY}:${p.cols}:${p.rows}:${p.z}:${p.native}:${p.destW}:${p.destH}@${cache.version(p.imageId)};`;
350
+ }
351
+ }
352
+ else {
353
+ this.imageCache?.sync();
354
+ }
355
+ this.layers = layers;
356
+ const changed = key !== this.lastImageKey;
357
+ this.lastImageKey = key;
358
+ return changed;
359
+ }
360
+ /** 行 `y` にかかる画像を、`top` から始まる 1 行ぶんだけ描く。 */
361
+ drawImages(list, y, offset) {
362
+ if (list.length === 0)
363
+ return;
364
+ const ctx = this.ctx;
365
+ const top = y * this.cellH;
366
+ const bottom = top + this.cellH;
367
+ const limit = this.source.cols * this.cellW;
368
+ ctx.globalAlpha = 1;
369
+ for (const { p, image } of list) {
370
+ const srcX = p.srcX;
371
+ const srcY = p.srcY;
372
+ const srcW = p.srcW > 0 ? p.srcW : image.width - srcX;
373
+ const srcH = p.srcH > 0 ? p.srcH : image.height - srcY;
374
+ if (srcW <= 0 || srcH <= 0)
375
+ continue;
376
+ // 隣の行と継ぎ目が出ないよう、置く場所は整数のデバイスピクセルにする
377
+ const dx = Math.round(p.col * this.cellW + p.offsetX);
378
+ const dy = Math.round((p.row + offset) * this.cellH + p.offsetY);
379
+ const dw = Math.round(p.destW > 0 ? p.destW : p.native ? srcW : p.cols * this.cellW);
380
+ const dh = Math.round(p.destH > 0 ? p.destH : p.native ? srcH : p.rows * this.cellH);
381
+ if (dw <= 0 || dh <= 0)
382
+ continue;
383
+ const x0 = Math.max(dx, 0);
384
+ const x1 = Math.min(dx + dw, limit);
385
+ const y0 = Math.max(dy, top);
386
+ const y1 = Math.min(dy + dh, bottom);
387
+ if (x1 <= x0 || y1 <= y0)
388
+ continue;
389
+ ctx.drawImage(image, srcX + ((x0 - dx) * srcW) / dw, srcY + ((y0 - dy) * srcH) / dh, ((x1 - x0) * srcW) / dw, ((y1 - y0) * srcH) / dh, x0, y0, x1 - x0, y1 - y0);
390
+ }
391
+ }
392
+ /** 追加のカーソルを、いま見える行ごとに集める。さかのぼって見ているときは出さない。 */
393
+ collectExtraCursors(offset) {
394
+ const src = this.source;
395
+ this.extra.clear();
396
+ this.extraColors = src.readExtraCursorColors?.() ?? [0, 0];
397
+ const flat = offset === 0 ? src.readExtraCursors?.() : undefined;
398
+ if (!flat)
399
+ return;
400
+ for (let i = 0; i + 2 < flat.length; i += 3) {
401
+ const x = flat[i];
402
+ const y = flat[i + 1];
403
+ if (x >= src.cols || y >= src.rows)
404
+ continue;
405
+ const list = this.extra.get(y);
406
+ if (list)
407
+ list.push({ x, shape: flat[i + 2] });
408
+ else
409
+ this.extra.set(y, [{ x, shape: flat[i + 2] }]);
410
+ }
411
+ }
412
+ cursorShown(offset) {
413
+ const c = this.source.cursor;
414
+ return c.visible && offset === 0;
415
+ }
416
+ drawRow(y, offset) {
417
+ const ctx = this.ctx;
418
+ const { palette } = this.options;
419
+ const src = this.source;
420
+ const row = src.readViewportRow(y);
421
+ const cellW = this.cellW;
422
+ const cellH = this.cellH;
423
+ const top = y * cellH;
424
+ const cols = src.cols;
425
+ const cursor = src.cursor;
426
+ const cursorOn = offset === 0 && cursor.visible && cursor.y === y && cursor.x < cols;
427
+ const blinkVisible = !cursor.blinking || !src.isFocused || this.blinkOn;
428
+ // フォーカスが無いときは、点滅せず中抜きのブロックにする
429
+ const solidBlock = cursorOn && cursor.shape === "block" && src.isFocused && blinkVisible;
430
+ // 追加のカーソル。主のカーソルの点滅に従う(主が見えなくても出る)
431
+ const extras = blinkVisible ? this.extra.get(y) : undefined;
432
+ const solidExtra = new Set();
433
+ if (extras && src.isFocused) {
434
+ for (const e of extras) {
435
+ if (this.extraShape(e.shape) === "block")
436
+ solidExtra.add(e.x);
437
+ }
438
+ }
439
+ ctx.globalAlpha = 1;
440
+ ctx.fillStyle = palette.background;
441
+ ctx.fillRect(0, top, cols * cellW, cellH);
442
+ // 下から順に: 背景色の下の画像、背景色、字の下の画像、選択範囲、字、字の上の画像
443
+ this.drawImages(this.layers.under, y, offset);
444
+ const n = Math.min(row.length, cols);
445
+ const colors = this.resolveRowColors(row.cells, n, solidBlock ? cursor.x : -1, solidExtra);
446
+ this.fillBackgroundRuns(row.cells, colors, n, top);
447
+ this.drawImages(this.layers.below, y, offset);
448
+ this.fillSelection(y, top);
449
+ this.drawGlyphsAndLines(row, colors, n, top);
450
+ ctx.globalAlpha = 1;
451
+ this.drawImages(this.layers.above, y, offset);
452
+ if (cursorOn && blinkVisible)
453
+ this.drawCursorShape(cursor, row.cells, top);
454
+ if (extras) {
455
+ for (const e of extras) {
456
+ if (e.x >= cols || solidExtra.has(e.x))
457
+ continue;
458
+ this.drawExtraCursorShape(e, row.cells, colors, top);
459
+ }
460
+ }
461
+ }
462
+ /** 追加のカーソルの形(主のカーソルに従うものは、いまの主の形にする)。 */
463
+ extraShape(shape) {
464
+ return (["block", "underline", "bar"][shape] ??
465
+ this.source.cursor.shape);
466
+ }
467
+ /** 追加のカーソルのブロックの、字の色と背景色。`fg`・`bg` はそのセルの色。 */
468
+ extraBlockColors(fg, bg) {
469
+ const { palette } = this.options;
470
+ const textWord = this.extraColors[0];
471
+ const cursorWord = this.extraColors[1];
472
+ const cursorKind = cursorWord >>> 24;
473
+ const textKind = textWord >>> 24;
474
+ // カーソルの色が反転のときは、字の色の指定を使わない
475
+ if (cursorKind === 3)
476
+ return [bg, fg];
477
+ const cursorBg = cursorKind === 0 ? palette.cursor : palette.resolve(cursorWord, true);
478
+ let text = palette.cursorText;
479
+ if (textKind === 3)
480
+ text = bg;
481
+ else if (textKind !== 0)
482
+ text = palette.resolve(textWord, false);
483
+ return [text, cursorBg];
484
+ }
485
+ /** 追加のカーソルのうち、ブロックで塗らないもの(中抜き・下線・縦棒)を描く。 */
486
+ drawExtraCursorShape(e, cells, colors, top) {
487
+ const ctx = this.ctx;
488
+ const { palette } = this.options;
489
+ const word = this.extraColors[1];
490
+ const kind = word >>> 24;
491
+ const color = kind === 0
492
+ ? palette.cursor
493
+ : kind === 3
494
+ ? (colors[e.x]?.fg ?? palette.cursor)
495
+ : palette.resolve(word, true);
496
+ const width = Math.max(1, cells[e.x * CELL_STRIDE + 1] || 1);
497
+ const left = e.x * this.cellW;
498
+ const w = width * this.cellW;
499
+ const t = this.lineThickness();
500
+ const shape = this.extraShape(e.shape);
501
+ ctx.fillStyle = color;
502
+ ctx.strokeStyle = color;
503
+ if (!this.source.isFocused) {
504
+ // 中抜きのブロック
505
+ ctx.lineWidth = t;
506
+ ctx.strokeRect(left + t / 2, top + t / 2, w - t, this.cellH - t);
507
+ }
508
+ else if (shape === "underline") {
509
+ ctx.fillRect(left, top + this.cellH - t, w, t);
510
+ }
511
+ else if (shape === "bar") {
512
+ ctx.fillRect(left, top, t, this.cellH);
513
+ }
514
+ }
515
+ /**
516
+ * 行のセルごとの前景色・背景色・属性を決める(反転を適用し、太字なら明るい色にする)。
517
+ * `blockX` 列は、ブロックカーソルの色にする(なければ -1)。`extraBlocks` の列は、
518
+ * 追加のカーソルのブロックの色にする。
519
+ */
520
+ resolveRowColors(cells, n, blockX, extraBlocks = new Set()) {
521
+ const { palette } = this.options;
522
+ const colors = new Array(n);
523
+ for (let x = 0; x < n; x++) {
524
+ const o = x * CELL_STRIDE;
525
+ const flags = cells[o + 4];
526
+ let fg = palette.resolve(cells[o + 2], false, (flags & 1) !== 0);
527
+ let bg = palette.resolve(cells[o + 3], true);
528
+ if (flags & 16)
529
+ [fg, bg] = [bg, fg];
530
+ if (x === blockX) {
531
+ bg = palette.cursor;
532
+ fg = palette.cursorText;
533
+ }
534
+ else if (extraBlocks.has(x)) {
535
+ [fg, bg] = this.extraBlockColors(fg, bg);
536
+ }
537
+ colors[x] = { fg, bg, flags };
538
+ }
539
+ return colors;
540
+ }
541
+ /** 背景を塗る。同じ色が続くところは 1 回で塗る。 */
542
+ fillBackgroundRuns(cells, colors, n, top) {
543
+ const ctx = this.ctx;
544
+ const defaultBg = this.options.palette.background;
545
+ let runStart = 0;
546
+ let runColor;
547
+ const flush = (end) => {
548
+ if (runColor !== undefined && runColor !== defaultBg) {
549
+ ctx.fillStyle = runColor;
550
+ ctx.fillRect(runStart * this.cellW, top, (end - runStart) * this.cellW, this.cellH);
551
+ }
552
+ };
553
+ for (let x = 0; x < n; x++) {
554
+ // 全角の右半分(幅 0)は、左半分と一緒に塗る
555
+ if (cells[x * CELL_STRIDE + 1] === 0)
556
+ continue;
557
+ const bg = colors[x].bg;
558
+ if (bg !== runColor) {
559
+ flush(x);
560
+ runColor = bg;
561
+ runStart = x;
562
+ }
563
+ }
564
+ flush(n);
565
+ }
566
+ /** 選択範囲を、背景の上に半透明で重ねる。字はこの上に描く。 */
567
+ fillSelection(y, top) {
568
+ const span = this.source.selectionSpan?.(y);
569
+ if (!span)
570
+ return;
571
+ const [c0, c1] = span;
572
+ const end = Math.min(c1, this.source.cols);
573
+ if (end <= c0)
574
+ return;
575
+ const ctx = this.ctx;
576
+ ctx.fillStyle = this.options.palette.selection;
577
+ ctx.fillRect(c0 * this.cellW, top, (end - c0) * this.cellW, this.cellH);
578
+ }
579
+ /** 字と、下線・取り消し線を描く。 */
580
+ drawGlyphsAndLines(row, colors, n, top) {
581
+ const ctx = this.ctx;
582
+ const cells = row.cells;
583
+ let lastFont = "";
584
+ for (let x = 0; x < n; x++) {
585
+ const o = x * CELL_STRIDE;
586
+ const width = cells[o + 1];
587
+ if (width === 0)
588
+ continue;
589
+ const { fg, flags } = colors[x];
590
+ if (flags & 32)
591
+ continue; // 不可視
592
+ const mark = cells[o + 6];
593
+ // 複数のセルにまたがる字は、この行にかかる四角の一番左のセルで、四角ごと描く
594
+ if ((mark & 0xffff) !== 0 && ((mark >>> 16) & 0xff) === 0) {
595
+ ctx.globalAlpha = flags & 2 ? 0.5 : 1;
596
+ this.drawSized(row.sized((mark & 0xffff) - 1), mark >>> 24, x, top, fg);
597
+ }
598
+ const cp = cells[o];
599
+ const ul = (flags >>> 8) & 7;
600
+ const strike = (flags & 64) !== 0;
601
+ const hasText = cp !== 0 && cp !== 0x20;
602
+ if (!hasText && ul === 0 && !strike)
603
+ continue;
604
+ const left = x * this.cellW;
605
+ ctx.globalAlpha = flags & 2 ? 0.5 : 1;
606
+ if (hasText) {
607
+ const font = this.font((flags & 1) !== 0, (flags & 4) !== 0);
608
+ if (font !== lastFont) {
609
+ ctx.font = font;
610
+ lastFont = font;
611
+ }
612
+ ctx.fillStyle = fg;
613
+ const text = cp & CLUSTER_FLAG
614
+ ? row.cluster(cp & ~CLUSTER_FLAG)
615
+ : String.fromCodePoint(cp);
616
+ // Unicode placeholder の字は描かない(画像が代わりに描かれる)
617
+ if (text.codePointAt(0) !== 0x10eeee) {
618
+ ctx.fillText(text, left, top + this.baseline);
619
+ }
620
+ }
621
+ const spanW = width * this.cellW;
622
+ if (ul !== 0) {
623
+ const word = cells[o + 5];
624
+ const color = word >>> 24 === 0 ? fg : this.options.palette.resolve(word, false);
625
+ this.drawUnderline(ul, color, left, top, spanW);
626
+ }
627
+ if (strike) {
628
+ ctx.fillStyle = fg;
629
+ ctx.fillRect(left, top + this.baseline - Math.round(this.baseline * 0.35), spanW, this.lineThickness());
630
+ }
631
+ }
632
+ }
633
+ /**
634
+ * 複数のセルにまたがる字(OSC 66)を描く。`dy` は、いまの行が四角の上から何行目か。
635
+ * 描くのは四角のうち、いまの行にかかる横長の帯だけ(行ごとに描き直すため)。
636
+ * 分数の大きさのときだけ、`v`・`h` の寄せ方で四角の中に置く。
637
+ */
638
+ drawSized(info, dy, x, top, fill) {
639
+ const frac = info.denominator > info.numerator ? info.numerator / info.denominator : 1;
640
+ const k = info.scale * frac;
641
+ if (k <= 0)
642
+ return;
643
+ const ctx = this.ctx;
644
+ const left = x * this.cellW;
645
+ const blockW = info.cols * this.cellW;
646
+ const blockH = info.scale * this.cellH;
647
+ const blockTop = top - dy * this.cellH;
648
+ ctx.save();
649
+ ctx.beginPath();
650
+ ctx.rect(left, top, blockW, this.cellH);
651
+ ctx.clip();
652
+ ctx.font = this.font(false, false, k);
653
+ ctx.fillStyle = fill;
654
+ const align = (a, space, size) => a === 1 ? space - size : a === 2 ? (space - size) / 2 : 0;
655
+ const textW = ctx.measureText(info.text).width;
656
+ const x0 = left + (frac < 1 ? align(info.horizontal, blockW, textW) : 0);
657
+ const y0 = blockTop + (frac < 1 ? align(info.vertical, blockH, this.cellH * k) : 0);
658
+ ctx.fillText(info.text, x0, y0 + this.baseline * k);
659
+ ctx.restore();
660
+ }
661
+ lineThickness() {
662
+ return Math.max(1, Math.round(this.dpr));
663
+ }
664
+ drawUnderline(style, color, left, top, width) {
665
+ const ctx = this.ctx;
666
+ const t = this.lineThickness();
667
+ ctx.fillStyle = color;
668
+ const bottom = top + this.cellH;
669
+ // ベースラインの少し下。セルに収まるようにする
670
+ const y = Math.min(bottom - t, top + this.baseline + Math.max(1, Math.round(this.descent / 3)));
671
+ switch (style) {
672
+ case 1:
673
+ ctx.fillRect(left, y, width, t);
674
+ break;
675
+ case 2: {
676
+ const y1 = Math.max(top, y - t - 1);
677
+ ctx.fillRect(left, y1, width, t);
678
+ ctx.fillRect(left, Math.min(bottom - t, y1 + t + 1), width, t);
679
+ break;
680
+ }
681
+ case 3: {
682
+ // 波線: 1 セルで 1 周期
683
+ ctx.strokeStyle = color;
684
+ ctx.lineWidth = t;
685
+ const amp = Math.max(1, Math.min(this.descent / 2, (bottom - y) / 2));
686
+ const mid = Math.min(bottom - t - amp, y);
687
+ ctx.beginPath();
688
+ const step = Math.max(2, width / 8);
689
+ for (let px = 0; px <= width; px += step) {
690
+ const py = mid + amp * Math.sin((px / this.cellW) * Math.PI * 2);
691
+ if (px === 0)
692
+ ctx.moveTo(left + px, py);
693
+ else
694
+ ctx.lineTo(left + px, py);
695
+ }
696
+ ctx.stroke();
697
+ break;
698
+ }
699
+ case 4: {
700
+ // 点線
701
+ const dot = t;
702
+ for (let px = 0; px < width; px += dot * 2) {
703
+ ctx.fillRect(left + px, y, Math.min(dot, width - px), t);
704
+ }
705
+ break;
706
+ }
707
+ case 5: {
708
+ // 破線
709
+ const dash = Math.max(2, Math.round(this.cellW / 2));
710
+ for (let px = 0; px < width; px += dash * 2) {
711
+ ctx.fillRect(left + px, y, Math.min(dash, width - px), t);
712
+ }
713
+ break;
714
+ }
715
+ default:
716
+ ctx.fillRect(left, y, width, t);
717
+ }
718
+ }
719
+ /** ブロックで塗る場合は字ごと `drawRow` が塗るので、ここは中抜きと下線・縦棒。 */
720
+ drawCursorShape(cursor, cells, top) {
721
+ const ctx = this.ctx;
722
+ const focused = this.source.isFocused;
723
+ const color = this.options.palette.cursor;
724
+ const x = cursor.x;
725
+ const width = Math.max(1, cells[x * CELL_STRIDE + 1] || 1);
726
+ const left = x * this.cellW;
727
+ const w = width * this.cellW;
728
+ const t = this.lineThickness();
729
+ ctx.fillStyle = color;
730
+ ctx.strokeStyle = color;
731
+ if (!focused) {
732
+ // 中抜きのブロック
733
+ ctx.lineWidth = t;
734
+ ctx.strokeRect(left + t / 2, top + t / 2, w - t, this.cellH - t);
735
+ }
736
+ else if (cursor.shape === "underline") {
737
+ ctx.fillRect(left, top + this.cellH - t, w, t);
738
+ }
739
+ else if (cursor.shape === "bar") {
740
+ ctx.fillRect(left, top, t, this.cellH);
741
+ }
742
+ // フォーカスがあるブロックは drawRow で塗り済み
743
+ }
744
+ /** 後始末。 */
745
+ dispose() {
746
+ this.disposed = true;
747
+ this.stopBlink();
748
+ if (this.frame !== undefined)
749
+ this.env.cancelFrame(this.frame);
750
+ this.frame = undefined;
751
+ this.cellSizeListeners.clear();
752
+ this.imageCache?.dispose();
753
+ this.imageCache = undefined;
754
+ }
755
+ }
756
+ /** スクロールバー。DOM で作る。見た目は xterm.js 6.0.0(VS Code のスクロールバー)に近づける。 */
757
+ export class Scrollbar {
758
+ api;
759
+ track;
760
+ slider;
761
+ cleanups = [];
762
+ dragging = false;
763
+ /** いまのドラッグを終える関数。 */
764
+ endDrag;
765
+ constructor(host, api) {
766
+ this.api = api;
767
+ const doc = host.ownerDocument;
768
+ this.track = doc.createElement("div");
769
+ this.track.className = "coneko-term-scrollbar";
770
+ Object.assign(this.track.style, {
771
+ position: "absolute",
772
+ top: "0",
773
+ right: "0",
774
+ width: `${SCROLLBAR_WIDTH}px`,
775
+ display: "none",
776
+ userSelect: "none",
777
+ });
778
+ this.slider = doc.createElement("div");
779
+ this.slider.className = "coneko-term-scrollbar-slider";
780
+ Object.assign(this.slider.style, {
781
+ position: "absolute",
782
+ left: "0",
783
+ width: "100%",
784
+ background: "rgba(121, 121, 121, 0.4)",
785
+ });
786
+ this.track.appendChild(this.slider);
787
+ host.appendChild(this.track);
788
+ this.bind();
789
+ }
790
+ bind() {
791
+ const slider = this.slider;
792
+ const track = this.track;
793
+ const setBg = (v) => (slider.style.background = v);
794
+ const onEnter = () => !this.dragging && setBg("rgba(100, 100, 100, 0.7)");
795
+ const onLeave = () => !this.dragging && setBg("rgba(121, 121, 121, 0.4)");
796
+ slider.addEventListener("pointerenter", onEnter);
797
+ slider.addEventListener("pointerleave", onLeave);
798
+ const onDown = (e) => {
799
+ if (e.button !== 0)
800
+ return;
801
+ e.preventDefault();
802
+ if (e.target === slider) {
803
+ this.startDrag(e);
804
+ }
805
+ else {
806
+ // トラックを押したら、その向きに 1 画面ぶん動く
807
+ const rect = slider.getBoundingClientRect();
808
+ this.api.scrollLines(e.clientY < rect.top ? -this.api.rows() : this.api.rows());
809
+ }
810
+ };
811
+ track.addEventListener("pointerdown", onDown);
812
+ this.cleanups.push(() => {
813
+ slider.removeEventListener("pointerenter", onEnter);
814
+ slider.removeEventListener("pointerleave", onLeave);
815
+ track.removeEventListener("pointerdown", onDown);
816
+ });
817
+ }
818
+ startDrag(e) {
819
+ const slider = this.slider;
820
+ this.dragging = true;
821
+ slider.style.background = "rgba(121, 121, 121, 0.6)";
822
+ const startY = e.clientY;
823
+ const startLine = this.api.viewportY();
824
+ const trackH = this.track.clientHeight;
825
+ const sliderH = slider.offsetHeight;
826
+ const maxLine = this.api.total() - this.api.rows();
827
+ const range = Math.max(1, trackH - sliderH);
828
+ const doc = this.track.ownerDocument;
829
+ const move = (ev) => {
830
+ const dy = ev.clientY - startY;
831
+ this.api.scrollToLine(Math.round(startLine + (dy / range) * maxLine));
832
+ };
833
+ const up = () => {
834
+ this.endDrag = undefined;
835
+ this.dragging = false;
836
+ slider.style.background = "rgba(121, 121, 121, 0.4)";
837
+ doc.removeEventListener("pointermove", move);
838
+ doc.removeEventListener("pointerup", up);
839
+ doc.removeEventListener("pointercancel", up);
840
+ };
841
+ doc.addEventListener("pointermove", move);
842
+ doc.addEventListener("pointerup", up);
843
+ doc.addEventListener("pointercancel", up);
844
+ this.endDrag = up;
845
+ }
846
+ /** 高さ(CSS ピクセル)と中身に合わせて、つまみの位置と大きさを決める。 */
847
+ update(heightPx, enabled) {
848
+ const total = this.api.total();
849
+ const rows = this.api.rows();
850
+ const scrollable = total - rows;
851
+ if (!enabled || scrollable <= 0 || heightPx <= 0) {
852
+ this.track.style.display = "none";
853
+ return;
854
+ }
855
+ this.track.style.display = "block";
856
+ this.track.style.height = `${heightPx}px`;
857
+ const sliderH = Math.min(heightPx, Math.max(20, Math.round((heightPx * rows) / total)));
858
+ const pos = Math.round(((heightPx - sliderH) * this.api.viewportY()) / scrollable);
859
+ this.slider.style.height = `${sliderH}px`;
860
+ this.slider.style.top = `${pos}px`;
861
+ }
862
+ /** 後始末。DOM から外し、ドラッグ中なら終える。 */
863
+ dispose() {
864
+ this.endDrag?.();
865
+ for (const c of this.cleanups)
866
+ c();
867
+ this.cleanups.length = 0;
868
+ this.track.remove();
869
+ }
870
+ }