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.
Files changed (109) hide show
  1. package/LICENSES/linux-firmware/LICENCE.nvidia +131 -0
  2. package/LICENSES/linux-firmware/LICENSE.amdgpu +51 -0
  3. package/LICENSES/linux-firmware/LICENSE.amdisp +31 -0
  4. package/LICENSES/linux-firmware/LICENSE.i915 +39 -0
  5. package/LICENSES/linux-firmware/LICENSE.radeon +51 -0
  6. package/LICENSES/linux-firmware/LICENSE.xe +39 -0
  7. package/LICENSES/linux-firmware/WHENCE +10280 -0
  8. package/NOTICE.md +64 -46
  9. package/README.md +410 -96
  10. package/README.zh.md +2034 -0
  11. package/build-image.sh +10 -1
  12. package/build-uki.sh +3 -1
  13. package/fetch-alpine.sh +15 -1
  14. package/graphics.md +31 -0
  15. package/index.js +28 -11
  16. package/initramfs/bin/buninu-browser +4 -1
  17. package/initramfs/bin/buninu-land +1 -1
  18. package/initramfs/bin/bunterm +3 -3
  19. package/initramfs/bin/chroot +13 -0
  20. package/initramfs/bin/chvt +11 -0
  21. package/initramfs/bin/dhclient +8 -0
  22. package/initramfs/bin/mount +4 -0
  23. package/initramfs/bin/ping +8 -0
  24. package/initramfs/bin/umount +6 -0
  25. package/initramfs/bin/xdg-open +33 -0
  26. package/initramfs/buninu/CHANGELOG.md +19 -0
  27. package/initramfs/buninu/apps/bunmsh/CHANGELOG.md +4 -0
  28. package/initramfs/buninu/apps/bunmsh/package.json +1 -1
  29. package/initramfs/buninu/apps/bunmsh/src/main.js +1 -1
  30. package/initramfs/buninu/apps/jsgotty/README.en.md +40 -0
  31. package/initramfs/buninu/apps/jsgotty/README.md +40 -0
  32. package/initramfs/buninu/apps/jsgotty/package-lock.json +2 -2
  33. package/initramfs/buninu/apps/jsgotty/package.json +1 -1
  34. package/initramfs/buninu/apps/jsgotty/static/index.html +1 -0
  35. package/initramfs/buninu/apps/jsgotty/static/js/gotty.js +82 -17
  36. package/initramfs/buninu/apps/jsgotty/static/js/kitty-keyboard.js +193 -0
  37. package/initramfs/buninu/apps/jsgotty/tests/android-alt.test.js +115 -0
  38. package/initramfs/buninu/apps/jsgotty/tests/arrow-modifiers.test.js +80 -0
  39. package/initramfs/buninu/apps/jsgotty/tests/kitty-keyboard-browser.test.js +53 -0
  40. package/initramfs/buninu/apps/jsgotty/tests/kitty-keyboard.test.js +102 -0
  41. package/initramfs/buninu/apps/jsgotty/tests/manual/README.md +20 -0
  42. package/initramfs/buninu/apps/jsgotty/tests/manual/keyboard-collector.cjs +55 -0
  43. package/initramfs/buninu/apps/jsgotty/tests/manual/keyboard-debug.js +74 -0
  44. package/initramfs/buninu/apps/jsgotty/tests/manual/kitty-keyboard.js +80 -0
  45. package/initramfs/buninu/package.json +1 -1
  46. package/initramfs/init.js +27 -10
  47. package/initramfs/lib/bunterm/clipboard.js +14 -0
  48. package/initramfs/lib/bunterm/commands.js +37 -0
  49. package/initramfs/lib/bunterm/fps.js +38 -0
  50. package/initramfs/lib/bunterm/images.js +9 -5
  51. package/initramfs/lib/bunterm/input.js +58 -15
  52. package/initramfs/lib/bunterm/keyboard.js +62 -0
  53. package/initramfs/lib/bunterm/keycodes.js +104 -0
  54. package/initramfs/lib/bunterm/kitty-keyboard.cjs +195 -0
  55. package/initramfs/lib/bunterm/raw-protocol.js +33 -0
  56. package/initramfs/lib/bunterm/renderer.js +90 -21
  57. package/initramfs/lib/bunterm/selection.js +60 -0
  58. package/initramfs/lib/bunterm/terminal.js +147 -12
  59. package/initramfs/lib/bunterm/vt-menu.js +181 -0
  60. package/initramfs/lib/bunterm/vt.js +27 -1
  61. package/initramfs/lib/canvas.js +99 -0
  62. package/initramfs/lib/chvt.js +31 -0
  63. package/initramfs/lib/dhclient.js +135 -0
  64. package/initramfs/lib/dhcp-start.js +85 -0
  65. package/initramfs/lib/dhcp-transport.js +44 -0
  66. package/initramfs/lib/dhcp.js +124 -0
  67. package/initramfs/lib/dlopen.js +58 -11
  68. package/initramfs/lib/fbdev.js +21 -3
  69. package/initramfs/lib/gpu.js +77 -0
  70. package/initramfs/lib/ping.js +110 -0
  71. package/initramfs/lib/raw-image.js +47 -0
  72. package/initramfs/lib/xdg-open.js +42 -0
  73. package/initramfs/usr/share/doc/buninu-linux/bunterm.md +86 -12
  74. package/initramfs/usr/share/doc/buninu-linux/chvt.md +34 -0
  75. package/initramfs/usr/share/doc/buninu-linux/dhclient.md +82 -0
  76. package/initramfs/usr/share/doc/buninu-linux/ping.md +91 -0
  77. package/initramfs/usr/share/doc/buninu-linux/xdg-open.md +43 -0
  78. package/package.json +2 -2
  79. package/scripts/arch.js +7 -5
  80. package/scripts/arch.sh +5 -5
  81. package/scripts/build.js +16 -3
  82. package/scripts/fetch-gpu.js +105 -0
  83. package/scripts/fetch.js +11 -1
  84. package/scripts/gpu-firmware.json +22 -0
  85. package/scripts/modules.js +5 -1
  86. package/scripts/npm-packages.md +17 -17
  87. package/test/bunterm-fps.test.js +17 -0
  88. package/test/bunterm-keyboard.test.js +116 -0
  89. package/test/bunterm-next-vt.test.js +9 -0
  90. package/test/bunterm-raw-protocol.test.js +19 -0
  91. package/test/bunterm-renderer.test.js +158 -0
  92. package/test/bunterm-selection.test.js +38 -0
  93. package/test/bunterm-vt-menu.test.js +178 -0
  94. package/test/chroot-bind.test.js +40 -2
  95. package/test/chvt.test.js +46 -0
  96. package/test/dhcp-start.test.js +67 -0
  97. package/test/dhcp.test.js +99 -0
  98. package/test/fbdev-sync.test.js +33 -0
  99. package/test/fetch-npm.test.js +1 -1
  100. package/test/gpu.test.js +155 -0
  101. package/test/image-size.test.js +17 -0
  102. package/test/libc.test.js +41 -0
  103. package/test/manual-bunterm-image-paths.js +31 -0
  104. package/test/mount-libc-lifetime.test.js +50 -0
  105. package/test/no-gpu.test.js +43 -0
  106. package/test/ping.test.js +80 -0
  107. package/test/readme-cli.test.js +29 -0
  108. package/test/stage-npm.test.js +11 -4
  109. 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 = 16, lineHeight = 1, theme = defaultTheme }) {
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) this.drawRow(buffer.getLine(top + row), row);
431
- if (cursor?.visible && cursor.y >= 0 && cursor.y < this.rows) {
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 (pointer) this.drawPointer(pointer);
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
- const sourceRect = source ? CanvasKit.LTRBRect(...source) : CanvasKit.LTRBRect(0, 0, image.width(), image.height());
448
- for (const row of dirtyRows) {
449
- const rowTop = this.offsetY + row * this.cellHeight;
450
- const rowBottom = rowTop + this.cellHeight;
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 = 16,
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
- return renderer.render(term, {
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: images.visible(),
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
- const session = createSession({ CanvasKit, screen, fonts, ...options });
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
- keyboard?.close();
342
- session.close();
343
- screen.close();
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
  };