buninu-linux 0.3.1 → 0.5.1
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 +101 -9
- package/fetch-alpine.sh +6 -0
- package/graphics.md +218 -0
- package/initramfs/bin/bunterm +70 -0
- package/initramfs/bin/chroot +213 -0
- package/initramfs/bin/stripansi +27 -0
- package/initramfs/bin/tar +189 -14
- package/initramfs/buninu/CHANGELOG.md +40 -0
- package/initramfs/buninu/apps/jsgotty/gotty.js +4 -3
- package/initramfs/buninu/apps/jsgotty/package.json +1 -1
- package/initramfs/buninu/package.json +1 -1
- package/initramfs/init.js +80 -1
- 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/mouse.js +222 -0
- package/initramfs/lib/bunterm/renderer.js +469 -0
- package/initramfs/lib/bunterm/terminal.js +324 -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 +18 -2
- package/initramfs/lib/document.js +5 -1
- package/initramfs/lib/fbdev.js +581 -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 +112 -0
- package/initramfs/usr/share/doc/buninu-linux/canvas.md +91 -0
- package/initramfs/usr/share/doc/buninu-linux/chroot.md +105 -0
- package/initramfs/usr/share/doc/buninu-linux/fbdev.md +113 -0
- package/initramfs/usr/share/doc/buninu-linux/tar.md +30 -6
- 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,324 @@
|
|
|
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
|
+
mouse = true,
|
|
54
|
+
env = {},
|
|
55
|
+
onError = (error) => console.error(`bunterm: ${error?.stack ?? error}`),
|
|
56
|
+
}) => {
|
|
57
|
+
const { BunPtyBackend, KittyGraphicsParser } = loadJsgotty();
|
|
58
|
+
const renderer = new Renderer({ CanvasKit, screen, fonts, fontSize, lineHeight, theme });
|
|
59
|
+
const term = new Terminal({
|
|
60
|
+
cols: renderer.columns,
|
|
61
|
+
rows: renderer.rows,
|
|
62
|
+
allowProposedApi: true,
|
|
63
|
+
scrollback,
|
|
64
|
+
});
|
|
65
|
+
term.loadAddon(new UnicodeGraphemesAddon());
|
|
66
|
+
term.unicode.activeVersion = "15-graphemes";
|
|
67
|
+
const images = new ImageStore({ CanvasKit, term, renderer });
|
|
68
|
+
const kitty = new KittyGraphicsParser();
|
|
69
|
+
|
|
70
|
+
const backend = new BunPtyBackend({
|
|
71
|
+
command,
|
|
72
|
+
argv,
|
|
73
|
+
width: renderer.columns,
|
|
74
|
+
height: renderer.rows,
|
|
75
|
+
closeSignal: "SIGHUP",
|
|
76
|
+
closeTimeout: -1,
|
|
77
|
+
// JSMDCUI_KITTY_MODE=extended: jsmdcui sends Markdown images as kitty
|
|
78
|
+
// placements with the original JPEG/WebP/GIF bytes (jsgotty's U= MIME
|
|
79
|
+
// extension); Skia decodes those directly, so nothing is transcoded.
|
|
80
|
+
headerEnv: { COLORTERM: "truecolor", TERM_PROGRAM: "bunterm", JSMDCUI_KITTY_MODE: "extended", ...env },
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
let frameTimer = null;
|
|
84
|
+
let blinkOn = true;
|
|
85
|
+
const cursorHidden = () => Boolean(term._core?.coreService?.isCursorHidden);
|
|
86
|
+
// Only set when a pointer is attached; without one the renderer is called
|
|
87
|
+
// exactly as before.
|
|
88
|
+
let pointer = null;
|
|
89
|
+
const paint = () => {
|
|
90
|
+
if (frameTimer) clearTimeout(frameTimer);
|
|
91
|
+
frameTimer = null;
|
|
92
|
+
const buffer = term.buffer.active;
|
|
93
|
+
// cursorY counts from the buffer's base, so a scrolled-back viewport
|
|
94
|
+
// moves the cursor down the screen and eventually off it.
|
|
95
|
+
const row = buffer.baseY + buffer.cursorY - buffer.viewportY;
|
|
96
|
+
return renderer.render(term, {
|
|
97
|
+
cursor: {
|
|
98
|
+
x: buffer.cursorX,
|
|
99
|
+
y: row,
|
|
100
|
+
visible: blinkOn && !cursorHidden() && row >= 0 && row < renderer.rows,
|
|
101
|
+
},
|
|
102
|
+
images: images.visible(),
|
|
103
|
+
pointer,
|
|
104
|
+
});
|
|
105
|
+
};
|
|
106
|
+
const requestFrame = () => {
|
|
107
|
+
frameTimer ??= setTimeout(paint, 16);
|
|
108
|
+
};
|
|
109
|
+
const blinkTimer = cursorBlink
|
|
110
|
+
? setInterval(() => { blinkOn = !blinkOn; requestFrame(); }, 500)
|
|
111
|
+
: null;
|
|
112
|
+
const resetBlink = () => {
|
|
113
|
+
blinkOn = true;
|
|
114
|
+
if (blinkTimer) blinkTimer.refresh();
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
// Output is processed in order: plain text is parsed before the kitty
|
|
118
|
+
// packet that follows it, so placements see the right cursor position.
|
|
119
|
+
let queue = Promise.resolve();
|
|
120
|
+
const pump = (chunk) => {
|
|
121
|
+
queue = queue.then(async () => {
|
|
122
|
+
const { events } = kitty.consume(chunk);
|
|
123
|
+
for (const event of events) {
|
|
124
|
+
if (event.kind === "plain") {
|
|
125
|
+
await writeTerminal(term, event.data);
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
const buffer = term.buffer.active;
|
|
129
|
+
const graphic = kitty.parsePacket(event.packet, { row: buffer.cursorY + 1, col: buffer.cursorX + 1 });
|
|
130
|
+
if (!graphic) continue;
|
|
131
|
+
const motion = await images.handle(graphic);
|
|
132
|
+
if (motion) await writeTerminal(term, motion);
|
|
133
|
+
const ack = kitty.acknowledge(graphic);
|
|
134
|
+
if (ack) backend.write(Buffer.from(ack, "utf8"));
|
|
135
|
+
}
|
|
136
|
+
requestFrame();
|
|
137
|
+
}).catch(onError);
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
let closed = false;
|
|
141
|
+
const exited = new Promise((resolve) => {
|
|
142
|
+
backend.onExit(() => { queue.then(() => resolve(backend.proc?.exitCode ?? 0)); });
|
|
143
|
+
});
|
|
144
|
+
backend.onData(pump);
|
|
145
|
+
term.onData((data) => backend.write(Buffer.from(data, "utf8")));
|
|
146
|
+
|
|
147
|
+
// Keyboard bytes from the console, rewritten for the program's key mode.
|
|
148
|
+
const input = (bytes) => {
|
|
149
|
+
const applicationCursorKeys = Boolean(term._core?.coreService?.decPrivateModes?.applicationCursorKeys);
|
|
150
|
+
const text = translateKeys(bytes.toString("latin1"), { applicationCursorKeys });
|
|
151
|
+
backend.write(Buffer.from(text, "latin1"));
|
|
152
|
+
// Typing returns to the live screen, as every terminal does.
|
|
153
|
+
if (term.buffer.active.viewportY !== term.buffer.active.baseY) {
|
|
154
|
+
term.scrollToBottom();
|
|
155
|
+
renderer.invalidate();
|
|
156
|
+
}
|
|
157
|
+
resetBlink();
|
|
158
|
+
requestFrame();
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
// Turns a pointer report from mouse.js into the mouse sequences the
|
|
162
|
+
// program asked for. xterm.js's CoreMouseService owns the protocol: it
|
|
163
|
+
// knows which events the program enabled (DECSET 1000/1002/1003), encodes
|
|
164
|
+
// them (SGR 1006 or the default form) and sends them through onData, so
|
|
165
|
+
// nothing here builds an escape sequence. Reports arrive only while a
|
|
166
|
+
// pointer is open, and this is the only place the terminal sees one.
|
|
167
|
+
const CoreMouseButton = { LEFT: 0, MIDDLE: 1, RIGHT: 2, NONE: 3, WHEEL: 4 };
|
|
168
|
+
const CoreMouseAction = { UP: 0, DOWN: 1, LEFT: 2, RIGHT: 3, MOVE: 32 };
|
|
169
|
+
const heldButton = (buttons) => (buttons.left ? CoreMouseButton.LEFT
|
|
170
|
+
: buttons.middle ? CoreMouseButton.MIDDLE
|
|
171
|
+
: buttons.right ? CoreMouseButton.RIGHT
|
|
172
|
+
: CoreMouseButton.NONE);
|
|
173
|
+
|
|
174
|
+
// Scrolls the viewport when the program is not reading the mouse itself:
|
|
175
|
+
// through the scrollback on the normal screen, and as arrow keys on the
|
|
176
|
+
// alternate screen, which has no scrollback and is what a pager expects.
|
|
177
|
+
const wheelScroll = (notches) => {
|
|
178
|
+
const lines = notches * 3;
|
|
179
|
+
if (term.buffer.active.type === "alternate") {
|
|
180
|
+
const application = Boolean(term._core?.coreService?.decPrivateModes?.applicationCursorKeys);
|
|
181
|
+
const key = `\u001b${application ? "O" : "["}${lines > 0 ? "A" : "B"}`;
|
|
182
|
+
backend.write(Buffer.from(key.repeat(Math.abs(lines)), "latin1"));
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
const before = term.buffer.active.viewportY;
|
|
186
|
+
term.scrollLines(-lines);
|
|
187
|
+
if (term.buffer.active.viewportY !== before) {
|
|
188
|
+
// Every row on screen is different now, and identical text at the same
|
|
189
|
+
// row would otherwise hash the same as before.
|
|
190
|
+
renderer.invalidate();
|
|
191
|
+
requestFrame();
|
|
192
|
+
}
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
const reportMouse = (report) => {
|
|
196
|
+
pointer = { x: report.x, y: report.y };
|
|
197
|
+
requestFrame();
|
|
198
|
+
const service = term._core?.coreMouseService;
|
|
199
|
+
if (!service?.areMouseEventsActive) {
|
|
200
|
+
if (report.wheel) wheelScroll(report.wheel);
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
const column = Math.floor((report.x - renderer.offsetX) / renderer.cellWidth);
|
|
204
|
+
const row = Math.floor((report.y - renderer.offsetY) / renderer.cellHeight);
|
|
205
|
+
const send = (button, action) => {
|
|
206
|
+
try {
|
|
207
|
+
service.triggerMouseEvent({ col: column, row, button, action, ctrl: false, alt: false, shift: false });
|
|
208
|
+
} catch (error) {
|
|
209
|
+
onError(error);
|
|
210
|
+
}
|
|
211
|
+
};
|
|
212
|
+
for (const name of report.released) {
|
|
213
|
+
send(CoreMouseButton[name.toUpperCase()], CoreMouseAction.UP);
|
|
214
|
+
}
|
|
215
|
+
for (const name of report.pressed) {
|
|
216
|
+
send(CoreMouseButton[name.toUpperCase()], CoreMouseAction.DOWN);
|
|
217
|
+
}
|
|
218
|
+
if (report.moved) send(heldButton(report.buttons), CoreMouseAction.MOVE);
|
|
219
|
+
for (let notch = 0; notch < Math.abs(report.wheel); notch++) {
|
|
220
|
+
send(CoreMouseButton.WHEEL, report.wheel > 0 ? CoreMouseAction.UP : CoreMouseAction.DOWN);
|
|
221
|
+
}
|
|
222
|
+
for (let notch = 0; notch < Math.abs(report.hwheel); notch++) {
|
|
223
|
+
send(CoreMouseButton.WHEEL, report.hwheel > 0 ? CoreMouseAction.RIGHT : CoreMouseAction.LEFT);
|
|
224
|
+
}
|
|
225
|
+
};
|
|
226
|
+
|
|
227
|
+
// The pointer is opened only when asked for, by a module that is imported
|
|
228
|
+
// only then, so a machine without one — or a broken input layer — cannot
|
|
229
|
+
// affect a plain session.
|
|
230
|
+
let pointerDevice = null;
|
|
231
|
+
const attachPointer = async () => {
|
|
232
|
+
if (!mouse) return null;
|
|
233
|
+
const { openPointer, findPointers } = await import("./mouse.js");
|
|
234
|
+
const devices = findPointers();
|
|
235
|
+
if (!devices.length) throw new Error("no pointing device in /dev/input (is the evdev module loaded?)");
|
|
236
|
+
pointerDevice = openPointer({
|
|
237
|
+
devices,
|
|
238
|
+
width: screen.width,
|
|
239
|
+
height: screen.height,
|
|
240
|
+
onSync: reportMouse,
|
|
241
|
+
onError,
|
|
242
|
+
});
|
|
243
|
+
pointer = { x: pointerDevice.state.x, y: pointerDevice.state.y };
|
|
244
|
+
paint();
|
|
245
|
+
return pointerDevice;
|
|
246
|
+
};
|
|
247
|
+
|
|
248
|
+
// Waits until every byte received so far has been parsed, then paints.
|
|
249
|
+
const settle = async () => {
|
|
250
|
+
await queue;
|
|
251
|
+
return paint();
|
|
252
|
+
};
|
|
253
|
+
|
|
254
|
+
const close = () => {
|
|
255
|
+
if (closed) return;
|
|
256
|
+
closed = true;
|
|
257
|
+
if (blinkTimer) clearInterval(blinkTimer);
|
|
258
|
+
if (frameTimer) clearTimeout(frameTimer);
|
|
259
|
+
frameTimer = null;
|
|
260
|
+
backend.close();
|
|
261
|
+
pointerDevice?.close();
|
|
262
|
+
pointerDevice = null;
|
|
263
|
+
images.delete();
|
|
264
|
+
renderer.delete();
|
|
265
|
+
term.dispose();
|
|
266
|
+
};
|
|
267
|
+
|
|
268
|
+
paint();
|
|
269
|
+
return {
|
|
270
|
+
term, backend, renderer, images, kitty,
|
|
271
|
+
paint, requestFrame, settle, input, exited, close, attachPointer, reportMouse, wheelScroll,
|
|
272
|
+
};
|
|
273
|
+
};
|
|
274
|
+
|
|
275
|
+
// Runs a session on the framebuffer with the console in graphics mode and
|
|
276
|
+
// the keyboard in raw mode, until the program exits. `console` names the
|
|
277
|
+
// virtual console to use (for example /dev/tty1); by default the process's
|
|
278
|
+
// own console and stdin are used.
|
|
279
|
+
export const runTerminal = async ({ console: consoleDevice = null, device = "/dev/fb0", ...options } = {}) => {
|
|
280
|
+
loadJsgotty();
|
|
281
|
+
const CanvasKit = await loadCanvasKit();
|
|
282
|
+
const fonts = loadFontSet(CanvasKit, "terminal");
|
|
283
|
+
const consolePaths = consoleDevice ? [consoleDevice] : undefined;
|
|
284
|
+
|
|
285
|
+
return runGraphics(async (display, { onRestore, onAcquire }) => {
|
|
286
|
+
const screen = openCanvas(CanvasKit, display);
|
|
287
|
+
const session = createSession({ CanvasKit, screen, fonts, ...options });
|
|
288
|
+
// Coming back from another console (Ctrl-Alt-Fn): fbcon has drawn over
|
|
289
|
+
// the framebuffer, so repaint everything.
|
|
290
|
+
onAcquire(() => {
|
|
291
|
+
session.renderer.invalidate();
|
|
292
|
+
session.paint();
|
|
293
|
+
});
|
|
294
|
+
let keyboard = null;
|
|
295
|
+
try {
|
|
296
|
+
keyboard = openKeyboard({
|
|
297
|
+
device: consoleDevice,
|
|
298
|
+
onData: session.input,
|
|
299
|
+
onError: (error) => {
|
|
300
|
+
console.error(`bunterm: keyboard: ${error.message}`);
|
|
301
|
+
session.backend.close();
|
|
302
|
+
},
|
|
303
|
+
});
|
|
304
|
+
onRestore(() => keyboard.close());
|
|
305
|
+
if (options.mouse !== false) {
|
|
306
|
+
// A pointer that cannot be opened is reported and the session
|
|
307
|
+
// continues as a keyboard-only terminal.
|
|
308
|
+
try {
|
|
309
|
+
const attached = await session.attachPointer();
|
|
310
|
+
if (attached?.devices.length) {
|
|
311
|
+
console.error(`bunterm: mouse: ${attached.devices.map((device) => device.name).join(", ")}`);
|
|
312
|
+
}
|
|
313
|
+
} catch (error) {
|
|
314
|
+
console.error(`bunterm: mouse: ${error.message}`);
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
return await session.exited;
|
|
318
|
+
} finally {
|
|
319
|
+
keyboard?.close();
|
|
320
|
+
session.close();
|
|
321
|
+
screen.close();
|
|
322
|
+
}
|
|
323
|
+
}, { device, console: consolePaths });
|
|
324
|
+
};
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
// /lib/canvas.js — CanvasKit (Skia in WebAssembly) on the Linux framebuffer.
|
|
2
|
+
//
|
|
3
|
+
// Skia draws into a buffer inside the wasm heap that already has the
|
|
4
|
+
// framebuffer's own pixel layout, so a frame reaches /dev/fb0 with a single
|
|
5
|
+
// copy: exactly what /lib/fbdev.js does with its JavaScript back buffer. The
|
|
6
|
+
// Framebuffer's `pixels` view is pointed at that heap buffer while a canvas is
|
|
7
|
+
// open, so the fbdev.js drawing calls and flush() keep working on the same
|
|
8
|
+
// frame and Skia and JavaScript drawing can be mixed freely.
|
|
9
|
+
|
|
10
|
+
import { readFileSync } from "node:fs";
|
|
11
|
+
import CanvasKitInit from "./canvaskit/canvaskit.js";
|
|
12
|
+
import { flush as flushFramebuffer } from "./fbdev.js";
|
|
13
|
+
|
|
14
|
+
export const canvasKitDirectory = `${import.meta.dir}/canvaskit`;
|
|
15
|
+
export const fontDirectory = `${import.meta.dir}/../usr/share/fonts`;
|
|
16
|
+
|
|
17
|
+
let canvasKitPromise = null;
|
|
18
|
+
|
|
19
|
+
// One CanvasKit instance per process; the wasm heap starts at 128 MiB.
|
|
20
|
+
export const loadCanvasKit = () => {
|
|
21
|
+
canvasKitPromise ??= CanvasKitInit({
|
|
22
|
+
wasmBinary: readFileSync(`${canvasKitDirectory}/canvaskit.wasm`),
|
|
23
|
+
});
|
|
24
|
+
return canvasKitPromise;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const fieldIs = (field, offset, length) =>
|
|
28
|
+
field.offset === offset && field.length === length;
|
|
29
|
+
|
|
30
|
+
// Maps the kernel's bitfield description to a Skia ColorType with the same
|
|
31
|
+
// byte order in memory. The fields are little-endian bit offsets, so 32-bit
|
|
32
|
+
// red@16 green@8 blue@0 is B,G,R,X in memory: Skia's BGRA_8888. Only formats
|
|
33
|
+
// Skia can rasterize into directly are accepted; others must go through
|
|
34
|
+
// blitImageData() in fbdev.js.
|
|
35
|
+
export const pickColorType = (CanvasKit, display) => {
|
|
36
|
+
const { red, green, blue } = display.fields;
|
|
37
|
+
if (display.bitsPerPixel === 32) {
|
|
38
|
+
if (fieldIs(red, 16, 8) && fieldIs(green, 8, 8) && fieldIs(blue, 0, 8)) {
|
|
39
|
+
return CanvasKit.ColorType.BGRA_8888;
|
|
40
|
+
}
|
|
41
|
+
if (fieldIs(red, 0, 8) && fieldIs(green, 8, 8) && fieldIs(blue, 16, 8)) {
|
|
42
|
+
return CanvasKit.ColorType.RGBA_8888;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
if (display.bitsPerPixel === 16
|
|
46
|
+
&& fieldIs(red, 11, 5) && fieldIs(green, 5, 6) && fieldIs(blue, 0, 5)) {
|
|
47
|
+
return CanvasKit.ColorType.RGB_565;
|
|
48
|
+
}
|
|
49
|
+
return null;
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const describeFields = ({ red, green, blue }) =>
|
|
53
|
+
`red@${red.offset}/${red.length} green@${green.offset}/${green.length} blue@${blue.offset}/${blue.length}`;
|
|
54
|
+
|
|
55
|
+
// Binds a Skia raster surface to an open Framebuffer. `display` only needs
|
|
56
|
+
// the fields fbdev.js's Framebuffer exposes (width, height, stride,
|
|
57
|
+
// bitsPerPixel, fields, pixels), so a plain object backed by memory works for
|
|
58
|
+
// tests and off-screen rendering.
|
|
59
|
+
export const openCanvas = (CanvasKit, display) => {
|
|
60
|
+
const colorType = pickColorType(CanvasKit, display);
|
|
61
|
+
if (colorType === null) {
|
|
62
|
+
throw new Error(`no Skia color type for a ${display.bitsPerPixel} bpp framebuffer `
|
|
63
|
+
+ `(${describeFields(display.fields)}); use blitImageData() instead`);
|
|
64
|
+
}
|
|
65
|
+
const length = display.stride * display.height;
|
|
66
|
+
const memory = CanvasKit.Malloc(Uint8Array, length);
|
|
67
|
+
const surface = CanvasKit.MakeRasterDirectSurface({
|
|
68
|
+
width: display.width,
|
|
69
|
+
height: display.height,
|
|
70
|
+
colorType,
|
|
71
|
+
alphaType: CanvasKit.AlphaType.Opaque,
|
|
72
|
+
colorSpace: CanvasKit.ColorSpace.SRGB,
|
|
73
|
+
}, memory, display.stride);
|
|
74
|
+
if (!surface) {
|
|
75
|
+
CanvasKit.Free(memory);
|
|
76
|
+
throw new Error("MakeRasterDirectSurface failed");
|
|
77
|
+
}
|
|
78
|
+
const previousPixels = display.pixels;
|
|
79
|
+
// Start from whatever the JavaScript back buffer held (usually zeros).
|
|
80
|
+
memory.toTypedArray().set(previousPixels.subarray(0, length));
|
|
81
|
+
display.pixels = memory.toTypedArray();
|
|
82
|
+
let open = true;
|
|
83
|
+
return {
|
|
84
|
+
CanvasKit,
|
|
85
|
+
display,
|
|
86
|
+
surface,
|
|
87
|
+
canvas: surface.getCanvas(),
|
|
88
|
+
colorType,
|
|
89
|
+
width: display.width,
|
|
90
|
+
height: display.height,
|
|
91
|
+
// Finishes pending Skia work and copies the frame to the framebuffer.
|
|
92
|
+
// toTypedArray() is re-read because wasm heap growth detaches old views.
|
|
93
|
+
flush() {
|
|
94
|
+
if (!open) throw new Error("canvas is closed");
|
|
95
|
+
surface.flush();
|
|
96
|
+
display.pixels = memory.toTypedArray();
|
|
97
|
+
flushFramebuffer(display);
|
|
98
|
+
},
|
|
99
|
+
// Releases the Skia surface and heap buffer; the Framebuffer stays open
|
|
100
|
+
// and gets its own back buffer back, holding the last frame.
|
|
101
|
+
close() {
|
|
102
|
+
if (!open) return;
|
|
103
|
+
open = false;
|
|
104
|
+
previousPixels.set(memory.toTypedArray().subarray(0, previousPixels.length));
|
|
105
|
+
display.pixels = previousPixels;
|
|
106
|
+
surface.delete();
|
|
107
|
+
CanvasKit.Free(memory);
|
|
108
|
+
},
|
|
109
|
+
};
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
// Font catalogue: /usr/share/fonts/fonts.json names every bundled file and
|
|
113
|
+
// the family sets built from them. Files load lazily, since the CJK
|
|
114
|
+
// collection alone is 32 MiB and is copied into the wasm heap.
|
|
115
|
+
let catalogue = null;
|
|
116
|
+
|
|
117
|
+
export const fontCatalogue = () => {
|
|
118
|
+
catalogue ??= JSON.parse(readFileSync(`${fontDirectory}/fonts.json`, "utf8"));
|
|
119
|
+
return catalogue;
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
export const fontPath = (name) => {
|
|
123
|
+
const entry = fontCatalogue().fonts[name];
|
|
124
|
+
if (!entry) throw new Error(`unknown font ${JSON.stringify(name)}`);
|
|
125
|
+
return `${fontDirectory}/${entry.file}`;
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
const fontData = new Map();
|
|
129
|
+
|
|
130
|
+
export const loadFontData = (name) => {
|
|
131
|
+
let data = fontData.get(name);
|
|
132
|
+
if (!data) {
|
|
133
|
+
const bytes = readFileSync(fontPath(name));
|
|
134
|
+
data = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength);
|
|
135
|
+
fontData.set(name, data);
|
|
136
|
+
}
|
|
137
|
+
return data;
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
// A named font set from fonts.json as CanvasKit objects:
|
|
141
|
+
// typefaces the "regular", "bold" and each fallback font as a Typeface
|
|
142
|
+
// (a collection such as Noto Sans CJK yields its preferred face)
|
|
143
|
+
// fontMgr every file of the set registered by family name, for the
|
|
144
|
+
// Paragraph API; a collection registers all of its families
|
|
145
|
+
// families family names for ParagraphStyle.textStyle.fontFamilies, in
|
|
146
|
+
// lookup order
|
|
147
|
+
export const loadFontSet = (CanvasKit, setName = "terminal") => {
|
|
148
|
+
const { sets, fonts } = fontCatalogue();
|
|
149
|
+
const set = sets[setName];
|
|
150
|
+
if (!set) throw new Error(`unknown font set ${JSON.stringify(setName)}`);
|
|
151
|
+
const names = [set.regular, set.bold, ...(set.fallback ?? [])].filter(Boolean);
|
|
152
|
+
const buffers = names.map(loadFontData);
|
|
153
|
+
const fontMgr = CanvasKit.FontMgr.FromData(...buffers);
|
|
154
|
+
if (!fontMgr) throw new Error("FontMgr.FromData failed");
|
|
155
|
+
const typefaces = {};
|
|
156
|
+
names.forEach((name, index) => {
|
|
157
|
+
// A collection's "preferred" family picks the face (Noto Sans CJK TC for
|
|
158
|
+
// Taiwanese glyph forms); a single-face file loads directly.
|
|
159
|
+
const preferred = fonts[name].preferred;
|
|
160
|
+
const typeface = preferred
|
|
161
|
+
? fontMgr.matchFamilyStyle(preferred, {})
|
|
162
|
+
: CanvasKit.Typeface.MakeFreeTypeFaceFromData(buffers[index]);
|
|
163
|
+
if (!typeface) throw new Error(`cannot load font ${name} (${fonts[name].file})`);
|
|
164
|
+
typefaces[name] = typeface;
|
|
165
|
+
});
|
|
166
|
+
const families = names.flatMap((name) => fonts[name].families ?? [name]);
|
|
167
|
+
return {
|
|
168
|
+
set,
|
|
169
|
+
names,
|
|
170
|
+
typefaces,
|
|
171
|
+
regular: typefaces[set.regular],
|
|
172
|
+
bold: set.bold ? typefaces[set.bold] : null,
|
|
173
|
+
fallback: (set.fallback ?? []).map((name) => typefaces[name]),
|
|
174
|
+
fontMgr,
|
|
175
|
+
families,
|
|
176
|
+
delete() {
|
|
177
|
+
for (const typeface of Object.values(typefaces)) typeface.delete();
|
|
178
|
+
fontMgr.delete();
|
|
179
|
+
},
|
|
180
|
+
};
|
|
181
|
+
};
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// Copyright (c) 2011 Google Inc. All rights reserved.
|
|
2
|
+
//
|
|
3
|
+
// Redistribution and use in source and binary forms, with or without
|
|
4
|
+
// modification, are permitted provided that the following conditions are
|
|
5
|
+
// met:
|
|
6
|
+
//
|
|
7
|
+
// * Redistributions of source code must retain the above copyright
|
|
8
|
+
// notice, this list of conditions and the following disclaimer.
|
|
9
|
+
// * Redistributions in binary form must reproduce the above
|
|
10
|
+
// copyright notice, this list of conditions and the following disclaimer
|
|
11
|
+
// in the documentation and/or other materials provided with the
|
|
12
|
+
// distribution.
|
|
13
|
+
// * Neither the name of Google Inc. nor the names of its
|
|
14
|
+
// contributors may be used to endorse or promote products derived from
|
|
15
|
+
// this software without specific prior written permission.
|
|
16
|
+
//
|
|
17
|
+
// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
|
|
18
|
+
// "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
|
|
19
|
+
// LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
|
|
20
|
+
// A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
|
|
21
|
+
// OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
|
|
22
|
+
// SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
|
|
23
|
+
// LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
|
|
24
|
+
// DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
|
|
25
|
+
// THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
|
|
26
|
+
// (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
|
27
|
+
// OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
|
28
|
+
|
|
29
|
+
--------------------------------------------------------------------------------
|