mirror of
https://github.com/wavetermdev/xterm.js.git
synced 2026-08-05 13:43:48 -07:00
Start integrating KittyGraphics stuff into imageAddon as suggested
This commit is contained in:
@@ -41,9 +41,6 @@ jobs:
|
||||
./addons/addon-image/lib/* \
|
||||
./addons/addon-image/out/* \
|
||||
./addons/addon-image/out-*/* \
|
||||
./addons/addon-kitty-graphics/lib/* \
|
||||
./addons/addon-kitty-graphics/out/* \
|
||||
./addons/addon-kitty-graphics/out-*/* \
|
||||
./addons/addon-ligatures/lib/* \
|
||||
./addons/addon-ligatures/out/* \
|
||||
./addons/addon-ligatures/out-*/* \
|
||||
@@ -219,8 +216,6 @@ jobs:
|
||||
run: npm run test-integration-${{ matrix.browser }} --workers=50% --forbid-only --suite=addon-fit
|
||||
- name: Integration tests (addon-image)
|
||||
run: npm run test-integration-${{ matrix.browser }} --workers=50% --forbid-only --suite=addon-image
|
||||
- name: Integration tests (addon-kitty-graphics)
|
||||
run: npm run test-integration-${{ matrix.browser }} --workers=50% --forbid-only --suite=addon-kitty-graphics
|
||||
- name: Integration tests (addon-progress)
|
||||
run: npm run test-integration-${{ matrix.browser }} --workers=50% --forbid-only --suite=addon-progress
|
||||
- name: Integration tests (addon-search)
|
||||
|
||||
|
Before Width: | Height: | Size: 123 B After Width: | Height: | Size: 123 B |
|
Before Width: | Height: | Size: 131 B After Width: | Height: | Size: 131 B |
@@ -8,6 +8,7 @@ import type { ImageAddon as IImageApi } from '@xterm/addon-image';
|
||||
import { IIPHandler } from './IIPHandler';
|
||||
import { ImageRenderer } from './ImageRenderer';
|
||||
import { ImageStorage, CELL_SIZE_DEFAULT } from './ImageStorage';
|
||||
import { KittyGraphicsHandler } from './kitty/KittyGraphicsHandler';
|
||||
import { SixelHandler } from './SixelHandler';
|
||||
import { ITerminalExt, IImageAddonOptions, IResetHandler } from './Types';
|
||||
|
||||
@@ -22,7 +23,9 @@ const DEFAULT_OPTIONS: IImageAddonOptions = {
|
||||
storageLimit: 128,
|
||||
showPlaceholder: true,
|
||||
iipSupport: true,
|
||||
iipSizeLimit: 20000000
|
||||
iipSizeLimit: 20000000,
|
||||
kittySupport: true,
|
||||
kittySizeLimit: 20000000
|
||||
};
|
||||
|
||||
// max palette size supported by the sixel lib (compile time setting)
|
||||
@@ -144,6 +147,15 @@ export class ImageAddon implements ITerminalAddon , IImageApi {
|
||||
terminal._core._inputHandler._parser.registerOscHandler(1337, iipHandler)
|
||||
);
|
||||
}
|
||||
|
||||
// Kitty graphics handler
|
||||
if (this._opts.kittySupport) {
|
||||
const kittyHandler = new KittyGraphicsHandler(this._opts, this._renderer!, this._storage!, terminal);
|
||||
this._handlers.set('kitty', kittyHandler);
|
||||
this._disposeLater(
|
||||
terminal._core._inputHandler._parser.registerApcHandler(0x47, kittyHandler)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Note: storageLimit is skipped here to not intoduce a surprising side effect.
|
||||
|
||||
@@ -8,7 +8,7 @@ import { IDisposable, IMarker, Terminal } from '@xterm/xterm';
|
||||
// private imports from base repo we build against
|
||||
import { Attributes, BgFlags, Content, ExtFlags, UnderlineStyle } from 'common/buffer/Constants';
|
||||
import type { AttributeData } from 'common/buffer/AttributeData';
|
||||
import type { IParams, IDcsHandler, IOscHandler, IEscapeSequenceParser } from 'common/parser/Types';
|
||||
import type { IParams, IDcsHandler, IOscHandler, IApcHandler, IEscapeSequenceParser } from 'common/parser/Types';
|
||||
import type { IBufferLine, IExtendedAttrs, IInputHandler } from 'common/Types';
|
||||
import type { ITerminal, ReadonlyColorSet } from 'browser/Types';
|
||||
import type { IRenderDimensions } from 'browser/renderer/shared/Types';
|
||||
@@ -22,7 +22,7 @@ export const enum Cell {
|
||||
}
|
||||
|
||||
// export some privates for local usage
|
||||
export { AttributeData, IParams, IDcsHandler, IOscHandler, BgFlags, IRenderDimensions, IRenderService, Content, ExtFlags, Attributes, UnderlineStyle, ReadonlyColorSet };
|
||||
export { AttributeData, IParams, IDcsHandler, IOscHandler, IApcHandler, BgFlags, IRenderDimensions, IRenderService, Content, ExtFlags, Attributes, UnderlineStyle, ReadonlyColorSet };
|
||||
|
||||
/**
|
||||
* Plugin ctor options.
|
||||
@@ -38,6 +38,8 @@ export interface IImageAddonOptions {
|
||||
sixelSizeLimit: number;
|
||||
iipSupport: boolean;
|
||||
iipSizeLimit: number;
|
||||
kittySupport: boolean;
|
||||
kittySizeLimit: number;
|
||||
}
|
||||
|
||||
export interface IResetHandler {
|
||||
|
||||
@@ -0,0 +1,494 @@
|
||||
/**
|
||||
* Copyright (c) 2025 The xterm.js authors. All rights reserved.
|
||||
* @license MIT
|
||||
*
|
||||
* Kitty graphics protocol APC handler.
|
||||
* Implements IApcHandler to integrate with xterm.js parser.
|
||||
*/
|
||||
|
||||
import { IApcHandler, IImageAddonOptions, IResetHandler, ITerminalExt } from '../Types';
|
||||
import { ImageRenderer } from '../ImageRenderer';
|
||||
import { ImageStorage, CELL_SIZE_DEFAULT } from '../ImageStorage';
|
||||
import {
|
||||
KittyAction,
|
||||
KittyFormat,
|
||||
KittyCompression,
|
||||
IKittyCommand,
|
||||
IPendingTransmission,
|
||||
IKittyImageData,
|
||||
BYTES_PER_PIXEL_RGB,
|
||||
BYTES_PER_PIXEL_RGBA,
|
||||
ALPHA_OPAQUE,
|
||||
parseKittyCommand
|
||||
} from './KittyGraphicsTypes';
|
||||
|
||||
/**
|
||||
* Kitty graphics protocol handler.
|
||||
*
|
||||
* Handles APC sequences for the Kitty graphics protocol, supporting:
|
||||
* - Image transmission (a=t)
|
||||
* - Image transmission + display (a=T)
|
||||
* - Query (a=q)
|
||||
* - Delete (a=d)
|
||||
*
|
||||
* TODO: File transmission (t=f) is not supported since browsers cannot access the filesystem.
|
||||
* Explore using the File System Access API for opt-in filesystem access when available.
|
||||
*/
|
||||
export class KittyGraphicsHandler implements IApcHandler, IResetHandler {
|
||||
private _aborted = false;
|
||||
private _data: string = '';
|
||||
|
||||
/**
|
||||
* Pending chunked transmissions keyed by image ID.
|
||||
* ID 0 is used for transmissions without an explicit ID (the "anonymous" transmission).
|
||||
*/
|
||||
private _pendingTransmissions: Map<number, IPendingTransmission> = new Map();
|
||||
private _nextImageId = 1;
|
||||
|
||||
/**
|
||||
* Stored images - kept for protocol compliance (transmit without display, then display later).
|
||||
*/
|
||||
private _images: Map<number, IKittyImageData> = new Map();
|
||||
|
||||
/**
|
||||
* Cached decoded bitmaps.
|
||||
*/
|
||||
private _decodedImages: Map<number, ImageBitmap> = new Map();
|
||||
|
||||
constructor(
|
||||
private readonly _opts: IImageAddonOptions,
|
||||
private readonly _renderer: ImageRenderer,
|
||||
private readonly _storage: ImageStorage,
|
||||
private readonly _coreTerminal: ITerminalExt
|
||||
) {}
|
||||
|
||||
public reset(): void {
|
||||
// Clear pending transmissions
|
||||
this._pendingTransmissions.clear();
|
||||
// Clear stored images
|
||||
this._images.clear();
|
||||
// Close decoded bitmaps
|
||||
for (const bitmap of this._decodedImages.values()) {
|
||||
bitmap.close();
|
||||
}
|
||||
this._decodedImages.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Called at the start of a new APC sequence.
|
||||
*/
|
||||
public start(): void {
|
||||
this._aborted = false;
|
||||
this._data = '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Called for each chunk of data in the APC sequence.
|
||||
* Accumulates codepoints as a string.
|
||||
*/
|
||||
public put(data: Uint32Array, start: number, end: number): void {
|
||||
if (this._aborted) return;
|
||||
|
||||
// Check size limit
|
||||
if (this._data.length + (end - start) > this._opts.kittySizeLimit) {
|
||||
console.warn('[KittyHandler] Data exceeds size limit, aborting');
|
||||
this._aborted = true;
|
||||
return;
|
||||
}
|
||||
|
||||
// Convert Uint32Array codepoints to string
|
||||
// TODO: This atob + charCodeAt pattern has bad runtime. Consider using the wasm-based
|
||||
// base64 decoder from xterm-wasm-parts which supports chunked data ingestion.
|
||||
// For now, we accumulate as string and decode at the end.
|
||||
for (let i = start; i < end; i++) {
|
||||
this._data += String.fromCodePoint(data[i]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Called at the end of the APC sequence.
|
||||
*/
|
||||
public end(success: boolean): boolean | Promise<boolean> {
|
||||
if (this._aborted || !success) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return this._handleCommand(this._data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle a complete Kitty graphics command.
|
||||
*/
|
||||
private _handleCommand(data: string): boolean | Promise<boolean> {
|
||||
const semiIdx = data.indexOf(';');
|
||||
const controlData = semiIdx === -1 ? data : data.substring(0, semiIdx);
|
||||
const payload = semiIdx === -1 ? '' : data.substring(semiIdx + 1);
|
||||
|
||||
const cmd = parseKittyCommand(controlData);
|
||||
cmd.payload = payload;
|
||||
|
||||
const action = cmd.action ?? 't';
|
||||
|
||||
switch (action) {
|
||||
case KittyAction.TRANSMIT:
|
||||
return this._handleTransmit(cmd);
|
||||
case KittyAction.TRANSMIT_DISPLAY:
|
||||
return this._handleTransmitDisplay(cmd);
|
||||
case KittyAction.QUERY:
|
||||
return this._handleQuery(cmd);
|
||||
case KittyAction.DELETE:
|
||||
return this._handleDelete(cmd);
|
||||
default:
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
private _handleTransmit(cmd: IKittyCommand): boolean {
|
||||
const payload = cmd.payload ?? '';
|
||||
const pendingKey = cmd.id ?? 0;
|
||||
const isMoreComing = cmd.more === 1;
|
||||
const pending = this._pendingTransmissions.get(pendingKey);
|
||||
|
||||
if (pending) {
|
||||
pending.data += payload;
|
||||
|
||||
if (isMoreComing) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const originalCmd = pending.cmd;
|
||||
const fullPayload = pending.data;
|
||||
this._pendingTransmissions.delete(pendingKey);
|
||||
|
||||
const id = originalCmd.id ?? this._nextImageId++;
|
||||
|
||||
const image: IKittyImageData = {
|
||||
id,
|
||||
data: fullPayload,
|
||||
width: originalCmd.width ?? 0,
|
||||
height: originalCmd.height ?? 0,
|
||||
format: (originalCmd.format ?? KittyFormat.PNG) as 24 | 32 | 100,
|
||||
compression: originalCmd.compression ?? ''
|
||||
};
|
||||
|
||||
this._images.set(image.id, image);
|
||||
|
||||
if (originalCmd.action === KittyAction.TRANSMIT_DISPLAY) {
|
||||
// Fire-and-forget the display, transmit itself succeeded
|
||||
void this._displayImage(image, originalCmd.columns, originalCmd.rows);
|
||||
return true;
|
||||
}
|
||||
|
||||
cmd.id = id;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (isMoreComing) {
|
||||
this._pendingTransmissions.set(pendingKey, {
|
||||
cmd: { ...cmd },
|
||||
data: payload
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
const id = cmd.id ?? this._nextImageId++;
|
||||
|
||||
const image: IKittyImageData = {
|
||||
id,
|
||||
data: payload,
|
||||
width: cmd.width ?? 0,
|
||||
height: cmd.height ?? 0,
|
||||
format: (cmd.format ?? KittyFormat.PNG) as 24 | 32 | 100,
|
||||
compression: cmd.compression ?? ''
|
||||
};
|
||||
|
||||
this._images.set(image.id, image);
|
||||
cmd.id = id;
|
||||
return true;
|
||||
}
|
||||
|
||||
private _handleTransmitDisplay(cmd: IKittyCommand): boolean | Promise<boolean> {
|
||||
const pendingKey = cmd.id ?? 0;
|
||||
const wasPendingBefore = this._pendingTransmissions.has(pendingKey);
|
||||
|
||||
this._handleTransmit(cmd);
|
||||
|
||||
if (cmd.more === 1) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (wasPendingBefore) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const id = cmd.id!;
|
||||
const image = this._images.get(id);
|
||||
if (image) {
|
||||
return this._displayImage(image, cmd.columns, cmd.rows);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle query action (a=q).
|
||||
*/
|
||||
private _handleQuery(cmd: IKittyCommand): boolean {
|
||||
const id = cmd.id ?? 0;
|
||||
const quiet = cmd.quiet ?? 0;
|
||||
const payload = cmd.payload || '';
|
||||
|
||||
if (!payload) {
|
||||
this._sendResponse(id, 'OK', quiet);
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
const binaryString = atob(payload);
|
||||
const bytes = new Uint8Array(binaryString.length);
|
||||
for (let i = 0; i < binaryString.length; i++) {
|
||||
bytes[i] = binaryString.charCodeAt(i);
|
||||
}
|
||||
|
||||
const format = cmd.format || KittyFormat.RGBA;
|
||||
|
||||
if (format === KittyFormat.PNG) {
|
||||
this._sendResponse(id, 'OK', quiet);
|
||||
} else {
|
||||
const width = cmd.width || 0;
|
||||
const height = cmd.height || 0;
|
||||
|
||||
if (!width || !height) {
|
||||
this._sendResponse(id, 'EINVAL:width and height required for raw pixel data', quiet);
|
||||
return true;
|
||||
}
|
||||
|
||||
const bytesPerPixel = format === KittyFormat.RGBA ? BYTES_PER_PIXEL_RGBA : BYTES_PER_PIXEL_RGB;
|
||||
const expectedBytes = width * height * bytesPerPixel;
|
||||
|
||||
if (bytes.length < expectedBytes) {
|
||||
this._sendResponse(id, `EINVAL:insufficient pixel data, got ${bytes.length}, expected ${expectedBytes}`, quiet);
|
||||
return true;
|
||||
}
|
||||
|
||||
this._sendResponse(id, 'OK', quiet);
|
||||
}
|
||||
} catch (e) {
|
||||
const errorMsg = e instanceof Error ? e.message : 'unknown error';
|
||||
this._sendResponse(id, `EINVAL:${errorMsg}`, quiet);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private _handleDelete(cmd: IKittyCommand): boolean {
|
||||
const id = cmd.id;
|
||||
|
||||
if (id !== undefined) {
|
||||
this._images.delete(id);
|
||||
const bitmap = this._decodedImages.get(id);
|
||||
if (bitmap) {
|
||||
bitmap.close();
|
||||
this._decodedImages.delete(id);
|
||||
}
|
||||
} else {
|
||||
this._images.clear();
|
||||
for (const bitmap of this._decodedImages.values()) {
|
||||
bitmap.close();
|
||||
}
|
||||
this._decodedImages.clear();
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a response back to the client.
|
||||
*/
|
||||
private _sendResponse(id: number, message: string, quiet: number): void {
|
||||
const isOk = message === 'OK';
|
||||
if (isOk && quiet === 1) return;
|
||||
if (!isOk && quiet === 2) return;
|
||||
|
||||
const response = `\x1b_Gi=${id};${message}\x1b\\`;
|
||||
this._coreTerminal._core.coreService.triggerDataEvent(response);
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode and display an image using the shared ImageStorage.
|
||||
*/
|
||||
private _displayImage(image: IKittyImageData, columns?: number, rows?: number): boolean | Promise<boolean> {
|
||||
return this._decodeAndDisplay(image, columns, rows)
|
||||
.then(() => true)
|
||||
.catch(err => {
|
||||
console.warn('[KittyHandler] Failed to decode/display image:', err);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
private async _decodeAndDisplay(image: IKittyImageData, columns?: number, rows?: number): Promise<void> {
|
||||
let bitmap = this._decodedImages.get(image.id);
|
||||
|
||||
if (!bitmap) {
|
||||
bitmap = await this._decodeImage(image);
|
||||
this._decodedImages.set(image.id, bitmap);
|
||||
}
|
||||
|
||||
// Calculate display size
|
||||
let w = bitmap.width;
|
||||
let h = bitmap.height;
|
||||
|
||||
if (columns || rows) {
|
||||
const cw = this._renderer.dimensions?.css.cell.width || CELL_SIZE_DEFAULT.width;
|
||||
const ch = this._renderer.dimensions?.css.cell.height || CELL_SIZE_DEFAULT.height;
|
||||
|
||||
if (columns) {
|
||||
w = columns * cw;
|
||||
}
|
||||
if (rows) {
|
||||
h = rows * ch;
|
||||
}
|
||||
// Maintain aspect ratio if only one dimension specified
|
||||
if (columns && !rows) {
|
||||
h = Math.round(w * (bitmap.height / bitmap.width));
|
||||
} else if (rows && !columns) {
|
||||
w = Math.round(h * (bitmap.width / bitmap.height));
|
||||
}
|
||||
}
|
||||
|
||||
// Check pixel limit
|
||||
if (w * h > this._opts.pixelLimit) {
|
||||
console.warn('[KittyHandler] Image exceeds pixel limit');
|
||||
return;
|
||||
}
|
||||
|
||||
// Use shared ImageStorage to add the image
|
||||
// This handles cursor movement and integration with the terminal
|
||||
if (w !== bitmap.width || h !== bitmap.height) {
|
||||
// Resize if needed
|
||||
const resized = await createImageBitmap(bitmap, { resizeWidth: w, resizeHeight: h });
|
||||
this._storage.addImage(resized);
|
||||
} else {
|
||||
this._storage.addImage(bitmap);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode base64 image data into an ImageBitmap.
|
||||
*/
|
||||
private async _decodeImage(image: IKittyImageData): Promise<ImageBitmap> {
|
||||
const format = image.format;
|
||||
const base64Data = image.data;
|
||||
|
||||
// TODO: This atob + charCodeAt loop has bad runtime and creates memory pressure with large
|
||||
// images. Consider using the wasm-based base64 decoder from xterm-wasm-parts.
|
||||
const binaryString = atob(base64Data);
|
||||
let bytes = new Uint8Array(binaryString.length);
|
||||
for (let i = 0; i < binaryString.length; i++) {
|
||||
bytes[i] = binaryString.charCodeAt(i);
|
||||
}
|
||||
|
||||
if (image.compression === KittyCompression.ZLIB) {
|
||||
bytes = await this._decompressZlib(bytes) as Uint8Array<ArrayBuffer>;
|
||||
}
|
||||
|
||||
if (format === KittyFormat.PNG) {
|
||||
const blob = new Blob([bytes], { type: 'image/png' });
|
||||
// Safari fallback pattern (from IIPHandler)
|
||||
if (!window.createImageBitmap) {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const img = new Image();
|
||||
return new Promise<ImageBitmap>((resolve, reject) => {
|
||||
img.addEventListener('load', () => {
|
||||
URL.revokeObjectURL(url);
|
||||
const canvas = ImageRenderer.createCanvas(window.document, img.width, img.height);
|
||||
canvas.getContext('2d')?.drawImage(img, 0, 0);
|
||||
createImageBitmap(canvas).then(resolve).catch(reject);
|
||||
});
|
||||
img.addEventListener('error', reject);
|
||||
img.src = url;
|
||||
});
|
||||
}
|
||||
return createImageBitmap(blob);
|
||||
}
|
||||
|
||||
// Raw pixel data (RGB or RGBA)
|
||||
const width = image.width;
|
||||
const height = image.height;
|
||||
|
||||
if (!width || !height) {
|
||||
throw new Error('Width and height required for raw pixel data');
|
||||
}
|
||||
|
||||
const bytesPerPixel = format === KittyFormat.RGBA ? BYTES_PER_PIXEL_RGBA : BYTES_PER_PIXEL_RGB;
|
||||
const expectedBytes = width * height * bytesPerPixel;
|
||||
|
||||
if (bytes.length < expectedBytes) {
|
||||
throw new Error(`Insufficient pixel data: got ${bytes.length}, expected ${expectedBytes}`);
|
||||
}
|
||||
|
||||
// Convert to RGBA ImageData
|
||||
// TODO: For RGBA, use bytes directly. For RGB, use Uint32Array bit manipulation
|
||||
// for 5-6x speedup.
|
||||
const pixelCount = width * height;
|
||||
const data = new Uint8ClampedArray(pixelCount * BYTES_PER_PIXEL_RGBA);
|
||||
const isRgba = format === KittyFormat.RGBA;
|
||||
|
||||
let srcOffset = 0;
|
||||
let dstOffset = 0;
|
||||
for (let i = 0; i < pixelCount; i++) {
|
||||
data[dstOffset ] = bytes[srcOffset ]; // R
|
||||
data[dstOffset + 1] = bytes[srcOffset + 1]; // G
|
||||
data[dstOffset + 2] = bytes[srcOffset + 2]; // B
|
||||
data[dstOffset + 3] = isRgba ? bytes[srcOffset + 3] : ALPHA_OPAQUE;
|
||||
srcOffset += bytesPerPixel;
|
||||
dstOffset += BYTES_PER_PIXEL_RGBA;
|
||||
}
|
||||
|
||||
return createImageBitmap(new ImageData(data, width, height));
|
||||
}
|
||||
|
||||
/**
|
||||
* Decompress zlib/deflate compressed data using the browser's DecompressionStream API.
|
||||
*/
|
||||
private async _decompressZlib(compressed: Uint8Array): Promise<Uint8Array> {
|
||||
try {
|
||||
return await this._decompress(compressed, 'deflate');
|
||||
} catch {
|
||||
return await this._decompress(compressed, 'deflate-raw');
|
||||
}
|
||||
}
|
||||
|
||||
private async _decompress(compressed: Uint8Array, format: 'deflate' | 'deflate-raw'): Promise<Uint8Array> {
|
||||
const ds = new DecompressionStream(format);
|
||||
const writer = ds.writable.getWriter();
|
||||
writer.write(new Uint8Array(compressed) as Uint8Array<ArrayBuffer>);
|
||||
writer.close();
|
||||
|
||||
const chunks: Uint8Array[] = [];
|
||||
const reader = ds.readable.getReader();
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
chunks.push(value);
|
||||
}
|
||||
|
||||
const totalLength = chunks.reduce((sum, chunk) => sum + chunk.length, 0);
|
||||
const result = new Uint8Array(totalLength);
|
||||
let offset = 0;
|
||||
for (const chunk of chunks) {
|
||||
result.set(chunk, offset);
|
||||
offset += chunk.length;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get stored images (for testing/debugging).
|
||||
*/
|
||||
public get images(): ReadonlyMap<number, IKittyImageData> {
|
||||
return this._images;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* Copyright (c) 2025 The xterm.js authors. All rights reserved.
|
||||
* @license MIT
|
||||
*/
|
||||
import { assert } from 'chai';
|
||||
import { parseKittyCommand, KittyAction, KittyFormat } from './KittyGraphicsTypes';
|
||||
|
||||
describe('KittyGraphicsTypes', () => {
|
||||
describe('parseKittyCommand', () => {
|
||||
it('should parse control data with action and format', () => {
|
||||
const cmd = parseKittyCommand('a=T,f=100');
|
||||
assert.strictEqual(cmd.action, 'T');
|
||||
assert.strictEqual(cmd.format, 100);
|
||||
});
|
||||
|
||||
it('should parse control data with all options', () => {
|
||||
const cmd = parseKittyCommand('a=t,f=32,i=5,s=10,v=20,c=3,r=2,m=1,q=2');
|
||||
assert.strictEqual(cmd.action, 't');
|
||||
assert.strictEqual(cmd.format, 32);
|
||||
assert.strictEqual(cmd.id, 5);
|
||||
assert.strictEqual(cmd.width, 10);
|
||||
assert.strictEqual(cmd.height, 20);
|
||||
assert.strictEqual(cmd.columns, 3);
|
||||
assert.strictEqual(cmd.rows, 2);
|
||||
assert.strictEqual(cmd.more, 1);
|
||||
assert.strictEqual(cmd.quiet, 2);
|
||||
});
|
||||
|
||||
it('should handle empty control data', () => {
|
||||
const cmd = parseKittyCommand('');
|
||||
assert.strictEqual(cmd.action, undefined);
|
||||
assert.strictEqual(cmd.format, undefined);
|
||||
});
|
||||
|
||||
it('should parse transmit action', () => {
|
||||
const cmd = parseKittyCommand('a=t,f=100');
|
||||
assert.strictEqual(cmd.action, KittyAction.TRANSMIT);
|
||||
assert.strictEqual(cmd.format, KittyFormat.PNG);
|
||||
});
|
||||
|
||||
it('should parse delete action', () => {
|
||||
const cmd = parseKittyCommand('a=d,i=5');
|
||||
assert.strictEqual(cmd.action, KittyAction.DELETE);
|
||||
assert.strictEqual(cmd.id, 5);
|
||||
});
|
||||
|
||||
it('should parse empty action as empty string', () => {
|
||||
const cmd = parseKittyCommand('a=,f=100');
|
||||
assert.strictEqual(cmd.action, '');
|
||||
assert.strictEqual(cmd.format, 100);
|
||||
});
|
||||
|
||||
it('should leave action undefined when key is not present', () => {
|
||||
const cmd = parseKittyCommand('f=100,i=5');
|
||||
assert.strictEqual(cmd.action, undefined);
|
||||
assert.strictEqual(cmd.format, 100);
|
||||
assert.strictEqual(cmd.id, 5);
|
||||
});
|
||||
|
||||
it('should parse compression key', () => {
|
||||
const cmd = parseKittyCommand('a=t,f=32,o=z');
|
||||
assert.strictEqual(cmd.action, 't');
|
||||
assert.strictEqual(cmd.format, 32);
|
||||
assert.strictEqual(cmd.compression, 'z');
|
||||
});
|
||||
|
||||
it('should parse x and y offset', () => {
|
||||
const cmd = parseKittyCommand('a=T,x=10,y=20');
|
||||
assert.strictEqual(cmd.x, 10);
|
||||
assert.strictEqual(cmd.y, 20);
|
||||
});
|
||||
|
||||
it('should handle keys without values', () => {
|
||||
const cmd = parseKittyCommand('a=t,f=,i=5');
|
||||
assert.strictEqual(cmd.action, 't');
|
||||
assert.ok(isNaN(cmd.format!));
|
||||
assert.strictEqual(cmd.id, 5);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,156 @@
|
||||
/**
|
||||
* Copyright (c) 2025 The xterm.js authors. All rights reserved.
|
||||
* @license MIT
|
||||
*
|
||||
* Kitty graphics protocol types, constants, and parsing utilities.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Kitty graphics protocol action types.
|
||||
* See: https://sw.kovidgoyal.net/kitty/graphics-protocol/#control-data-reference under key 'a'.
|
||||
*/
|
||||
export const enum KittyAction {
|
||||
TRANSMIT = 't',
|
||||
TRANSMIT_DISPLAY = 'T',
|
||||
QUERY = 'q',
|
||||
PLACEMENT = 'p',
|
||||
DELETE = 'd'
|
||||
}
|
||||
|
||||
/**
|
||||
* Kitty graphics protocol format types.
|
||||
* See: https://sw.kovidgoyal.net/kitty/graphics-protocol/#control-data-reference
|
||||
*/
|
||||
export const enum KittyFormat {
|
||||
RGB = 24,
|
||||
RGBA = 32,
|
||||
PNG = 100
|
||||
}
|
||||
|
||||
/**
|
||||
* Kitty graphics protocol compression types.
|
||||
* See: https://sw.kovidgoyal.net/kitty/graphics-protocol/#control-data-reference under key 'o'.
|
||||
*/
|
||||
export const enum KittyCompression {
|
||||
NONE = '',
|
||||
ZLIB = 'z'
|
||||
}
|
||||
|
||||
/**
|
||||
* Kitty graphics protocol control data keys.
|
||||
* See: https://sw.kovidgoyal.net/kitty/graphics-protocol/#control-data-reference
|
||||
*/
|
||||
export const enum KittyKey {
|
||||
// Action to perform (t=transmit, T=transmit+display, q=query, p=placement, d=delete)
|
||||
ACTION = 'a',
|
||||
// Image format (24=RGB, 32=RGBA, 100=PNG)
|
||||
FORMAT = 'f',
|
||||
// Image ID for referencing stored images
|
||||
ID = 'i',
|
||||
// Source image width in pixels
|
||||
WIDTH = 's',
|
||||
// Source image height in pixels
|
||||
HEIGHT = 'v',
|
||||
// The left edge (in pixels) of the image area to display
|
||||
X_OFFSET = 'x',
|
||||
// The top edge (in pixels) of the image area to display
|
||||
Y_OFFSET = 'y',
|
||||
// Number of terminal columns to display the image over
|
||||
COLUMNS = 'c',
|
||||
// Number of terminal rows to display the image over
|
||||
ROWS = 'r',
|
||||
// More data flag (1=more chunks coming, 0=final chunk)
|
||||
MORE = 'm',
|
||||
// Compression type (z=zlib). This is essential for chunking larger images.
|
||||
COMPRESSION = 'o',
|
||||
// Quiet mode (1=suppress OK responses, 2=suppress error responses)
|
||||
QUIET = 'q'
|
||||
}
|
||||
|
||||
// Pixel format constants
|
||||
export const BYTES_PER_PIXEL_RGB = 3;
|
||||
export const BYTES_PER_PIXEL_RGBA = 4;
|
||||
export const ALPHA_OPAQUE = 255;
|
||||
|
||||
/**
|
||||
* Parsed Kitty graphics command.
|
||||
*/
|
||||
export interface IKittyCommand {
|
||||
action?: string;
|
||||
format?: number;
|
||||
id?: number;
|
||||
width?: number;
|
||||
height?: number;
|
||||
x?: number;
|
||||
y?: number;
|
||||
columns?: number;
|
||||
rows?: number;
|
||||
more?: number;
|
||||
quiet?: number;
|
||||
compression?: string;
|
||||
payload?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pending chunked transmission state.
|
||||
* Stores metadata from the first chunk while accumulating payload data.
|
||||
*/
|
||||
export interface IPendingTransmission {
|
||||
/** The parsed command from the first chunk (contains action, format, dimensions, etc.) */
|
||||
cmd: IKittyCommand;
|
||||
/** Accumulated base64 payload data */
|
||||
data: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stored Kitty image data.
|
||||
*/
|
||||
export interface IKittyImageData {
|
||||
id: number;
|
||||
data: string;
|
||||
width: number;
|
||||
height: number;
|
||||
format: 24 | 32 | 100;
|
||||
compression?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses Kitty graphics control data into a command object.
|
||||
*/
|
||||
export function parseKittyCommand(data: string): IKittyCommand {
|
||||
const cmd: IKittyCommand = {};
|
||||
const parts = data.split(',');
|
||||
|
||||
for (const part of parts) {
|
||||
const eqIdx = part.indexOf('=');
|
||||
if (eqIdx === -1) continue;
|
||||
|
||||
const key = part.substring(0, eqIdx);
|
||||
const value = part.substring(eqIdx + 1);
|
||||
|
||||
// Handle string keys first
|
||||
if (key === KittyKey.ACTION) {
|
||||
cmd.action = value;
|
||||
continue;
|
||||
}
|
||||
if (key === KittyKey.COMPRESSION) {
|
||||
cmd.compression = value;
|
||||
continue;
|
||||
}
|
||||
const numValue = parseInt(value);
|
||||
switch (key) {
|
||||
case KittyKey.FORMAT: cmd.format = numValue; break;
|
||||
case KittyKey.ID: cmd.id = numValue; break;
|
||||
case KittyKey.WIDTH: cmd.width = numValue; break;
|
||||
case KittyKey.HEIGHT: cmd.height = numValue; break;
|
||||
case KittyKey.X_OFFSET: cmd.x = numValue; break;
|
||||
case KittyKey.Y_OFFSET: cmd.y = numValue; break;
|
||||
case KittyKey.COLUMNS: cmd.columns = numValue; break;
|
||||
case KittyKey.ROWS: cmd.rows = numValue; break;
|
||||
case KittyKey.MORE: cmd.more = numValue; break;
|
||||
case KittyKey.QUIET: cmd.quiet = numValue; break;
|
||||
}
|
||||
}
|
||||
|
||||
return cmd;
|
||||
}
|
||||
@@ -23,6 +23,8 @@ export interface IImageAddonOptions {
|
||||
sixelSizeLimit: number;
|
||||
iipSupport: boolean;
|
||||
iipSizeLimit: number;
|
||||
kittySupport: boolean;
|
||||
kittySizeLimit: number;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line
|
||||
@@ -73,6 +75,10 @@ const TESTDATA_IIP: [string, [number, number]][] = [
|
||||
[readFileSync('./addons/addon-image/fixture/iip/w3c_png.iip', { encoding: 'utf-8' }), [72, 48]]
|
||||
];
|
||||
|
||||
// Kitty graphics test images
|
||||
const KITTY_BLACK_1X1_BASE64 = readFileSync('./addons/addon-image/fixture/kitty/black-1x1.png').toString('base64');
|
||||
const KITTY_RGB_3X1_BASE64 = readFileSync('./addons/addon-image/fixture/kitty/rgb-3x1.png').toString('base64');
|
||||
|
||||
let ctx: ITestContext;
|
||||
test.beforeAll(async ({ browser }) => {
|
||||
ctx = await createTestContext(browser);
|
||||
@@ -134,7 +140,9 @@ test.describe('ImageAddon', () => {
|
||||
storageLimit: 128,
|
||||
showPlaceholder: true,
|
||||
iipSupport: true,
|
||||
iipSizeLimit: 20000000
|
||||
iipSizeLimit: 20000000,
|
||||
kittySupport: true,
|
||||
kittySizeLimit: 20000000
|
||||
};
|
||||
deepStrictEqual(await ctx.page.evaluate(`window.imageAddon._opts`), DEFAULT_OPTIONS);
|
||||
});
|
||||
@@ -149,7 +157,9 @@ test.describe('ImageAddon', () => {
|
||||
storageLimit: 10,
|
||||
showPlaceholder: false,
|
||||
iipSupport: false,
|
||||
iipSizeLimit: 1000
|
||||
iipSizeLimit: 1000,
|
||||
kittySupport: false,
|
||||
kittySizeLimit: 1000
|
||||
};
|
||||
await ctx.page.evaluate(opts => {
|
||||
(window as any).imageAddonCustom = new ImageAddon(opts.opts);
|
||||
@@ -294,6 +304,247 @@ test.describe('ImageAddon', () => {
|
||||
deepStrictEqual(await getOrigSize(1), TESTDATA_IIP[4][1]);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Kitty graphics support', () => {
|
||||
test('stores 1x1 black PNG with a=T (transmit and display)', async () => {
|
||||
const seq = `\x1b_Ga=T,f=100;${KITTY_BLACK_1X1_BASE64}\x1b\\`;
|
||||
await ctx.proxy.write(seq);
|
||||
await timeout(100);
|
||||
strictEqual(await getImageStorageLength(), 1);
|
||||
deepStrictEqual(await getOrigSize(1), [1, 1]);
|
||||
});
|
||||
|
||||
test('stores 3x1 RGB PNG with a=T', async () => {
|
||||
const seq = `\x1b_Ga=T,f=100;${KITTY_RGB_3X1_BASE64}\x1b\\`;
|
||||
await ctx.proxy.write(seq);
|
||||
await timeout(100);
|
||||
strictEqual(await getImageStorageLength(), 1);
|
||||
deepStrictEqual(await getOrigSize(1), [3, 1]);
|
||||
});
|
||||
|
||||
test('transmit only (a=t) does not display but stores in handler', async () => {
|
||||
const seq = `\x1b_Ga=t,f=100;${KITTY_BLACK_1X1_BASE64}\x1b\\`;
|
||||
await ctx.proxy.write(seq);
|
||||
await timeout(100);
|
||||
strictEqual(await ctx.page.evaluate(`window.imageAddon._handlers.get('kitty').images.size`), 1);
|
||||
});
|
||||
|
||||
test('uses specified image ID', async () => {
|
||||
const seq = `\x1b_Ga=t,f=100,i=42;${KITTY_BLACK_1X1_BASE64}\x1b\\`;
|
||||
await ctx.proxy.write(seq);
|
||||
await timeout(100);
|
||||
strictEqual(await ctx.page.evaluate(`window.imageAddon._handlers.get('kitty').images.has(42)`), true);
|
||||
strictEqual(await ctx.page.evaluate(`window.imageAddon._handlers.get('kitty').images.has(1)`), false);
|
||||
});
|
||||
|
||||
test('assigns auto-incrementing IDs when not specified', async () => {
|
||||
await ctx.proxy.write(`\x1b_Ga=t,f=100;${KITTY_BLACK_1X1_BASE64}\x1b\\`);
|
||||
await ctx.proxy.write(`\x1b_Ga=t,f=100;${KITTY_RGB_3X1_BASE64}\x1b\\`);
|
||||
await timeout(100);
|
||||
strictEqual(await ctx.page.evaluate(`window.imageAddon._handlers.get('kitty').images.size`), 2);
|
||||
strictEqual(await ctx.page.evaluate(`window.imageAddon._handlers.get('kitty').images.has(1)`), true);
|
||||
strictEqual(await ctx.page.evaluate(`window.imageAddon._handlers.get('kitty').images.has(2)`), true);
|
||||
});
|
||||
|
||||
test('defaults to transmit action when action is omitted', async () => {
|
||||
const seq = `\x1b_Gf=100;${KITTY_BLACK_1X1_BASE64}\x1b\\`;
|
||||
await ctx.proxy.write(seq);
|
||||
await timeout(100);
|
||||
strictEqual(await ctx.page.evaluate(`window.imageAddon._handlers.get('kitty').images.size`), 1);
|
||||
});
|
||||
|
||||
test('ignores command when action is empty string', async () => {
|
||||
const seq = `\x1b_Ga=,f=100;${KITTY_BLACK_1X1_BASE64}\x1b\\`;
|
||||
await ctx.proxy.write(seq);
|
||||
await timeout(100);
|
||||
strictEqual(await ctx.page.evaluate(`window.imageAddon._handlers.get('kitty').images.size`), 0);
|
||||
});
|
||||
|
||||
test('handles chunked transmission (m=1)', async () => {
|
||||
const half = Math.floor(KITTY_BLACK_1X1_BASE64.length / 2);
|
||||
const part1 = KITTY_BLACK_1X1_BASE64.substring(0, half);
|
||||
const part2 = KITTY_BLACK_1X1_BASE64.substring(half);
|
||||
|
||||
const seq1 = `\x1b_Ga=T,f=100,i=99,m=1;${part1}\x1b\\`;
|
||||
const seq2 = `\x1b_Ga=T,f=100,i=99;${part2}\x1b\\`;
|
||||
|
||||
await ctx.proxy.write(seq1);
|
||||
await timeout(50);
|
||||
strictEqual(await getImageStorageLength(), 0);
|
||||
|
||||
await ctx.proxy.write(seq2);
|
||||
await timeout(100);
|
||||
strictEqual(await getImageStorageLength(), 1);
|
||||
});
|
||||
|
||||
test('verifies chunked data is assembled correctly', async () => {
|
||||
const half = Math.floor(KITTY_BLACK_1X1_BASE64.length / 2);
|
||||
const part1 = KITTY_BLACK_1X1_BASE64.substring(0, half);
|
||||
const part2 = KITTY_BLACK_1X1_BASE64.substring(half);
|
||||
|
||||
await ctx.proxy.write(`\x1b_Ga=t,f=100,i=99,m=1;${part1}\x1b\\`);
|
||||
await ctx.proxy.write(`\x1b_Ga=t,f=100,i=99;${part2}\x1b\\`);
|
||||
await timeout(100);
|
||||
|
||||
const storedData = await ctx.page.evaluate(`window.imageAddon._handlers.get('kitty').images.get(99).data`);
|
||||
strictEqual(storedData, KITTY_BLACK_1X1_BASE64);
|
||||
});
|
||||
|
||||
test('delete command (a=d) removes specific image by id', async () => {
|
||||
await ctx.proxy.write(`\x1b_Ga=t,f=100,i=10;${KITTY_BLACK_1X1_BASE64}\x1b\\`);
|
||||
await timeout(50);
|
||||
strictEqual(await ctx.page.evaluate(`window.imageAddon._handlers.get('kitty').images.size`), 1);
|
||||
|
||||
await ctx.proxy.write(`\x1b_Ga=d,i=10\x1b\\`);
|
||||
await timeout(50);
|
||||
strictEqual(await ctx.page.evaluate(`window.imageAddon._handlers.get('kitty').images.size`), 0);
|
||||
});
|
||||
|
||||
test('delete command (a=d) removes all images when no id specified', async () => {
|
||||
await ctx.proxy.write(`\x1b_Ga=t,f=100,i=1;${KITTY_BLACK_1X1_BASE64}\x1b\\`);
|
||||
await ctx.proxy.write(`\x1b_Ga=t,f=100,i=2;${KITTY_RGB_3X1_BASE64}\x1b\\`);
|
||||
await timeout(50);
|
||||
strictEqual(await ctx.page.evaluate(`window.imageAddon._handlers.get('kitty').images.size`), 2);
|
||||
|
||||
await ctx.proxy.write(`\x1b_Ga=d\x1b\\`);
|
||||
await timeout(50);
|
||||
strictEqual(await ctx.page.evaluate(`window.imageAddon._handlers.get('kitty').images.size`), 0);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Kitty query support (a=q)', () => {
|
||||
test('responds with OK for capability query without payload', async () => {
|
||||
let response = '';
|
||||
await ctx.page.evaluate(() => {
|
||||
(window as any).kittyResponse = '';
|
||||
(window as any).term.onData((data: string) => { (window as any).kittyResponse = data; });
|
||||
});
|
||||
|
||||
await ctx.proxy.write('\x1b_Gi=31,a=q;\x1b\\');
|
||||
await timeout(100);
|
||||
|
||||
response = await ctx.page.evaluate('window.kittyResponse');
|
||||
strictEqual(response, '\x1b_Gi=31;OK\x1b\\');
|
||||
});
|
||||
|
||||
test('responds with OK for valid PNG query', async () => {
|
||||
let response = '';
|
||||
await ctx.page.evaluate(() => {
|
||||
(window as any).kittyResponse = '';
|
||||
(window as any).term.onData((data: string) => { (window as any).kittyResponse = data; });
|
||||
});
|
||||
|
||||
await ctx.proxy.write(`\x1b_Gi=42,a=q,f=100;${KITTY_BLACK_1X1_BASE64}\x1b\\`);
|
||||
await timeout(100);
|
||||
|
||||
response = await ctx.page.evaluate('window.kittyResponse');
|
||||
strictEqual(response, '\x1b_Gi=42;OK\x1b\\');
|
||||
});
|
||||
|
||||
test('query does NOT store the image (unlike transmit)', async () => {
|
||||
await ctx.page.evaluate(() => {
|
||||
(window as any).term.onData(() => { /* consume response */ });
|
||||
});
|
||||
|
||||
await ctx.proxy.write(`\x1b_Gi=50,a=q,f=100;${KITTY_BLACK_1X1_BASE64}\x1b\\`);
|
||||
await timeout(100);
|
||||
|
||||
strictEqual(await ctx.page.evaluate(`window.imageAddon._handlers.get('kitty').images.has(50)`), false);
|
||||
});
|
||||
|
||||
test('responds with error for invalid base64', async () => {
|
||||
let response = '';
|
||||
await ctx.page.evaluate(() => {
|
||||
(window as any).kittyResponse = '';
|
||||
(window as any).term.onData((data: string) => { (window as any).kittyResponse = data; });
|
||||
});
|
||||
|
||||
await ctx.proxy.write('\x1b_Gi=60,a=q,f=100;!!!invalid!!!\x1b\\');
|
||||
await timeout(100);
|
||||
|
||||
response = await ctx.page.evaluate('window.kittyResponse');
|
||||
strictEqual(response.startsWith('\x1b_Gi=60;EINVAL:'), true);
|
||||
});
|
||||
|
||||
test('responds with error for RGB data without dimensions', async () => {
|
||||
let response = '';
|
||||
await ctx.page.evaluate(() => {
|
||||
(window as any).kittyResponse = '';
|
||||
(window as any).term.onData((data: string) => { (window as any).kittyResponse = data; });
|
||||
});
|
||||
|
||||
await ctx.proxy.write('\x1b_Gi=70,a=q,f=24;AAAA\x1b\\');
|
||||
await timeout(100);
|
||||
|
||||
response = await ctx.page.evaluate('window.kittyResponse');
|
||||
strictEqual(response, '\x1b_Gi=70;EINVAL:width and height required for raw pixel data\x1b\\');
|
||||
});
|
||||
|
||||
test('suppresses OK response when q=1', async () => {
|
||||
await ctx.page.evaluate(() => {
|
||||
(window as any).kittyGotResponse = false;
|
||||
(window as any).term.onData(() => { (window as any).kittyGotResponse = true; });
|
||||
});
|
||||
|
||||
await ctx.proxy.write(`\x1b_Gi=80,a=q,q=1,f=100;${KITTY_BLACK_1X1_BASE64}\x1b\\`);
|
||||
await timeout(100);
|
||||
|
||||
strictEqual(await ctx.page.evaluate('window.kittyGotResponse'), false);
|
||||
});
|
||||
|
||||
test('suppresses error response when q=2', async () => {
|
||||
await ctx.page.evaluate(() => {
|
||||
(window as any).kittyGotResponse = false;
|
||||
(window as any).term.onData(() => { (window as any).kittyGotResponse = true; });
|
||||
});
|
||||
|
||||
await ctx.proxy.write('\x1b_Gi=90,a=q,q=2,f=100;!!!invalid!!!\x1b\\');
|
||||
await timeout(100);
|
||||
|
||||
strictEqual(await ctx.page.evaluate('window.kittyGotResponse'), false);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Kitty pixel verification', () => {
|
||||
test('renders 1x1 black PNG at cursor position', async () => {
|
||||
const seq = `\x1b_Ga=T,f=100;${KITTY_BLACK_1X1_BASE64}\x1b\\`;
|
||||
await ctx.proxy.write(seq);
|
||||
await timeout(100);
|
||||
|
||||
const pixel = await ctx.page.evaluate(() => {
|
||||
const canvas = (window as any).imageAddon.getImageAtBufferCell(0, 0);
|
||||
if (!canvas) return null;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return null;
|
||||
return Array.from(ctx.getImageData(0, 0, 1, 1).data);
|
||||
});
|
||||
|
||||
deepStrictEqual(pixel, [0, 0, 0, 255]);
|
||||
});
|
||||
|
||||
test('renders 3x1 RGB PNG (red, green, blue pixels)', async () => {
|
||||
const seq = `\x1b_Ga=T,f=100;${KITTY_RGB_3X1_BASE64}\x1b\\`;
|
||||
await ctx.proxy.write(seq);
|
||||
await timeout(100);
|
||||
|
||||
const pixels = await ctx.page.evaluate(() => {
|
||||
const canvas = (window as any).imageAddon.getImageAtBufferCell(0, 0);
|
||||
if (!canvas) return null;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return null;
|
||||
const imageData = ctx.getImageData(0, 0, 3, 1).data;
|
||||
return {
|
||||
red: Array.from(imageData.slice(0, 4)),
|
||||
green: Array.from(imageData.slice(4, 8)),
|
||||
blue: Array.from(imageData.slice(8, 12))
|
||||
};
|
||||
});
|
||||
|
||||
deepStrictEqual(pixels?.red, [255, 0, 0, 255]);
|
||||
deepStrictEqual(pixels?.green, [0, 255, 0, 255]);
|
||||
deepStrictEqual(pixels?.blue, [0, 0, 255, 255]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
|
||||
+9
@@ -76,6 +76,15 @@ declare module '@xterm/addon-image' {
|
||||
iipSupport?: boolean;
|
||||
/** IIP sequence size limit (default 20000000 bytes). */
|
||||
iipSizeLimit?: number;
|
||||
|
||||
/**
|
||||
* Kitty graphics protocol settings
|
||||
*/
|
||||
|
||||
/** Whether Kitty graphics protocol is enabled (default is true). */
|
||||
kittySupport?: boolean;
|
||||
/** Kitty image size limit in bytes (default 20000000 bytes). */
|
||||
kittySizeLimit?: number;
|
||||
}
|
||||
|
||||
export class ImageAddon implements ITerminalAddon {
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
Copyright (c) 2025, The xterm.js authors (https://github.com/xtermjs/xterm.js)
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in
|
||||
all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
THE SOFTWARE.
|
||||
@@ -1,47 +0,0 @@
|
||||
# @xterm/addon-kitty-graphics
|
||||
|
||||
An addon for [xterm.js](https://github.com/xtermjs/xterm.js) that adds support for the [Kitty graphics protocol](https://sw.kovidgoyal.net/kitty/graphics-protocol/).
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
npm install --save @xterm/addon-kitty-graphics @xterm/xterm
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
```typescript
|
||||
import { Terminal } from '@xterm/xterm';
|
||||
import { KittyGraphicsAddon } from '@xterm/addon-kitty-graphics';
|
||||
|
||||
const terminal = new Terminal();
|
||||
const kittyGraphicsAddon = new KittyGraphicsAddon();
|
||||
terminal.loadAddon(kittyGraphicsAddon);
|
||||
```
|
||||
|
||||
## Features
|
||||
|
||||
This addon implements the Kitty graphics protocol, allowing applications to display images directly in the terminal using APC (Application Program Command) escape sequences.
|
||||
|
||||
### Supported Features
|
||||
|
||||
- PNG image transmission (f=100)
|
||||
- Direct RGB/RGBA pixel data (f=24, f=32)
|
||||
- Image placement at cursor position
|
||||
- Basic query support (a=q)
|
||||
|
||||
### Protocol Format
|
||||
|
||||
The Kitty graphics protocol uses APC escape sequences:
|
||||
|
||||
```
|
||||
<ESC>_G<key>=<value>,<key>=<value>,...;<base64 data><ESC>\
|
||||
```
|
||||
|
||||
Key parameters:
|
||||
- `a`: Action (t=transmit, T=transmit+display, q=query)
|
||||
- `f`: Format (100=PNG, 24=RGB, 32=RGBA)
|
||||
- `i`: Image ID
|
||||
- `m`: More data follows (1=yes, 0=no)
|
||||
|
||||
See the [Kitty graphics protocol documentation](https://sw.kovidgoyal.net/kitty/graphics-protocol/) for full details.
|
||||
@@ -1,27 +0,0 @@
|
||||
{
|
||||
"name": "@xterm/addon-kitty-graphics",
|
||||
"version": "0.1.0",
|
||||
"author": {
|
||||
"name": "The xterm.js authors",
|
||||
"url": "https://xtermjs.org/"
|
||||
},
|
||||
"main": "lib/addon-kitty-graphics.js",
|
||||
"module": "lib/addon-kitty-graphics.mjs",
|
||||
"types": "typings/addon-kitty-graphics.d.ts",
|
||||
"repository": "https://github.com/xtermjs/xterm.js/tree/master/addons/addon-kitty-graphics",
|
||||
"license": "MIT",
|
||||
"keywords": [
|
||||
"terminal",
|
||||
"xterm",
|
||||
"xterm.js",
|
||||
"kitty",
|
||||
"graphics"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "../../node_modules/.bin/tsc -p .",
|
||||
"prepackage": "npm run build",
|
||||
"package": "../../node_modules/.bin/webpack",
|
||||
"prepublishOnly": "npm run package",
|
||||
"start": "node ../../demo/start"
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 938 KiB |
File diff suppressed because it is too large
Load Diff
@@ -1,188 +0,0 @@
|
||||
/**
|
||||
* Copyright (c) 2025 The xterm.js authors. All rights reserved.
|
||||
* @license MIT
|
||||
*/
|
||||
import { assert } from 'chai';
|
||||
import { Terminal } from 'browser/public/Terminal';
|
||||
import { KittyGraphicsAddon } from './KittyGraphicsAddon';
|
||||
import { parseKittyCommand } from './KittyApcHandler';
|
||||
|
||||
/**
|
||||
* Write data to terminal and wait for completion.
|
||||
*/
|
||||
function writeP(terminal: Terminal, data: string | Uint8Array): Promise<void> {
|
||||
return new Promise(r => terminal.write(data, r));
|
||||
}
|
||||
|
||||
// Test image: 1x1 black PNG (captured from `send-png fixture/black-1x1.png`)
|
||||
// Get the below base64-encoded PNG file by: `python3 send-png addons/addon-kitty-graphics/fixture/black-1x1.png`
|
||||
const BLACK_1X1_BASE64 = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAMAAAAoyzS7AAAAA1BMVEUAAACnej3aAAAACklEQVR4nGNgAAAAAgABSK+kcQAAAAt0RVh0Q29tbWVudAAA1LTqjgAAAApJREFUeJxjYAAAAGQA2AAAAAt0RVh0Q29tbWVudAAA1LTqjg5JREFUAAAAASUVORK5CYII=';
|
||||
|
||||
// Test image: 3x1 RGB PNG (red, green, blue pixels)
|
||||
const RGB_3X1_BASE64 = 'iVBORw0KGgoAAAANSUhEUgAAAAMAAAABCAMAAAAsPuSGAAAACVBMVEX/AAAA/wAAAP8tSs2KAAAADElEQVR4nGNgYGQCAAAIAAQ24LCmAAAAHXRFWHRTb2Z0d2FyZQBAbHVuYXBhaW50L3BuZy1jb2RlY/VDGR4AAAAASUVORK5CYII=';
|
||||
|
||||
// Currently tests the flow: write escape sequence -> addon stores image.
|
||||
// Pixel-level verification of rendered images is done in Playwright tests.
|
||||
describe('KittyGraphicsAddon', () => {
|
||||
let terminal: Terminal;
|
||||
let addon: KittyGraphicsAddon;
|
||||
|
||||
beforeEach(() => {
|
||||
terminal = new Terminal({ cols: 80, rows: 24, allowProposedApi: true });
|
||||
addon = new KittyGraphicsAddon({ debug: false });
|
||||
terminal.loadAddon(addon);
|
||||
});
|
||||
|
||||
describe('parseKittyCommand', () => {
|
||||
it('should parse control data with action and format', () => {
|
||||
const cmd = parseKittyCommand('a=T,f=100');
|
||||
assert.equal(cmd.action, 'T');
|
||||
assert.equal(cmd.format, 100);
|
||||
});
|
||||
|
||||
it('should parse control data with all options', () => {
|
||||
const cmd = parseKittyCommand('a=t,f=32,i=5,s=10,v=20,c=3,r=2,m=1,q=2');
|
||||
assert.equal(cmd.action, 't');
|
||||
assert.equal(cmd.format, 32);
|
||||
assert.equal(cmd.id, 5);
|
||||
assert.equal(cmd.width, 10);
|
||||
assert.equal(cmd.height, 20);
|
||||
assert.equal(cmd.columns, 3);
|
||||
assert.equal(cmd.rows, 2);
|
||||
assert.equal(cmd.more, 1);
|
||||
assert.equal(cmd.quiet, 2);
|
||||
});
|
||||
|
||||
it('should handle empty control data', () => {
|
||||
const cmd = parseKittyCommand('');
|
||||
assert.equal(cmd.action, undefined);
|
||||
assert.equal(cmd.format, undefined);
|
||||
});
|
||||
|
||||
it('should parse transmit action', () => {
|
||||
const cmd = parseKittyCommand('a=t,f=100');
|
||||
assert.equal(cmd.action, 't');
|
||||
assert.equal(cmd.format, 100);
|
||||
});
|
||||
|
||||
it('should parse delete action', () => {
|
||||
const cmd = parseKittyCommand('a=d,i=5');
|
||||
assert.equal(cmd.action, 'd');
|
||||
assert.equal(cmd.id, 5);
|
||||
});
|
||||
|
||||
it('should parse empty action as empty string', () => {
|
||||
const cmd = parseKittyCommand('a=,f=100');
|
||||
assert.equal(cmd.action, '');
|
||||
assert.equal(cmd.format, 100);
|
||||
});
|
||||
|
||||
it('should leave action undefined when key is not present (parser only)', () => {
|
||||
const cmd = parseKittyCommand('f=100,i=5');
|
||||
assert.equal(cmd.action, undefined);
|
||||
assert.equal(cmd.format, 100);
|
||||
assert.equal(cmd.id, 5);
|
||||
});
|
||||
});
|
||||
|
||||
describe('APC handler', () => {
|
||||
it('should store image when transmit+display sequence is written', async () => {
|
||||
// Write Kitty graphics sequence: ESC _ G <control>;<payload> ESC \
|
||||
const sequence = `\x1b_Ga=T,f=100;${BLACK_1X1_BASE64}\x1b\\`;
|
||||
await writeP(terminal, sequence);
|
||||
|
||||
// Addon should have stored the image
|
||||
assert.equal(addon.images.size, 1);
|
||||
|
||||
const image = addon.images.get(1)!;
|
||||
assert.exists(image);
|
||||
assert.equal(image.format, 100); // PNG format
|
||||
assert.equal(image.data, BLACK_1X1_BASE64);
|
||||
});
|
||||
|
||||
it('should store RGB image with correct payload', async () => {
|
||||
const sequence = `\x1b_Ga=T,f=100;${RGB_3X1_BASE64}\x1b\\`;
|
||||
await writeP(terminal, sequence);
|
||||
|
||||
assert.equal(addon.images.size, 1);
|
||||
const image = addon.images.get(1)!;
|
||||
assert.equal(image.data, RGB_3X1_BASE64);
|
||||
});
|
||||
|
||||
it('should use explicit image id when provided', async () => {
|
||||
const sequence = `\x1b_Ga=T,f=100,i=42;${BLACK_1X1_BASE64}\x1b\\`;
|
||||
await writeP(terminal, sequence);
|
||||
|
||||
assert.equal(addon.images.size, 1);
|
||||
assert.isTrue(addon.images.has(42));
|
||||
assert.isFalse(addon.images.has(1));
|
||||
});
|
||||
|
||||
it('should handle transmit-only (a=t) without display', async () => {
|
||||
const sequence = `\x1b_Ga=t,f=100;${BLACK_1X1_BASE64}\x1b\\`;
|
||||
await writeP(terminal, sequence);
|
||||
|
||||
// Image should still be stored??
|
||||
assert.equal(addon.images.size, 1);
|
||||
});
|
||||
|
||||
it('should default to transmit action when action is omitted', async () => {
|
||||
// No a= key - should default to 't' (transmit)
|
||||
const sequence = `\x1b_Gf=100;${BLACK_1X1_BASE64}\x1b\\`;
|
||||
await writeP(terminal, sequence);
|
||||
|
||||
// Image should be stored (transmit action)
|
||||
assert.equal(addon.images.size, 1);
|
||||
});
|
||||
|
||||
it('should ignore command when action is empty string', async () => {
|
||||
// a= with no value is invalid - should be ignored
|
||||
const sequence = `\x1b_Ga=,f=100;${BLACK_1X1_BASE64}\x1b\\`;
|
||||
await writeP(terminal, sequence);
|
||||
|
||||
// Empty action is invalid, command should be ignored
|
||||
assert.equal(addon.images.size, 0);
|
||||
});
|
||||
|
||||
it('should delete image by id', async () => {
|
||||
// First store an image with id=5
|
||||
await writeP(terminal, `\x1b_Ga=T,f=100,i=5;${BLACK_1X1_BASE64}\x1b\\`);
|
||||
assert.equal(addon.images.size, 1);
|
||||
|
||||
// Delete it
|
||||
await writeP(terminal, `\x1b_Ga=d,i=5\x1b\\`);
|
||||
assert.equal(addon.images.size, 0);
|
||||
});
|
||||
|
||||
it('should delete all images when no id specified', async () => {
|
||||
// Store multiple images
|
||||
await writeP(terminal, `\x1b_Ga=T,f=100,i=1;${BLACK_1X1_BASE64}\x1b\\`);
|
||||
await writeP(terminal, `\x1b_Ga=T,f=100,i=2;${RGB_3X1_BASE64}\x1b\\`);
|
||||
assert.equal(addon.images.size, 2);
|
||||
|
||||
// Delete all
|
||||
await writeP(terminal, `\x1b_Ga=d\x1b\\`);
|
||||
assert.equal(addon.images.size, 0);
|
||||
});
|
||||
|
||||
it('should handle chunked transmission (m=1 flag)', async () => {
|
||||
// Split payload into chunks using m=1 (more data coming)
|
||||
const half = Math.floor(BLACK_1X1_BASE64.length / 2);
|
||||
const chunk1 = BLACK_1X1_BASE64.substring(0, half);
|
||||
const chunk2 = BLACK_1X1_BASE64.substring(half);
|
||||
|
||||
// First chunk with m=1
|
||||
await writeP(terminal, `\x1b_Ga=t,f=100,i=10,m=1;${chunk1}\x1b\\`);
|
||||
// Image not complete yet (pending)
|
||||
assert.equal(addon.images.size, 0);
|
||||
|
||||
// Final chunk without m=1
|
||||
await writeP(terminal, `\x1b_Ga=t,f=100,i=10;${chunk2}\x1b\\`);
|
||||
// Now image should be stored
|
||||
assert.equal(addon.images.size, 1);
|
||||
|
||||
const image = addon.images.get(10)!;
|
||||
assert.equal(image.data, BLACK_1X1_BASE64);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,71 +0,0 @@
|
||||
/**
|
||||
* Copyright (c) 2025 The xterm.js authors. All rights reserved.
|
||||
* @license MIT
|
||||
*/
|
||||
|
||||
import type { Terminal, ITerminalAddon, IDisposable } from '@xterm/xterm';
|
||||
import type { KittyGraphicsAddon as IKittyGraphicsApi, IKittyGraphicsOptions, IKittyImage } from '@xterm/addon-kitty-graphics';
|
||||
import { KittyImageRenderer } from './KittyImageRenderer';
|
||||
import type { ITerminalExt } from './Types';
|
||||
import { KittyApcHandler } from './KittyApcHandler';
|
||||
|
||||
export class KittyGraphicsAddon implements ITerminalAddon, IKittyGraphicsApi {
|
||||
private _terminal: ITerminalExt | undefined;
|
||||
private _apcHandler: IDisposable | undefined;
|
||||
private _kittyApcHandler: KittyApcHandler | undefined;
|
||||
private _renderer: KittyImageRenderer | undefined;
|
||||
private _images: Map<number, IKittyImage> = new Map();
|
||||
private _decodedImages: Map<number, ImageBitmap> = new Map();
|
||||
private _debug: boolean;
|
||||
|
||||
constructor(options?: IKittyGraphicsOptions) {
|
||||
this._debug = options?.debug ?? false;
|
||||
}
|
||||
|
||||
public get images(): ReadonlyMap<number, IKittyImage> {
|
||||
return this._images;
|
||||
}
|
||||
|
||||
public activate(terminal: Terminal): void {
|
||||
this._terminal = terminal as ITerminalExt;
|
||||
this._renderer = new KittyImageRenderer(terminal);
|
||||
|
||||
if (this._debug) {
|
||||
console.log('[KittyGraphicsAddon] Registering APC handler for G (0x47)');
|
||||
}
|
||||
|
||||
this._kittyApcHandler = new KittyApcHandler(
|
||||
this._images,
|
||||
this._decodedImages,
|
||||
this._renderer,
|
||||
this._terminal,
|
||||
this._debug
|
||||
);
|
||||
|
||||
// Register APC handler for 'G' (0x47) - Kitty graphics protocol
|
||||
// APC sequence format: ESC _ G <data> ESC \
|
||||
// TODO: Follow jerch's feedback: The string-based handler interface is limited to 10MB
|
||||
// and has bad runtime due to string conversion overhead. Implement IApcHandler interface with
|
||||
// start/put/end methods to receive raw Uint32Array codepoints without copying.
|
||||
// See SixelHandler and IIPHandler.
|
||||
this._apcHandler = terminal.parser.registerApcHandler(0x47, (data: string) => {
|
||||
return this._kittyApcHandler?.handle(data) ?? true;
|
||||
});
|
||||
}
|
||||
|
||||
public dispose(): void {
|
||||
this._apcHandler?.dispose();
|
||||
this._kittyApcHandler?.clearPendingTransmissions();
|
||||
this._renderer?.dispose();
|
||||
this._images.clear();
|
||||
|
||||
// Close all decoded bitmaps
|
||||
for (const bitmap of this._decodedImages.values()) {
|
||||
bitmap.close();
|
||||
}
|
||||
this._decodedImages.clear();
|
||||
|
||||
this._terminal = undefined;
|
||||
this._kittyApcHandler = undefined;
|
||||
}
|
||||
}
|
||||
@@ -1,237 +0,0 @@
|
||||
/**
|
||||
* Copyright (c) 2025 The xterm.js authors. All rights reserved.
|
||||
* @license MIT
|
||||
*/
|
||||
|
||||
import type { Terminal, IDisposable } from '@xterm/xterm';
|
||||
|
||||
/**
|
||||
* A placed image in the terminal.
|
||||
*/
|
||||
export interface IPlacedImage {
|
||||
/** The decoded image bitmap */
|
||||
bitmap: ImageBitmap;
|
||||
/** Column position in terminal */
|
||||
col: number;
|
||||
/** Row position in terminal (relative to buffer, not viewport) */
|
||||
row: number;
|
||||
/** Width to render (in pixels, 0 = original) */
|
||||
width: number;
|
||||
/** Height to render (in pixels, 0 = original) */
|
||||
height: number;
|
||||
/** The image ID for reference */
|
||||
id: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles canvas layer management and image rendering for Kitty graphics.
|
||||
*
|
||||
* Similar to ImageRenderer in addon-image but simplified for Kitty protocol.
|
||||
*/
|
||||
export class KittyImageRenderer implements IDisposable {
|
||||
private _canvas: HTMLCanvasElement | undefined;
|
||||
private _ctx: CanvasRenderingContext2D | null | undefined;
|
||||
private _terminal: Terminal;
|
||||
private _placements: Map<number, IPlacedImage> = new Map();
|
||||
private _placementIdCounter = 0;
|
||||
private _renderDisposable: IDisposable | undefined;
|
||||
private _resizeDisposable: IDisposable | undefined;
|
||||
|
||||
constructor(terminal: Terminal) {
|
||||
this._terminal = terminal;
|
||||
}
|
||||
|
||||
public dispose(): void {
|
||||
this._renderDisposable?.dispose();
|
||||
this._resizeDisposable?.dispose();
|
||||
|
||||
// Close all bitmaps
|
||||
for (const placement of this._placements.values()) {
|
||||
placement.bitmap.close();
|
||||
}
|
||||
this._placements.clear();
|
||||
|
||||
if (this._canvas) {
|
||||
this._canvas.remove();
|
||||
this._canvas = undefined;
|
||||
this._ctx = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the canvas layer. Called when first image is placed.
|
||||
*/
|
||||
public ensureCanvasLayer(): void {
|
||||
if (this._canvas) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Access internal screenElement
|
||||
const core = (this._terminal as any)._core;
|
||||
const screenElement = core?.screenElement;
|
||||
if (!screenElement) {
|
||||
console.warn('[KittyGraphicsAddon] Cannot create canvas: no screenElement');
|
||||
return;
|
||||
}
|
||||
|
||||
// Get dimensions from terminal
|
||||
const dimensions = this._terminal.dimensions;
|
||||
const width = dimensions?.css.canvas.width || 800;
|
||||
const height = dimensions?.css.canvas.height || 600;
|
||||
|
||||
// Create canvas
|
||||
this._canvas = document.createElement('canvas');
|
||||
this._canvas.width = width;
|
||||
this._canvas.height = height;
|
||||
this._canvas.classList.add('xterm-kitty-graphics-layer');
|
||||
|
||||
// Position absolutely over the terminal
|
||||
this._canvas.style.position = 'absolute';
|
||||
this._canvas.style.top = '0';
|
||||
this._canvas.style.left = '0';
|
||||
this._canvas.style.pointerEvents = 'none';
|
||||
this._canvas.style.zIndex = '10';
|
||||
|
||||
screenElement.appendChild(this._canvas);
|
||||
this._ctx = this._canvas.getContext('2d', { alpha: true });
|
||||
|
||||
// Hook into render events to redraw when terminal scrolls
|
||||
this._renderDisposable = this._terminal.onRender(() => this._draw());
|
||||
|
||||
// Handle resize
|
||||
this._resizeDisposable = this._terminal.onResize(() => this._resizeCanvas());
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cell dimensions from terminal.
|
||||
*/
|
||||
public getCellSize(): { width: number, height: number } {
|
||||
const dimensions = this._terminal.dimensions;
|
||||
return {
|
||||
width: dimensions?.css.cell.width || 9,
|
||||
height: dimensions?.css.cell.height || 17
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Place a decoded image at cursor position.
|
||||
* @param bitmap - The decoded ImageBitmap to place
|
||||
* @param id - The image ID this placement belongs to
|
||||
* @param col - Optional column position (defaults to cursor X)
|
||||
* @param row - Optional row position (defaults to cursor Y + baseY)
|
||||
* @param width - Optional width in pixels (0 = original)
|
||||
* @param height - Optional height in pixels (0 = original)
|
||||
*/
|
||||
public placeImage(bitmap: ImageBitmap, id: number, col?: number, row?: number, width?: number, height?: number): number {
|
||||
this.ensureCanvasLayer();
|
||||
|
||||
const buffer = this._terminal.buffer.active;
|
||||
const placementId = ++this._placementIdCounter;
|
||||
|
||||
const placement: IPlacedImage = {
|
||||
bitmap,
|
||||
col: col ?? buffer.cursorX,
|
||||
row: row ?? (buffer.cursorY + buffer.baseY),
|
||||
width: width || 0,
|
||||
height: height || 0,
|
||||
id
|
||||
};
|
||||
|
||||
this._placements.set(placementId, placement);
|
||||
this._draw();
|
||||
|
||||
return placementId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a placed image.
|
||||
*/
|
||||
public removePlacement(placementId: number): void {
|
||||
const placement = this._placements.get(placementId);
|
||||
if (placement) {
|
||||
this._placements.delete(placementId);
|
||||
this._draw();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove all placements for an image ID.
|
||||
*/
|
||||
public removeByImageId(imageId: number): void {
|
||||
const toDelete: number[] = [];
|
||||
for (const [placementId, placement] of this._placements) {
|
||||
if (placement.id === imageId) {
|
||||
toDelete.push(placementId);
|
||||
}
|
||||
}
|
||||
for (const id of toDelete) {
|
||||
this._placements.delete(id);
|
||||
}
|
||||
if (toDelete.length > 0) {
|
||||
this._draw();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all images.
|
||||
*/
|
||||
public clearAll(): void {
|
||||
this._placements.clear();
|
||||
if (this._ctx && this._canvas) {
|
||||
this._ctx.clearRect(0, 0, this._canvas.width, this._canvas.height);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Redraw all placed images.
|
||||
*/
|
||||
private _draw(): void {
|
||||
if (!this._ctx || !this._canvas) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Clear canvas
|
||||
this._ctx.clearRect(0, 0, this._canvas.width, this._canvas.height);
|
||||
|
||||
const buffer = this._terminal.buffer.active;
|
||||
const viewportStartRow = buffer.baseY;
|
||||
const viewportRows = this._terminal.rows;
|
||||
const cellSize = this.getCellSize();
|
||||
|
||||
// Draw each placement that's visible
|
||||
for (const placement of this._placements.values()) {
|
||||
// Check if placement is in viewport
|
||||
const relativeRow = placement.row - viewportStartRow;
|
||||
if (relativeRow < 0 || relativeRow >= viewportRows) {
|
||||
continue; // Not in viewport
|
||||
}
|
||||
|
||||
const x = placement.col * cellSize.width;
|
||||
const y = relativeRow * cellSize.height;
|
||||
|
||||
const width = placement.width || placement.bitmap.width;
|
||||
const height = placement.height || placement.bitmap.height;
|
||||
|
||||
this._ctx.drawImage(placement.bitmap, x, y, width, height);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when terminal resizes.
|
||||
*/
|
||||
private _resizeCanvas(): void {
|
||||
if (!this._canvas) {
|
||||
return;
|
||||
}
|
||||
|
||||
const dimensions = this._terminal.dimensions;
|
||||
const width = dimensions?.css.canvas.width || 800;
|
||||
const height = dimensions?.css.canvas.height || 600;
|
||||
|
||||
if (this._canvas.width !== width || this._canvas.height !== height) {
|
||||
this._canvas.width = width;
|
||||
this._canvas.height = height;
|
||||
this._draw();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
/**
|
||||
* Copyright (c) 2025 The xterm.js authors. All rights reserved.
|
||||
* @license MIT
|
||||
*/
|
||||
|
||||
import type { Terminal } from '@xterm/xterm';
|
||||
|
||||
/**
|
||||
Question: Can I delete this since we switched from triggerDataEvent to this._terminal.input
|
||||
*/
|
||||
export interface ITerminalExt extends Terminal {}
|
||||
@@ -1,27 +0,0 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"module": "commonjs",
|
||||
"target": "es2021",
|
||||
"lib": ["dom", "es2015"],
|
||||
"rootDir": ".",
|
||||
"outDir": "../out",
|
||||
"sourceMap": true,
|
||||
"removeComments": true,
|
||||
"strict": true,
|
||||
"types": ["../../../node_modules/@types/mocha"],
|
||||
"paths": {
|
||||
"browser/*": ["../../../src/browser/*"],
|
||||
"vs/*": ["../../../src/vs/*"],
|
||||
"@xterm/addon-kitty-graphics": ["../typings/addon-kitty-graphics.d.ts"]
|
||||
}
|
||||
},
|
||||
"include": ["./**/*", "../../../typings/xterm.d.ts"],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../../src/browser"
|
||||
},
|
||||
{
|
||||
"path": "../../../src/vs"
|
||||
}
|
||||
]
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user