buninu-linux 0.13.56 → 0.15.58
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSES/linux-firmware/LICENCE.nvidia +131 -0
- package/LICENSES/linux-firmware/LICENSE.amdgpu +51 -0
- package/LICENSES/linux-firmware/LICENSE.amdisp +31 -0
- package/LICENSES/linux-firmware/LICENSE.i915 +39 -0
- package/LICENSES/linux-firmware/LICENSE.radeon +51 -0
- package/LICENSES/linux-firmware/LICENSE.xe +39 -0
- package/LICENSES/linux-firmware/WHENCE +10280 -0
- package/NOTICE.md +64 -46
- package/README.md +410 -96
- package/README.zh.md +2034 -0
- package/build-image.sh +10 -1
- package/build-uki.sh +3 -1
- package/fetch-alpine.sh +15 -1
- package/graphics.md +31 -0
- package/index.js +28 -11
- package/initramfs/bin/buninu-browser +4 -1
- package/initramfs/bin/buninu-land +1 -1
- package/initramfs/bin/bunterm +3 -3
- package/initramfs/bin/chroot +13 -0
- package/initramfs/bin/chvt +11 -0
- package/initramfs/bin/dhclient +8 -0
- package/initramfs/bin/mount +4 -0
- package/initramfs/bin/ping +8 -0
- package/initramfs/bin/umount +6 -0
- package/initramfs/bin/xdg-open +33 -0
- package/initramfs/buninu/CHANGELOG.md +19 -0
- package/initramfs/buninu/apps/bunmsh/CHANGELOG.md +4 -0
- package/initramfs/buninu/apps/bunmsh/package.json +1 -1
- package/initramfs/buninu/apps/bunmsh/src/main.js +1 -1
- package/initramfs/buninu/apps/jsgotty/README.en.md +40 -0
- package/initramfs/buninu/apps/jsgotty/README.md +40 -0
- package/initramfs/buninu/apps/jsgotty/package-lock.json +2 -2
- package/initramfs/buninu/apps/jsgotty/package.json +1 -1
- package/initramfs/buninu/apps/jsgotty/static/index.html +1 -0
- package/initramfs/buninu/apps/jsgotty/static/js/gotty.js +82 -17
- package/initramfs/buninu/apps/jsgotty/static/js/kitty-keyboard.js +193 -0
- package/initramfs/buninu/apps/jsgotty/tests/android-alt.test.js +115 -0
- package/initramfs/buninu/apps/jsgotty/tests/arrow-modifiers.test.js +80 -0
- package/initramfs/buninu/apps/jsgotty/tests/kitty-keyboard-browser.test.js +53 -0
- package/initramfs/buninu/apps/jsgotty/tests/kitty-keyboard.test.js +102 -0
- package/initramfs/buninu/apps/jsgotty/tests/manual/README.md +20 -0
- package/initramfs/buninu/apps/jsgotty/tests/manual/keyboard-collector.cjs +55 -0
- package/initramfs/buninu/apps/jsgotty/tests/manual/keyboard-debug.js +74 -0
- package/initramfs/buninu/apps/jsgotty/tests/manual/kitty-keyboard.js +80 -0
- package/initramfs/buninu/package.json +1 -1
- package/initramfs/init.js +27 -10
- package/initramfs/lib/bunterm/clipboard.js +14 -0
- package/initramfs/lib/bunterm/commands.js +37 -0
- package/initramfs/lib/bunterm/fps.js +38 -0
- package/initramfs/lib/bunterm/images.js +9 -5
- package/initramfs/lib/bunterm/input.js +58 -15
- package/initramfs/lib/bunterm/keyboard.js +62 -0
- package/initramfs/lib/bunterm/keycodes.js +104 -0
- package/initramfs/lib/bunterm/kitty-keyboard.cjs +195 -0
- package/initramfs/lib/bunterm/raw-protocol.js +33 -0
- package/initramfs/lib/bunterm/renderer.js +90 -21
- package/initramfs/lib/bunterm/selection.js +60 -0
- package/initramfs/lib/bunterm/terminal.js +147 -12
- package/initramfs/lib/bunterm/vt-menu.js +181 -0
- package/initramfs/lib/bunterm/vt.js +27 -1
- package/initramfs/lib/canvas.js +99 -0
- package/initramfs/lib/chvt.js +31 -0
- package/initramfs/lib/dhclient.js +135 -0
- package/initramfs/lib/dhcp-start.js +85 -0
- package/initramfs/lib/dhcp-transport.js +44 -0
- package/initramfs/lib/dhcp.js +124 -0
- package/initramfs/lib/dlopen.js +58 -11
- package/initramfs/lib/fbdev.js +21 -3
- package/initramfs/lib/gpu.js +77 -0
- package/initramfs/lib/ping.js +110 -0
- package/initramfs/lib/raw-image.js +47 -0
- package/initramfs/lib/xdg-open.js +42 -0
- package/initramfs/usr/share/doc/buninu-linux/bunterm.md +86 -12
- package/initramfs/usr/share/doc/buninu-linux/chvt.md +34 -0
- package/initramfs/usr/share/doc/buninu-linux/dhclient.md +82 -0
- package/initramfs/usr/share/doc/buninu-linux/ping.md +91 -0
- package/initramfs/usr/share/doc/buninu-linux/xdg-open.md +43 -0
- package/package.json +2 -2
- package/scripts/arch.js +7 -5
- package/scripts/arch.sh +5 -5
- package/scripts/build.js +16 -3
- package/scripts/fetch-gpu.js +105 -0
- package/scripts/fetch.js +11 -1
- package/scripts/gpu-firmware.json +22 -0
- package/scripts/modules.js +5 -1
- package/scripts/npm-packages.md +17 -17
- package/test/bunterm-fps.test.js +17 -0
- package/test/bunterm-keyboard.test.js +116 -0
- package/test/bunterm-next-vt.test.js +9 -0
- package/test/bunterm-raw-protocol.test.js +19 -0
- package/test/bunterm-renderer.test.js +158 -0
- package/test/bunterm-selection.test.js +38 -0
- package/test/bunterm-vt-menu.test.js +178 -0
- package/test/chroot-bind.test.js +40 -2
- package/test/chvt.test.js +46 -0
- package/test/dhcp-start.test.js +67 -0
- package/test/dhcp.test.js +99 -0
- package/test/fbdev-sync.test.js +33 -0
- package/test/fetch-npm.test.js +1 -1
- package/test/gpu.test.js +155 -0
- package/test/image-size.test.js +17 -0
- package/test/libc.test.js +41 -0
- package/test/manual-bunterm-image-paths.js +31 -0
- package/test/mount-libc-lifetime.test.js +50 -0
- package/test/no-gpu.test.js +43 -0
- package/test/ping.test.js +80 -0
- package/test/readme-cli.test.js +29 -0
- package/test/stage-npm.test.js +11 -4
- package/test/xdg-open.test.js +55 -0
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import {skiaImage} from '../raw-image.js';
|
|
1
2
|
// /lib/bunterm/renderer.js — paints an xterm.js headless buffer onto a Skia
|
|
2
3
|
// canvas as a grid of cells.
|
|
3
4
|
//
|
|
@@ -58,12 +59,13 @@ const isSingleCodePoint = (text) => {
|
|
|
58
59
|
};
|
|
59
60
|
|
|
60
61
|
export class Renderer {
|
|
61
|
-
constructor({ CanvasKit, screen, fonts, fontSize =
|
|
62
|
+
constructor({ CanvasKit, screen, fonts, fontSize = 13, lineHeight = 1, theme = defaultTheme }) {
|
|
62
63
|
this.CanvasKit = CanvasKit;
|
|
63
64
|
this.screen = screen;
|
|
64
65
|
this.canvas = screen.canvas;
|
|
65
66
|
this.fonts = fonts;
|
|
66
67
|
this.fontSize = fontSize;
|
|
68
|
+
this.lineHeight = lineHeight;
|
|
67
69
|
this.theme = theme;
|
|
68
70
|
this.palette = buildPalette(theme);
|
|
69
71
|
this.foreground = parseColor(theme.foreground);
|
|
@@ -127,6 +129,30 @@ export class Renderer {
|
|
|
127
129
|
return font;
|
|
128
130
|
}
|
|
129
131
|
|
|
132
|
+
setFontSize(size) {
|
|
133
|
+
if (!Number.isInteger(size) || size < 6 || size > 96) throw new Error("font size must be an integer from 6 to 96");
|
|
134
|
+
if (size === this.fontSize) return;
|
|
135
|
+
for (const font of this.fontCache.values()) font.delete();
|
|
136
|
+
for (const paragraph of this.paragraphCache.values()) paragraph.delete();
|
|
137
|
+
this.fontCache.clear();
|
|
138
|
+
this.metricsCache.clear();
|
|
139
|
+
this.paragraphCache.clear();
|
|
140
|
+
this.fontSize = size;
|
|
141
|
+
const font = this.fontFor(this.fonts.regular, size), metrics = font.getMetrics();
|
|
142
|
+
this.cellWidth = Math.max(1, Math.round(font.getGlyphWidths(this.fonts.regular.getGlyphIDs("M"))[0]));
|
|
143
|
+
this.cellHeight = Math.max(1, Math.round((metrics.descent-metrics.ascent+(metrics.leading||0))*this.lineHeight));
|
|
144
|
+
this.columns = Math.max(1, Math.floor(this.screen.width/this.cellWidth));
|
|
145
|
+
this.rows = Math.max(1, Math.floor(this.screen.height/this.cellHeight));
|
|
146
|
+
this.offsetX = Math.floor((this.screen.width-this.columns*this.cellWidth)/2);
|
|
147
|
+
this.offsetY = Math.floor((this.screen.height-this.rows*this.cellHeight)/2);
|
|
148
|
+
this.rowHashes = new Int32Array(this.rows);
|
|
149
|
+
this.rawCoveredRows = new Set();
|
|
150
|
+
this.forcedRows.clear();
|
|
151
|
+
this.lastCursor = {x:-1,y:-1,visible:false};
|
|
152
|
+
this.lastPointer = null;
|
|
153
|
+
this.invalidate();
|
|
154
|
+
}
|
|
155
|
+
|
|
130
156
|
metricsFor(font) {
|
|
131
157
|
let metrics = this.metricsCache.get(font);
|
|
132
158
|
if (!metrics) {
|
|
@@ -214,7 +240,7 @@ export class Renderer {
|
|
|
214
240
|
width * this.cellWidth, height * this.cellHeight);
|
|
215
241
|
}
|
|
216
242
|
|
|
217
|
-
drawText(text, x, y, cells, color, { bold, italic, emoji }) {
|
|
243
|
+
drawText(text, x, y, cells, color, { bold, italic, emoji, align = "center" }) {
|
|
218
244
|
const { CanvasKit, canvas } = this;
|
|
219
245
|
const available = cells * this.cellWidth;
|
|
220
246
|
const paint = this.paintFor(color);
|
|
@@ -235,7 +261,7 @@ export class Renderer {
|
|
|
235
261
|
}
|
|
236
262
|
const metrics = this.metricsFor(font);
|
|
237
263
|
const baseline = y + (this.cellHeight - (metrics.descent - metrics.ascent)) / 2 - metrics.ascent;
|
|
238
|
-
canvas.drawGlyphs([glyph], [x + (available - advance) / 2, baseline], 0, 0, font, paint);
|
|
264
|
+
canvas.drawGlyphs([glyph], [align === "left" ? x : x + (available - advance) / 2, baseline], 0, 0, font, paint);
|
|
239
265
|
return;
|
|
240
266
|
}
|
|
241
267
|
// Grapheme clusters are shaped by the Paragraph API and cached.
|
|
@@ -272,13 +298,13 @@ export class Renderer {
|
|
|
272
298
|
const height = paragraph.getHeight();
|
|
273
299
|
canvas.save();
|
|
274
300
|
canvas.clipRect(CanvasKit.XYWHRect(x, y, available, this.cellHeight), CanvasKit.ClipOp.Intersect, false);
|
|
275
|
-
if (width > available) {
|
|
301
|
+
if (width > available && align !== "left") {
|
|
276
302
|
const scale = available / width;
|
|
277
303
|
canvas.translate(x, y + (this.cellHeight - height * scale) / 2);
|
|
278
304
|
canvas.scale(scale, scale);
|
|
279
305
|
canvas.drawParagraph(paragraph, 0, 0);
|
|
280
306
|
} else {
|
|
281
|
-
canvas.drawParagraph(paragraph, x + (available - width) / 2, y + (this.cellHeight - height) / 2);
|
|
307
|
+
canvas.drawParagraph(paragraph, align === "left" ? x : x + (available - width) / 2, y + (this.cellHeight - height) / 2);
|
|
282
308
|
}
|
|
283
309
|
canvas.restore();
|
|
284
310
|
}
|
|
@@ -395,25 +421,44 @@ export class Renderer {
|
|
|
395
421
|
// screen pixels, from images.js, drawn over the text; `pointer` is the
|
|
396
422
|
// mouse position in pixels, drawn last. Returns true when anything was
|
|
397
423
|
// painted.
|
|
398
|
-
render(term, { cursor, images = [], pointer = null } = {}) {
|
|
424
|
+
render(term, { cursor, images = [], pointer = null, overlay = null } = {}) {
|
|
399
425
|
const buffer = term.buffer.active;
|
|
400
426
|
const top = buffer.viewportY;
|
|
401
427
|
const dirty = [];
|
|
428
|
+
// Only proven opaque, exact raw copies may hide text work. Partial rows,
|
|
429
|
+
// scaling, transparency and unsupported framebuffer layouts remain unchanged.
|
|
430
|
+
const coveredRows = new Set();
|
|
431
|
+
const previouslyCovered = this.rawCoveredRows ?? new Set();
|
|
432
|
+
for (const {image, rect, source} of images) {
|
|
433
|
+
const bounds = source || [0, 0, image.width(), image.height()];
|
|
434
|
+
if (rect[0] > this.offsetX || rect[2] < this.offsetX + this.columns * this.cellWidth
|
|
435
|
+
|| !this.screen.canDrawRawImage?.(image, rect, bounds)) continue;
|
|
436
|
+
const first = Math.max(0, Math.ceil((rect[1] - this.offsetY) / this.cellHeight));
|
|
437
|
+
const end = Math.min(this.rows, Math.floor((rect[3] - this.offsetY) / this.cellHeight));
|
|
438
|
+
for (let row = first; row < end; row++) coveredRows.add(row);
|
|
439
|
+
}
|
|
402
440
|
for (let row = 0; row < this.rows; row++) {
|
|
403
441
|
const line = buffer.getLine(top + row);
|
|
404
|
-
const hash = line ? this.rowHash(line, this.columns) : 0;
|
|
405
|
-
if (this.dirtyAll || hash !== this.rowHashes[row] || this.forcedRows.has(row)
|
|
442
|
+
const hash = coveredRows.has(row) ? this.rowHashes[row] : line ? this.rowHash(line, this.columns) : 0;
|
|
443
|
+
if (this.dirtyAll || hash !== this.rowHashes[row] || this.forcedRows.has(row)
|
|
444
|
+
|| coveredRows.has(row) !== previouslyCovered.has(row)) {
|
|
406
445
|
dirty.push(row);
|
|
407
446
|
this.rowHashes[row] = hash;
|
|
408
447
|
}
|
|
409
448
|
}
|
|
449
|
+
this.rawCoveredRows = coveredRows;
|
|
410
450
|
this.forcedRows.clear();
|
|
451
|
+
const localPointer = Boolean(this.screen.saveRegion && this.screen.restoreRegion);
|
|
452
|
+
const pointerChanged = this.lastPointer?.x !== pointer?.x || this.lastPointer?.y !== pointer?.y;
|
|
453
|
+
const cursorChanged = this.lastCursor.x !== (cursor?.x ?? -1)
|
|
454
|
+
|| this.lastCursor.y !== (cursor?.y ?? -1)
|
|
455
|
+
|| this.lastCursor.visible !== Boolean(cursor?.visible);
|
|
411
456
|
const cursorRows = new Set();
|
|
412
|
-
if (this.lastCursor.visible) cursorRows.add(this.lastCursor.y);
|
|
413
|
-
if (cursor?.visible) cursorRows.add(cursor.y);
|
|
457
|
+
if ((!localPointer || cursorChanged) && this.lastCursor.visible) cursorRows.add(this.lastCursor.y);
|
|
458
|
+
if ((!localPointer || cursorChanged) && cursor?.visible) cursorRows.add(cursor.y);
|
|
414
459
|
// Wherever the pointer was and is has to be repainted: the text under it
|
|
415
460
|
// has not changed, so no row hash would say so.
|
|
416
|
-
for (const position of [this.lastPointer, pointer]) {
|
|
461
|
+
for (const position of localPointer ? [] : [this.lastPointer, pointer]) {
|
|
417
462
|
if (!position) continue;
|
|
418
463
|
const [first, last] = this.pointerRows(position);
|
|
419
464
|
for (let row = Math.max(0, first); row < Math.min(this.rows, last); row++) cursorRows.add(row);
|
|
@@ -421,19 +466,35 @@ export class Renderer {
|
|
|
421
466
|
for (const row of cursorRows) {
|
|
422
467
|
if (row >= 0 && row < this.rows && !dirty.includes(row)) dirty.push(row);
|
|
423
468
|
}
|
|
469
|
+
if (!dirty.length && !this.dirtyAll && (!localPointer || !pointerChanged)) return false;
|
|
470
|
+
if (this.pointerBackground) {
|
|
471
|
+
this.screen.restoreRegion(this.pointerBackground);
|
|
472
|
+
this.pointerBackground = null;
|
|
473
|
+
}
|
|
424
474
|
if (this.dirtyAll) {
|
|
425
475
|
this.canvas.clear(this.CanvasKit.Color(this.background[0], this.background[1], this.background[2]));
|
|
426
476
|
}
|
|
427
477
|
this.dirtyAll = false;
|
|
428
|
-
if (!dirty.length) return false;
|
|
429
478
|
dirty.sort((a, b) => a - b);
|
|
430
|
-
for (const row of dirty)
|
|
431
|
-
|
|
479
|
+
for (const row of dirty) {
|
|
480
|
+
if (!coveredRows.has(row)) this.drawRow(buffer.getLine(top + row), row);
|
|
481
|
+
}
|
|
482
|
+
if (cursor?.visible && !coveredRows.has(cursor.y) && dirty.includes(cursor.y)) {
|
|
432
483
|
this.drawCursor(buffer.getLine(top + cursor.y), cursor.x, cursor.y);
|
|
433
484
|
}
|
|
434
485
|
this.lastCursor = { x: cursor?.x ?? -1, y: cursor?.y ?? -1, visible: Boolean(cursor?.visible) };
|
|
435
|
-
if (images.length) this.drawImages(images, dirty);
|
|
436
|
-
if (
|
|
486
|
+
if (images.length && dirty.length) this.drawImages(images, dirty);
|
|
487
|
+
if (overlay && dirty.length) overlay();
|
|
488
|
+
if (pointer) {
|
|
489
|
+
if (localPointer) {
|
|
490
|
+
const size = Math.max(10, Math.round(this.fontSize * 0.9));
|
|
491
|
+
// Include the mitered outline and anti-aliasing outside the arrow path.
|
|
492
|
+
const padding = Math.ceil(Math.max(1, size / 8) * 2) + 2;
|
|
493
|
+
this.pointerBackground = this.screen.saveRegion(pointer.x - padding, pointer.y - padding,
|
|
494
|
+
pointer.x + size * 0.72 + padding, pointer.y + size * 1.08 + padding);
|
|
495
|
+
}
|
|
496
|
+
this.drawPointer(pointer);
|
|
497
|
+
}
|
|
437
498
|
this.lastPointer = pointer ? { x: pointer.x, y: pointer.y } : null;
|
|
438
499
|
this.screen.flush();
|
|
439
500
|
return true;
|
|
@@ -442,16 +503,24 @@ export class Renderer {
|
|
|
442
503
|
// Redraws the parts of image placements that intersect the repainted rows.
|
|
443
504
|
drawImages(images, dirtyRows) {
|
|
444
505
|
const { CanvasKit, canvas } = this;
|
|
506
|
+
const bands = [];
|
|
507
|
+
for (const row of dirtyRows) {
|
|
508
|
+
const top = this.offsetY + row * this.cellHeight, bottom = top + this.cellHeight;
|
|
509
|
+
const last = bands.at(-1);
|
|
510
|
+
if (last && last[1] === top) last[1] = bottom;
|
|
511
|
+
else bands.push([top, bottom]);
|
|
512
|
+
}
|
|
445
513
|
for (const { image, rect, source } of images) {
|
|
446
514
|
const [left, top, right, bottom] = rect;
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
515
|
+
if (!bands.some(([y0, y1]) => bottom > y0 && top < y1)) continue;
|
|
516
|
+
const bounds = source || [0, 0, image.width(), image.height()];
|
|
517
|
+
if (this.screen.drawOpaqueImage?.(image, rect, bounds, bands)) continue;
|
|
518
|
+
const sourceRect = CanvasKit.LTRBRect(...bounds);
|
|
519
|
+
for (const [rowTop, rowBottom] of bands) {
|
|
451
520
|
if (bottom <= rowTop || top >= rowBottom) continue;
|
|
452
521
|
canvas.save();
|
|
453
522
|
canvas.clipRect(CanvasKit.LTRBRect(0, rowTop, this.screen.width, rowBottom), CanvasKit.ClipOp.Intersect, false);
|
|
454
|
-
canvas.drawImageRectOptions(image, sourceRect, CanvasKit.LTRBRect(left, top, right, bottom),
|
|
523
|
+
canvas.drawImageRectOptions(skiaImage(image), sourceRect, CanvasKit.LTRBRect(left, top, right, bottom),
|
|
455
524
|
CanvasKit.FilterMode.Linear, CanvasKit.MipmapMode.Linear, null);
|
|
456
525
|
canvas.restore();
|
|
457
526
|
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// Coordinates are buffer cells, not UTF-16 indices. Endpoints include a whole
|
|
2
|
+
// grapheme, even when the mouse hits the continuation cell of a wide character.
|
|
3
|
+
export function createSelection(term, renderer, changed) {
|
|
4
|
+
let anchor = null, end = null, dragging = false;
|
|
5
|
+
const clear = () => { if (anchor) { anchor = end = null; dragging = false; changed(); } };
|
|
6
|
+
const point = report => {
|
|
7
|
+
const x = Math.max(0, Math.min(term.cols - 1, Math.floor((report.x - renderer.offsetX) / renderer.cellWidth)));
|
|
8
|
+
const y = term.buffer.active.viewportY + Math.max(0, Math.min(term.rows - 1,
|
|
9
|
+
Math.floor((report.y - renderer.offsetY) / renderer.cellHeight)));
|
|
10
|
+
return {x,y};
|
|
11
|
+
};
|
|
12
|
+
const bounds = () => {
|
|
13
|
+
if (!anchor) return null;
|
|
14
|
+
let [a,b] = anchor.y < end.y || anchor.y === end.y && anchor.x <= end.x ? [anchor,end] : [end,anchor];
|
|
15
|
+
const first = term.buffer.active.getLine(a.y), last = term.buffer.active.getLine(b.y);
|
|
16
|
+
let start = a.x, stop = b.x;
|
|
17
|
+
if (first?.getCell(start)?.getWidth() === 0) start = Math.max(0,start-1);
|
|
18
|
+
if (last?.getCell(stop)?.getWidth() === 0) stop = Math.max(0,stop-1);
|
|
19
|
+
stop += Math.max(1,last?.getCell(stop)?.getWidth() || 1);
|
|
20
|
+
return {a:{x:start,y:a.y},b:{x:stop,y:b.y}};
|
|
21
|
+
};
|
|
22
|
+
return {
|
|
23
|
+
clear,
|
|
24
|
+
stop() { dragging = false; },
|
|
25
|
+
get active() { return !!anchor; },
|
|
26
|
+
pointer(report) {
|
|
27
|
+
if (term._core?.coreMouseService?.areMouseEventsActive) { clear(); return false; }
|
|
28
|
+
if (report.pressed.includes("left")) { anchor = end = point(report); dragging = true; changed(); }
|
|
29
|
+
if (dragging && (report.moved || report.released.includes("left"))) { end = point(report); changed(); }
|
|
30
|
+
if (report.released.includes("left")) dragging = false;
|
|
31
|
+
return true;
|
|
32
|
+
},
|
|
33
|
+
text() {
|
|
34
|
+
const range = bounds();
|
|
35
|
+
if (!range) return "";
|
|
36
|
+
const {a,b} = range;
|
|
37
|
+
let text = "";
|
|
38
|
+
for (let y = a.y; y <= b.y; y++) {
|
|
39
|
+
const line = term.buffer.active.getLine(y);
|
|
40
|
+
if (!line) continue;
|
|
41
|
+
const start = y === a.y ? a.x : 0, stop = y === b.y ? b.x : term.cols;
|
|
42
|
+
if (y > a.y && !line.isWrapped) text += "\n";
|
|
43
|
+
// Wrapped rows join without a newline or lost meaningful spaces.
|
|
44
|
+
const nextWrapped = y < b.y && term.buffer.active.getLine(y + 1)?.isWrapped;
|
|
45
|
+
text += line.translateToString(!nextWrapped, start, stop);
|
|
46
|
+
}
|
|
47
|
+
return text;
|
|
48
|
+
},
|
|
49
|
+
draw() {
|
|
50
|
+
const range = bounds();
|
|
51
|
+
if (!range) return;
|
|
52
|
+
const {a,b} = range, top = term.buffer.active.viewportY;
|
|
53
|
+
for (let y = Math.max(a.y,top); y <= Math.min(b.y,top+term.rows-1); y++) {
|
|
54
|
+
const start = y === a.y ? a.x : 0, stop = y === b.y ? b.x : term.cols;
|
|
55
|
+
renderer.canvas.drawRect(renderer.cellRect(start,y-top,stop-start),
|
|
56
|
+
renderer.paintFor([70,150,220],95));
|
|
57
|
+
}
|
|
58
|
+
},
|
|
59
|
+
};
|
|
60
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import {bgraParser} from './raw-protocol.js';
|
|
1
2
|
// /lib/bunterm/terminal.js — a graphical terminal on the Linux framebuffer.
|
|
2
3
|
//
|
|
3
4
|
// Bun.spawn({ terminal }) ──── PTY output ────▶ KittyGraphicsParser
|
|
@@ -13,17 +14,22 @@
|
|
|
13
14
|
// keyboard are Buninu's own.
|
|
14
15
|
|
|
15
16
|
import { createRequire } from "node:module";
|
|
16
|
-
import { runGraphics } from "../fbdev.js";
|
|
17
|
+
import { runGraphics, activeConsole } from "../fbdev.js";
|
|
17
18
|
import { loadCanvasKit, openCanvas, loadFontSet } from "../canvas.js";
|
|
18
19
|
import { Terminal } from "../xterm/xterm-headless.mjs";
|
|
19
20
|
import { UnicodeGraphemesAddon } from "../xterm/addon-unicode-graphemes.mjs";
|
|
20
21
|
import { Renderer, defaultTheme } from "./renderer.js";
|
|
21
22
|
import { ImageStore } from "./images.js";
|
|
22
23
|
import { openKeyboard, translateKeys } from "./input.js";
|
|
23
|
-
import { claimVirtualConsole } from "./vt.js";
|
|
24
|
+
import { claimVirtualConsole, listVirtualTerminals, nextVirtualTerminal } from "./vt.js";
|
|
25
|
+
import { createFpsMeter } from "./fps.js";
|
|
26
|
+
import { createVtMenu } from "./vt-menu.js";
|
|
24
27
|
import { attachGeometry } from "./geometry.js";
|
|
25
28
|
import { terminalMouseEvent } from "./pointer-report.js";
|
|
26
|
-
import { attachClipboard } from "./clipboard.js";
|
|
29
|
+
import { attachClipboard, readClipboard, writeClipboard, pasteText } from "./clipboard.js";
|
|
30
|
+
import { createSelection } from "./selection.js";
|
|
31
|
+
import { createCommands } from "./commands.js";
|
|
32
|
+
import { attachKeyboard as createKeyboard } from "./keyboard.js";
|
|
27
33
|
|
|
28
34
|
const require = createRequire(import.meta.url);
|
|
29
35
|
|
|
@@ -49,7 +55,7 @@ export const createSession = ({
|
|
|
49
55
|
fonts,
|
|
50
56
|
command = "/bin/sh",
|
|
51
57
|
argv = [],
|
|
52
|
-
fontSize =
|
|
58
|
+
fontSize = 13,
|
|
53
59
|
lineHeight = 1,
|
|
54
60
|
theme = defaultTheme,
|
|
55
61
|
scrollback = 1000,
|
|
@@ -57,6 +63,7 @@ export const createSession = ({
|
|
|
57
63
|
mouse = true,
|
|
58
64
|
clipboard = true,
|
|
59
65
|
env = {},
|
|
66
|
+
vtMenu = null,
|
|
60
67
|
onError = (error) => console.error(`bunterm: ${error?.stack ?? error}`),
|
|
61
68
|
}) => {
|
|
62
69
|
const { BunPtyBackend, KittyGraphicsParser } = loadJsgotty();
|
|
@@ -70,7 +77,7 @@ export const createSession = ({
|
|
|
70
77
|
term.loadAddon(new UnicodeGraphemesAddon());
|
|
71
78
|
term.unicode.activeVersion = "15-graphemes";
|
|
72
79
|
const images = new ImageStore({ CanvasKit, term, renderer });
|
|
73
|
-
const kitty = new KittyGraphicsParser({ binaryData: true });
|
|
80
|
+
const kitty = new (bgraParser(KittyGraphicsParser,screen.display))({ binaryData: true });
|
|
74
81
|
|
|
75
82
|
const backend = new BunPtyBackend({
|
|
76
83
|
command,
|
|
@@ -92,6 +99,11 @@ export const createSession = ({
|
|
|
92
99
|
// Only set when a pointer is attached; without one the renderer is called
|
|
93
100
|
// exactly as before.
|
|
94
101
|
let pointer = null;
|
|
102
|
+
let menu = null;
|
|
103
|
+
const fps = createFpsMeter();
|
|
104
|
+
let fpsTimer = null;
|
|
105
|
+
let keyboardProtocol = null;
|
|
106
|
+
let consoleKeyboard = null;
|
|
95
107
|
const paint = () => {
|
|
96
108
|
if (frameTimer) clearTimeout(frameTimer);
|
|
97
109
|
frameTimer = null;
|
|
@@ -100,28 +112,76 @@ export const createSession = ({
|
|
|
100
112
|
// cursorY counts from the buffer's base, so a scrolled-back viewport
|
|
101
113
|
// moves the cursor down the screen and eventually off it.
|
|
102
114
|
const row = buffer.baseY + buffer.cursorY - buffer.viewportY;
|
|
103
|
-
|
|
115
|
+
if (menu?.visible || selection.active) renderer.invalidate();
|
|
116
|
+
const visibleImages = images.visible();
|
|
117
|
+
const rendered = renderer.render(term, {
|
|
104
118
|
cursor: {
|
|
105
119
|
x: buffer.cursorX,
|
|
106
120
|
y: row,
|
|
107
121
|
visible: blinkOn && !cursorHidden() && row >= 0 && row < renderer.rows,
|
|
108
122
|
},
|
|
109
|
-
images:
|
|
123
|
+
images: visibleImages,
|
|
110
124
|
pointer,
|
|
125
|
+
overlay: () => { selection.draw(); fps.draw(renderer); if (menu?.visible) menu.draw(renderer); },
|
|
111
126
|
});
|
|
127
|
+
if (rendered && fps.enabled) fps.presented(visibleImages);
|
|
128
|
+
return rendered;
|
|
112
129
|
};
|
|
113
130
|
const requestFrame = () => {
|
|
114
131
|
if (!active) return;
|
|
115
132
|
frameTimer ??= setTimeout(paint, 16);
|
|
116
133
|
};
|
|
134
|
+
const changed = () => { renderer.invalidate(); requestFrame(); };
|
|
135
|
+
const selection = createSelection(term, renderer, changed);
|
|
136
|
+
const setFontSize = size => {
|
|
137
|
+
renderer.setFontSize(size);
|
|
138
|
+
selection.clear();
|
|
139
|
+
term.resize(renderer.columns, renderer.rows);
|
|
140
|
+
backend.resize(renderer.columns, renderer.rows);
|
|
141
|
+
changed();
|
|
142
|
+
};
|
|
143
|
+
const copy = async () => {
|
|
144
|
+
if (!clipboard) throw new Error("Clipboard disabled (--no-clipboard)");
|
|
145
|
+
if (!selection.active) throw new Error("Drag to select text first (application mouse reporting must be off)");
|
|
146
|
+
await writeClipboard(selection.text());
|
|
147
|
+
};
|
|
148
|
+
const paste = async () => {
|
|
149
|
+
if (!clipboard) throw new Error("Clipboard disabled (--no-clipboard)");
|
|
150
|
+
const text = await readClipboard();
|
|
151
|
+
if (closed) return;
|
|
152
|
+
pasteText(term, text, value => backend.write(Buffer.from(value, "utf8")));
|
|
153
|
+
menu?.close();
|
|
154
|
+
term.scrollToBottom();
|
|
155
|
+
changed();
|
|
156
|
+
};
|
|
157
|
+
const toggleFps = () => {
|
|
158
|
+
if (fpsTimer) clearInterval(fpsTimer);
|
|
159
|
+
fpsTimer = null;
|
|
160
|
+
if (fps.toggle(images.visible())) fpsTimer = setInterval(() => {
|
|
161
|
+
fps.sample();
|
|
162
|
+
if (active) { renderer.invalidateRows(0,1); requestFrame(); }
|
|
163
|
+
},1000);
|
|
164
|
+
renderer.invalidateRows(0,1); requestFrame();
|
|
165
|
+
};
|
|
166
|
+
const commands = createCommands({fontSize:()=>renderer.fontSize,setFontSize,copy,paste,
|
|
167
|
+
switchVT: number => vtMenu?.select(number)});
|
|
168
|
+
if (vtMenu) menu = createVtMenu({
|
|
169
|
+
...vtMenu, width: screen.width, height: screen.height, rowHeight: renderer.cellHeight,
|
|
170
|
+
changed,
|
|
171
|
+
controls: {fpsEnabled:()=>fps.enabled,toggleFps,fontSize:()=>renderer.fontSize,cellHeight:()=>renderer.cellHeight,execute:line=>commands.execute(line)},
|
|
172
|
+
});
|
|
117
173
|
const suspend = () => {
|
|
174
|
+
keyboardProtocol?.blur();
|
|
175
|
+
selection.stop();
|
|
118
176
|
active = false;
|
|
177
|
+
menu?.close();
|
|
119
178
|
if (frameTimer) clearTimeout(frameTimer);
|
|
120
179
|
frameTimer = null;
|
|
121
180
|
};
|
|
122
181
|
const resume = () => {
|
|
123
182
|
if (active) return;
|
|
124
183
|
active = true;
|
|
184
|
+
consoleKeyboard?.clearHeld();
|
|
125
185
|
renderer.invalidate();
|
|
126
186
|
paint();
|
|
127
187
|
};
|
|
@@ -141,6 +201,9 @@ export const createSession = ({
|
|
|
141
201
|
const { events } = kitty.consume(chunk);
|
|
142
202
|
for (const event of events) {
|
|
143
203
|
if (event.kind === "plain") {
|
|
204
|
+
// A selection is a snapshot of the displayed cells. Drop it before
|
|
205
|
+
// output scrolls/replaces those cells, rather than copying stale rows.
|
|
206
|
+
selection.clear();
|
|
144
207
|
await writeTerminal(term, event.data);
|
|
145
208
|
continue;
|
|
146
209
|
}
|
|
@@ -169,6 +232,8 @@ export const createSession = ({
|
|
|
169
232
|
|
|
170
233
|
// Keyboard bytes from the console, rewritten for the program's key mode.
|
|
171
234
|
const input = (bytes) => {
|
|
235
|
+
if (!active) return;
|
|
236
|
+
if (menu?.input(bytes)) return;
|
|
172
237
|
const applicationCursorKeys = Boolean(term._core?.coreService?.decPrivateModes?.applicationCursorKeys);
|
|
173
238
|
const text = translateKeys(bytes.toString("latin1"), { applicationCursorKeys });
|
|
174
239
|
backend.write(Buffer.from(text, "latin1"));
|
|
@@ -180,6 +245,31 @@ export const createSession = ({
|
|
|
180
245
|
resetBlink();
|
|
181
246
|
requestFrame();
|
|
182
247
|
};
|
|
248
|
+
const attachKeyboard = device => {
|
|
249
|
+
consoleKeyboard = device;
|
|
250
|
+
keyboardProtocol = createKeyboard({
|
|
251
|
+
term,
|
|
252
|
+
setEnhanced: value => device.setEnhanced(value),
|
|
253
|
+
write: text => {
|
|
254
|
+
backend.write(Buffer.from(text,"utf8"));
|
|
255
|
+
term.scrollToBottom(); resetBlink(); changed();
|
|
256
|
+
},
|
|
257
|
+
localInput: (event, text) => {
|
|
258
|
+
if (!menu?.visible) return false;
|
|
259
|
+
if (text) menu.input(Buffer.from(text,"utf8"));
|
|
260
|
+
return true;
|
|
261
|
+
},
|
|
262
|
+
switchVT: number => { Promise.resolve(vtMenu?.select(number)).catch(onError); },
|
|
263
|
+
switchRelative: delta => {
|
|
264
|
+
try {
|
|
265
|
+
const number = Math.max(1,Math.min(63,activeConsole({fd:device.fd})+delta));
|
|
266
|
+
Promise.resolve(vtMenu?.select(number)).catch(onError);
|
|
267
|
+
} catch (error) { onError(error); }
|
|
268
|
+
},
|
|
269
|
+
onError,
|
|
270
|
+
});
|
|
271
|
+
};
|
|
272
|
+
const keyEvent = event => { if (active) keyboardProtocol?.event(event); };
|
|
183
273
|
|
|
184
274
|
// Turns a pointer report from mouse.js into the mouse sequences the
|
|
185
275
|
// program asked for. xterm.js's CoreMouseService owns the protocol: it
|
|
@@ -221,11 +311,18 @@ export const createSession = ({
|
|
|
221
311
|
if (!active) return;
|
|
222
312
|
pointer = { x: report.x, y: report.y };
|
|
223
313
|
requestFrame();
|
|
314
|
+
if (menu?.pointer(report)) {
|
|
315
|
+
selection.stop();
|
|
316
|
+
keyboardProtocol?.blur();
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
224
319
|
const service = term._core?.coreMouseService;
|
|
225
320
|
if (!service?.areMouseEventsActive) {
|
|
226
321
|
if (report.wheel) wheelScroll(report.wheel);
|
|
322
|
+
selection.pointer(report);
|
|
227
323
|
return;
|
|
228
324
|
}
|
|
325
|
+
selection.clear();
|
|
229
326
|
const send = (button, action) => {
|
|
230
327
|
try {
|
|
231
328
|
service.triggerMouseEvent(terminalMouseEvent(report, renderer, button, action));
|
|
@@ -278,10 +375,15 @@ export const createSession = ({
|
|
|
278
375
|
const close = () => {
|
|
279
376
|
if (closed) return;
|
|
280
377
|
closed = true;
|
|
378
|
+
active = false;
|
|
281
379
|
if (blinkTimer) clearInterval(blinkTimer);
|
|
380
|
+
if (fpsTimer) clearInterval(fpsTimer);
|
|
381
|
+
fpsTimer = null;
|
|
282
382
|
if (frameTimer) clearTimeout(frameTimer);
|
|
283
383
|
frameTimer = null;
|
|
284
384
|
backend.close();
|
|
385
|
+
try { keyboardProtocol?.dispose(); } catch (error) { onError(error); }
|
|
386
|
+
keyboardProtocol = null;
|
|
285
387
|
pointerDevice?.close();
|
|
286
388
|
pointerDevice = null;
|
|
287
389
|
images.delete();
|
|
@@ -293,6 +395,7 @@ export const createSession = ({
|
|
|
293
395
|
return {
|
|
294
396
|
term, backend, renderer, images, kitty,
|
|
295
397
|
paint, requestFrame, suspend, resume, settle, input, exited, close, attachPointer, reportMouse, wheelScroll,
|
|
398
|
+
attachKeyboard, keyEvent, selection, setFontSize, commands,
|
|
296
399
|
};
|
|
297
400
|
};
|
|
298
401
|
|
|
@@ -308,21 +411,52 @@ export const runTerminal = async ({ console: consoleDevice = null, device = "/de
|
|
|
308
411
|
|
|
309
412
|
return runGraphics(async (display, { onRestore, onRelease, onAcquire }) => {
|
|
310
413
|
const screen = openCanvas(CanvasKit, display);
|
|
311
|
-
|
|
414
|
+
let switching = false;
|
|
415
|
+
let keyboard = null;
|
|
416
|
+
const session = createSession({ CanvasKit, screen, fonts, ...options, vtMenu: {
|
|
417
|
+
list: () => {
|
|
418
|
+
const current = keyboard ? activeConsole(keyboard) : null;
|
|
419
|
+
return listVirtualTerminals().map(entry => ({...entry,current:entry.number===current}));
|
|
420
|
+
},
|
|
421
|
+
create: async fontSize => {
|
|
422
|
+
// Re-scan at activation, since another session may have opened while
|
|
423
|
+
// the menu was visible. The launcher detaches onto the new console;
|
|
424
|
+
// its normal VT ownership checks still apply.
|
|
425
|
+
const number = nextVirtualTerminal();
|
|
426
|
+
const child = Bun.spawn([process.execPath, `${import.meta.dir}/../../bin/bunterm`,
|
|
427
|
+
String(number), "-s", String(fontSize), "--fb", device],
|
|
428
|
+
{ stdin: "ignore", stdout: "ignore", stderr: "pipe" });
|
|
429
|
+
const [status, error] = await Promise.all([child.exited, new Response(child.stderr).text()]);
|
|
430
|
+
if (status !== 0) throw new Error(error.trim() || "無法啟動 / Failed to start bunterm");
|
|
431
|
+
},
|
|
432
|
+
select: async number => {
|
|
433
|
+
if (switching) return;
|
|
434
|
+
switching = true;
|
|
435
|
+
try {
|
|
436
|
+
// Waiting in this process would block its VT_PROCESS signal handlers.
|
|
437
|
+
const child = Bun.spawn([process.execPath, `${import.meta.dir}/../../bin/chvt`, String(number)],
|
|
438
|
+
{ stdin: "ignore", stdout: "ignore", stderr: "pipe" });
|
|
439
|
+
const [status, error] = await Promise.all([child.exited, new Response(child.stderr).text()]);
|
|
440
|
+
if (status !== 0) console.error(`bunterm: ${error.trim() || "VT switch failed"}`);
|
|
441
|
+
} catch (error) { console.error(`bunterm: VT switch: ${error.message}`); }
|
|
442
|
+
finally { switching = false; }
|
|
443
|
+
},
|
|
444
|
+
} });
|
|
312
445
|
onRelease(session.suspend);
|
|
313
446
|
// Coming back from another console (Ctrl-Alt-Fn): fbcon or another
|
|
314
447
|
// bunterm has drawn over the framebuffer, so repaint everything.
|
|
315
448
|
onAcquire(session.resume);
|
|
316
|
-
let keyboard = null;
|
|
317
449
|
try {
|
|
318
450
|
keyboard = openKeyboard({
|
|
319
451
|
device: consoleDevice,
|
|
320
452
|
onData: session.input,
|
|
453
|
+
onKey: session.keyEvent,
|
|
321
454
|
onError: (error) => {
|
|
322
455
|
console.error(`bunterm: keyboard: ${error.message}`);
|
|
323
456
|
session.backend.close();
|
|
324
457
|
},
|
|
325
458
|
});
|
|
459
|
+
session.attachKeyboard(keyboard);
|
|
326
460
|
onRestore(() => keyboard.close());
|
|
327
461
|
if (options.mouse !== false) {
|
|
328
462
|
// A pointer that cannot be opened is reported and the session
|
|
@@ -338,9 +472,10 @@ export const runTerminal = async ({ console: consoleDevice = null, device = "/de
|
|
|
338
472
|
}
|
|
339
473
|
return await session.exited;
|
|
340
474
|
} finally {
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
475
|
+
try { session.close(); }
|
|
476
|
+
finally {
|
|
477
|
+
try { keyboard?.close(); } finally { screen.close(); }
|
|
478
|
+
}
|
|
344
479
|
}
|
|
345
480
|
}, { device, console: consolePaths, prepareConsole: claimVirtualConsole });
|
|
346
481
|
};
|