buninu-linux 0.14.57 → 0.15.58
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSES/linux-firmware/LICENCE.nvidia +131 -0
- package/LICENSES/linux-firmware/LICENSE.amdgpu +51 -0
- package/LICENSES/linux-firmware/LICENSE.amdisp +31 -0
- package/LICENSES/linux-firmware/LICENSE.i915 +39 -0
- package/LICENSES/linux-firmware/LICENSE.radeon +51 -0
- package/LICENSES/linux-firmware/LICENSE.xe +39 -0
- package/LICENSES/linux-firmware/WHENCE +10280 -0
- package/NOTICE.md +64 -46
- package/README.md +371 -87
- package/README.zh.md +2034 -0
- package/build-image.sh +10 -1
- package/build-uki.sh +3 -1
- package/fetch-alpine.sh +14 -0
- package/graphics.md +31 -0
- package/index.js +28 -11
- package/initramfs/bin/buninu-browser +4 -1
- package/initramfs/bin/buninu-land +1 -1
- package/initramfs/bin/bunterm +3 -3
- package/initramfs/bin/chroot +13 -0
- package/initramfs/bin/chvt +11 -0
- package/initramfs/bin/mount +4 -0
- package/initramfs/bin/umount +6 -0
- package/initramfs/bin/xdg-open +33 -0
- package/initramfs/buninu/CHANGELOG.md +19 -0
- package/initramfs/buninu/apps/bunmsh/CHANGELOG.md +4 -0
- package/initramfs/buninu/apps/bunmsh/package.json +1 -1
- package/initramfs/buninu/apps/bunmsh/src/main.js +1 -1
- package/initramfs/buninu/apps/jsgotty/README.en.md +40 -0
- package/initramfs/buninu/apps/jsgotty/README.md +40 -0
- package/initramfs/buninu/apps/jsgotty/package-lock.json +2 -2
- package/initramfs/buninu/apps/jsgotty/package.json +1 -1
- package/initramfs/buninu/apps/jsgotty/static/index.html +1 -0
- package/initramfs/buninu/apps/jsgotty/static/js/gotty.js +82 -17
- package/initramfs/buninu/apps/jsgotty/static/js/kitty-keyboard.js +193 -0
- package/initramfs/buninu/apps/jsgotty/tests/android-alt.test.js +115 -0
- package/initramfs/buninu/apps/jsgotty/tests/arrow-modifiers.test.js +80 -0
- package/initramfs/buninu/apps/jsgotty/tests/kitty-keyboard-browser.test.js +53 -0
- package/initramfs/buninu/apps/jsgotty/tests/kitty-keyboard.test.js +102 -0
- package/initramfs/buninu/apps/jsgotty/tests/manual/README.md +20 -0
- package/initramfs/buninu/apps/jsgotty/tests/manual/keyboard-collector.cjs +55 -0
- package/initramfs/buninu/apps/jsgotty/tests/manual/keyboard-debug.js +74 -0
- package/initramfs/buninu/apps/jsgotty/tests/manual/kitty-keyboard.js +80 -0
- package/initramfs/buninu/package.json +1 -1
- package/initramfs/init.js +19 -3
- package/initramfs/lib/bunterm/clipboard.js +14 -0
- package/initramfs/lib/bunterm/commands.js +37 -0
- package/initramfs/lib/bunterm/fps.js +38 -0
- package/initramfs/lib/bunterm/images.js +9 -5
- package/initramfs/lib/bunterm/input.js +58 -15
- package/initramfs/lib/bunterm/keyboard.js +62 -0
- package/initramfs/lib/bunterm/keycodes.js +104 -0
- package/initramfs/lib/bunterm/kitty-keyboard.cjs +195 -0
- package/initramfs/lib/bunterm/raw-protocol.js +33 -0
- package/initramfs/lib/bunterm/renderer.js +89 -20
- package/initramfs/lib/bunterm/selection.js +60 -0
- package/initramfs/lib/bunterm/terminal.js +146 -11
- package/initramfs/lib/bunterm/vt-menu.js +181 -0
- package/initramfs/lib/bunterm/vt.js +27 -1
- package/initramfs/lib/canvas.js +99 -0
- package/initramfs/lib/chvt.js +31 -0
- package/initramfs/lib/fbdev.js +21 -3
- package/initramfs/lib/gpu.js +77 -0
- package/initramfs/lib/raw-image.js +47 -0
- package/initramfs/lib/xdg-open.js +42 -0
- package/initramfs/usr/share/doc/buninu-linux/bunterm.md +85 -11
- package/initramfs/usr/share/doc/buninu-linux/chvt.md +34 -0
- package/initramfs/usr/share/doc/buninu-linux/xdg-open.md +43 -0
- package/package.json +1 -1
- package/scripts/arch.js +7 -5
- package/scripts/arch.sh +5 -5
- package/scripts/build.js +16 -3
- package/scripts/fetch-gpu.js +105 -0
- package/scripts/fetch.js +11 -1
- package/scripts/gpu-firmware.json +22 -0
- package/scripts/modules.js +4 -1
- package/scripts/npm-packages.md +17 -17
- package/test/bunterm-fps.test.js +17 -0
- package/test/bunterm-keyboard.test.js +116 -0
- package/test/bunterm-next-vt.test.js +9 -0
- package/test/bunterm-raw-protocol.test.js +19 -0
- package/test/bunterm-renderer.test.js +158 -0
- package/test/bunterm-selection.test.js +38 -0
- package/test/bunterm-vt-menu.test.js +178 -0
- package/test/chroot-bind.test.js +40 -2
- package/test/chvt.test.js +46 -0
- package/test/fbdev-sync.test.js +33 -0
- package/test/gpu.test.js +155 -0
- package/test/image-size.test.js +17 -0
- package/test/manual-bunterm-image-paths.js +31 -0
- package/test/mount-libc-lifetime.test.js +50 -0
- package/test/no-gpu.test.js +43 -0
- package/test/readme-cli.test.js +29 -0
- package/test/stage-npm.test.js +11 -4
- package/test/xdg-open.test.js +55 -0
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
// Firmware is image data, not a running service. Only cfg.gpu loads GPU drivers.
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import { readFileSync, existsSync, mkdirSync, writeFileSync, rmSync, symlinkSync, statSync, mkdtempSync, renameSync } from "node:fs";
|
|
4
|
+
import { join, dirname, posix } from "node:path";
|
|
5
|
+
import pins from "./gpu-firmware.json" with { type: "json" };
|
|
6
|
+
import { config, runIfMain, withUmask022 } from "./arch.js";
|
|
7
|
+
import { download, readTar } from "./fetch.js";
|
|
8
|
+
|
|
9
|
+
const fingerprint = (cfg) => createHash("sha256").update(JSON.stringify({ revision: 2, arch: cfg.arch, release: cfg.kernelRelease, real: !!cfg.real, noGpu: !!cfg.noGpu, pins })).digest("hex");
|
|
10
|
+
const marker = (cfg) => join(cfg.modulesRoot, "gpu-support.json");
|
|
11
|
+
|
|
12
|
+
export const gpuReady = (cfg) => {
|
|
13
|
+
try {
|
|
14
|
+
const state = JSON.parse(readFileSync(marker(cfg), "utf8"));
|
|
15
|
+
if (cfg.noGpu && existsSync(join(cfg.nativeDir, "lib/firmware"))) return false;
|
|
16
|
+
return state.fingerprint === fingerprint(cfg) && state.files.every(path => statSync(join(cfg.nativeDir, path)).size > 0);
|
|
17
|
+
} catch { return false; }
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
// Validate paths before writing; firmware packages can link across GPU folders.
|
|
21
|
+
export const firmwarePath = (name) => {
|
|
22
|
+
if (!name.startsWith("lib/firmware/") || name.includes("\\") || posix.normalize(name) !== name || name.endsWith("/"))
|
|
23
|
+
throw Error(`Invalid firmware path: ${name}`);
|
|
24
|
+
return name;
|
|
25
|
+
};
|
|
26
|
+
export const firmwareLinkTarget = (name, { target, hard }) => {
|
|
27
|
+
firmwarePath(name);
|
|
28
|
+
if (target.startsWith("/") || target.includes("\\")) throw Error(`Invalid firmware link: ${name} -> ${target}`);
|
|
29
|
+
return firmwarePath(posix.normalize(hard ? target : posix.join(posix.dirname(name), target)));
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export const extractFirmware = (archives, staging) => {
|
|
33
|
+
const files = new Map(), links = new Map();
|
|
34
|
+
for (const archive of archives) {
|
|
35
|
+
for (const [name, bytes] of archive.entries()) if (name.startsWith("lib/firmware/")) files.set(firmwarePath(name), bytes);
|
|
36
|
+
for (const [name, link] of archive.links()) if (name.startsWith("lib/firmware/")) {
|
|
37
|
+
firmwareLinkTarget(name, link);
|
|
38
|
+
links.set(name, link);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
const directories = new Set();
|
|
42
|
+
for (const name of [...files.keys(), ...links.keys()])
|
|
43
|
+
for (let parent = posix.dirname(name); parent !== "."; parent = posix.dirname(parent)) directories.add(parent);
|
|
44
|
+
// NVIDIA also aliases entire GPU firmware directories. Resolve links in any
|
|
45
|
+
// path component, without following host filesystem links during validation.
|
|
46
|
+
const resolve = (name, seen = new Set()) => {
|
|
47
|
+
if (seen.has(name)) throw Error(`Firmware link cycle: ${name}`);
|
|
48
|
+
seen.add(name);
|
|
49
|
+
for (let prefix = name; prefix !== "."; prefix = posix.dirname(prefix)) {
|
|
50
|
+
const link = links.get(prefix);
|
|
51
|
+
if (link) {
|
|
52
|
+
if (seen.has("link:" + prefix)) throw Error(`Firmware link cycle: ${prefix}`);
|
|
53
|
+
seen.add("link:" + prefix);
|
|
54
|
+
return resolve(firmwareLinkTarget(prefix, link) + name.slice(prefix.length), seen);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
if (files.has(name)) return files.get(name);
|
|
58
|
+
if (directories.has(name)) return null;
|
|
59
|
+
throw Error(`Missing firmware link target: ${name}`);
|
|
60
|
+
};
|
|
61
|
+
for (const name of [...files.keys(), ...links.keys()]) {
|
|
62
|
+
for (let parent = posix.dirname(name); parent !== "."; parent = posix.dirname(parent))
|
|
63
|
+
if (links.has(parent) || files.has(parent)) throw Error(`Firmware parent is not a directory: ${parent}`);
|
|
64
|
+
if (links.has(name)) {
|
|
65
|
+
if (files.has(name)) throw Error(`Duplicate firmware file/link: ${name}`);
|
|
66
|
+
if (resolve(name) === null && links.get(name).hard) throw Error(`Firmware hardlink to directory: ${name}`);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
for (const [name, bytes] of files) {
|
|
70
|
+
const path = join(staging, name); mkdirSync(dirname(path), { recursive: true }); writeFileSync(path, bytes);
|
|
71
|
+
}
|
|
72
|
+
for (const [name, link] of links) {
|
|
73
|
+
const path = join(staging, name); mkdirSync(dirname(path), { recursive: true });
|
|
74
|
+
if (files.has(name)) throw Error(`Duplicate firmware file/link: ${name}`);
|
|
75
|
+
if (link.hard) writeFileSync(path, resolve(name));
|
|
76
|
+
else symlinkSync(link.target, path);
|
|
77
|
+
}
|
|
78
|
+
return [...files.keys(), ...links.keys()];
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
export const fetchGpuFirmware = (cfg = config()) => withUmask022(async () => {
|
|
82
|
+
const dep = readFileSync(join(cfg.modulesRoot, "modules.dep"), "utf8");
|
|
83
|
+
const builtin = readFileSync(join(cfg.modulesRoot, "modules.builtin"), "utf8");
|
|
84
|
+
const pc = !cfg.noGpu && cfg.arch === "x86_64" && /\/(?:i915|xe|amdgpu|radeon|nouveau)\.ko(?:[:\n]|$)/.test(dep + "\n" + builtin);
|
|
85
|
+
// Firmware bytes are architecture-independent; these pins refer to the x86_64 APKs.
|
|
86
|
+
const packages = pc ? pins : [];
|
|
87
|
+
const archives = [];
|
|
88
|
+
for (const pkg of packages) archives.push(readTar(await download(cfg, `${cfg.alpineBase}/${pkg.name}`, pkg.name, pkg.sha256)));
|
|
89
|
+
mkdirSync(join(cfg.nativeDir, "lib"), { recursive: true });
|
|
90
|
+
const staging = mkdtempSync(join(cfg.nativeDir, "lib", ".gpu-firmware-"));
|
|
91
|
+
try {
|
|
92
|
+
const files = extractFirmware(archives, staging);
|
|
93
|
+
const target = join(cfg.nativeDir, "lib/firmware");
|
|
94
|
+
// This generated directory is owned by the firmware fetch stage.
|
|
95
|
+
rmSync(target, { recursive: true, force: true });
|
|
96
|
+
if (files.length) renameSync(join(staging, "lib/firmware"), target);
|
|
97
|
+
writeFileSync(marker(cfg), JSON.stringify({ fingerprint: fingerprint(cfg), packages, files }, null, 2) + "\n");
|
|
98
|
+
console.log(`GPU support: ${packages.length} firmware packages, ${files.length} files/links; drivers stay opt-in via cfg.gpu`);
|
|
99
|
+
} finally { rmSync(staging, { recursive: true, force: true }); }
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
runIfMain(import.meta.url, async () => {
|
|
103
|
+
if (process.argv.includes("--check")) process.exitCode = gpuReady(config()) ? 0 : 1;
|
|
104
|
+
else await fetchGpuFirmware();
|
|
105
|
+
});
|
package/scripts/fetch.js
CHANGED
|
@@ -73,7 +73,9 @@ export const download = async (cfg, url, name, sha256) => {
|
|
|
73
73
|
export const readTar = (path) => {
|
|
74
74
|
const data = gunzipSync(readFileSync(path));
|
|
75
75
|
const files = new Map();
|
|
76
|
+
const links = new Map();
|
|
76
77
|
let longName = null;
|
|
78
|
+
let longLink = null;
|
|
77
79
|
for (let offset = 0; offset + 512 <= data.length;) {
|
|
78
80
|
const header = data.subarray(offset, offset + 512);
|
|
79
81
|
if (header.every((byte) => byte === 0)) { offset += 512; continue; }
|
|
@@ -87,19 +89,25 @@ export const readTar = (path) => {
|
|
|
87
89
|
for (const record of body.toString("utf8").split("\n")) {
|
|
88
90
|
const match = /^\d+ path=(.*)$/.exec(record);
|
|
89
91
|
if (match) longName = match[1];
|
|
92
|
+
const link = /^\d+ linkpath=(.*)$/.exec(record);
|
|
93
|
+
if (link) longLink = link[1];
|
|
90
94
|
}
|
|
91
95
|
continue;
|
|
92
96
|
}
|
|
93
97
|
if (type === "L") { longName = body.toString("utf8").replace(/\0.*$/s, ""); continue; }
|
|
98
|
+
if (type === "K") { longLink = body.toString("utf8").replace(/\0.*$/s, ""); continue; }
|
|
94
99
|
if (type === "g") continue;
|
|
95
100
|
const prefix = field(345, 155);
|
|
96
101
|
const name = longName ?? (prefix ? `${prefix}/${field(0, 100)}` : field(0, 100));
|
|
97
102
|
longName = null;
|
|
98
103
|
if (type === "0" || type === "\0") files.set(name.replace(/^\.\//, ""), body);
|
|
104
|
+
if (type === "1" || type === "2") links.set(name.replace(/^\.\//, ""), { target: longLink ?? field(157, 100), hard: type === "1" });
|
|
105
|
+
longLink = null;
|
|
99
106
|
}
|
|
100
107
|
return {
|
|
101
108
|
// Enumerate regular files without exposing the Map's mutation methods.
|
|
102
109
|
entries() { return files.entries(); },
|
|
110
|
+
links() { return links.entries(); },
|
|
103
111
|
get(name) {
|
|
104
112
|
const bytes = files.get(name);
|
|
105
113
|
if (!bytes) throw new Error(`${path}: no ${name}`);
|
|
@@ -132,7 +140,7 @@ export const fetchAlpine = (cfg = config()) => withUmask022(async () => {
|
|
|
132
140
|
const release = `lib/modules/${cfg.kernelRelease}`;
|
|
133
141
|
const dep = linux.get(`${release}/modules.dep`).toString();
|
|
134
142
|
const builtin = linux.get(`${release}/modules.builtin`);
|
|
135
|
-
const { selected, notes } = select({ dep, builtin: builtin.toString(), packageName: cfg.linuxPackage, arch: cfg.arch, flavor: cfg.flavor, real: cfg.real });
|
|
143
|
+
const { selected, notes } = select({ dep, builtin: builtin.toString(), packageName: cfg.linuxPackage, arch: cfg.arch, flavor: cfg.flavor, real: cfg.real, noGpu: cfg.noGpu });
|
|
136
144
|
for (const note of notes) console.error(note);
|
|
137
145
|
rmSync(cfg.modulesRoot, { recursive: true, force: true });
|
|
138
146
|
writeTo(join(cfg.modulesRoot, "modules.builtin"), builtin);
|
|
@@ -142,6 +150,8 @@ export const fetchAlpine = (cfg = config()) => withUmask022(async () => {
|
|
|
142
150
|
writeTo(join(cfg.modulesRoot, "modules.dep"), trimDependencies(dep, selected));
|
|
143
151
|
writeTo(join(cfg.modulesRoot, "modules.alias"), filterAliases(linux.get(`${release}/modules.alias`).toString(), selected));
|
|
144
152
|
|
|
153
|
+
await (await import("./fetch-gpu.js")).fetchGpuFirmware(cfg);
|
|
154
|
+
|
|
145
155
|
chmodSync(join(cfg.nativeDir, "lib", cfg.muslLoader), 0o755);
|
|
146
156
|
console.log(`Fetched Alpine ${cfg.arch} linux-${cfg.flavor} kernel, ${selected.length} modules, musl, and the ${cfg.efiStub} UKI stub.`);
|
|
147
157
|
});
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
[
|
|
2
|
+
{
|
|
3
|
+
"name": "linux-firmware-amdgpu-20260519-r0.apk",
|
|
4
|
+
"sha256": "70bc5c972a2da62b84b53e06b6d4c5aec0a8e9d6d280802755c3c8643f3bb8c6"
|
|
5
|
+
},
|
|
6
|
+
{
|
|
7
|
+
"name": "linux-firmware-i915-20260519-r0.apk",
|
|
8
|
+
"sha256": "3946f8b0a8f0dd187bd71d9aa9b3085a10cb1e8cf4700aa876799e6368309fa4"
|
|
9
|
+
},
|
|
10
|
+
{
|
|
11
|
+
"name": "linux-firmware-nvidia-20260519-r0.apk",
|
|
12
|
+
"sha256": "9fd3fa7eff65792ef8558c01a27246862e2701a99ef97dba5111b9f6db2c80ee"
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
"name": "linux-firmware-radeon-20260519-r0.apk",
|
|
16
|
+
"sha256": "3b0d6d3982563d17df0c33c2d695a5a547487cb56f2fd9a004ed729fac8f0684"
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
"name": "linux-firmware-xe-20260519-r0.apk",
|
|
20
|
+
"sha256": "6ebc37da64ecb48973c1814d1499c2c2f8f38593e8cf7227811d840c8b17172f"
|
|
21
|
+
}
|
|
22
|
+
]
|
package/scripts/modules.js
CHANGED
|
@@ -16,7 +16,10 @@
|
|
|
16
16
|
const required = (names) => names.map((name) => ({ name }));
|
|
17
17
|
const optional = (names) => names.map((name) => ({ name, optional: true }));
|
|
18
18
|
|
|
19
|
-
export const requests = ({ arch, flavor, real }) => [
|
|
19
|
+
export const requests = ({ arch, flavor, real, noGpu = false }) => [
|
|
20
|
+
// Pack, but never coldplug at boot or through cfg.all. cfg.gpu matches hardware.
|
|
21
|
+
...(!noGpu ? optional(["virtio-gpu", "vmwgfx"]) : []),
|
|
22
|
+
...(!noGpu && arch === "x86_64" ? optional(["i915", "xe", "amdgpu", "radeon", "nouveau"]) : []),
|
|
20
23
|
...required([
|
|
21
24
|
"failover",
|
|
22
25
|
"net_failover",
|
package/scripts/npm-packages.md
CHANGED
|
@@ -1,24 +1,24 @@
|
|
|
1
1
|
# npm packages
|
|
2
2
|
# Latest versions checked on 2026-10-11; SHA-256 pins verified against registry SHA-512 integrity.
|
|
3
|
-
# buninu-browser 0.
|
|
4
|
-
#
|
|
3
|
+
# buninu-browser 0.3.0, buninu-land 0.2.0, casty 1.4.1, and jspulse 0.2.3 verified on 2026-10-22.
|
|
4
|
+
# jspinyin 0.2.0 verified on 2026-10-22.
|
|
5
5
|
|
|
6
|
-
"buninu-browser@0.
|
|
7
|
-
- https://registry.npmjs.org/buninu-browser/-/buninu-browser-0.
|
|
8
|
-
- sha256:
|
|
6
|
+
"buninu-browser@0.3.0":
|
|
7
|
+
- https://registry.npmjs.org/buninu-browser/-/buninu-browser-0.3.0.tgz
|
|
8
|
+
- sha256:4590c523f76ce7c3a079894b80a541b788c95895036d0094c0e98c2acc6d5179
|
|
9
9
|
|
|
10
|
-
"buninu-land@0.
|
|
11
|
-
- https://registry.npmjs.org/buninu-land/-/buninu-land-0.
|
|
12
|
-
- sha256:
|
|
10
|
+
"buninu-land@0.2.0":
|
|
11
|
+
- https://registry.npmjs.org/buninu-land/-/buninu-land-0.2.0.tgz
|
|
12
|
+
- sha256:543098c9f934fe4d723ca65b5a0c8a0bd11a04268228c23c3272073dc4549b56
|
|
13
13
|
|
|
14
|
-
"@drxiaozhi/casty@1.
|
|
15
|
-
- https://registry.npmjs.org/@drxiaozhi/casty/-/casty-1.
|
|
16
|
-
- sha256:
|
|
14
|
+
"@drxiaozhi/casty@1.4.1":
|
|
15
|
+
- https://registry.npmjs.org/@drxiaozhi/casty/-/casty-1.4.1.tgz
|
|
16
|
+
- sha256:0179c699a82202dc3d84f8736c8d88b29941d87995d6c6b554fd534daa70d280
|
|
17
17
|
|
|
18
|
-
"@drxiaozhi/jspulse@0.2.
|
|
19
|
-
- https://registry.npmjs.org/@drxiaozhi/jspulse/-/jspulse-0.2.
|
|
20
|
-
- sha256:
|
|
18
|
+
"@drxiaozhi/jspulse@0.2.3":
|
|
19
|
+
- https://registry.npmjs.org/@drxiaozhi/jspulse/-/jspulse-0.2.3.tgz
|
|
20
|
+
- sha256:c4a1a7adb559f94096243bebaac0890dc5096d518f7f83ed489c38d5cdfaa263
|
|
21
21
|
|
|
22
|
-
"@drxiaozhi/jspinyin@0.
|
|
23
|
-
- https://registry.npmjs.org/@drxiaozhi/jspinyin/-/jspinyin-0.
|
|
24
|
-
- sha256:
|
|
22
|
+
"@drxiaozhi/jspinyin@0.2.0":
|
|
23
|
+
- https://registry.npmjs.org/@drxiaozhi/jspinyin/-/jspinyin-0.2.0.tgz
|
|
24
|
+
- sha256:b6dfb18f8a8602b9449b75823bce1790fd9682fac68fcbc57c55b4ebd98baa6e
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import {test,expect} from 'bun:test';
|
|
2
|
+
import {createFpsMeter} from '../initramfs/lib/bunterm/fps.js';
|
|
3
|
+
test('FPS counts new image presentations, ignores redraws, idles at zero and resets on toggle',()=>{
|
|
4
|
+
let time=0;const meter=createFpsMeter(()=>time),old={},a={},b={};
|
|
5
|
+
meter.presented([{image:old}]);expect(meter.enabled).toBe(false);
|
|
6
|
+
expect(meter.toggle([{image:old}])).toBe(true);
|
|
7
|
+
meter.presented([{image:old}]);
|
|
8
|
+
meter.presented([{image:a}]);meter.presented([{image:a}]);
|
|
9
|
+
meter.presented([{image:a},{image:b}]);meter.presented([]);
|
|
10
|
+
time=1000;expect(meter.sample()).toBe(2);
|
|
11
|
+
time=2000;expect(meter.sample()).toBe(0);
|
|
12
|
+
expect(meter.toggle()).toBe(false);
|
|
13
|
+
meter.presented([{image:{}}]);
|
|
14
|
+
time=3000;expect(meter.sample()).toBe(0);
|
|
15
|
+
meter.toggle([{image:b}]);meter.presented([{image:b}]);
|
|
16
|
+
meter.presented([{image:{}}]);time=3500;expect(meter.sample()).toBe(2);
|
|
17
|
+
});
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import {test,expect} from "bun:test";
|
|
2
|
+
import {Terminal} from "../initramfs/lib/xterm/xterm-headless.mjs";
|
|
3
|
+
import {ConsoleKeys,MediumRawDecoder,legacyKey} from "../initramfs/lib/bunterm/keycodes.js";
|
|
4
|
+
import {attachKeyboard} from "../initramfs/lib/bunterm/keyboard.js";
|
|
5
|
+
const write = (term,text) => new Promise(resolve=>term.write(text,resolve));
|
|
6
|
+
const letters = {16:"q",17:"w",18:"e",19:"r",20:"t",21:"y",22:"u",23:"i",24:"o",25:"p",
|
|
7
|
+
30:"a",31:"s",32:"d",33:"f",34:"g",35:"h",36:"j",37:"k",38:"l",44:"z",45:"x",46:"c",
|
|
8
|
+
47:"v",48:"b",49:"n",50:"m"};
|
|
9
|
+
const keys = () => new ConsoleKeys({lookup:(table,n) => {
|
|
10
|
+
const letter = letters[n];
|
|
11
|
+
if (letter) return 0xb00 | (table & 1 ? letter.toUpperCase() : letter).charCodeAt(0);
|
|
12
|
+
return n === 57 ? 32 : n === 53 ? (table & 1 ? 63 : 47) : null;
|
|
13
|
+
}});
|
|
14
|
+
function fixture(extra = {}) {
|
|
15
|
+
const term = new Terminal({allowProposedApi:true}), output = [], modes = [], switches = [];
|
|
16
|
+
const keyboard = attachKeyboard({term,write:text=>output.push(text),
|
|
17
|
+
setEnhanced:v=>modes.push(v),switchVT:n=>switches.push(n),...extra});
|
|
18
|
+
return {term,output,modes,switches,keyboard,close(){keyboard.dispose();term.dispose();}};
|
|
19
|
+
}
|
|
20
|
+
test("mediumraw extended codes survive every possible read boundary", () => {
|
|
21
|
+
const bytes = Buffer.from([30,158,0,129,183,128,129,183]); // F13 = 183
|
|
22
|
+
for (let split=0;split<=bytes.length;split++) {
|
|
23
|
+
const decoder = new MediumRawDecoder(), events=[];
|
|
24
|
+
decoder.feed(bytes.subarray(0,split),(c,d)=>events.push([c,d]));
|
|
25
|
+
decoder.feed(bytes.subarray(split),(c,d)=>events.push([c,d]));
|
|
26
|
+
expect(events).toEqual([[30,true],[30,false],[183,true],[183,false]]);
|
|
27
|
+
}
|
|
28
|
+
});
|
|
29
|
+
test("all Ctrl/Alt/Shift/Super letter combinations stay distinct", async () => {
|
|
30
|
+
const f=fixture();
|
|
31
|
+
try {
|
|
32
|
+
await write(f.term,"\x1b[>9u");
|
|
33
|
+
for (let bits=0;bits<16;bits++) for (const [number,letter] of Object.entries(letters)) {
|
|
34
|
+
const state=keys();
|
|
35
|
+
for (const [mask,code] of [[1,42],[2,56],[4,29],[8,125]]) if(bits&mask) f.keyboard.event(state.event(code,true));
|
|
36
|
+
f.output.length=0;
|
|
37
|
+
f.keyboard.event(state.event(Number(number),true));
|
|
38
|
+
expect(f.output).toEqual([`\x1b[${letter.charCodeAt(0)}${bits?";"+(bits+1):""}u`]);
|
|
39
|
+
f.keyboard.blur();
|
|
40
|
+
}
|
|
41
|
+
} finally {f.close();}
|
|
42
|
+
});
|
|
43
|
+
test("push/pop, repeat/release, recovery keys, independent buffers and reset", async () => {
|
|
44
|
+
const f=fixture(), state=keys();
|
|
45
|
+
try {
|
|
46
|
+
await write(f.term,"\x1b[?u\x1b[>11u");
|
|
47
|
+
expect(f.output).toEqual(["\x1b[?0u"]);
|
|
48
|
+
f.output.length=0;
|
|
49
|
+
f.keyboard.event(state.event(125,true));
|
|
50
|
+
f.keyboard.event(state.event(50,true));
|
|
51
|
+
f.keyboard.event(state.event(50,true));
|
|
52
|
+
f.keyboard.event(state.event(50,false));
|
|
53
|
+
f.keyboard.event(state.event(125,false));
|
|
54
|
+
expect(f.output).toEqual(["\x1b[57444;9u","\x1b[109;9u","\x1b[109;9:2u","\x1b[109;9:3u","\x1b[57444;1:3u"]);
|
|
55
|
+
await write(f.term,"\x1b[>1u");
|
|
56
|
+
f.output.length=0;
|
|
57
|
+
f.keyboard.event(state.event(28,true));
|
|
58
|
+
f.keyboard.event(state.event(28,false));
|
|
59
|
+
f.keyboard.event(state.event(30,true));
|
|
60
|
+
expect(f.output).toEqual(["\r","a"]);
|
|
61
|
+
await write(f.term,"\x1b[<u");
|
|
62
|
+
expect(f.keyboard.flags).toBe(11);
|
|
63
|
+
await write(f.term,"\x1b[?1049h");
|
|
64
|
+
expect(f.keyboard.flags).toBe(0);
|
|
65
|
+
await write(f.term,"\x1b[?1049l");
|
|
66
|
+
expect(f.keyboard.flags).toBe(11);
|
|
67
|
+
await write(f.term,"\x1bc");
|
|
68
|
+
expect(f.keyboard.flags).toBe(0);
|
|
69
|
+
expect(f.modes).toEqual([true,false,true,false]);
|
|
70
|
+
} finally {f.close();}
|
|
71
|
+
});
|
|
72
|
+
test("local menu and reserved VT keys never reach the child", async () => {
|
|
73
|
+
let menu=false;
|
|
74
|
+
const f=fixture({localInput:()=>menu}), state=keys();
|
|
75
|
+
try {
|
|
76
|
+
await write(f.term,"\x1b[>11u");
|
|
77
|
+
f.keyboard.event(state.event(29,true));
|
|
78
|
+
f.keyboard.event(state.event(56,true));
|
|
79
|
+
f.output.length=0;
|
|
80
|
+
f.keyboard.event(state.event(60,true));
|
|
81
|
+
f.keyboard.event(state.event(60,false));
|
|
82
|
+
expect(f.switches).toEqual([2]);
|
|
83
|
+
expect(f.output).toEqual([]);
|
|
84
|
+
f.keyboard.blur(); f.output.length=0;
|
|
85
|
+
menu=true;
|
|
86
|
+
f.keyboard.event(state.event(30,true));
|
|
87
|
+
f.keyboard.event(state.event(30,false));
|
|
88
|
+
expect(f.output).toEqual([]);
|
|
89
|
+
} finally {f.close();}
|
|
90
|
+
});
|
|
91
|
+
test("layout translation, caps, AltGr, keypad and Unicode map", () => {
|
|
92
|
+
const state=keys();
|
|
93
|
+
state.event(58,true); state.event(58,false);
|
|
94
|
+
expect(state.event(30,true).key).toBe("A");
|
|
95
|
+
state.event(42,true);
|
|
96
|
+
expect(state.event(30,true).key).toBe("a");
|
|
97
|
+
expect(state.event(100,true).key).toBe("AltGraph");
|
|
98
|
+
expect(state.event(53,true).getModifierState("AltGraph")).toBe(true);
|
|
99
|
+
const unicode=new ConsoleKeys({lookup:()=>0xf000^0x4e2d});
|
|
100
|
+
expect(unicode.event(30,true).key).toBe("中");
|
|
101
|
+
const num=keys();
|
|
102
|
+
num.event(69,true);
|
|
103
|
+
expect(num.event(79,true).key).toBe("1");
|
|
104
|
+
expect(num.event(79,false).location).toBe(3);
|
|
105
|
+
expect(legacyKey({type:"keydown",key:"/",ctrlKey:true})).toBe("\x1f");
|
|
106
|
+
});
|
|
107
|
+
test("failed console transition resets flags and does not strand the protocol", async () => {
|
|
108
|
+
const errors=[];
|
|
109
|
+
const f=fixture({setEnhanced(){throw new Error("not a VT");},onError:e=>errors.push(e.message)});
|
|
110
|
+
try {
|
|
111
|
+
await write(f.term,"\x1b[>11u\x1b[?u");
|
|
112
|
+
expect(f.keyboard.flags).toBe(0);
|
|
113
|
+
expect(f.output).toEqual(["\x1b[?0u"]);
|
|
114
|
+
expect(errors).toEqual(["not a VT"]);
|
|
115
|
+
} finally {f.close();}
|
|
116
|
+
});
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import {expect,test} from 'bun:test';
|
|
2
|
+
import {nextVirtualTerminal} from '../initramfs/lib/bunterm/vt.js';
|
|
3
|
+
test('new bunterm appends after known sessions and reserves the initial console',()=>{
|
|
4
|
+
expect(nextVirtualTerminal([])).toBe(2);
|
|
5
|
+
expect(nextVirtualTerminal([{number:1}])).toBe(2);
|
|
6
|
+
expect(nextVirtualTerminal([{number:1},{number:2},{number:6}])).toBe(7);
|
|
7
|
+
expect(nextVirtualTerminal([{number:4}])).toBe(5);
|
|
8
|
+
expect(()=>nextVirtualTerminal([{number:63}])).toThrow('No next tty');
|
|
9
|
+
});
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import {test,expect} from 'bun:test';
|
|
2
|
+
import {createRequire} from 'node:module';
|
|
3
|
+
import {preferredPixelFormat,bgraParser,BGRA_FORMAT} from '../initramfs/lib/bunterm/raw-protocol.js';
|
|
4
|
+
const {KittyGraphicsParser}=createRequire(import.meta.url)('../initramfs/buninu/apps/jsgotty/gotty.js');
|
|
5
|
+
const field=offset=>({offset,length:8,msbRight:0});
|
|
6
|
+
const fb=(red=16,blue=0)=>({bitsPerPixel:32,fields:{red:field(red),green:field(8),blue:field(blue),transp:{length:0}}});
|
|
7
|
+
test('native preference follows framebuffer bitfields; browser parser remains unchanged',()=>{
|
|
8
|
+
for(const [display,preferred] of [[fb(),'bgra'],[fb(0,16),'rgba'],[{bitsPerPixel:16},'png'],[undefined,'png']]){
|
|
9
|
+
expect(preferredPixelFormat(display)).toBe(preferred);
|
|
10
|
+
const p=new (bgraParser(KittyGraphicsParser,display))({binaryData:true});
|
|
11
|
+
const packet=Buffer.from(`a=q,f=${BGRA_FORMAT},s=1,v=1,i=41;AAAA/w==`);
|
|
12
|
+
expect(p.parsePacket(packet,{}).responseMessage).toBe('OK Buninu1 '+preferred);
|
|
13
|
+
expect(new KittyGraphicsParser().parsePacket(packet,{}).responseMessage).toBe('OK');
|
|
14
|
+
expect(p.images.size).toBe(0);
|
|
15
|
+
expect(p.parsePacket(Buffer.from(`a=q,f=${BGRA_FORMAT},s=2,v=1,i=41;AAAA/w==`),{}).responseMessage).toStartWith('ERROR:');
|
|
16
|
+
}
|
|
17
|
+
const bad=fb();bad.fields.red.msbRight=1;
|
|
18
|
+
expect(preferredPixelFormat(bad)).toBe('png');expect(preferredPixelFormat(fb(),false)).toBe('png');
|
|
19
|
+
});
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import {test,expect} from "bun:test";
|
|
2
|
+
import {loadCanvasKit,loadFontSet} from "../initramfs/lib/canvas.js";
|
|
3
|
+
import {Renderer} from "../initramfs/lib/bunterm/renderer.js";
|
|
4
|
+
import {Terminal} from "../initramfs/lib/xterm/xterm-headless.mjs";
|
|
5
|
+
import {createVtMenu} from "../initramfs/lib/bunterm/vt-menu.js";
|
|
6
|
+
|
|
7
|
+
test("real CanvasKit redraws resized text and overlay with fresh font metrics", async () => {
|
|
8
|
+
const CanvasKit=await loadCanvasKit(), fonts=loadFontSet(CanvasKit,"terminal");
|
|
9
|
+
const surface=CanvasKit.MakeSurface(800,480);
|
|
10
|
+
const screen={width:800,height:480,canvas:surface.getCanvas(),flush:()=>surface.flush()};
|
|
11
|
+
const renderer=new Renderer({CanvasKit,screen,fonts});
|
|
12
|
+
const term=new Terminal({cols:renderer.columns,rows:renderer.rows,allowProposedApi:true});
|
|
13
|
+
try {
|
|
14
|
+
await new Promise(resolve=>term.write("Hello 中文 👨👩👧\r\nSelection and font resize",resolve));
|
|
15
|
+
const columns=renderer.columns;
|
|
16
|
+
renderer.render(term);
|
|
17
|
+
renderer.setFontSize(20);
|
|
18
|
+
expect(renderer.columns).toBeLessThan(columns);
|
|
19
|
+
term.resize(renderer.columns,renderer.rows);
|
|
20
|
+
const menu=createVtMenu({list:()=>[{number:1,label:"tty1 — initial console"}],select(){},changed(){renderer.invalidate();},
|
|
21
|
+
width:800,height:480,rowHeight:renderer.cellHeight,
|
|
22
|
+
controls:{fontSize:()=>renderer.fontSize,cellHeight:()=>renderer.cellHeight,execute:()=>""}});
|
|
23
|
+
menu.pointer({x:0,y:0,pressed:["left"],buttons:{left:true},wheel:0});
|
|
24
|
+
renderer.render(term,{overlay:()=>menu.draw(renderer)});
|
|
25
|
+
const image=surface.makeImageSnapshot();
|
|
26
|
+
expect(image.width()).toBe(800);
|
|
27
|
+
image.delete();
|
|
28
|
+
renderer.setFontSize(13);
|
|
29
|
+
expect(renderer.columns).toBe(columns);
|
|
30
|
+
expect(()=>renderer.setFontSize(NaN)).toThrow();
|
|
31
|
+
} finally {term.dispose();renderer.delete();fonts.delete();surface.delete();}
|
|
32
|
+
},20000);
|
|
33
|
+
|
|
34
|
+
test("local pointer restores exact pixels across images, edges, text and font changes", async () => {
|
|
35
|
+
const {openCanvas}=await import('../initramfs/lib/canvas.js');
|
|
36
|
+
const CK=await loadCanvasKit(), fonts=loadFontSet(CK,'terminal');
|
|
37
|
+
const make=()=>{
|
|
38
|
+
const display={width:320,height:200,stride:1296,bitsPerPixel:32,
|
|
39
|
+
fields:{red:{offset:16,length:8},green:{offset:8,length:8},blue:{offset:0,length:8}},pixels:new Uint8Array(1296*200)};
|
|
40
|
+
const screen=openCanvas(CK,display);screen.flush=()=>screen.surface.flush();
|
|
41
|
+
return screen;
|
|
42
|
+
};
|
|
43
|
+
const screen=make(),reference=make();
|
|
44
|
+
reference.drawOpaqueImage = undefined;
|
|
45
|
+
const r=new Renderer({CanvasKit:CK,screen,fonts}), ref=new Renderer({CanvasKit:CK,screen:reference,fonts});
|
|
46
|
+
const term=new Terminal({cols:r.columns,rows:r.rows,allowProposedApi:true});
|
|
47
|
+
const imageSurface=CK.MakeSurface(37,29);imageSurface.getCanvas().clear(CK.Color(30,90,170));
|
|
48
|
+
const image=imageSurface.makeImageSnapshot(), images=[{image,rect:[0,20,320,200]}];
|
|
49
|
+
let imageDraws=0;const draw=r.drawImages.bind(r);r.drawImages=(...args)=>{imageDraws++;return draw(...args);};
|
|
50
|
+
const check=(pointer,cursor={x:1,y:0,visible:true})=>{
|
|
51
|
+
const options={pointer,cursor,images};r.render(term,options);ref.invalidate();ref.render(term,options);
|
|
52
|
+
expect(screen.saveRegion(0,0,320,200).pixels).toEqual(reference.saveRegion(0,0,320,200).pixels);
|
|
53
|
+
};
|
|
54
|
+
try {
|
|
55
|
+
await new Promise(resolve=>term.write('Hello world',resolve));
|
|
56
|
+
check({x:100,y:100});const initial=imageDraws;
|
|
57
|
+
for(const point of [{x:101,y:101},{x:0,y:0},{x:319,y:199},{x:150.5,y:78.5},null])check(point);
|
|
58
|
+
expect(imageDraws).toBe(initial);
|
|
59
|
+
check({x:20,y:20});
|
|
60
|
+
await new Promise(resolve=>term.write('\rChanged',resolve));check({x:20,y:20});
|
|
61
|
+
check({x:50,y:50},{x:2,y:0,visible:false});
|
|
62
|
+
r.setFontSize(24);ref.setFontSize(24);term.resize(r.columns,r.rows);check({x:318,y:198});
|
|
63
|
+
check(null);
|
|
64
|
+
} finally {term.dispose();r.delete();ref.delete();image.delete();imageSurface.delete();screen.close();reference.close();fonts.delete();}
|
|
65
|
+
},20000);
|
|
66
|
+
|
|
67
|
+
test('opaque copies match Skia for clipped crops and dirty bands; transparency and scaling fall back', async()=>{
|
|
68
|
+
const {openCanvas}=await import('../initramfs/lib/canvas.js');
|
|
69
|
+
const CK=await loadCanvasKit();
|
|
70
|
+
const make=()=>openCanvas(CK,{width:64,height:48,stride:272,bitsPerPixel:32,
|
|
71
|
+
fields:{red:{offset:16,length:8},green:{offset:8,length:8},blue:{offset:0,length:8}},pixels:new Uint8Array(272*48)});
|
|
72
|
+
const a=make(),b=make();
|
|
73
|
+
const data=new Uint8Array(32*24*4);for(let i=0;i<data.length;i+=4){data[i]=i%251;data[i+1]=(i*7)%253;data[i+2]=(i*13)%255;data[i+3]=255;}
|
|
74
|
+
const info={width:32,height:24,colorType:CK.ColorType.RGBA_8888,alphaType:CK.AlphaType.Unpremul,colorSpace:CK.ColorSpace.SRGB};
|
|
75
|
+
const image=CK.MakeImage(info,data,128);
|
|
76
|
+
const bands=[[0,9],[12,35]];
|
|
77
|
+
try {
|
|
78
|
+
for(const rect of [[5,4,37,28],[-5,-3,27,21],[55,39,87,63]]){
|
|
79
|
+
a.canvas.clear(CK.Color(7,11,13));b.canvas.clear(CK.Color(7,11,13));
|
|
80
|
+
expect(a.drawOpaqueImage(image,rect,[0,0,32,24],bands)).toBe(true);
|
|
81
|
+
for(const [y0,y1] of bands){b.canvas.save();b.canvas.clipRect(CK.LTRBRect(0,y0,64,y1),CK.ClipOp.Intersect,false);b.canvas.drawImageRectOptions(image,CK.LTRBRect(0,0,32,24),CK.LTRBRect(...rect),CK.FilterMode.Linear,CK.MipmapMode.Linear,null);b.canvas.restore();}
|
|
82
|
+
expect(a.saveRegion(0,0,64,48).pixels).toEqual(b.saveRegion(0,0,64,48).pixels);
|
|
83
|
+
}
|
|
84
|
+
expect(a.drawOpaqueImage(image,[0,0,64,48],[0,0,32,24],bands)).toBe(false);
|
|
85
|
+
expect(a.drawOpaqueImage(image,[0.5,0,32.5,24],[0,0,32,24],bands)).toBe(false);
|
|
86
|
+
data[3]=100;const alpha=CK.MakeImage(info,data,128);
|
|
87
|
+
try{expect(a.drawOpaqueImage(alpha,[0,0,32,24],[0,0,32,24],bands)).toBe(false);}finally{alpha.delete();}
|
|
88
|
+
}finally{image.delete();a.close();b.close();}
|
|
89
|
+
},20000);
|
|
90
|
+
|
|
91
|
+
test('opaque image readback reuses WASM destination and falls back when native BGRA is rejected',async()=>{
|
|
92
|
+
const {openCanvas}=await import('../initramfs/lib/canvas.js');const CK=await loadCanvasKit();
|
|
93
|
+
const screen=openCanvas(CK,{width:8,height:8,stride:32,bitsPerPixel:32,
|
|
94
|
+
fields:{red:{offset:16,length:8},green:{offset:8,length:8},blue:{offset:0,length:8}},pixels:new Uint8Array(256)});
|
|
95
|
+
const info={width:8,height:8,colorType:CK.ColorType.RGBA_8888,alphaType:CK.AlphaType.Unpremul,colorSpace:CK.ColorSpace.SRGB};
|
|
96
|
+
const bytes=new Uint8Array(256);for(let i=0;i<256;i+=4)bytes.set([201,71,19,255],i);
|
|
97
|
+
const images=[],destinations=[];
|
|
98
|
+
try{
|
|
99
|
+
for(const fallback of [false,true]){
|
|
100
|
+
const image=CK.MakeImage(info,bytes,32);images.push(image);
|
|
101
|
+
const read=image.readPixels.bind(image);let reads=0;
|
|
102
|
+
image.readPixels=(x,y,format,destination,stride)=>{reads++;expect(destination).toBeTruthy();destinations.push(destination);
|
|
103
|
+
if(fallback && format.colorType===CK.ColorType.BGRA_8888)return null;
|
|
104
|
+
return read(x,y,format,destination,stride);};
|
|
105
|
+
for(let n=0;n<2;n++)expect(screen.drawOpaqueImage(image,[0,0,8,8],[0,0,8,8],[[0,8]])).toBe(true);
|
|
106
|
+
expect(reads).toBe(fallback?2:1);
|
|
107
|
+
const actual=screen.saveRegion(0,0,8,8).pixels;
|
|
108
|
+
for(let i=0;i<256;i+=4)expect([...actual.subarray(i,i+4)]).toEqual([19,71,201,255]);
|
|
109
|
+
}
|
|
110
|
+
expect(destinations.every(d=>d===destinations[0])).toBe(true);
|
|
111
|
+
}finally{images.forEach(i=>i.delete());screen.close();}
|
|
112
|
+
},20000);
|
|
113
|
+
|
|
114
|
+
test('raw images bypass Skia allocation/readback; covered text, removal, crops and fallback match full repaint', async()=>{
|
|
115
|
+
const {openCanvas}=await import('../initramfs/lib/canvas.js');
|
|
116
|
+
const {decodeImage}=await import('../initramfs/lib/bunterm/images.js');
|
|
117
|
+
const {skiaImage,rawImageData}=await import('../initramfs/lib/raw-image.js');
|
|
118
|
+
const CK=await loadCanvasKit(),fonts=loadFontSet(CK,'terminal');
|
|
119
|
+
const make=()=>{const s=openCanvas(CK,{width:320,height:200,stride:1296,bitsPerPixel:32,
|
|
120
|
+
fields:{red:{offset:16,length:8},green:{offset:8,length:8},blue:{offset:0,length:8}},pixels:new Uint8Array(1296*200)});s.flush=()=>s.surface.flush();return s;};
|
|
121
|
+
const screen=make(),reference=make();reference.drawOpaqueImage=undefined;reference.canDrawRawImage=undefined;
|
|
122
|
+
const r=new Renderer({CanvasKit:CK,screen,fonts}),ref=new Renderer({CanvasKit:CK,screen:reference,fonts});
|
|
123
|
+
const term=new Terminal({cols:r.columns,rows:r.rows,allowProposedApi:true});
|
|
124
|
+
const handles=[];let allocations=0,draws=0;
|
|
125
|
+
const makeImage=CK.MakeImage.bind(CK),drawRow=r.drawRow.bind(r);
|
|
126
|
+
const proxy=Object.create(CK);proxy.MakeImage=(...args)=>{allocations++;return makeImage(...args);};
|
|
127
|
+
r.drawRow=(...args)=>{draws++;return drawRow(...args);};
|
|
128
|
+
const create=async(format=0x42475241,alpha=255)=>{
|
|
129
|
+
const bytes=new Uint8Array(320*200*4);
|
|
130
|
+
for(let i=0;i<bytes.length;i+=4)bytes.set([i%251,83,211,alpha],i);
|
|
131
|
+
const handle=await decodeImage(proxy,{format,width:320,height:200,data:bytes},{deferRaw:true});handles.push(handle);
|
|
132
|
+
return handle;
|
|
133
|
+
};
|
|
134
|
+
const check=(image,rect=[0,0,320,200],source=[0,0,320,200],pointer=null,invalidate=true)=>{
|
|
135
|
+
const images=image?[{image,rect,source}]:[];
|
|
136
|
+
if(invalidate)r.invalidate();r.render(term,{images,pointer,cursor:{x:1,y:1,visible:true}});
|
|
137
|
+
const before=allocations;
|
|
138
|
+
ref.invalidate();ref.render(term,{images:images.map(p=>({...p,image:skiaImage(p.image)})),pointer,cursor:{x:1,y:1,visible:true}});
|
|
139
|
+
expect(screen.saveRegion(0,0,320,200).pixels).toEqual(reference.saveRegion(0,0,320,200).pixels);
|
|
140
|
+
return before;
|
|
141
|
+
};
|
|
142
|
+
try{
|
|
143
|
+
await new Promise(resolve=>term.write('Hello 中文\r\nHidden text',resolve));
|
|
144
|
+
const raw=await create();
|
|
145
|
+
expect(allocations).toBe(0);expect(check(raw)).toBe(0);expect(draws).toBe(0);
|
|
146
|
+
// Editing covered text must be visible once the image disappears.
|
|
147
|
+
await new Promise(resolve=>term.write('\rChanged underlying text',resolve));
|
|
148
|
+
check(raw,[0,0,320,200],[0,0,320,200],{x:100,y:100});
|
|
149
|
+
check(null,undefined,undefined,null,false);expect(draws).toBeGreaterThan(0);
|
|
150
|
+
check(raw,[-5,11,310,171],[5,20,320,180]);
|
|
151
|
+
check(raw,[0,0,160,100]); // scaling materializes lazily
|
|
152
|
+
check(await create(32)); // channel mismatch falls back
|
|
153
|
+
check(await create(0x42475241,100)); // transparency must paint underlying text
|
|
154
|
+
check(raw,[0.5,0,320.5,200]);
|
|
155
|
+
r.setFontSize(20);ref.setFontSize(20);term.resize(r.columns,r.rows);check(raw);check(null);
|
|
156
|
+
raw.delete();expect(rawImageData(raw)).toBeUndefined();raw.delete();
|
|
157
|
+
}finally{handles.forEach(i=>i.delete());term.dispose();r.delete();ref.delete();screen.close();reference.close();fonts.delete();}
|
|
158
|
+
},20000);
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import {test,expect} from "bun:test";
|
|
2
|
+
import {Terminal} from "../initramfs/lib/xterm/xterm-headless.mjs";
|
|
3
|
+
import {createSelection} from "../initramfs/lib/bunterm/selection.js";
|
|
4
|
+
import {pasteText} from "../initramfs/lib/bunterm/clipboard.js";
|
|
5
|
+
const write = (term,text) => new Promise(resolve=>term.write(text,resolve));
|
|
6
|
+
const point = (x,y,pressed=[],released=[]) => ({x,y,pressed,released,moved:true});
|
|
7
|
+
test("selection joins wraps, includes wide graphemes and works backwards", async () => {
|
|
8
|
+
const term=new Terminal({cols:8,rows:4,allowProposedApi:true});
|
|
9
|
+
try {
|
|
10
|
+
await write(term,"ab中文efghij\r\nlast");
|
|
11
|
+
const s=createSelection(term,{offsetX:0,offsetY:0,cellWidth:1,cellHeight:1},()=>{});
|
|
12
|
+
s.pointer(point(3,0,["left"]));
|
|
13
|
+
s.pointer(point(1,1,[],["left"]));
|
|
14
|
+
expect(s.text()).toBe("中文efgh");
|
|
15
|
+
s.pointer(point(1,1,["left"]));
|
|
16
|
+
s.pointer(point(3,0,[],["left"]));
|
|
17
|
+
expect(s.text()).toBe("中文efgh");
|
|
18
|
+
s.pointer(point(0,1,["left"]));
|
|
19
|
+
s.pointer(point(3,2,[],["left"]));
|
|
20
|
+
expect(s.text()).toBe("ghij\nlast");
|
|
21
|
+
await write(term,"\x1b[?1000h");
|
|
22
|
+
expect(s.pointer(point(0,0,["left"]))).toBe(false);
|
|
23
|
+
expect(s.active).toBe(false);
|
|
24
|
+
} finally {term.dispose();}
|
|
25
|
+
});
|
|
26
|
+
test("paste uses one negotiated bracketed write, strips embedded ESC, normalizes LF", async () => {
|
|
27
|
+
const term=new Terminal({allowProposedApi:true}),out=[];
|
|
28
|
+
try {
|
|
29
|
+
pasteText(term,"a\nb\r\nc",s=>out.push(s));
|
|
30
|
+
expect(out.pop()).toBe("a\rb\rc");
|
|
31
|
+
await write(term,"\x1b[?2004h");
|
|
32
|
+
pasteText(term,"a\n\x1b[201~b",s=>out.push(s));
|
|
33
|
+
expect(out.pop()).toBe("\x1b[200~a\r[201~b\x1b[201~");
|
|
34
|
+
await write(term,"\x1b[?2004l");
|
|
35
|
+
pasteText(term,"text",s=>out.push(s));
|
|
36
|
+
expect(out).toEqual(["text"]);
|
|
37
|
+
} finally {term.dispose();}
|
|
38
|
+
});
|