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.
- package/LICENSES/Apache-2.0.txt +202 -0
- package/LICENSES/BSD-3-Clause-Skia.txt +29 -0
- package/LICENSES/Bitstream-Vera.txt +44 -0
- package/LICENSES/MIT-xterm.js.txt +21 -0
- package/LICENSES/OFL-1.1.txt +91 -0
- package/NOTICE.md +18 -1
- package/README.md +226 -22
- package/fetch-alpine.sh +9 -4
- package/graphics.md +213 -0
- package/initramfs/bin/bunterm +66 -0
- package/initramfs/bin/ip +2 -1
- package/initramfs/bin/mount +2 -1
- package/initramfs/bin/poweroff +6 -27
- package/initramfs/bin/ps +256 -0
- package/initramfs/bin/reboot +10 -0
- package/initramfs/bin/tar +169 -0
- package/initramfs/bin/top +129 -0
- package/initramfs/bin/umount +2 -1
- package/initramfs/buninu/CHANGELOG.md +40 -0
- package/initramfs/buninu/apps/bunmsh/CHANGELOG.md +36 -0
- package/initramfs/buninu/apps/bunmsh/README.md +7 -0
- package/initramfs/buninu/apps/bunmsh/package.json +1 -1
- package/initramfs/buninu/apps/bunmsh/src/fancy-ls.js +29 -14
- package/initramfs/buninu/apps/bunmsh/src/line-edit.js +43 -0
- package/initramfs/buninu/apps/bunmsh/src/main.js +15 -1
- package/initramfs/buninu/apps/bunmsh/src/mouse.js +29 -4
- package/initramfs/buninu/apps/bunmsh/src/shell.js +9 -1
- package/initramfs/buninu/apps/bunmsh/test/shell1.test.js +70 -0
- package/initramfs/buninu/apps/jsgotty/gotty.js +4 -3
- package/initramfs/buninu/apps/jsgotty/package.json +1 -1
- package/initramfs/buninu/apps/jsmdcui/CHANGELOG.md +10 -0
- package/initramfs/buninu/apps/jsmdcui/README.md +2 -0
- package/initramfs/buninu/apps/jsmdcui/package.json +1 -1
- package/initramfs/buninu/apps/jsmdcui/runtime/help/commands.md +4 -1
- package/initramfs/buninu/apps/jsmdcui/runtime/help/help.md +2 -0
- package/initramfs/buninu/apps/jsmdcui/src/index.js +40 -13
- package/initramfs/buninu/package.json +1 -1
- package/initramfs/init.js +6 -0
- package/initramfs/lib/bunterm/glyphs.js +446 -0
- package/initramfs/lib/bunterm/images.js +225 -0
- package/initramfs/lib/bunterm/input.js +140 -0
- package/initramfs/lib/bunterm/renderer.js +424 -0
- package/initramfs/lib/bunterm/terminal.js +203 -0
- package/initramfs/lib/canvas.js +181 -0
- package/initramfs/lib/canvaskit/LICENSE +29 -0
- package/initramfs/lib/canvaskit/canvaskit.js +264 -0
- package/initramfs/lib/canvaskit/canvaskit.wasm +0 -0
- package/initramfs/lib/dlopen.js +14 -11
- package/initramfs/lib/document.js +6 -0
- package/initramfs/lib/fbdev.js +581 -0
- package/initramfs/lib/power.js +35 -0
- package/initramfs/lib/xterm/LICENSE +21 -0
- package/initramfs/lib/xterm/addon-unicode-graphemes.mjs +44 -0
- package/initramfs/lib/xterm/xterm-headless.mjs +46 -0
- package/initramfs/usr/share/doc/buninu-linux/bunterm.md +83 -0
- package/initramfs/usr/share/doc/buninu-linux/canvas.md +91 -0
- package/initramfs/usr/share/doc/buninu-linux/fbdev.md +113 -0
- package/initramfs/usr/share/doc/buninu-linux/ps.md +94 -0
- package/initramfs/usr/share/doc/buninu-linux/reboot.md +15 -0
- package/initramfs/usr/share/doc/buninu-linux/tar.md +73 -0
- package/initramfs/usr/share/doc/buninu-linux/top.md +23 -0
- package/initramfs/usr/share/fonts/DejaVuSansMono-Bold.ttf +0 -0
- package/initramfs/usr/share/fonts/DejaVuSansMono.ttf +0 -0
- package/initramfs/usr/share/fonts/LICENSE-Apache-2.0.txt +202 -0
- package/initramfs/usr/share/fonts/LICENSE-DejaVu.txt +44 -0
- package/initramfs/usr/share/fonts/LICENSE-OFL-1.1.txt +91 -0
- package/initramfs/usr/share/fonts/NotoColorEmoji.ttf +0 -0
- package/initramfs/usr/share/fonts/NotoColorEmojiFlags.ttf +0 -0
- package/initramfs/usr/share/fonts/NotoSansCJK-Regular.ttc +0 -0
- package/initramfs/usr/share/fonts/NotoSansSymbols-Regular-Subsetted.ttf +0 -0
- package/initramfs/usr/share/fonts/NotoSansSymbols-Regular-Subsetted2.ttf +0 -0
- package/initramfs/usr/share/fonts/Roboto-Regular.ttf +0 -0
- package/initramfs/usr/share/fonts/fonts.json +28 -0
- package/package.json +2 -2
- 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
|
+
};
|