buninu-linux 0.3.0 → 0.4.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.
Files changed (75) hide show
  1. package/LICENSES/Apache-2.0.txt +202 -0
  2. package/LICENSES/BSD-3-Clause-Skia.txt +29 -0
  3. package/LICENSES/Bitstream-Vera.txt +44 -0
  4. package/LICENSES/MIT-xterm.js.txt +21 -0
  5. package/LICENSES/OFL-1.1.txt +91 -0
  6. package/NOTICE.md +18 -1
  7. package/README.md +226 -22
  8. package/fetch-alpine.sh +9 -4
  9. package/graphics.md +213 -0
  10. package/initramfs/bin/bunterm +66 -0
  11. package/initramfs/bin/ip +2 -1
  12. package/initramfs/bin/mount +2 -1
  13. package/initramfs/bin/poweroff +6 -27
  14. package/initramfs/bin/ps +256 -0
  15. package/initramfs/bin/reboot +10 -0
  16. package/initramfs/bin/tar +169 -0
  17. package/initramfs/bin/top +129 -0
  18. package/initramfs/bin/umount +2 -1
  19. package/initramfs/buninu/CHANGELOG.md +40 -0
  20. package/initramfs/buninu/apps/bunmsh/CHANGELOG.md +36 -0
  21. package/initramfs/buninu/apps/bunmsh/README.md +7 -0
  22. package/initramfs/buninu/apps/bunmsh/package.json +1 -1
  23. package/initramfs/buninu/apps/bunmsh/src/fancy-ls.js +29 -14
  24. package/initramfs/buninu/apps/bunmsh/src/line-edit.js +43 -0
  25. package/initramfs/buninu/apps/bunmsh/src/main.js +15 -1
  26. package/initramfs/buninu/apps/bunmsh/src/mouse.js +29 -4
  27. package/initramfs/buninu/apps/bunmsh/src/shell.js +9 -1
  28. package/initramfs/buninu/apps/bunmsh/test/shell1.test.js +70 -0
  29. package/initramfs/buninu/apps/jsgotty/gotty.js +4 -3
  30. package/initramfs/buninu/apps/jsgotty/package.json +1 -1
  31. package/initramfs/buninu/apps/jsmdcui/CHANGELOG.md +10 -0
  32. package/initramfs/buninu/apps/jsmdcui/README.md +2 -0
  33. package/initramfs/buninu/apps/jsmdcui/package.json +1 -1
  34. package/initramfs/buninu/apps/jsmdcui/runtime/help/commands.md +4 -1
  35. package/initramfs/buninu/apps/jsmdcui/runtime/help/help.md +2 -0
  36. package/initramfs/buninu/apps/jsmdcui/src/index.js +40 -13
  37. package/initramfs/buninu/package.json +1 -1
  38. package/initramfs/init.js +6 -0
  39. package/initramfs/lib/bunterm/glyphs.js +446 -0
  40. package/initramfs/lib/bunterm/images.js +225 -0
  41. package/initramfs/lib/bunterm/input.js +140 -0
  42. package/initramfs/lib/bunterm/renderer.js +424 -0
  43. package/initramfs/lib/bunterm/terminal.js +203 -0
  44. package/initramfs/lib/canvas.js +181 -0
  45. package/initramfs/lib/canvaskit/LICENSE +29 -0
  46. package/initramfs/lib/canvaskit/canvaskit.js +264 -0
  47. package/initramfs/lib/canvaskit/canvaskit.wasm +0 -0
  48. package/initramfs/lib/dlopen.js +14 -11
  49. package/initramfs/lib/document.js +6 -0
  50. package/initramfs/lib/fbdev.js +581 -0
  51. package/initramfs/lib/power.js +35 -0
  52. package/initramfs/lib/xterm/LICENSE +21 -0
  53. package/initramfs/lib/xterm/addon-unicode-graphemes.mjs +44 -0
  54. package/initramfs/lib/xterm/xterm-headless.mjs +46 -0
  55. package/initramfs/usr/share/doc/buninu-linux/bunterm.md +83 -0
  56. package/initramfs/usr/share/doc/buninu-linux/canvas.md +91 -0
  57. package/initramfs/usr/share/doc/buninu-linux/fbdev.md +113 -0
  58. package/initramfs/usr/share/doc/buninu-linux/ps.md +94 -0
  59. package/initramfs/usr/share/doc/buninu-linux/reboot.md +15 -0
  60. package/initramfs/usr/share/doc/buninu-linux/tar.md +73 -0
  61. package/initramfs/usr/share/doc/buninu-linux/top.md +23 -0
  62. package/initramfs/usr/share/fonts/DejaVuSansMono-Bold.ttf +0 -0
  63. package/initramfs/usr/share/fonts/DejaVuSansMono.ttf +0 -0
  64. package/initramfs/usr/share/fonts/LICENSE-Apache-2.0.txt +202 -0
  65. package/initramfs/usr/share/fonts/LICENSE-DejaVu.txt +44 -0
  66. package/initramfs/usr/share/fonts/LICENSE-OFL-1.1.txt +91 -0
  67. package/initramfs/usr/share/fonts/NotoColorEmoji.ttf +0 -0
  68. package/initramfs/usr/share/fonts/NotoColorEmojiFlags.ttf +0 -0
  69. package/initramfs/usr/share/fonts/NotoSansCJK-Regular.ttc +0 -0
  70. package/initramfs/usr/share/fonts/NotoSansSymbols-Regular-Subsetted.ttf +0 -0
  71. package/initramfs/usr/share/fonts/NotoSansSymbols-Regular-Subsetted2.ttf +0 -0
  72. package/initramfs/usr/share/fonts/Roboto-Regular.ttf +0 -0
  73. package/initramfs/usr/share/fonts/fonts.json +28 -0
  74. package/package.json +2 -2
  75. package/run-qemu.sh +1 -1
@@ -0,0 +1,424 @@
1
+ // /lib/bunterm/renderer.js — paints an xterm.js headless buffer onto a Skia
2
+ // canvas as a grid of cells.
3
+ //
4
+ // Every cell is placed by the renderer itself, never by a text layout: the
5
+ // primary monospace font sets the cell size, each character is looked up in
6
+ // the primary font and then the fallback fonts, and the glyph is centred in
7
+ // the one or two cells the terminal assigned it. Box drawing and block
8
+ // characters come from glyphs.js so they join seamlessly. Only rows whose
9
+ // content changed since the last frame are repainted.
10
+
11
+ import { drawCustomGlyph, hasCustomGlyph } from "./glyphs.js";
12
+
13
+ // xterm.js's default theme.
14
+ export const defaultTheme = {
15
+ foreground: "#ffffff",
16
+ background: "#000000",
17
+ cursor: "#ffffff",
18
+ ansi: [
19
+ "#2e3436", "#cc0000", "#4e9a06", "#c4a000", "#3465a4", "#75507b", "#06989a", "#d3d7cf",
20
+ "#555753", "#ef2929", "#8ae234", "#fce94f", "#729fcf", "#ad7fa8", "#34e2e2", "#eeeeec",
21
+ ],
22
+ };
23
+
24
+ const parseColor = (hex) => [
25
+ parseInt(hex.slice(1, 3), 16), parseInt(hex.slice(3, 5), 16), parseInt(hex.slice(5, 7), 16),
26
+ ];
27
+
28
+ // The 256-colour palette: 16 theme colours, a 6x6x6 cube and 24 greys.
29
+ export const buildPalette = (theme) => {
30
+ const palette = theme.ansi.map(parseColor);
31
+ const steps = [0, 95, 135, 175, 215, 255];
32
+ for (let r = 0; r < 6; r++) {
33
+ for (let g = 0; g < 6; g++) {
34
+ for (let b = 0; b < 6; b++) palette.push([steps[r], steps[g], steps[b]]);
35
+ }
36
+ }
37
+ for (let i = 0; i < 24; i++) {
38
+ const level = 8 + i * 10;
39
+ palette.push([level, level, level]);
40
+ }
41
+ return palette;
42
+ };
43
+
44
+ const packColor = ([r, g, b]) => (r << 16) | (g << 8) | b;
45
+ const unpackColor = (packed) => [(packed >> 16) & 255, (packed >> 8) & 255, packed & 255];
46
+
47
+ // Code points that default to emoji presentation get the colour emoji
48
+ // fonts before the monochrome symbol fonts; everything else the reverse.
49
+ const emojiPresentation = /^\p{Emoji_Presentation}/u;
50
+ const isEmojiText = (text) => emojiPresentation.test(text) || text.includes("\uFE0F");
51
+
52
+ // A cell may hold a multi-code-point grapheme (a flag, a ZWJ sequence, a
53
+ // base with combining marks). Those need shaping, so they go through the
54
+ // Paragraph API; single code points are drawn as one glyph.
55
+ const isSingleCodePoint = (text) => {
56
+ const first = text.codePointAt(0);
57
+ return text.length === (first > 0xffff ? 2 : 1);
58
+ };
59
+
60
+ export class Renderer {
61
+ constructor({ CanvasKit, screen, fonts, fontSize = 16, lineHeight = 1, theme = defaultTheme }) {
62
+ this.CanvasKit = CanvasKit;
63
+ this.screen = screen;
64
+ this.canvas = screen.canvas;
65
+ this.fonts = fonts;
66
+ this.fontSize = fontSize;
67
+ this.theme = theme;
68
+ this.palette = buildPalette(theme);
69
+ this.foreground = parseColor(theme.foreground);
70
+ this.background = parseColor(theme.background);
71
+ this.cursorColor = parseColor(theme.cursor);
72
+
73
+ this.typefaceIds = new Map();
74
+ this.fontCache = new Map();
75
+ this.metricsCache = new Map();
76
+ this.glyphCache = new Map();
77
+ this.paragraphCache = new Map();
78
+ this.paintCache = new Map();
79
+
80
+ const regular = this.fontFor(fonts.regular, fontSize);
81
+ const metrics = regular.getMetrics();
82
+ const advance = regular.getGlyphWidths(fonts.regular.getGlyphIDs("M"))[0];
83
+ this.cellWidth = Math.max(1, Math.round(advance));
84
+ this.cellHeight = Math.max(1, Math.round((metrics.descent - metrics.ascent + (metrics.leading || 0)) * lineHeight));
85
+ this.columns = Math.max(1, Math.floor(screen.width / this.cellWidth));
86
+ this.rows = Math.max(1, Math.floor(screen.height / this.cellHeight));
87
+ // The grid is centred; the margins keep the background colour.
88
+ this.offsetX = Math.floor((screen.width - this.columns * this.cellWidth) / 2);
89
+ this.offsetY = Math.floor((screen.height - this.rows * this.cellHeight) / 2);
90
+
91
+ // Fallback typefaces split by kind so lookup order can depend on the text.
92
+ this.monoFallback = [];
93
+ this.colorFallback = [];
94
+ fonts.names.slice(fonts.bold ? 2 : 1).forEach((name, index) => {
95
+ const typeface = fonts.fallback[index];
96
+ (name.includes("Emoji") ? this.colorFallback : this.monoFallback).push(typeface);
97
+ });
98
+
99
+ this.emojiFamilies = [
100
+ ...fonts.families.filter((name) => name.includes("Emoji")),
101
+ ...fonts.families.filter((name) => !name.includes("Emoji")),
102
+ ];
103
+
104
+ this.rowHashes = new Int32Array(this.rows).fill(0);
105
+ this.forcedRows = new Set();
106
+ this.dirtyAll = true;
107
+ this.lastCursor = { x: -1, y: -1, visible: false };
108
+ }
109
+
110
+ fontFor(typeface, size, { bold = false, italic = false } = {}) {
111
+ let id = this.typefaceIds.get(typeface);
112
+ if (id === undefined) {
113
+ id = this.typefaceIds.size;
114
+ this.typefaceIds.set(typeface, id);
115
+ }
116
+ const key = `${id}:${size}:${bold ? "b" : ""}${italic ? "i" : ""}`;
117
+ let font = this.fontCache.get(key);
118
+ if (!font) {
119
+ font = new this.CanvasKit.Font(typeface, size);
120
+ font.setSubpixel(true);
121
+ font.setEdging(this.CanvasKit.FontEdging.AntiAlias);
122
+ if (bold) font.setEmbolden(true);
123
+ if (italic) font.setSkewX(-0.2);
124
+ this.fontCache.set(key, font);
125
+ }
126
+ return font;
127
+ }
128
+
129
+ metricsFor(font) {
130
+ let metrics = this.metricsCache.get(font);
131
+ if (!metrics) {
132
+ metrics = font.getMetrics();
133
+ this.metricsCache.set(font, metrics);
134
+ }
135
+ return metrics;
136
+ }
137
+
138
+ paintFor(color, alpha = 255) {
139
+ const key = packColor(color) * 256 + alpha;
140
+ let paint = this.paintCache.get(key);
141
+ if (!paint) {
142
+ paint = new this.CanvasKit.Paint();
143
+ paint.setColor(this.CanvasKit.Color(color[0], color[1], color[2], alpha / 255));
144
+ paint.setAntiAlias(true);
145
+ this.paintCache.set(key, paint);
146
+ }
147
+ return paint;
148
+ }
149
+
150
+ // Finds the typeface holding `text` (a single code point) and its glyph.
151
+ resolveGlyph(text, emoji) {
152
+ const key = (emoji ? "e" : "m") + text;
153
+ let entry = this.glyphCache.get(key);
154
+ if (entry === undefined) {
155
+ entry = null;
156
+ const order = emoji
157
+ ? [this.fonts.regular, ...this.colorFallback, ...this.monoFallback]
158
+ : [this.fonts.regular, ...this.monoFallback, ...this.colorFallback];
159
+ for (const typeface of order) {
160
+ const id = typeface.getGlyphIDs(text, 1)[0];
161
+ if (id) {
162
+ entry = { typeface, glyph: id };
163
+ break;
164
+ }
165
+ }
166
+ this.glyphCache.set(key, entry);
167
+ }
168
+ return entry;
169
+ }
170
+
171
+ colorOf(cell, foreground) {
172
+ const rgb = foreground ? cell.isFgRGB() : cell.isBgRGB();
173
+ const palette = foreground ? cell.isFgPalette() : cell.isBgPalette();
174
+ const value = foreground ? cell.getFgColor() : cell.getBgColor();
175
+ if (rgb) return unpackColor(value);
176
+ if (palette) {
177
+ // Bold text in the eight base colours uses the bright variants.
178
+ const index = foreground && cell.isBold() && value < 8 ? value + 8 : value;
179
+ return this.palette[index] ?? this.foreground;
180
+ }
181
+ return foreground ? this.foreground : this.background;
182
+ }
183
+
184
+ rowHash(line, columns) {
185
+ let hash = 0x811c9dc5 | 0;
186
+ const mix = (value) => { hash = Math.imul(hash ^ value, 0x01000193); };
187
+ for (let x = 0; x < columns; x++) {
188
+ const cell = line.getCell(x);
189
+ if (!cell) break;
190
+ mix(cell.getCode());
191
+ mix(cell.getChars().length);
192
+ mix(cell.getWidth());
193
+ mix(cell.getFgColor() ^ (cell.getFgColorMode() >>> 16));
194
+ mix(cell.getBgColor() ^ (cell.getBgColorMode() >>> 16));
195
+ mix(cell.isBold() | cell.isItalic() | cell.isDim() | cell.isUnderline()
196
+ | cell.isInverse() | cell.isInvisible() | cell.isStrikethrough() | cell.isBlink());
197
+ }
198
+ return hash;
199
+ }
200
+
201
+ invalidate() {
202
+ this.dirtyAll = true;
203
+ }
204
+
205
+ // Marks rows [start, end) for repainting even if their text is unchanged,
206
+ // for image placements appearing or disappearing over them.
207
+ invalidateRows(start, end) {
208
+ for (let row = Math.max(0, start); row < Math.min(this.rows, end); row++) this.forcedRows.add(row);
209
+ }
210
+
211
+ cellRect(x, y, width = 1, height = 1) {
212
+ return this.CanvasKit.XYWHRect(this.offsetX + x * this.cellWidth, this.offsetY + y * this.cellHeight,
213
+ width * this.cellWidth, height * this.cellHeight);
214
+ }
215
+
216
+ drawText(text, x, y, cells, color, { bold, italic, emoji }) {
217
+ const { CanvasKit, canvas } = this;
218
+ const available = cells * this.cellWidth;
219
+ const paint = this.paintFor(color);
220
+ if (isSingleCodePoint(text)) {
221
+ const entry = this.resolveGlyph(text, emoji);
222
+ if (!entry) return;
223
+ const primary = entry.typeface === this.fonts.regular;
224
+ const typeface = primary && bold && this.fonts.bold ? this.fonts.bold : entry.typeface;
225
+ const glyph = primary && bold && this.fonts.bold ? this.fonts.bold.getGlyphIDs(text, 1)[0] : entry.glyph;
226
+ let font = this.fontFor(typeface, this.fontSize, { bold: bold && !primary, italic });
227
+ let advance = font.getGlyphWidths([glyph])[0];
228
+ // A fallback glyph wider than its cells (emoji, some symbols) is
229
+ // scaled down to fit rather than overlapping its neighbour.
230
+ if (advance > available + 0.5) {
231
+ const size = Math.floor(this.fontSize * available / advance);
232
+ font = this.fontFor(typeface, size, { bold: bold && !primary, italic });
233
+ advance = font.getGlyphWidths([glyph])[0];
234
+ }
235
+ const metrics = this.metricsFor(font);
236
+ const baseline = y + (this.cellHeight - (metrics.descent - metrics.ascent)) / 2 - metrics.ascent;
237
+ canvas.drawGlyphs([glyph], [x + (available - advance) / 2, baseline], 0, 0, font, paint);
238
+ return;
239
+ }
240
+ // Grapheme clusters are shaped by the Paragraph API and cached.
241
+ const key = `${text}|${packColor(color)}|${bold ? 1 : 0}${italic ? 1 : 0}`;
242
+ let paragraph = this.paragraphCache.get(key);
243
+ if (!paragraph) {
244
+ if (this.paragraphCache.size > 512) {
245
+ for (const [oldKey, old] of this.paragraphCache) {
246
+ old.delete();
247
+ this.paragraphCache.delete(oldKey);
248
+ if (this.paragraphCache.size <= 256) break;
249
+ }
250
+ }
251
+ const style = new CanvasKit.ParagraphStyle({
252
+ textStyle: {
253
+ color: CanvasKit.Color(color[0], color[1], color[2]),
254
+ fontFamilies: emoji ? this.emojiFamilies : this.fonts.families,
255
+ fontSize: this.fontSize,
256
+ fontStyle: {
257
+ weight: bold ? CanvasKit.FontWeight.Bold : CanvasKit.FontWeight.Normal,
258
+ slant: italic ? CanvasKit.FontSlant.Italic : CanvasKit.FontSlant.Upright,
259
+ },
260
+ },
261
+ maxLines: 1,
262
+ });
263
+ const builder = CanvasKit.ParagraphBuilder.Make(style, this.fonts.fontMgr);
264
+ builder.addText(text);
265
+ paragraph = builder.build();
266
+ builder.delete();
267
+ paragraph.layout(available * 2 + this.fontSize * 2);
268
+ this.paragraphCache.set(key, paragraph);
269
+ }
270
+ const width = paragraph.getLongestLine();
271
+ const height = paragraph.getHeight();
272
+ canvas.save();
273
+ canvas.clipRect(CanvasKit.XYWHRect(x, y, available, this.cellHeight), CanvasKit.ClipOp.Intersect, false);
274
+ if (width > available) {
275
+ const scale = available / width;
276
+ canvas.translate(x, y + (this.cellHeight - height * scale) / 2);
277
+ canvas.scale(scale, scale);
278
+ canvas.drawParagraph(paragraph, 0, 0);
279
+ } else {
280
+ canvas.drawParagraph(paragraph, x + (available - width) / 2, y + (this.cellHeight - height) / 2);
281
+ }
282
+ canvas.restore();
283
+ }
284
+
285
+ drawCell(cell, column, row) {
286
+ const width = cell.getWidth();
287
+ if (width === 0) return;
288
+ const { CanvasKit, canvas } = this;
289
+ let foreground = this.colorOf(cell, true);
290
+ let background = this.colorOf(cell, false);
291
+ if (cell.isInverse()) [foreground, background] = [background, foreground];
292
+ const x = this.offsetX + column * this.cellWidth;
293
+ const y = this.offsetY + row * this.cellHeight;
294
+ if (packColor(background) !== packColor(this.background)) {
295
+ canvas.drawRect(CanvasKit.XYWHRect(x, y, width * this.cellWidth, this.cellHeight), this.paintFor(background));
296
+ }
297
+ if (cell.isInvisible()) return;
298
+ if (cell.isDim()) {
299
+ foreground = foreground.map((channel, index) => Math.round((channel + background[index]) / 2));
300
+ }
301
+ const text = cell.getChars();
302
+ const bold = Boolean(cell.isBold());
303
+ const italic = Boolean(cell.isItalic());
304
+ if (text && text !== " ") {
305
+ if (hasCustomGlyph(text)) {
306
+ drawCustomGlyph(CanvasKit, canvas, text, x, y, width * this.cellWidth, this.cellHeight,
307
+ this.fontSize, this.paintFor(foreground), foreground);
308
+ } else {
309
+ this.drawText(text, x, y, width, foreground, { bold, italic, emoji: isEmojiText(text) });
310
+ }
311
+ }
312
+ if (cell.isUnderline() || cell.isStrikethrough()) {
313
+ const paint = this.paintFor(foreground);
314
+ const thickness = Math.max(1, Math.floor(this.fontSize / 14));
315
+ if (cell.isUnderline()) {
316
+ canvas.drawRect(CanvasKit.XYWHRect(x, y + this.cellHeight - thickness - 1, width * this.cellWidth, thickness), paint);
317
+ }
318
+ if (cell.isStrikethrough()) {
319
+ canvas.drawRect(CanvasKit.XYWHRect(x, y + Math.round(this.cellHeight * 0.55), width * this.cellWidth, thickness), paint);
320
+ }
321
+ }
322
+ }
323
+
324
+ drawRow(line, row) {
325
+ const { canvas } = this;
326
+ canvas.drawRect(this.cellRect(0, row, this.columns, 1), this.paintFor(this.background));
327
+ if (!line) return;
328
+ for (let column = 0; column < this.columns; column++) {
329
+ const cell = line.getCell(column);
330
+ if (!cell) break;
331
+ this.drawCell(cell, column, row);
332
+ }
333
+ }
334
+
335
+ drawCursor(line, x, y) {
336
+ const { CanvasKit, canvas } = this;
337
+ const cell = line?.getCell(x);
338
+ const width = cell?.getWidth() || 1;
339
+ const rect = this.cellRect(x, y, width, 1);
340
+ canvas.drawRect(rect, this.paintFor(this.cursorColor));
341
+ if (cell) {
342
+ // Repaint the character in the background colour over the block.
343
+ const text = cell.getChars();
344
+ const left = this.offsetX + x * this.cellWidth;
345
+ const top = this.offsetY + y * this.cellHeight;
346
+ if (text && text !== " ") {
347
+ if (hasCustomGlyph(text)) {
348
+ drawCustomGlyph(CanvasKit, canvas, text, left, top, width * this.cellWidth, this.cellHeight,
349
+ this.fontSize, this.paintFor(this.background), this.background);
350
+ } else {
351
+ this.drawText(text, left, top, width, this.background,
352
+ { bold: Boolean(cell.isBold()), italic: Boolean(cell.isItalic()), emoji: isEmojiText(text) });
353
+ }
354
+ }
355
+ }
356
+ }
357
+
358
+ // Repaints what changed and flushes to the framebuffer. `cursor` is
359
+ // { x, y, visible }; `images` is a list of { image, rect } placements in
360
+ // screen pixels, from images.js, drawn over the text. Returns true when
361
+ // anything was painted.
362
+ render(term, { cursor, images = [] } = {}) {
363
+ const buffer = term.buffer.active;
364
+ const top = buffer.viewportY;
365
+ const dirty = [];
366
+ for (let row = 0; row < this.rows; row++) {
367
+ const line = buffer.getLine(top + row);
368
+ const hash = line ? this.rowHash(line, this.columns) : 0;
369
+ if (this.dirtyAll || hash !== this.rowHashes[row] || this.forcedRows.has(row)) {
370
+ dirty.push(row);
371
+ this.rowHashes[row] = hash;
372
+ }
373
+ }
374
+ this.forcedRows.clear();
375
+ const cursorRows = new Set();
376
+ if (this.lastCursor.visible) cursorRows.add(this.lastCursor.y);
377
+ if (cursor?.visible) cursorRows.add(cursor.y);
378
+ for (const row of cursorRows) {
379
+ if (row >= 0 && row < this.rows && !dirty.includes(row)) dirty.push(row);
380
+ }
381
+ if (this.dirtyAll) {
382
+ this.canvas.clear(this.CanvasKit.Color(this.background[0], this.background[1], this.background[2]));
383
+ }
384
+ this.dirtyAll = false;
385
+ if (!dirty.length) return false;
386
+ dirty.sort((a, b) => a - b);
387
+ for (const row of dirty) this.drawRow(buffer.getLine(top + row), row);
388
+ if (cursor?.visible && cursor.y >= 0 && cursor.y < this.rows) {
389
+ this.drawCursor(buffer.getLine(top + cursor.y), cursor.x, cursor.y);
390
+ }
391
+ this.lastCursor = { x: cursor?.x ?? -1, y: cursor?.y ?? -1, visible: Boolean(cursor?.visible) };
392
+ if (images.length) this.drawImages(images, dirty);
393
+ this.screen.flush();
394
+ return true;
395
+ }
396
+
397
+ // Redraws the parts of image placements that intersect the repainted rows.
398
+ drawImages(images, dirtyRows) {
399
+ const { CanvasKit, canvas } = this;
400
+ for (const { image, rect, source } of images) {
401
+ const [left, top, right, bottom] = rect;
402
+ const sourceRect = source ? CanvasKit.LTRBRect(...source) : CanvasKit.LTRBRect(0, 0, image.width(), image.height());
403
+ for (const row of dirtyRows) {
404
+ const rowTop = this.offsetY + row * this.cellHeight;
405
+ const rowBottom = rowTop + this.cellHeight;
406
+ if (bottom <= rowTop || top >= rowBottom) continue;
407
+ canvas.save();
408
+ canvas.clipRect(CanvasKit.LTRBRect(0, rowTop, this.screen.width, rowBottom), CanvasKit.ClipOp.Intersect, false);
409
+ canvas.drawImageRectOptions(image, sourceRect, CanvasKit.LTRBRect(left, top, right, bottom),
410
+ CanvasKit.FilterMode.Linear, CanvasKit.MipmapMode.Linear, null);
411
+ canvas.restore();
412
+ }
413
+ }
414
+ }
415
+
416
+ delete() {
417
+ for (const font of this.fontCache.values()) font.delete();
418
+ for (const paint of this.paintCache.values()) paint.delete();
419
+ for (const paragraph of this.paragraphCache.values()) paragraph.delete();
420
+ this.fontCache.clear();
421
+ this.paintCache.clear();
422
+ this.paragraphCache.clear();
423
+ }
424
+ }
@@ -0,0 +1,203 @@
1
+ // /lib/bunterm/terminal.js — a graphical terminal on the Linux framebuffer.
2
+ //
3
+ // Bun.spawn({ terminal }) ──── PTY output ────▶ KittyGraphicsParser
4
+ // ▲ │ plain │ APC
5
+ // │ keyboard / replies ▼ ▼
6
+ // jsgotty BunPtyBackend @xterm/headless Terminal ImageStore
7
+ // │ buffer │ placements
8
+ // ▼ ▼
9
+ // Renderer (CanvasKit) ──▶ /dev/fb0
10
+ //
11
+ // The PTY, the terminal emulation and the kitty packet parser are reused
12
+ // from jsgotty and xterm.js; only the cell renderer, the images and the
13
+ // keyboard are Buninu's own.
14
+
15
+ import { createRequire } from "node:module";
16
+ import { runGraphics } from "../fbdev.js";
17
+ import { loadCanvasKit, openCanvas, loadFontSet } from "../canvas.js";
18
+ import { Terminal } from "../xterm/xterm-headless.mjs";
19
+ import { UnicodeGraphemesAddon } from "../xterm/addon-unicode-graphemes.mjs";
20
+ import { Renderer, defaultTheme } from "./renderer.js";
21
+ import { ImageStore } from "./images.js";
22
+ import { openKeyboard, translateKeys } from "./input.js";
23
+
24
+ const require = createRequire(import.meta.url);
25
+
26
+ // jsgotty is CommonJS and guards its server start on require.main, so this
27
+ // only loads the classes.
28
+ export const loadJsgotty = () => {
29
+ const gotty = require("../../buninu/apps/jsgotty/gotty.js");
30
+ if (!gotty.BunPtyBackend) {
31
+ throw new Error("this jsgotty does not export BunPtyBackend; bunterm needs jsgotty ≥ 1.1.12");
32
+ }
33
+ return gotty;
34
+ };
35
+
36
+ const writeTerminal = (term, data) => new Promise((resolve) => term.write(data, resolve));
37
+
38
+ // One terminal session: a program on a PTY, the emulator fed from it, and a
39
+ // renderer painting onto `screen` (an open canvas from canvas.js). Frames
40
+ // are coalesced, so whatever arrives between two paints is drawn together,
41
+ // at most ~60 times a second. `exited` resolves with the program's status.
42
+ export const createSession = ({
43
+ CanvasKit,
44
+ screen,
45
+ fonts,
46
+ command = "/bin/sh",
47
+ argv = [],
48
+ fontSize = 16,
49
+ lineHeight = 1,
50
+ theme = defaultTheme,
51
+ scrollback = 1000,
52
+ cursorBlink = true,
53
+ env = {},
54
+ onError = (error) => console.error(`bunterm: ${error?.stack ?? error}`),
55
+ }) => {
56
+ const { BunPtyBackend, KittyGraphicsParser } = loadJsgotty();
57
+ const renderer = new Renderer({ CanvasKit, screen, fonts, fontSize, lineHeight, theme });
58
+ const term = new Terminal({
59
+ cols: renderer.columns,
60
+ rows: renderer.rows,
61
+ allowProposedApi: true,
62
+ scrollback,
63
+ });
64
+ term.loadAddon(new UnicodeGraphemesAddon());
65
+ term.unicode.activeVersion = "15-graphemes";
66
+ const images = new ImageStore({ CanvasKit, term, renderer });
67
+ const kitty = new KittyGraphicsParser();
68
+
69
+ const backend = new BunPtyBackend({
70
+ command,
71
+ argv,
72
+ width: renderer.columns,
73
+ height: renderer.rows,
74
+ closeSignal: "SIGHUP",
75
+ closeTimeout: -1,
76
+ // JSMDCUI_KITTY_MODE=extended: jsmdcui sends Markdown images as kitty
77
+ // placements with the original JPEG/WebP/GIF bytes (jsgotty's U= MIME
78
+ // extension); Skia decodes those directly, so nothing is transcoded.
79
+ headerEnv: { COLORTERM: "truecolor", TERM_PROGRAM: "bunterm", JSMDCUI_KITTY_MODE: "extended", ...env },
80
+ });
81
+
82
+ let frameTimer = null;
83
+ let blinkOn = true;
84
+ const cursorHidden = () => Boolean(term._core?.coreService?.isCursorHidden);
85
+ const paint = () => {
86
+ if (frameTimer) clearTimeout(frameTimer);
87
+ frameTimer = null;
88
+ const buffer = term.buffer.active;
89
+ return renderer.render(term, {
90
+ cursor: { x: buffer.cursorX, y: buffer.cursorY, visible: blinkOn && !cursorHidden() },
91
+ images: images.visible(),
92
+ });
93
+ };
94
+ const requestFrame = () => {
95
+ frameTimer ??= setTimeout(paint, 16);
96
+ };
97
+ const blinkTimer = cursorBlink
98
+ ? setInterval(() => { blinkOn = !blinkOn; requestFrame(); }, 500)
99
+ : null;
100
+ const resetBlink = () => {
101
+ blinkOn = true;
102
+ if (blinkTimer) blinkTimer.refresh();
103
+ };
104
+
105
+ // Output is processed in order: plain text is parsed before the kitty
106
+ // packet that follows it, so placements see the right cursor position.
107
+ let queue = Promise.resolve();
108
+ const pump = (chunk) => {
109
+ queue = queue.then(async () => {
110
+ const { events } = kitty.consume(chunk);
111
+ for (const event of events) {
112
+ if (event.kind === "plain") {
113
+ await writeTerminal(term, event.data);
114
+ continue;
115
+ }
116
+ const buffer = term.buffer.active;
117
+ const graphic = kitty.parsePacket(event.packet, { row: buffer.cursorY + 1, col: buffer.cursorX + 1 });
118
+ if (!graphic) continue;
119
+ const motion = await images.handle(graphic);
120
+ if (motion) await writeTerminal(term, motion);
121
+ const ack = kitty.acknowledge(graphic);
122
+ if (ack) backend.write(Buffer.from(ack, "utf8"));
123
+ }
124
+ requestFrame();
125
+ }).catch(onError);
126
+ };
127
+
128
+ let closed = false;
129
+ const exited = new Promise((resolve) => {
130
+ backend.onExit(() => { queue.then(() => resolve(backend.proc?.exitCode ?? 0)); });
131
+ });
132
+ backend.onData(pump);
133
+ term.onData((data) => backend.write(Buffer.from(data, "utf8")));
134
+
135
+ // Keyboard bytes from the console, rewritten for the program's key mode.
136
+ const input = (bytes) => {
137
+ const applicationCursorKeys = Boolean(term._core?.coreService?.decPrivateModes?.applicationCursorKeys);
138
+ const text = translateKeys(bytes.toString("latin1"), { applicationCursorKeys });
139
+ backend.write(Buffer.from(text, "latin1"));
140
+ resetBlink();
141
+ requestFrame();
142
+ };
143
+
144
+ // Waits until every byte received so far has been parsed, then paints.
145
+ const settle = async () => {
146
+ await queue;
147
+ return paint();
148
+ };
149
+
150
+ const close = () => {
151
+ if (closed) return;
152
+ closed = true;
153
+ if (blinkTimer) clearInterval(blinkTimer);
154
+ if (frameTimer) clearTimeout(frameTimer);
155
+ frameTimer = null;
156
+ backend.close();
157
+ images.delete();
158
+ renderer.delete();
159
+ term.dispose();
160
+ };
161
+
162
+ paint();
163
+ return { term, backend, renderer, images, kitty, paint, requestFrame, settle, input, exited, close };
164
+ };
165
+
166
+ // Runs a session on the framebuffer with the console in graphics mode and
167
+ // the keyboard in raw mode, until the program exits. `console` names the
168
+ // virtual console to use (for example /dev/tty1); by default the process's
169
+ // own console and stdin are used.
170
+ export const runTerminal = async ({ console: consoleDevice = null, device = "/dev/fb0", ...options } = {}) => {
171
+ loadJsgotty();
172
+ const CanvasKit = await loadCanvasKit();
173
+ const fonts = loadFontSet(CanvasKit, "terminal");
174
+ const consolePaths = consoleDevice ? [consoleDevice] : undefined;
175
+
176
+ return runGraphics(async (display, { onRestore, onAcquire }) => {
177
+ const screen = openCanvas(CanvasKit, display);
178
+ const session = createSession({ CanvasKit, screen, fonts, ...options });
179
+ // Coming back from another console (Ctrl-Alt-Fn): fbcon has drawn over
180
+ // the framebuffer, so repaint everything.
181
+ onAcquire(() => {
182
+ session.renderer.invalidate();
183
+ session.paint();
184
+ });
185
+ let keyboard = null;
186
+ try {
187
+ keyboard = openKeyboard({
188
+ device: consoleDevice,
189
+ onData: session.input,
190
+ onError: (error) => {
191
+ console.error(`bunterm: keyboard: ${error.message}`);
192
+ session.backend.close();
193
+ },
194
+ });
195
+ onRestore(() => keyboard.close());
196
+ return await session.exited;
197
+ } finally {
198
+ keyboard?.close();
199
+ session.close();
200
+ screen.close();
201
+ }
202
+ }, { device, console: consolePaths });
203
+ };