mirror of
https://github.com/sehugg/8bitworkshop.git
synced 2026-03-10 21:25:31 +00:00
cli: added --json and color mode, run w/ bitmap display, --platform
This commit is contained in:
14
package-lock.json
generated
14
package-lock.json
generated
@@ -53,6 +53,7 @@
|
||||
"lzg": "^1.0.x",
|
||||
"mocha": "^10.7.3",
|
||||
"mocha-simple-html-reporter": "^2.0.0",
|
||||
"supports-terminal-graphics": "^0.1.0",
|
||||
"typescript": "^5.9.2",
|
||||
"typescript-formatter": "^7.2.2"
|
||||
},
|
||||
@@ -7872,6 +7873,19 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/supports-terminal-graphics": {
|
||||
"version": "0.1.0",
|
||||
"resolved": "https://registry.npmjs.org/supports-terminal-graphics/-/supports-terminal-graphics-0.1.0.tgz",
|
||||
"integrity": "sha512-+KdfozhS0Fw8y5Sghw8kkZNGT8nWYzJ1EzcoIvVjxhl+26TJTs26y02yfBgvc1jh5AS/c8jcI3xtahhR95KRyQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/symbol-tree": {
|
||||
"version": "3.2.4",
|
||||
"resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz",
|
||||
|
||||
@@ -54,6 +54,7 @@
|
||||
"lzg": "^1.0.x",
|
||||
"mocha": "^10.7.3",
|
||||
"mocha-simple-html-reporter": "^2.0.0",
|
||||
"supports-terminal-graphics": "^0.1.0",
|
||||
"typescript": "^5.9.2",
|
||||
"typescript-formatter": "^7.2.2"
|
||||
},
|
||||
|
||||
@@ -12,26 +12,142 @@ interface CLIResult {
|
||||
error?: string;
|
||||
}
|
||||
|
||||
function outputJSON(result: CLIResult): void {
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
// ANSI color helpers
|
||||
const c = {
|
||||
reset: '\x1b[0m',
|
||||
bold: '\x1b[1m',
|
||||
dim: '\x1b[2m',
|
||||
red: '\x1b[31m',
|
||||
green: '\x1b[32m',
|
||||
yellow: '\x1b[33m',
|
||||
blue: '\x1b[34m',
|
||||
magenta: '\x1b[35m',
|
||||
cyan: '\x1b[36m',
|
||||
white: '\x1b[37m',
|
||||
bgRed: '\x1b[41m',
|
||||
bgGreen: '\x1b[42m',
|
||||
};
|
||||
|
||||
var jsonMode = false;
|
||||
|
||||
function output(result: CLIResult): void {
|
||||
if (jsonMode) {
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
} else {
|
||||
outputPretty(result);
|
||||
}
|
||||
}
|
||||
|
||||
function usage(): void {
|
||||
outputJSON({
|
||||
success: false,
|
||||
command: 'help',
|
||||
data: {
|
||||
commands: {
|
||||
'compile': 'compile --platform <platform> [--tool <tool>] [--output <file>] <source>',
|
||||
'check': 'check --platform <platform> [--tool <tool>] <source>',
|
||||
'run': 'run --platform <platform> [--frames N] <rom>',
|
||||
'list-tools': 'list-tools',
|
||||
'list-platforms': 'list-platforms',
|
||||
}
|
||||
},
|
||||
error: 'No command specified'
|
||||
});
|
||||
process.exit(1);
|
||||
function outputPretty(result: CLIResult): void {
|
||||
// Status badge
|
||||
if (result.success) {
|
||||
process.stderr.write(`${c.bgGreen}${c.bold}${c.white} OK ${c.reset} `);
|
||||
} else {
|
||||
process.stderr.write(`${c.bgRed}${c.bold}${c.white} FAIL ${c.reset} `);
|
||||
}
|
||||
// Command name
|
||||
process.stderr.write(`${c.bold}${c.cyan}${result.command}${c.reset}\n`);
|
||||
|
||||
// Error message
|
||||
if (result.error) {
|
||||
process.stderr.write(`${c.red}Error: ${result.error}${c.reset}\n`);
|
||||
}
|
||||
|
||||
// Data
|
||||
if (result.data) {
|
||||
formatData(result.command, result.data);
|
||||
}
|
||||
}
|
||||
|
||||
function formatData(command: string, data: any): void {
|
||||
switch (command) {
|
||||
case 'help':
|
||||
formatHelp(data);
|
||||
break;
|
||||
case 'compile':
|
||||
case 'check':
|
||||
formatCompile(data);
|
||||
break;
|
||||
case 'list-tools':
|
||||
formatListTools(data);
|
||||
break;
|
||||
case 'list-platforms':
|
||||
formatListPlatforms(data);
|
||||
break;
|
||||
default:
|
||||
// Fallback: print as indented key-value pairs
|
||||
formatGeneric(data);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
function formatHelp(data: any): void {
|
||||
if (data.commands) {
|
||||
console.log(`\n${c.bold}Usage:${c.reset} 8bws <command> [options]\n`);
|
||||
console.log(`${c.bold}Commands:${c.reset}`);
|
||||
for (var [cmd, usage] of Object.entries(data.commands)) {
|
||||
console.log(` ${c.green}${cmd}${c.reset}${c.dim} - ${usage}${c.reset}`);
|
||||
}
|
||||
console.log(`\n${c.bold}Global options:${c.reset}`);
|
||||
console.log(` ${c.yellow}--json${c.reset}${c.dim} Output raw JSON instead of formatted text${c.reset}`);
|
||||
console.log();
|
||||
}
|
||||
}
|
||||
|
||||
function formatCompile(data: any): void {
|
||||
if (data.errors) {
|
||||
for (var err of data.errors) {
|
||||
var loc = '';
|
||||
if (err.path) loc += `${c.cyan}${err.path}${c.reset}`;
|
||||
if (err.line) loc += `${c.dim}:${c.reset}${c.yellow}${err.line}${c.reset}`;
|
||||
if (loc) loc += ` ${c.dim}-${c.reset} `;
|
||||
console.log(` ${c.red}●${c.reset} ${loc}${err.msg || err.message || JSON.stringify(err)}`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (data.tool) console.log(` ${c.dim}Tool:${c.reset} ${c.green}${data.tool}${c.reset}`);
|
||||
if (data.platform) console.log(` ${c.dim}Platform:${c.reset} ${c.green}${data.platform}${c.reset}`);
|
||||
if (data.source) console.log(` ${c.dim}Source:${c.reset} ${c.cyan}${data.source}${c.reset}`);
|
||||
if (data.outputSize != null) console.log(` ${c.dim}Size:${c.reset} ${c.yellow}${data.outputSize}${c.reset} bytes`);
|
||||
if (data.outputFile) console.log(` ${c.dim}Output:${c.reset} ${c.cyan}${data.outputFile}${c.reset}`);
|
||||
if (data.hasListings) console.log(` ${c.dim}Listings:${c.reset} ${c.green}yes${c.reset}`);
|
||||
if (data.hasSymbolmap) console.log(` ${c.dim}Symbols:${c.reset} ${c.green}yes${c.reset}`);
|
||||
}
|
||||
|
||||
function formatListTools(data: any): void {
|
||||
console.log(`\n${c.bold}Available tools${c.reset} ${c.dim}(${data.count})${c.reset}\n`);
|
||||
for (var tool of data.tools) {
|
||||
console.log(` ${c.green}●${c.reset} ${tool}`);
|
||||
}
|
||||
console.log();
|
||||
}
|
||||
|
||||
function formatListPlatforms(data: any): void {
|
||||
console.log(`\n${c.bold}Available platforms${c.reset} ${c.dim}(${data.count})${c.reset}\n`);
|
||||
// Group by arch
|
||||
let byArch: { [arch: string]: string[] } = {};
|
||||
for (let [name, info] of Object.entries(data.platforms) as [string, any][]) {
|
||||
let arch = info.arch || 'unknown';
|
||||
if (!byArch[arch]) byArch[arch] = [];
|
||||
byArch[arch].push(name);
|
||||
}
|
||||
for (let [arch, platforms] of Object.entries(byArch).sort()) {
|
||||
console.log(` ${c.bold}${c.magenta}${arch}${c.reset}`);
|
||||
for (let p of platforms.sort()) {
|
||||
console.log(` ${c.green}●${c.reset} ${p}`);
|
||||
}
|
||||
}
|
||||
console.log();
|
||||
}
|
||||
|
||||
function formatGeneric(data: any): void {
|
||||
for (var [key, value] of Object.entries(data)) {
|
||||
if (typeof value === 'object' && value !== null) {
|
||||
console.log(` ${c.dim}${key}:${c.reset} ${JSON.stringify(value)}`);
|
||||
} else {
|
||||
console.log(` ${c.dim}${key}:${c.reset} ${value}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function parseArgs(argv: string[]): { command: string; args: { [key: string]: string }; positional: string[] } {
|
||||
@@ -55,6 +171,24 @@ function parseArgs(argv: string[]): { command: string; args: { [key: string]: st
|
||||
return { command, args, positional };
|
||||
}
|
||||
|
||||
function usage(): void {
|
||||
output({
|
||||
success: false,
|
||||
command: 'help',
|
||||
data: {
|
||||
commands: {
|
||||
'compile': 'compile --platform <platform> [--tool <tool>] [--output <file>] <source>',
|
||||
'check': 'check --platform <platform> [--tool <tool>] <source>',
|
||||
'run': 'run (--platform <id> | --machine <module:ClassName>) [--frames N] [--output <file.png>] <rom>',
|
||||
'list-tools': 'list-tools',
|
||||
'list-platforms': 'list-platforms',
|
||||
}
|
||||
},
|
||||
error: 'No command specified'
|
||||
});
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
async function doCompile(args: { [key: string]: string }, positional: string[], checkOnly: boolean): Promise<void> {
|
||||
var tool = args['tool'];
|
||||
var platform = args['platform'];
|
||||
@@ -62,7 +196,7 @@ async function doCompile(args: { [key: string]: string }, positional: string[],
|
||||
var sourceFile = positional[0];
|
||||
|
||||
if (!platform || !sourceFile) {
|
||||
outputJSON({
|
||||
output({
|
||||
success: false,
|
||||
command: checkOnly ? 'check' : 'compile',
|
||||
error: 'Required: --platform <platform> <source> [--tool <tool>]'
|
||||
@@ -76,7 +210,7 @@ async function doCompile(args: { [key: string]: string }, positional: string[],
|
||||
}
|
||||
|
||||
if (!TOOLS[tool]) {
|
||||
outputJSON({
|
||||
output({
|
||||
success: false,
|
||||
command: checkOnly ? 'check' : 'compile',
|
||||
error: `Unknown tool: ${tool}. Use list-tools to see available tools.`
|
||||
@@ -94,7 +228,7 @@ async function doCompile(args: { [key: string]: string }, positional: string[],
|
||||
var result = await compileSourceFile(tool, platform, sourceFile);
|
||||
|
||||
if (!result.success) {
|
||||
outputJSON({
|
||||
output({
|
||||
success: false,
|
||||
command: checkOnly ? 'check' : 'compile',
|
||||
data: { errors: result.errors }
|
||||
@@ -103,7 +237,7 @@ async function doCompile(args: { [key: string]: string }, positional: string[],
|
||||
}
|
||||
|
||||
if (checkOnly) {
|
||||
outputJSON({
|
||||
output({
|
||||
success: true,
|
||||
command: 'check',
|
||||
data: {
|
||||
@@ -133,7 +267,7 @@ async function doCompile(args: { [key: string]: string }, positional: string[],
|
||||
outputSize = result.output.code ? result.output.code.length : result.output.length;
|
||||
}
|
||||
|
||||
outputJSON({
|
||||
output({
|
||||
success: true,
|
||||
command: 'compile',
|
||||
data: {
|
||||
@@ -149,42 +283,97 @@ async function doCompile(args: { [key: string]: string }, positional: string[],
|
||||
}
|
||||
|
||||
async function doRun(args: { [key: string]: string }, positional: string[]): Promise<void> {
|
||||
var platform = args['platform'];
|
||||
var platformId = args['platform'];
|
||||
var machine = args['machine'];
|
||||
var frames = parseInt(args['frames'] || '1');
|
||||
var outputFile = args['output'];
|
||||
var romFile = positional[0];
|
||||
|
||||
if (!platform || !romFile) {
|
||||
outputJSON({
|
||||
if ((!machine && !platformId) || !romFile) {
|
||||
output({
|
||||
success: false,
|
||||
command: 'run',
|
||||
error: 'Required: --platform <platform> <rom>'
|
||||
error: 'Required: (--platform <id> | --machine <module:ClassName>) [--frames N] [--output <file.png>] <rom>'
|
||||
});
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Dynamic import of MachineRunner
|
||||
try {
|
||||
var runmachine = require('./runmachine.js');
|
||||
var MachineRunner = runmachine.MachineRunner;
|
||||
} catch (e) {
|
||||
outputJSON({
|
||||
success: false,
|
||||
command: 'run',
|
||||
error: `Could not load MachineRunner: ${e.message}`
|
||||
});
|
||||
process.exit(1);
|
||||
var romData = new Uint8Array(fs.readFileSync(romFile));
|
||||
var pixels: Uint32Array | null = null;
|
||||
var vid: { width: number; height: number } | null = null;
|
||||
|
||||
if (platformId) {
|
||||
// Platform mode: load platform module, mock video, run via Platform API
|
||||
var { PlatformRunner, loadPlatform } = await import('./runmachine');
|
||||
var platformRunner = new PlatformRunner(await loadPlatform(platformId));
|
||||
await platformRunner.start();
|
||||
platformRunner.loadROM("ROM", romData);
|
||||
for (var i = 0; i < frames; i++) {
|
||||
platformRunner.run();
|
||||
}
|
||||
pixels = platformRunner.pixels;
|
||||
vid = platformRunner.videoParams;
|
||||
} else {
|
||||
// Machine mode: load machine class directly
|
||||
var parts = machine.split(':');
|
||||
if (parts.length !== 2) {
|
||||
output({
|
||||
success: false,
|
||||
command: 'run',
|
||||
error: 'Machine must be in format module:ClassName (e.g. apple2:AppleII)'
|
||||
});
|
||||
process.exit(1);
|
||||
}
|
||||
var [modname, clsname] = parts;
|
||||
var { MachineRunner, loadMachine } = await import('./runmachine');
|
||||
var machineInstance = await loadMachine(modname, clsname);
|
||||
var runner = new MachineRunner(machineInstance);
|
||||
runner.setup();
|
||||
machineInstance.loadROM(romData);
|
||||
for (var i = 0; i < frames; i++) {
|
||||
runner.run();
|
||||
}
|
||||
pixels = runner.pixels;
|
||||
vid = pixels ? (machineInstance as any).getVideoParams() : null;
|
||||
}
|
||||
|
||||
outputJSON({
|
||||
success: false,
|
||||
// Encode framebuffer as PNG if video is available
|
||||
var pngData: Uint8Array | null = null;
|
||||
if (pixels && vid) {
|
||||
var { encode } = await import('fast-png');
|
||||
var rgba = new Uint8Array(pixels.buffer);
|
||||
pngData = encode({ width: vid.width, height: vid.height, data: rgba, channels: 4 });
|
||||
}
|
||||
|
||||
// Write PNG to file if requested
|
||||
if (outputFile && pngData) {
|
||||
fs.writeFileSync(outputFile, pngData);
|
||||
}
|
||||
|
||||
output({
|
||||
success: true,
|
||||
command: 'run',
|
||||
error: 'Run command not yet fully implemented (requires platform machine loading)'
|
||||
data: {
|
||||
platform: platformId || null,
|
||||
machine: machine || null,
|
||||
rom: romFile,
|
||||
frames: frames,
|
||||
width: vid ? vid.width : null,
|
||||
height: vid ? vid.height : null,
|
||||
outputFile: outputFile || null,
|
||||
}
|
||||
});
|
||||
|
||||
// Display image in terminal if connected to a TTY
|
||||
if (pngData && process.stdout.isTTY) {
|
||||
var { displayImageInTerminal } = await import('./termimage');
|
||||
displayImageInTerminal(pngData, vid.width, vid.height);
|
||||
}
|
||||
}
|
||||
|
||||
function doListTools(): void {
|
||||
var tools = listTools();
|
||||
outputJSON({
|
||||
output({
|
||||
success: true,
|
||||
command: 'list-tools',
|
||||
data: {
|
||||
@@ -202,7 +391,7 @@ function doListPlatforms(): void {
|
||||
arch: PLATFORM_PARAMS[p].arch || 'unknown',
|
||||
};
|
||||
}
|
||||
outputJSON({
|
||||
output({
|
||||
success: true,
|
||||
command: 'list-platforms',
|
||||
data: {
|
||||
@@ -219,6 +408,11 @@ async function main() {
|
||||
|
||||
var { command, args, positional } = parseArgs(process.argv);
|
||||
|
||||
// Check for --json flag (can appear before or after the command)
|
||||
if (args['json'] === 'true' || process.argv.includes('--json')) {
|
||||
jsonMode = true;
|
||||
}
|
||||
|
||||
try {
|
||||
switch (command) {
|
||||
case 'compile':
|
||||
@@ -241,7 +435,7 @@ async function main() {
|
||||
doListPlatforms();
|
||||
break;
|
||||
default:
|
||||
outputJSON({
|
||||
output({
|
||||
success: false,
|
||||
command: command,
|
||||
error: `Unknown command: ${command}`
|
||||
@@ -249,7 +443,7 @@ async function main() {
|
||||
process.exit(1);
|
||||
}
|
||||
} catch (e) {
|
||||
outputJSON({
|
||||
output({
|
||||
success: false,
|
||||
command: command,
|
||||
error: e.message || String(e)
|
||||
|
||||
@@ -1,9 +1,19 @@
|
||||
|
||||
import { hasAudio, hasSerialIO, hasVideo, Machine } from "../common/baseplatform";
|
||||
import { hasAudio, hasSerialIO, hasVideo, Machine, Platform } from "../common/baseplatform";
|
||||
import { SampledAudioSink, SerialIOInterface } from "../common/devices";
|
||||
import { PLATFORMS } from "../common/emu";
|
||||
import * as emu from "../common/emu";
|
||||
import { getRootBasePlatform } from "../common/util";
|
||||
|
||||
global.atob = require('atob');
|
||||
global.btoa = require('btoa');
|
||||
if (typeof window === 'undefined') {
|
||||
(global as any).window = global;
|
||||
(global as any).window.addEventListener = (global as any).window.addEventListener || function () { };
|
||||
(global as any).window.removeEventListener = (global as any).window.removeEventListener || function () { };
|
||||
(global as any).document = (global as any).document || { addEventListener() { }, removeEventListener() { } };
|
||||
}
|
||||
try { (global as any).navigator = (global as any).navigator || {}; } catch (e) { }
|
||||
|
||||
class NullAudio implements SampledAudioSink {
|
||||
feedSample(value: number, count: number): void {
|
||||
@@ -58,6 +68,98 @@ class SerialTestHarness implements SerialIOInterface {
|
||||
|
||||
///
|
||||
|
||||
// Headless mock for RasterVideo/VectorVideo (used by Platform.start() in Node)
|
||||
// Patches the emu module exports so that platform modules pick up the mock.
|
||||
function installHeadlessVideo() {
|
||||
var lastPixels: Uint32Array | null = null;
|
||||
var lastVideoParams: { width: number; height: number } | null = null;
|
||||
(emu as any).RasterVideo = function (_mainElement: any, width: number, height: number, _options?: any) {
|
||||
var buffer = new ArrayBuffer(width * height * 4);
|
||||
var datau8 = new Uint8Array(buffer);
|
||||
var datau32 = new Uint32Array(buffer);
|
||||
lastVideoParams = { width, height };
|
||||
lastPixels = datau32;
|
||||
this.create = function () { this.width = width; this.height = height; };
|
||||
this.setKeyboardEvents = function () { };
|
||||
this.getFrameData = function () { return datau32; };
|
||||
this.getImageData = function () { return { data: datau8, width, height }; };
|
||||
this.updateFrame = function () { };
|
||||
this.clearRect = function () { };
|
||||
this.setupMouseEvents = function () { };
|
||||
this.canvas = this;
|
||||
this.getContext = function () { return this; };
|
||||
this.fillRect = function () { };
|
||||
this.fillStyle = '';
|
||||
this.putImageData = function () { };
|
||||
};
|
||||
(emu as any).VectorVideo = function (_mainElement: any, _width: number, _height: number, _options?: any) {
|
||||
this.create = function () { this.drawops = 0; };
|
||||
this.setKeyboardEvents = function () { };
|
||||
this.clear = function () { };
|
||||
this.drawLine = function () { this.drawops++; };
|
||||
};
|
||||
// Also mock AnimationTimer to be a no-op in headless mode
|
||||
(emu as any).AnimationTimer = function (_fps: number, _callback: any) {
|
||||
this.running = false;
|
||||
this.start = function () { };
|
||||
this.stop = function () { };
|
||||
this.isRunning = function () { return this.running; };
|
||||
};
|
||||
return { getPixels: () => lastPixels, getVideoParams: () => lastVideoParams };
|
||||
}
|
||||
|
||||
///
|
||||
|
||||
export class PlatformRunner {
|
||||
platform: Platform;
|
||||
private headless: ReturnType<typeof installHeadlessVideo>;
|
||||
|
||||
constructor(platform: Platform) {
|
||||
this.platform = platform;
|
||||
this.headless = installHeadlessVideo();
|
||||
}
|
||||
async start() {
|
||||
await this.platform.start();
|
||||
}
|
||||
loadROM(title: string, data: Uint8Array) {
|
||||
this.platform.loadROM(title, data);
|
||||
}
|
||||
run() {
|
||||
// nextFrame() is on BaseDebugPlatform, not the Platform interface
|
||||
if ((this.platform as any).nextFrame) {
|
||||
(this.platform as any).nextFrame();
|
||||
} else if (this.platform.advance) {
|
||||
this.platform.advance(false);
|
||||
}
|
||||
}
|
||||
get pixels(): Uint32Array | null {
|
||||
return this.headless.getPixels();
|
||||
}
|
||||
get videoParams(): { width: number; height: number } | null {
|
||||
return this.headless.getVideoParams();
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadPlatform(platformId: string): Promise<Platform> {
|
||||
// Derive the base module name (e.g. "nes" from "nes-asm", "c64" from "c64.wasm")
|
||||
var baseId = getRootBasePlatform(platformId);
|
||||
// Dynamically load the platform module which registers into PLATFORMS
|
||||
await import('../platform/' + baseId);
|
||||
var PlatformClass = PLATFORMS[platformId];
|
||||
if (!PlatformClass) {
|
||||
// Try the base platform ID
|
||||
PlatformClass = PLATFORMS[baseId];
|
||||
}
|
||||
if (!PlatformClass) {
|
||||
var available = Object.keys(PLATFORMS).join(', ');
|
||||
throw new Error(`Platform '${platformId}' not found. Available: ${available}`);
|
||||
}
|
||||
var platform = new PlatformClass(null);
|
||||
return platform;
|
||||
}
|
||||
|
||||
///
|
||||
|
||||
export class MachineRunner {
|
||||
machine: Machine;
|
||||
pixels: Uint32Array;
|
||||
@@ -86,10 +188,13 @@ export class MachineRunner {
|
||||
}
|
||||
}
|
||||
|
||||
async function loadMachine(modname: string, clsname: string): Promise<Machine> {
|
||||
export async function loadMachine(modname: string, clsname: string, ...ctorArgs: any[]): Promise<Machine> {
|
||||
var mod = await import('../machine/' + modname);
|
||||
var cls = mod[clsname];
|
||||
var machine = new cls();
|
||||
if (!cls) {
|
||||
throw new Error(`Class '${clsname}' not found in module '../machine/${modname}'`);
|
||||
}
|
||||
var machine = new cls(...ctorArgs);
|
||||
return machine;
|
||||
}
|
||||
|
||||
|
||||
102
src/tools/termimage.ts
Normal file
102
src/tools/termimage.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
// Terminal image display using Kitty or iTerm2 protocols.
|
||||
// Uses supports-terminal-graphics for detection; rendering has no npm deps.
|
||||
|
||||
import supportsTerminalGraphics from 'supports-terminal-graphics';
|
||||
|
||||
/**
|
||||
* Auto-pick a nearest-neighbor scale (2x, 3x, or 4x) so the image
|
||||
* is large enough to see but fits roughly within the terminal.
|
||||
*/
|
||||
function autoScale(imageWidth: number, imageHeight: number): number {
|
||||
const termCols = process.stdout.columns || 80;
|
||||
// Assume ~8px per cell width; aim to fill about half the terminal width
|
||||
const targetPx = termCols * 4;
|
||||
const scale = Math.max(1, Math.min(4, Math.round(targetPx / imageWidth)));
|
||||
return scale;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect terminal type and display a PNG image inline.
|
||||
* imageWidth/imageHeight are the original pixel dimensions (avoids re-decoding for Kitty/iTerm2).
|
||||
* Returns true if the image was displayed, false otherwise.
|
||||
*/
|
||||
export function displayImageInTerminal(pngData: Uint8Array, imageWidth?: number, imageHeight?: number): boolean {
|
||||
if (!process.stdout.isTTY) return false;
|
||||
|
||||
// If dimensions not provided, read from PNG header
|
||||
if (!imageWidth || !imageHeight) {
|
||||
const dims = readPNGDimensions(pngData);
|
||||
imageWidth = dims.width;
|
||||
imageHeight = dims.height;
|
||||
}
|
||||
if (!imageWidth || !imageHeight) return false;
|
||||
|
||||
const scale = autoScale(imageWidth, imageHeight);
|
||||
const support = supportsTerminalGraphics.stdout;
|
||||
|
||||
if (support.kitty) {
|
||||
displayKitty(pngData, imageWidth, imageHeight, scale);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (support.iterm2) {
|
||||
displayITerm2(pngData, imageWidth, imageHeight, scale);
|
||||
return true;
|
||||
}
|
||||
|
||||
/*
|
||||
if (support.sixel) {
|
||||
displaySixel(pngData);
|
||||
return true;
|
||||
}
|
||||
*/
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Read width/height from a PNG IHDR chunk without full decode. */
|
||||
function readPNGDimensions(data: Uint8Array): { width: number; height: number } {
|
||||
if (data.length >= 24 && data[12] === 0x49 && data[13] === 0x48 && data[14] === 0x44 && data[15] === 0x52) {
|
||||
return { width: readU32(data, 16), height: readU32(data, 20) };
|
||||
}
|
||||
return { width: 0, height: 0 };
|
||||
}
|
||||
|
||||
/**
|
||||
* Kitty graphics protocol: transmit PNG data as base64 chunks.
|
||||
* c= and r= specify display size in terminal cells (~8px wide, ~16px tall).
|
||||
* https://sw.kovidgoyal.net/kitty/graphics-protocol/
|
||||
*/
|
||||
function displayKitty(pngData: Uint8Array, w: number, h: number, scale: number): void {
|
||||
const b64 = Buffer.from(pngData).toString('base64');
|
||||
const chunkSize = 4096;
|
||||
const cols = Math.ceil(w * scale / 8);
|
||||
const rows = Math.ceil(h * scale / 16);
|
||||
|
||||
for (let i = 0; i < b64.length; i += chunkSize) {
|
||||
const chunk = b64.slice(i, i + chunkSize);
|
||||
const more = i + chunkSize < b64.length ? 1 : 0;
|
||||
if (i === 0) {
|
||||
process.stdout.write(`\x1b_Gf=100,a=T,c=${cols},r=${rows},m=${more};${chunk}\x1b\\`);
|
||||
} else {
|
||||
process.stdout.write(`\x1b_Gm=${more};${chunk}\x1b\\`);
|
||||
}
|
||||
}
|
||||
process.stdout.write('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* iTerm2 inline image protocol with pixel-level width/height.
|
||||
* https://iterm2.com/documentation-images.html
|
||||
*/
|
||||
function displayITerm2(pngData: Uint8Array, w: number, h: number, scale: number): void {
|
||||
const b64 = Buffer.from(pngData).toString('base64');
|
||||
const pw = w * scale;
|
||||
const ph = h * scale;
|
||||
process.stdout.write(`\x1b]1337;File=inline=1;size=${pngData.length};width=${pw}px;height=${ph}px:${b64}\x07\n`);
|
||||
}
|
||||
|
||||
function readU32(data: Uint8Array, offset: number): number {
|
||||
return (data[offset] << 24 | data[offset + 1] << 16 | data[offset + 2] << 8 | data[offset + 3]) >>> 0;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user