Render Kitty images at Unicode placeholder cells (#26031)

* fix: render Kitty Unicode placeholder images with bounded ownership

* Make image repaint checks independent of display profile and shell output

* Capture repaint diagnostics only after the pixel assertion fails
This commit is contained in:
Neil
2026-10-15 21:29:26 -07:00
committed by GitHub
parent 9c863a6f83
commit 26278b32ce
17 changed files with 3457 additions and 92 deletions
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -105,10 +105,18 @@ index 559b907416eb38318f439d060d7f89311ed34c7e..73cedbe99f831bffd202697cc8ba80a4
const cw = this._renderer.dimensions?.css.cell.width || CELL_SIZE_DEFAULT.width;
const ch = this._renderer.dimensions?.css.cell.height || CELL_SIZE_DEFAULT.height;
diff --git a/src/ImageAddon.ts b/src/ImageAddon.ts
index 8fd39543118cd420e36c1614c1af370b6c7bbfbb..0c44d2a81642113417bf8dc10a4faa76d7cc5864 100644
index 8fd39543118cd420e36c1614c1af370b6c7bbfbb..fcabb492ab1e21853f7c473f8c4d085e1ab12414 100644
--- a/src/ImageAddon.ts
+++ b/src/ImageAddon.ts
@@ -113,6 +113,7 @@ export class ImageAddon implements ITerminalAddon, IImageApi {
@@ -6,6 +6,7 @@
import type { ITerminalAddon, IDisposable } from '@xterm/xterm';
import type { ImageAddon as IImageApi } from '@xterm/addon-image';
import { Emitter, type IEvent } from 'common/Event';
+import { MicrotaskTimer } from 'common/Async';
import { IIPHandler } from './IIPHandler';
import { ImageRenderer } from './ImageRenderer';
import { ImageStorage, CELL_SIZE_DEFAULT } from './ImageStorage';
@@ -113,6 +114,7 @@ export class ImageAddon implements ITerminalAddon, IImageApi {
}
public dispose(): void {
@@ -116,11 +124,45 @@ index 8fd39543118cd420e36c1614c1af370b6c7bbfbb..0c44d2a81642113417bf8dc10a4faa76
for (const obj of this._disposables) {
obj.dispose();
}
@@ -196,14 +198,21 @@ export class ImageAddon implements ITerminalAddon, IImageApi {
// Kitty graphics handler
if (this._opts.kittySupport) {
- const kittyStorage = new KittyImageStorage(this._storage!);
+ const kittyStorage = new KittyImageStorage(this._storage!, this._renderer!);
const kittyHandler = new KittyGraphicsHandler(this._opts, this._renderer!, kittyStorage, terminal);
this._handlers.set('kitty', kittyHandler);
+ const scrollMask = new MicrotaskTimer();
+ const hide = (): void => kittyStorage.hidePlaceholderGlyphs(terminal);
this._disposeLater(
kittyStorage,
kittyHandler,
+ scrollMask,
+ terminal.onWriteParsed(() => { scrollMask.cancel(); hide(); }),
+ terminal.onScroll(() => scrollMask.set(hide)),
+ terminal.onResize(hide),
terminal._core._inputHandler._parser.registerApcHandler({ final: 'G' }, kittyHandler)
);
+ hide();
}
}
diff --git a/src/ImageRenderer.ts b/src/ImageRenderer.ts
index 5854efaec1fdf9dfcb886023542998a563b6d2f2..3afaf9bd63ffd7a4cdf32bf0ac24cf33a8aa814f 100644
index 5854efaec1fdf9dfcb886023542998a563b6d2f2..85448368ec3d65e4f718ac4991a34d6a3fa0ca7d 100644
--- a/src/ImageRenderer.ts
+++ b/src/ImageRenderer.ts
@@ -186,16 +186,17 @@ export class ImageRenderer extends Disposable implements IDisposable {
@@ -140,6 +140,10 @@ export class ImageRenderer extends Disposable implements IDisposable {
};
}
+ public refreshAll(): void {
+ this._renderService?.refreshRows(0, this._terminal.rows - 1);
+ }
+
/**
* Clear a region of the image layer canvas.
*/
@@ -186,16 +190,17 @@ export class ImageRenderer extends Disposable implements IDisposable {
this._rescaleImage(imgSpec, width, height);
const img = imgSpec.actual!;
@@ -143,7 +185,7 @@ index 5854efaec1fdf9dfcb886023542998a563b6d2f2..3afaf9bd63ffd7a4cdf32bf0ac24cf33
// Floor all pixel offsets to get stable tile mapping without any overflows.
// Note: For not pixel perfect aligned cells like in the DOM renderer
@@ -204,7 +205,7 @@ export class ImageRenderer extends Disposable implements IDisposable {
@@ -204,10 +209,72 @@ export class ImageRenderer extends Disposable implements IDisposable {
ctx.drawImage(
img,
Math.floor(sx), Math.floor(sy), Math.ceil(finalWidth), Math.ceil(finalHeight),
@@ -152,7 +194,72 @@ index 5854efaec1fdf9dfcb886023542998a563b6d2f2..3afaf9bd63ffd7a4cdf32bf0ac24cf33
);
}
@@ -219,19 +220,20 @@ export class ImageRenderer extends Disposable implements IDisposable {
+ // Fit the original source into the cell grid without distorting its aspect ratio.
+ public drawCellImage(imgSpec: IImageSpec, boxCols: number, boxRows: number, gridRow: number, gridCol: number, count: number, col: number, row: number): void {
+ const ctx = this._layers.get(imgSpec.layer);
+ if (!ctx) {
+ return;
+ }
+ const { width, height } = this.cellSize;
+ // Don't try to draw anything, if we cannot get valid renderer metrics.
+ if (width === -1 || height === -1) {
+ return;
+ }
+ const img = imgSpec.orig;
+ if (!img) {
+ return;
+ }
+
+ const boxWidth = boxCols * width;
+ const boxHeight = boxRows * height;
+ const fitWidth = img.width * boxHeight > img.height * boxWidth;
+ const scale = fitWidth
+ ? boxWidth / Math.max(1, img.width)
+ : boxHeight / Math.max(1, img.height);
+ const xOffset = fitWidth ? 0 : (boxWidth - img.width * scale) / 2;
+ const yOffset = fitWidth ? (boxHeight - img.height * scale) / 2 : 0;
+
+ // Clip source and destination together so edge tiles are not stretched.
+ let dx = col * width;
+ let dy = row * height;
+ let dw = count * width;
+ let dh = height;
+ let sx = (gridCol * width - xOffset) / scale;
+ let sy = (gridRow * height - yOffset) / scale;
+ let sw = dw / scale;
+ let sh = dh / scale;
+ if (sx < 0) {
+ const clipped = -sx * scale;
+ dx += clipped;
+ dw -= clipped;
+ sw += sx;
+ sx = 0;
+ }
+ if (sy < 0) {
+ const clipped = -sy * scale;
+ dy += clipped;
+ dh -= clipped;
+ sh += sy;
+ sy = 0;
+ }
+ if (sx + sw > img.width) {
+ sw = img.width - sx;
+ dw = sw * scale;
+ }
+ if (sy + sh > img.height) {
+ sh = img.height - sy;
+ dh = sh * scale;
+ }
+ if (sw <= 0 || sh <= 0 || dw <= 0 || dh <= 0) {
+ return;
+ }
+ ctx.drawImage(img, sx, sy, sw, sh, dx, dy, dw, dh);
+ }
+
/**
* Extract a single tile from an image.
*/
@@ -219,19 +286,20 @@ export class ImageRenderer extends Disposable implements IDisposable {
}
this._rescaleImage(imgSpec, width, height);
const img = imgSpec.actual!;
@@ -181,7 +288,7 @@ index 5854efaec1fdf9dfcb886023542998a563b6d2f2..3afaf9bd63ffd7a4cdf32bf0ac24cf33
);
return canvas;
}
@@ -299,11 +301,16 @@ export class ImageRenderer extends Disposable implements IDisposable {
@@ -299,11 +367,16 @@ export class ImageRenderer extends Disposable implements IDisposable {
spec.actualCellSize.height = originalHeight;
return;
}
@@ -203,7 +310,7 @@ index 5854efaec1fdf9dfcb886023542998a563b6d2f2..3afaf9bd63ffd7a4cdf32bf0ac24cf33
const ctx = canvas.getContext('2d');
if (ctx) {
ctx.drawImage(spec.orig!, 0, 0, canvas.width, canvas.height);
@@ -415,7 +422,11 @@ export class ImageRenderer extends Disposable implements IDisposable {
@@ -415,7 +488,11 @@ export class ImageRenderer extends Disposable implements IDisposable {
for (let i = 0; i < width; i += bWidth) {
ctx2.drawImage(blueprint, i, 0);
}
@@ -216,6 +323,105 @@ index 5854efaec1fdf9dfcb886023542998a563b6d2f2..3afaf9bd63ffd7a4cdf32bf0ac24cf33
}
public get document(): Document | undefined {
diff --git a/src/ImageStorage.ts b/src/ImageStorage.ts
index 5dfca3f69fb5fcb5104ded9853b29111f926fe76..189288f0cab7a50ffc2b255c0c8f4e18d3065147 100644
--- a/src/ImageStorage.ts
+++ b/src/ImageStorage.ts
@@ -49,6 +49,8 @@ export class ImageStorage implements IDisposable {
private _viewportMetrics: { cols: number, rows: number };
public onImageAdded: (() => void) | undefined;
public onImageDeleted: ((storageId: number) => void) | undefined;
+ // Placeholder cells carry image references in text and colors, not tile attributes.
+ public onRenderLine: ((line: IBufferLine, row: number, cols: number) => void) | undefined;
constructor(
private _terminal: ITerminalExt,
@@ -74,12 +76,9 @@ export class ImageStorage implements IDisposable {
}
public reset(): void {
- for (const spec of this._images.values()) {
- spec.marker?.dispose();
+ for (const id of [...this._images.keys()]) {
+ this.deleteImage(id);
}
- // NOTE: marker.dispose above already calls ImageBitmap.close
- // therefore we can just wipe the map here
- this._images.clear();
this._renderer.clearAll();
}
@@ -121,6 +120,7 @@ export class ImageStorage implements IDisposable {
spec.orig.close();
}
this.onImageDeleted?.(id);
+ this._renderer.refreshAll();
}
/**
@@ -155,6 +155,36 @@ export class ImageStorage implements IDisposable {
}
}
+ public addUnplacedImage(img: HTMLCanvasElement | ImageBitmap, opts: IAddImageOpts): number {
+ // never allow storage to exceed memory limit
+ this._evictOldest(img.width * img.height);
+
+ let cellSize = this._renderer.cellSize;
+ if (cellSize.width === -1 || cellSize.height === -1) {
+ cellSize = CELL_SIZE_DEFAULT;
+ }
+
+ const imageId = ++this._lastId;
+ this._images.set(imageId, {
+ orig: img,
+ origCellSize: cellSize,
+ actual: img,
+ actualCellSize: { ...cellSize },
+ marker: undefined,
+ tileCount: 0,
+ virtual: true,
+ bufferType: this._terminal.buffer.active.type,
+ layer: opts.layer,
+ zIndex: opts.zIndex
+ });
+ this.onImageAdded?.();
+ return imageId;
+ }
+
+ public getImageSpec(id: number): IImageSpec | undefined {
+ return this._images.get(id);
+ }
+
/**
* Method to add an image to the storage.
* @param img - The image to add (canvas or bitmap).
@@ -226,7 +256,7 @@ export class ImageStorage implements IDisposable {
// deleted images with zero tile count
const zero = [];
for (const [id, spec] of this._images.entries()) {
- if (spec.tileCount < 1) {
+ if (spec.tileCount < 1 && !spec.virtual) {
spec.marker?.dispose();
zero.push(id);
}
@@ -349,6 +379,7 @@ export class ImageStorage implements IDisposable {
for (let row = start; row <= end; ++row) {
const line = buffer.lines.get(row + buffer.ydisp);
if (!line) return;
+ this.onRenderLine?.(line, row, cols);
for (let col = 0; col < cols; ++col) {
const e = line.getExtended(col)?.payload;
if (e instanceof ImageTileInfo) {
@@ -571,7 +602,7 @@ export class ImageStorage implements IDisposable {
// deleted images with zero tile count
const zero = [];
for (const [id, spec] of this._images.entries()) {
- if (spec.bufferType === 'alternate' && !spec.tileCount) {
+ if (spec.bufferType === 'alternate' && !spec.tileCount && !spec.virtual) {
spec.marker?.dispose();
zero.push(id);
}
diff --git a/src/SixelHandler.ts b/src/SixelHandler.ts
index 1af2d85bcdd541ed60b1e707f6186a91f1bbf1b2..0711a122ea43d5212a2851add9e744b65550ec85 100644
--- a/src/SixelHandler.ts
@@ -364,8 +570,20 @@ index 1af2d85bcdd541ed60b1e707f6186a91f1bbf1b2..0711a122ea43d5212a2851add9e744b6
this._storage.addImage(canvas);
return true;
}
diff --git a/src/Types.ts b/src/Types.ts
index 04328420c2bae02010bb4c9adb3fd341aed56c0c..7c4929058e1cd469515d607861ef68894b1c0854 100644
--- a/src/Types.ts
+++ b/src/Types.ts
@@ -103,6 +103,7 @@ export interface IImageSpec {
actualCellSize: ICellSize;
marker: IMarker | undefined;
tileCount: number;
+ virtual?: boolean;
bufferType: 'alternate' | 'normal';
layer: ImageLayer;
zIndex: number;
diff --git a/src/kitty/KittyGraphicsHandler.ts b/src/kitty/KittyGraphicsHandler.ts
index de889dfff75d9ecc8ab47a025e6989ffe75bb202..cf66e5b75c78645b1641e53d1425d88201134f78 100644
index de889dfff75d9ecc8ab47a025e6989ffe75bb202..7de3b740021dae5e8900a51bdec51b4a738b6561 100644
--- a/src/kitty/KittyGraphicsHandler.ts
+++ b/src/kitty/KittyGraphicsHandler.ts
@@ -7,6 +7,7 @@ import { IDisposable } from '@xterm/xterm';
@@ -433,7 +651,92 @@ index de889dfff75d9ecc8ab47a025e6989ffe75bb202..cf66e5b75c78645b1641e53d1425d882
}
if (this._activeDecoder.put(data.subarray(start, end)) !== DECODER_OK) {
@@ -550,9 +583,11 @@ export class KittyGraphicsHandler implements IApcHandler, IResetHandler, IDispos
@@ -382,14 +415,16 @@ export class KittyGraphicsHandler implements IApcHandler, IResetHandler, IDispos
this._sendResponse(id, 'ENOENT:image not found', cmd.quiet ?? 0, cmd.placementId);
return true;
}
- const result = this._displayImage(image, cmd);
+ const result = cmd.unicodePlaceholder === 1
+ ? this._placeVirtual(image, cmd)
+ : this._displayImage(image, cmd);
return result.then(success => {
this._sendResponse(id, success ? 'OK' : 'EINVAL:image rendering failed', cmd.quiet ?? 0, cmd.placementId);
return true;
});
}
- private _handleTransmit(cmd: IKittyCommand, bytes: Uint8Array, decodeError: boolean): boolean {
+ private _handleTransmit(cmd: IKittyCommand, bytes: Uint8Array, decodeError: boolean, retainVirtual = false): boolean {
// TODO: Support file-based transmission modes (t=f, t=t, t=s)
// Currently only supports direct transmission (t=d, the default).
// - t=f (file): Payload is base64-encoded file path. Terminal reads image from that path.
@@ -416,7 +451,7 @@ export class KittyGraphicsHandler implements IApcHandler, IResetHandler, IDispos
height: cmd.height ?? 0,
format: (cmd.format ?? KittyFormat.RGBA) as 24 | 32 | 100,
compression: cmd.compression ?? ''
- });
+ }, retainVirtual);
return true;
}
@@ -428,12 +463,15 @@ export class KittyGraphicsHandler implements IApcHandler, IResetHandler, IDispos
return true;
}
- this._handleTransmit(cmd, bytes, decodeError);
+ const virtual = cmd.unicodePlaceholder === 1;
+ this._handleTransmit(cmd, bytes, decodeError, virtual);
const id = cmd.id ?? this._kittyStorage.lastImageId;
const image = this._kittyStorage.getImage(id);
if (image) {
- const result = this._displayImage(image, cmd);
+ const result = virtual
+ ? this._placeVirtual(image, cmd, true)
+ : this._displayImage(image, cmd);
if (cmd.id !== undefined) {
return result.then(success => {
this._sendResponse(id, success ? 'OK' : 'EINVAL:image rendering failed', cmd.quiet ?? 0);
@@ -500,28 +538,25 @@ export class KittyGraphicsHandler implements IApcHandler, IResetHandler, IDispos
// Per spec: default delete selector is 'a' (delete all visible placements)
const selector = cmd.deleteSelector ?? 'a';
- // TODO: Distinguish lowercase (delete placements only) from uppercase
- // (delete placements + free stored image data). Currently both variants
- // free everything since we don't separate stored data from placements.
switch (selector) {
case 'a':
+ this._kittyStorage.deletePhysicalPlacements();
+ break;
case 'A':
this._cleanupAllPending();
- this._kittyStorage.deleteAll();
+ this._kittyStorage.deleteAll(true);
break;
case 'i':
case 'I':
- // TODO: When placement id tracking is implemented (see TODO in
- // KittyImageStorage), d=i with p=<pid> should delete only that
- // specific placement, while d=i without p should delete all
- // placements for the image.
if (cmd.id !== undefined) {
- const pending = this._pendingTransmissions.get(cmd.id);
- if (pending) {
- pending.decoder.release();
+ if (selector === 'I') {
+ const pending = this._pendingTransmissions.get(cmd.id);
+ pending?.decoder.release();
+ this._removePendingEntry(cmd.id);
+ this._kittyStorage.deleteById(cmd.id, cmd.placementId);
+ } else {
+ this._kittyStorage.deletePlacementsById(cmd.id, cmd.placementId);
}
- this._removePendingEntry(cmd.id);
- this._kittyStorage.deleteById(cmd.id);
}
break;
default:
@@ -550,49 +585,17 @@ export class KittyGraphicsHandler implements IApcHandler, IResetHandler, IDispos
}
private async _decodeAndDisplay(image: IKittyImageData, cmd: IKittyCommand): Promise<void> {
@@ -441,11 +744,54 @@ index de889dfff75d9ecc8ab47a025e6989ffe75bb202..cf66e5b75c78645b1641e53d1425d882
let bitmap: ImageBitmap | undefined = await this._createBitmap(image);
try {
- const cropX = Math.max(0, cmd.x ?? 0);
- const cropY = Math.max(0, cmd.y ?? 0);
- const cropW = cmd.sourceWidth || (bitmap.width - cropX);
- const cropH = cmd.sourceHeight || (bitmap.height - cropY);
-
- const maxCropW = Math.max(0, bitmap.width - cropX);
- const maxCropH = Math.max(0, bitmap.height - cropY);
- const finalCropW = Math.max(0, Math.min(cropW, maxCropW));
- const finalCropH = Math.max(0, Math.min(cropH, maxCropH));
-
- if (finalCropW === 0 || finalCropH === 0) {
- throw new Error('invalid source rectangle');
- }
-
- if (cropX !== 0 || cropY !== 0 || finalCropW !== bitmap.width || finalCropH !== bitmap.height) {
- const cropped = await createImageBitmap(bitmap, cropX, cropY, finalCropW, finalCropH);
- bitmap.close();
- bitmap = cropped;
- }
+ if (generation !== this._generation) throw new Error('image decode canceled');
const cropX = Math.max(0, cmd.x ?? 0);
const cropY = Math.max(0, cmd.y ?? 0);
const cropW = cmd.sourceWidth || (bitmap.width - cropX);
@@ -660,6 +695,7 @@ export class KittyGraphicsHandler implements IApcHandler, IResetHandler, IDispos
+ const cropped = this._cropImage(bitmap, cmd);
+ bitmap = cropped instanceof Promise ? await cropped : cropped;
const cw = this._renderer.dimensions?.css.cell.width || CELL_SIZE_DEFAULT.width;
const ch = this._renderer.dimensions?.css.cell.height || CELL_SIZE_DEFAULT.height;
-
- // Per spec: c/r default to image's natural cell dimensions.
- // If only one of c/r is specified, compute the other from image aspect ratio.
- let imgCols: number;
- let imgRows: number;
- if (cmd.columns !== undefined && cmd.rows !== undefined) {
- imgCols = cmd.columns;
- imgRows = cmd.rows;
- } else if (cmd.columns !== undefined) {
- imgCols = cmd.columns;
- imgRows = Math.max(1, Math.ceil((bitmap.height / bitmap.width) * (imgCols * cw) / ch));
- } else if (cmd.rows !== undefined) {
- imgRows = cmd.rows;
- imgCols = Math.max(1, Math.ceil((bitmap.width / bitmap.height) * (imgRows * ch) / cw));
- } else {
- imgCols = Math.ceil(bitmap.width / cw);
- imgRows = Math.ceil(bitmap.height / ch);
- }
+ let { cols: imgCols, rows: imgRows } = this._placementGrid(bitmap, cmd, cw, ch);
let w = bitmap.width;
let h = bitmap.height;
@@ -660,6 +663,7 @@ export class KittyGraphicsHandler implements IApcHandler, IResetHandler, IDispos
}
}
@@ -453,7 +799,83 @@ index de889dfff75d9ecc8ab47a025e6989ffe75bb202..cf66e5b75c78645b1641e53d1425d882
const zIndex = cmd.zIndex ?? 0;
this._kittyStorage.addImage(image.id, bitmap, true, layer, zIndex);
bitmap = undefined; // ownership transferred to storage
@@ -693,6 +729,12 @@ export class KittyGraphicsHandler implements IApcHandler, IResetHandler, IDispos
@@ -684,6 +688,75 @@ export class KittyGraphicsHandler implements IApcHandler, IResetHandler, IDispos
}
}
+ // Keep the uncropped path synchronous to preserve its parser scheduling.
+ private _cropImage(bitmap: ImageBitmap, cmd: IKittyCommand): ImageBitmap | Promise<ImageBitmap> {
+ const cropX = Math.max(0, cmd.x ?? 0);
+ const cropY = Math.max(0, cmd.y ?? 0);
+ const cropW = cmd.sourceWidth || (bitmap.width - cropX);
+ const cropH = cmd.sourceHeight || (bitmap.height - cropY);
+
+ const maxCropW = Math.max(0, bitmap.width - cropX);
+ const maxCropH = Math.max(0, bitmap.height - cropY);
+ const finalCropW = Math.max(0, Math.min(cropW, maxCropW));
+ const finalCropH = Math.max(0, Math.min(cropH, maxCropH));
+
+ if (finalCropW === 0 || finalCropH === 0) {
+ throw new Error('invalid source rectangle');
+ }
+
+ if (cropX !== 0 || cropY !== 0 || finalCropW !== bitmap.width || finalCropH !== bitmap.height) {
+ return createImageBitmap(bitmap, cropX, cropY, finalCropW, finalCropH).then(cropped => {
+ bitmap.close();
+ return cropped;
+ });
+ }
+ return bitmap;
+ }
+
+ // An omitted grid dimension follows the source aspect ratio.
+ private _placementGrid(bitmap: ImageBitmap, cmd: IKittyCommand, cw: number, ch: number): { cols: number; rows: number } {
+ if (cmd.columns !== undefined && cmd.rows !== undefined) {
+ return { cols: cmd.columns, rows: cmd.rows };
+ }
+ if (cmd.columns !== undefined) {
+ return { cols: cmd.columns, rows: Math.max(1, Math.ceil((bitmap.height / bitmap.width) * (cmd.columns * cw) / ch)) };
+ }
+ if (cmd.rows !== undefined) {
+ return { cols: Math.max(1, Math.ceil((bitmap.width / bitmap.height) * (cmd.rows * ch) / cw)), rows: cmd.rows };
+ }
+ return { cols: Math.ceil(bitmap.width / cw), rows: Math.ceil(bitmap.height / ch) };
+ }
+
+ private async _placeVirtual(image: IKittyImageData, cmd: IKittyCommand, replaces = false): Promise<boolean> {
+ const generation = this._generation;
+ const isCurrent = (): boolean => generation === this._generation && this._kittyStorage.getImage(image.id) === image;
+ let bitmap: ImageBitmap | undefined;
+ try {
+ bitmap = await this._createBitmap(image);
+ if (!isCurrent()) throw new Error('image decode canceled');
+ const cropped = this._cropImage(bitmap, cmd);
+ bitmap = cropped instanceof Promise ? await cropped : cropped;
+
+ const cw = this._renderer.dimensions?.css.cell.width || CELL_SIZE_DEFAULT.width;
+ const ch = this._renderer.dimensions?.css.cell.height || CELL_SIZE_DEFAULT.height;
+ const { cols, rows } = this._placementGrid(bitmap, cmd, cw, ch);
+
+ if (bitmap.width * bitmap.height > this._opts.pixelLimit) {
+ throw new Error('image exceeds pixel limit');
+ }
+
+ if (!isCurrent()) throw new Error('image decode canceled');
+ this._kittyStorage.addVirtualPlacement(image.id, cmd.placementId ?? 0, bitmap, cols, rows);
+ bitmap = undefined; // ownership transferred to storage
+ if (replaces) this._kittyStorage.discardVirtualPlacements(image.id, cmd.placementId ?? 0);
+ return true;
+ } catch {
+ bitmap?.close();
+ if (replaces && isCurrent()) this._kittyStorage.discardVirtualPlacements(image.id);
+ return false;
+ }
+ }
+
// Create ImageBitmap from already-decoded image data.
private async _createBitmap(image: IKittyImageData): Promise<ImageBitmap> {
let bytes: Uint8Array = new Uint8Array(await image.data.arrayBuffer());
@@ -693,6 +766,12 @@ export class KittyGraphicsHandler implements IApcHandler, IResetHandler, IDispos
}
if (image.format === KittyFormat.PNG) {
@@ -466,7 +888,7 @@ index de889dfff75d9ecc8ab47a025e6989ffe75bb202..cf66e5b75c78645b1641e53d1425d882
const blob = new Blob([bytes as BlobPart], { type: 'image/png' });
if (!window.createImageBitmap) {
const url = URL.createObjectURL(blob);
@@ -775,27 +817,45 @@ export class KittyGraphicsHandler implements IApcHandler, IResetHandler, IDispos
@@ -775,27 +854,45 @@ export class KittyGraphicsHandler implements IApcHandler, IResetHandler, IDispos
private async _decompressZlib(compressed: Uint8Array): Promise<Uint8Array> {
try {
return await this._decompress(compressed, 'deflate');
@@ -525,11 +947,251 @@ index de889dfff75d9ecc8ab47a025e6989ffe75bb202..cf66e5b75c78645b1641e53d1425d882
const result = new Uint8Array(totalLength);
let offset = 0;
for (const chunk of chunks) {
diff --git a/src/kitty/KittyGraphicsTypes.ts b/src/kitty/KittyGraphicsTypes.ts
index 4288dd0a4180eca8f3ae28ec0805f834b2a0a629..77ddbf365cd76fdc9b109e5ac497a261d2c76293 100644
--- a/src/kitty/KittyGraphicsTypes.ts
+++ b/src/kitty/KittyGraphicsTypes.ts
@@ -78,7 +78,9 @@ export const enum KittyKey {
// Delete selector (a/A=all, i/I=by id, c/C=at cursor, etc.) — only used when a=d
DELETE_SELECTOR = 'd',
// Placement ID for targeting specific placements
- PLACEMENT_ID = 'p'
+ PLACEMENT_ID = 'p',
+ // U=1 creates a prototype for placeholder cells without placing cursor tiles.
+ UNICODE_PLACEHOLDER = 'U'
}
// Pixel format constants
@@ -112,6 +114,7 @@ export interface IKittyCommand {
deleteSelector?: string;
placementId?: number;
compression?: string;
+ unicodePlaceholder?: number;
payload?: string;
}
@@ -139,6 +142,13 @@ export interface IKittyImageData {
compression?: string;
}
+export interface IVirtualPlacement {
+ placementId: number;
+ storageId: number;
+ cols: number;
+ rows: number;
+}
+
// Parses Kitty graphics control data into a command object.
export function parseKittyCommand(data: string): IKittyCommand {
const cmd: IKittyCommand = {};
@@ -188,8 +198,73 @@ export function parseKittyCommand(data: string): IKittyCommand {
case KittyKey.CURSOR_MOVEMENT: cmd.cursorMovement = numValue; break;
case KittyKey.Z_INDEX: cmd.zIndex = numValue; break;
case KittyKey.PLACEMENT_ID: cmd.placementId = numValue; break;
+ case KittyKey.UNICODE_PLACEHOLDER: cmd.unicodePlaceholder = numValue; break;
}
}
return cmd;
}
+
+
+// Source patches can only name files included in the published package.
+
+export const PLACEHOLDER_CHAR = 0x10EEEE;
+
+// Protocol table order; values are one-based so zero can mean an omitted diacritic.
+export const PLACEHOLDER_DIACRITICS: readonly number[] = [
+ 0x305, 0x30D, 0x30E, 0x310, 0x312, 0x33D, 0x33E, 0x33F, 0x346, 0x34A,
+ 0x34B, 0x34C, 0x350, 0x351, 0x352, 0x357, 0x35B, 0x363, 0x364, 0x365,
+ 0x366, 0x367, 0x368, 0x369, 0x36A, 0x36B, 0x36C, 0x36D, 0x36E, 0x36F,
+ 0x483, 0x484, 0x485, 0x486, 0x487, 0x592, 0x593, 0x594, 0x595, 0x597,
+ 0x598, 0x599, 0x59C, 0x59D, 0x59E, 0x59F, 0x5A0, 0x5A1, 0x5A8, 0x5A9,
+ 0x5AB, 0x5AC, 0x5AF, 0x5C4, 0x610, 0x611, 0x612, 0x613, 0x614, 0x615,
+ 0x616, 0x617, 0x657, 0x658, 0x659, 0x65A, 0x65B, 0x65D, 0x65E, 0x6D6,
+ 0x6D7, 0x6D8, 0x6D9, 0x6DA, 0x6DB, 0x6DC, 0x6DF, 0x6E0, 0x6E1, 0x6E2,
+ 0x6E4, 0x6E7, 0x6E8, 0x6EB, 0x6EC, 0x730, 0x732, 0x733, 0x735, 0x736,
+ 0x73A, 0x73D, 0x73F, 0x740, 0x741, 0x743, 0x745, 0x747, 0x749, 0x74A,
+ 0x7EB, 0x7EC, 0x7ED, 0x7EE, 0x7EF, 0x7F0, 0x7F1, 0x7F3, 0x816, 0x817,
+ 0x818, 0x819, 0x81B, 0x81C, 0x81D, 0x81E, 0x81F, 0x820, 0x821, 0x822,
+ 0x823, 0x825, 0x826, 0x827, 0x829, 0x82A, 0x82B, 0x82C, 0x82D, 0x951,
+ 0x953, 0x954, 0xF82, 0xF83, 0xF86, 0xF87, 0x135D, 0x135E, 0x135F, 0x17DD,
+ 0x193A, 0x1A17, 0x1A75, 0x1A76, 0x1A77, 0x1A78, 0x1A79, 0x1A7A, 0x1A7B, 0x1A7C,
+ 0x1B6B, 0x1B6D, 0x1B6E, 0x1B6F, 0x1B70, 0x1B71, 0x1B72, 0x1B73, 0x1CD0, 0x1CD1,
+ 0x1CD2, 0x1CDA, 0x1CDB, 0x1CE0, 0x1DC0, 0x1DC1, 0x1DC3, 0x1DC4, 0x1DC5, 0x1DC6,
+ 0x1DC7, 0x1DC8, 0x1DC9, 0x1DCB, 0x1DCC, 0x1DD1, 0x1DD2, 0x1DD3, 0x1DD4, 0x1DD5,
+ 0x1DD6, 0x1DD7, 0x1DD8, 0x1DD9, 0x1DDA, 0x1DDB, 0x1DDC, 0x1DDD, 0x1DDE, 0x1DDF,
+ 0x1DE0, 0x1DE1, 0x1DE2, 0x1DE3, 0x1DE4, 0x1DE5, 0x1DE6, 0x1DFE, 0x20D0, 0x20D1,
+ 0x20D4, 0x20D5, 0x20D6, 0x20D7, 0x20DB, 0x20DC, 0x20E1, 0x20E7, 0x20E9, 0x20F0,
+ 0x2CEF, 0x2CF0, 0x2CF1, 0x2DE0, 0x2DE1, 0x2DE2, 0x2DE3, 0x2DE4, 0x2DE5, 0x2DE6,
+ 0x2DE7, 0x2DE8, 0x2DE9, 0x2DEA, 0x2DEB, 0x2DEC, 0x2DED, 0x2DEE, 0x2DEF, 0x2DF0,
+ 0x2DF1, 0x2DF2, 0x2DF3, 0x2DF4, 0x2DF5, 0x2DF6, 0x2DF7, 0x2DF8, 0x2DF9, 0x2DFA,
+ 0x2DFB, 0x2DFC, 0x2DFD, 0x2DFE, 0x2DFF, 0xA66F, 0xA67C, 0xA67D, 0xA6F0, 0xA6F1,
+ 0xA8E0, 0xA8E1, 0xA8E2, 0xA8E3, 0xA8E4, 0xA8E5, 0xA8E6, 0xA8E7, 0xA8E8, 0xA8E9,
+ 0xA8EA, 0xA8EB, 0xA8EC, 0xA8ED, 0xA8EE, 0xA8EF, 0xA8F0, 0xA8F1, 0xAAB0, 0xAAB2,
+ 0xAAB3, 0xAAB7, 0xAAB8, 0xAABE, 0xAABF, 0xAAC1, 0xFE20, 0xFE21, 0xFE22, 0xFE23,
+ 0xFE24, 0xFE25, 0xFE26, 0x10A0F, 0x10A38, 0x1D185, 0x1D186, 0x1D187, 0x1D188, 0x1D189,
+ 0x1D1AA, 0x1D1AB, 0x1D1AC, 0x1D1AD, 0x1D242, 0x1D243, 0x1D244,
+];
+
+const PLACEHOLDER_DIACRITIC_VALUES: Map<number, number> = new Map();
+for (let i = 0; i < PLACEHOLDER_DIACRITICS.length; ++i) {
+ PLACEHOLDER_DIACRITIC_VALUES.set(PLACEHOLDER_DIACRITICS[i], i + 1);
+}
+
+// Zero values may inherit from the preceding placeholder cell.
+export interface IPlaceholderLocation {
+ row: number;
+ column: number;
+ highByte: number;
+}
+
+function placeholderDiacriticValue(mark: string | undefined): number {
+ return mark === undefined ? 0 : PLACEHOLDER_DIACRITIC_VALUES.get(mark.codePointAt(0) ?? -1) ?? -1;
+}
+
+export function decodePlaceholderLocation(chars: string): IPlaceholderLocation {
+ const [, row, column, highByte] = chars;
+ return {
+ row: placeholderDiacriticValue(row),
+ column: placeholderDiacriticValue(column),
+ highByte: placeholderDiacriticValue(highByte)
+ };
+}
diff --git a/src/kitty/KittyImageStorage.ts b/src/kitty/KittyImageStorage.ts
index 1f5c09ec9e2700f8f6dbd8436a1802217dfc99ef..016943a77c7e8e27da5899d54cd48d82761a87c8 100644
index 1f5c09ec9e2700f8f6dbd8436a1802217dfc99ef..2a0df51c965c4e2c4c6f1935c129052cadf12153 100644
--- a/src/kitty/KittyImageStorage.ts
+++ b/src/kitty/KittyImageStorage.ts
@@ -83,6 +83,25 @@ export class KittyImageStorage implements IDisposable {
@@ -3,16 +3,27 @@
* @license MIT
*/
+import type { IBufferLine } from 'common/buffer/Types';
import { IDisposable } from '@xterm/xterm';
+import { CellData } from 'common/buffer/CellData';
+import { FgFlags } from 'common/buffer/Constants';
+import { ImageRenderer } from '../ImageRenderer';
import { ImageStorage } from '../ImageStorage';
-import { ImageLayer, IAddImageOpts } from '../Types';
-import { IKittyImageData } from './KittyGraphicsTypes';
+import { Attributes, ITerminalExt, ImageLayer, IAddImageOpts } from '../Types';
+import { decodePlaceholderLocation, IKittyImageData, IPlaceholderLocation, IVirtualPlacement, PLACEHOLDER_CHAR } from './KittyGraphicsTypes';
+
+interface IPlaceholderCell {
+ imageId: number;
+ placementId: number;
+ location: IPlaceholderLocation;
+}
// Kitty-specific image storage controller.
//
// Wraps shared ImageStorage with kitty protocol semantics:
// - tracks transmitted image payloads by kitty image id
// - tracks kitty image id -> shared ImageStorage id mapping for displayed images
+// - tracks virtual placements that U+10EEEE cells display
// - mirrors shared-storage evictions into kitty maps
// - applies protocol-level undisplayed-image eviction policy
export class KittyImageStorage implements IDisposable {
@@ -20,32 +31,33 @@ export class KittyImageStorage implements IDisposable {
private _nextImageId = 1;
private readonly _images: Map<number, IKittyImageData> = new Map();
- // TODO: Support multiple placements per image. The kitty spec identifies
- // placements by an (image id, placement id) pair — same i + different p
- // values should coexist, and same i + same p should replace the prior
- // placement. Currently we track only one storage entry per kitty image id,
- // so multiple placements of the same image overwrite each other. Fixing
- // this requires changing these maps to Map<number, Map<number, number>>
- // (kittyId → placementId → storageId) and updating addImage/deleteById
- // accordingly. The underlying shared ImageStorage would also need to
- // support multiple entries per logical image.
+ // TODO: Track physical placements by (image id, placement id), like virtual placements.
private readonly _kittyIdToStorageId: Map<number, number> = new Map();
private readonly _storageIdToKittyId: Map<number, number> = new Map();
+ private readonly _virtualPlacements: Map<number, Map<number, IVirtualPlacement>> = new Map();
+ private readonly _virtualStorageIds = new Map<number, { kittyId: number, placementId: number }>();
private readonly _previousOnImageDeleted: ((storageId: number) => void) | undefined;
private readonly _wrappedOnImageDeleted: (storageId: number) => void;
private readonly _handleStorageImageDeleted = (storageId: number): void => {
+ const virtual = this._virtualStorageIds.get(storageId);
+ if (virtual) {
+ this._virtualStorageIds.delete(storageId);
+ const placements = this._virtualPlacements.get(virtual.kittyId);
+ placements?.delete(virtual.placementId);
+ if (!placements?.size) this._virtualPlacements.delete(virtual.kittyId);
+ }
const kittyId = this._storageIdToKittyId.get(storageId);
if (kittyId !== undefined) {
this._kittyIdToStorageId.delete(kittyId);
this._storageIdToKittyId.delete(storageId);
- this._images.delete(kittyId);
}
};
private _addImageOpts: IAddImageOpts = { scrolling: true, layer: 'top', zIndex: 0, cursorPos: 'iip' };
constructor(
- private readonly _storage: ImageStorage
+ private readonly _storage: ImageStorage,
+ private readonly _renderer: ImageRenderer
) {
this._previousOnImageDeleted = this._storage.onImageDeleted;
this._wrappedOnImageDeleted = (storageId: number) => {
@@ -53,6 +65,27 @@ export class KittyImageStorage implements IDisposable {
this._handleStorageImageDeleted(storageId);
};
this._storage.onImageDeleted = this._wrappedOnImageDeleted;
+ this._storage.onRenderLine = this._renderPlaceholderLine;
+ }
+
+ // Reserved placeholder text stays in the buffer but never draws as a glyph.
+ public hidePlaceholderGlyphs(terminal: ITerminalExt): void {
+ const buffer = terminal._core.buffer;
+ let cell: CellData | undefined;
+ const start = terminal.buffer.active.viewportY;
+ for (let row = start; row < start + terminal.rows; ++row) {
+ const line = buffer.lines.get(row);
+ if (!line) continue;
+ for (let col = 0; col < terminal.cols; ++col) {
+ if (line.getFg(col) & FgFlags.INVISIBLE) continue;
+ if (line.getCodePoint(col) !== PLACEHOLDER_CHAR
+ && (!line.isCombined(col) || line.getString(col).codePointAt(0) !== PLACEHOLDER_CHAR)) continue;
+ cell ??= new CellData();
+ line.loadCell(col, cell);
+ cell.fg |= FgFlags.INVISIBLE;
+ line.setCell(col, cell);
+ }
+ }
}
public reset(): void {
@@ -60,6 +93,8 @@ export class KittyImageStorage implements IDisposable {
this._images.clear();
this._kittyIdToStorageId.clear();
this._storageIdToKittyId.clear();
+ this._virtualPlacements.clear();
+ this._virtualStorageIds.clear();
}
public dispose(): void {
@@ -67,11 +102,15 @@ export class KittyImageStorage implements IDisposable {
if (this._storage.onImageDeleted === this._wrappedOnImageDeleted) {
this._storage.onImageDeleted = this._previousOnImageDeleted;
}
+ if (this._storage.onRenderLine === this._renderPlaceholderLine) {
+ this._storage.onRenderLine = undefined;
+ }
}
- public storeImage(id: number | undefined, imageData: Omit<IKittyImageData, 'id'>): number {
+ public storeImage(id: number | undefined, imageData: Omit<IKittyImageData, 'id'>, retainVirtual = false): number {
const imageId = id ?? this._nextImageId++;
+ if (!retainVirtual) this._deleteVirtualPlacements(imageId);
const oldStorageId = this._kittyIdToStorageId.get(imageId);
if (oldStorageId !== undefined) {
this._storage.deleteImage(oldStorageId);
@@ -83,6 +122,25 @@ export class KittyImageStorage implements IDisposable {
this._evictUndisplayedImages();
}
@@ -546,7 +1208,7 @@ index 1f5c09ec9e2700f8f6dbd8436a1802217dfc99ef..016943a77c7e8e27da5899d54cd48d82
+ for (const evictPlaced of [false, true]) {
+ for (const [oldestId, image] of this._images) {
+ if (retainedBytes + imageData.data.size <= byteLimit) break;
+ if (this._kittyIdToStorageId.has(oldestId) !== evictPlaced) continue;
+ if ((this._kittyIdToStorageId.has(oldestId) || this._virtualPlacements.has(oldestId)) !== evictPlaced) continue;
+ retainedBytes -= image.data.size;
+ this.deleteById(oldestId);
+ }
@@ -555,3 +1217,183 @@ index 1f5c09ec9e2700f8f6dbd8436a1802217dfc99ef..016943a77c7e8e27da5899d54cd48d82
this._images.set(imageId, {
...imageData,
id: imageId
@@ -111,23 +169,73 @@ export class KittyImageStorage implements IDisposable {
return this._images.get(kittyId);
}
- public deleteById(kittyId: number): void {
- this._images.delete(kittyId);
+ // Fit prototypes at render time so each placement holds only the source pixels.
+ public addVirtualPlacement(kittyId: number, placementId: number, image: HTMLCanvasElement | ImageBitmap, cols: number, rows: number): void {
+ const previous = this._virtualPlacements.get(kittyId)?.get(placementId);
+ if (image.width * image.height * 4 > this._storage.getLimit() * 1000000) throw new Error('virtual image exceeds storage limit');
+ if (!previous && this._virtualStorageIds.size >= KittyImageStorage._maxStoredImages) throw new Error('virtual placement limit exceeded');
+ if (previous) this._storage.deleteImage(previous.storageId);
+ const storageId = this._storage.addUnplacedImage(image, { scrolling: false, layer: 'top', zIndex: 0, cursorPos: 'iip' });
+ let placements = this._virtualPlacements.get(kittyId);
+ if (!placements) {
+ placements = new Map();
+ this._virtualPlacements.set(kittyId, placements);
+ }
+ placements.set(placementId, { placementId, storageId, cols, rows });
+ this._virtualStorageIds.set(storageId, { kittyId, placementId });
+ this._renderer.refreshAll();
+ }
+
+ private _virtualPlacementFor(kittyId: number, placementId: number): IVirtualPlacement | undefined {
+ const placements = this._virtualPlacements.get(kittyId);
+ return placementId === 0 ? placements?.values().next().value : placements?.get(placementId);
+ }
+
+ public discardVirtualPlacements(kittyId: number, keepPlacementId?: number): void {
+ this._deleteVirtualPlacements(kittyId, undefined, keepPlacementId);
+ }
+
+ private _deleteVirtualPlacements(kittyId: number, placementId?: number, keepPlacementId?: number): void {
+ const placements = this._virtualPlacements.get(kittyId);
+ if (!placements) return;
+ for (const placement of [...placements.values()]) {
+ if ((placementId === undefined || placementId === placement.placementId) && placement.placementId !== keepPlacementId) {
+ this._storage.deleteImage(placement.storageId);
+ }
+ }
+ }
+
+ public deletePlacementsById(kittyId: number, placementId?: number): void {
+ this._deleteVirtualPlacements(kittyId, placementId);
+ if (placementId !== undefined) return;
const storageId = this._kittyIdToStorageId.get(kittyId);
if (storageId !== undefined) {
- this._storage.deleteImage(storageId);
this._kittyIdToStorageId.delete(kittyId);
this._storageIdToKittyId.delete(storageId);
+ this._storage.deleteImage(storageId);
}
}
- public deleteAll(): void {
- this._images.clear();
- for (const storageId of this._kittyIdToStorageId.values()) {
+ public deleteById(kittyId: number, placementId?: number): void {
+ this.deletePlacementsById(kittyId, placementId);
+ if (!this._virtualPlacements.has(kittyId) && !this._kittyIdToStorageId.has(kittyId)) {
+ this._images.delete(kittyId);
+ }
+ }
+
+ public deletePhysicalPlacements(): void {
+ for (const [kittyId, storageId] of [...this._kittyIdToStorageId]) {
+ this._kittyIdToStorageId.delete(kittyId);
+ this._storageIdToKittyId.delete(storageId);
this._storage.deleteImage(storageId);
}
- this._kittyIdToStorageId.clear();
- this._storageIdToKittyId.clear();
+ }
+
+ public deleteAll(preserveVirtual = false): void {
+ if (preserveVirtual) this.deletePhysicalPlacements();
+ for (const kittyId of [...this._images.keys()]) {
+ if (!preserveVirtual || !this._virtualPlacements.has(kittyId)) this.deleteById(kittyId);
+ }
}
public get images(): ReadonlyMap<number, IKittyImageData> {
@@ -147,9 +255,96 @@ export class KittyImageStorage implements IDisposable {
if (this._images.size <= KittyImageStorage._maxStoredImages / 2) {
break;
}
- if (!this._kittyIdToStorageId.has(kittyId)) {
+ if (!this._kittyIdToStorageId.has(kittyId) && !this._virtualPlacements.has(kittyId)) {
this._images.delete(kittyId);
+ this._virtualPlacements.delete(kittyId);
+ }
+ }
+ }
+
+ // Omitted diacritics inherit only from a consecutive matching placeholder.
+ private readonly _renderPlaceholderLine = (line: IBufferLine, row: number, cols: number): void => {
+ if (this._virtualPlacements.size === 0) {
+ return;
+ }
+ let runLength = 0;
+ let previous: IPlaceholderCell | undefined;
+ for (let col = 0; col <= cols; ++col) {
+ const cell = col < cols ? this._placeholderCell(line, col) : undefined;
+ if (runLength > 0 && cell !== undefined && previous !== undefined
+ && cell.imageId === previous.imageId
+ && cell.placementId === previous.placementId
+ && (cell.location.row === 0 || cell.location.row === previous.location.row)
+ && (cell.location.column === 0 || cell.location.column === previous.location.column + 1)
+ && (cell.location.highByte === 0 || cell.location.highByte === previous.location.highByte)) {
+ runLength++;
+ cell.location.row = Math.max(previous.location.row, 1);
+ cell.location.column = previous.location.column + 1;
+ cell.location.highByte = Math.max(previous.location.highByte, 1);
+ } else {
+ if (runLength > 0 && previous !== undefined) {
+ this._drawPlaceholderRun(previous, col - runLength, runLength, row);
+ }
+ runLength = 0;
+ if (cell !== undefined) {
+ runLength = 1;
+ if (!cell.location.row) cell.location.row = 1;
+ if (!cell.location.column) cell.location.column = 1;
+ if (!cell.location.highByte) cell.location.highByte = 1;
+ }
}
+ previous = cell ?? previous;
+ }
+ };
+
+ private _placeholderCell(line: IBufferLine, col: number): IPlaceholderCell | undefined {
+ if (!line.isCombined(col) && line.getCodePoint(col) !== PLACEHOLDER_CHAR) {
+ return undefined;
+ }
+ const chars = line.getString(col);
+ if (chars.codePointAt(0) !== PLACEHOLDER_CHAR) {
+ return undefined;
+ }
+
+ const fg = line.getFg(col);
+ const fgMode = fg & Attributes.CM_MASK;
+ const imageId = fgMode === Attributes.CM_RGB
+ ? fg & Attributes.RGB_MASK
+ : fgMode === 0 ? 0 : fg & Attributes.PCOLOR_MASK;
+ if (fgMode === 0) {
+ return undefined;
+ }
+
+ // Placement IDs use underline color without an underline style.
+ let placementId = 0;
+ const underlineColor = line.getExtended(col)?.underlineColor ?? 0;
+ if (underlineColor > 0) {
+ const underlineMode = underlineColor & Attributes.CM_MASK;
+ placementId = underlineMode === Attributes.CM_RGB
+ ? underlineColor & Attributes.RGB_MASK
+ : underlineMode === 0 ? 0 : underlineColor & Attributes.PCOLOR_MASK;
+ }
+
+ const location = decodePlaceholderLocation(chars);
+ if (location.row < 0 || location.column < 0 || location.highByte < 0 || location.highByte > 256) return undefined;
+ return { imageId, placementId, location };
+ }
+
+ private _drawPlaceholderRun(cell: IPlaceholderCell, startCol: number, count: number, row: number): void {
+ const imageId = (cell.imageId | ((cell.location.highByte - 1) << 24)) >>> 0;
+ const placement = this._virtualPlacementFor(imageId, cell.placementId);
+ const spec = placement && this._storage.getImageSpec(placement.storageId);
+ // `column` is 1-based and holds the last cell of the run.
+ const gridRow = cell.location.row - 1;
+ const gridCol = cell.location.column - count;
+ if (!placement || !spec || gridRow < 0 || gridRow >= placement.rows || gridCol < 0) {
+ return;
+ }
+ // Cells past the placement's box show nothing.
+ const tiles = Math.min(count, placement.cols - gridCol);
+ if (tiles <= 0) {
+ return;
}
+ this._renderer.drawCellImage(spec, placement.cols, placement.rows, gridRow, gridCol, tiles, startCol, row);
}
}
@@ -1263,6 +1263,18 @@ index d45938515e7a9903de0da0d61716fd42e4deea0c..17d6577e388eb2e4397338093e353738
private _canvas: OffscreenCanvas | HTMLCanvasElement;
private _ctx: OffscreenCanvasRenderingContext2D | CanvasRenderingContext2D;
diff --git a/src/common/InputHandler.ts b/src/common/InputHandler.ts
index 995fe7670a10f11fb9b0b42332484b038ea6c989..3df13f60209ccd097b861d94efd481988d38400f 100644
--- a/src/common/InputHandler.ts
+++ b/src/common/InputHandler.ts
@@ -2468,6 +2468,7 @@ export class InputHandler extends Disposable implements IInputHandler {
case 58:
attr.extended = attr.extended.clone();
attr.extended.underlineColor = this._updateAttrColor(attr.extended.underlineColor, accu[1], accu[3], accu[4], accu[5]);
+ attr.updateExtended();
}
return advance;
diff --git a/src/common/SortedList.ts b/src/common/SortedList.ts
index 8a10076e3963e33b4a7d1e4602333eb3f4772dc9..c6dcf18b762e3c56fe22e9c2d49b8e550d96f915 100644
--- a/src/common/SortedList.ts
@@ -1385,3 +1397,25 @@ index 8a10076e3963e33b4a7d1e4602333eb3f4772dc9..c6dcf18b762e3c56fe22e9c2d49b8e55
this._flushDeletedTask.flush();
}
}
diff --git a/src/common/buffer/AttributeData.ts b/src/common/buffer/AttributeData.ts
index b37c1550887d467d8af0c46da96defa296ede197..9569755e6d6b112c4403f424b5d2f394e789ebf5 100644
--- a/src/common/buffer/AttributeData.ts
+++ b/src/common/buffer/AttributeData.ts
@@ -168,7 +168,7 @@ export class ExtendedAttrs implements IExtendedAttrs {
}
public set underlineColor(value: number) {
this._ext &= ~(Attributes.CM_MASK | Attributes.RGB_MASK);
- this._ext |= value & (Attributes.CM_MASK | Attributes.RGB_MASK);
+ this._ext |= (value === -1 ? 0 : value) & (Attributes.CM_MASK | Attributes.RGB_MASK);
}
private _urlId: number = 0;
@@ -208,6 +208,7 @@ export class ExtendedAttrs implements IExtendedAttrs {
* that needs to be persistant in the buffer.
*/
public isEmpty(): boolean {
- return this.underlineStyle === UnderlineStyle.NONE && this._urlId === 0 && this.payload === undefined;
+ return this.underlineStyle === UnderlineStyle.NONE && this._urlId === 0 && this.payload === undefined
+ && (this.underlineColor & Attributes.CM_MASK) === 0;
}
}
@@ -0,0 +1,103 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import {
createPlaceholderTerminal,
PNG,
TrackedBitmap,
writeKitty
} from './xterm-kitty-placeholder-test-terminal.mjs'
afterEach(() => vi.unstubAllGlobals())
describe('virtual prototype admission before replacement retirement', () => {
it('rejects a decoded source larger than the current shared pixel budget', async () => {
const h = createPlaceholderTerminal()
const oversized = new TrackedBitmap(3000, 3000)
vi.stubGlobal('createImageBitmap', async () => oversized)
try {
await writeKitty(h.terminal, 'a=T,f=100,i=7,U=1,p=10,c=2,r=2', PNG)
expect(h.storage._images.size).toBe(0)
expect(h.kitty._virtualStorageIds.size).toBe(0)
expect(oversized.close).toHaveBeenCalledOnce()
expect(h.addon.storageUsage).toBeLessThanOrEqual(h.addon.storageLimit)
} finally {
h.terminal.dispose()
}
})
it('bounds markerless prototypes across distinct sources while allowing replacement at the bound', async () => {
const h = createPlaceholderTerminal()
vi.stubGlobal('createImageBitmap', async () => {
const bitmap = new TrackedBitmap(1, 1)
h.bitmaps.push(bitmap)
return bitmap
})
try {
for (let id = 1; id <= 256; id++) {
await writeKitty(h.terminal, `a=T,f=100,i=${id},U=1,p=10,c=1,r=1`, PNG)
}
expect(h.kitty._virtualStorageIds.size).toBe(256)
await writeKitty(h.terminal, 'a=T,f=100,i=257,U=1,p=10,c=1,r=1', PNG)
expect(h.kitty._virtualStorageIds.size).toBe(256)
expect(h.storage._images.size).toBe(256)
expect(h.bitmaps[256].close).toHaveBeenCalledOnce()
const replaced = h.bitmaps[0]
await writeKitty(h.terminal, 'a=T,f=100,i=1,U=1,p=10,c=1,r=1', PNG)
expect(h.kitty._virtualStorageIds.size).toBe(256)
expect(h.storage._images.size).toBe(256)
expect(replaced.close).toHaveBeenCalledOnce()
expect(h.bitmaps[257].close).not.toHaveBeenCalled()
h.addon.reset()
expect(h.kitty._virtualStorageIds.size).toBe(0)
expect(h.storage._images.size).toBe(0)
for (const bitmap of h.bitmaps) {
expect(bitmap.close).toHaveBeenCalledOnce()
}
} finally {
h.terminal.dispose()
}
})
it('counts separate named placements of one source against the same bound', async () => {
const h = createPlaceholderTerminal()
vi.stubGlobal('createImageBitmap', async () => new TrackedBitmap(1, 1))
try {
await writeKitty(h.terminal, 'a=T,f=100,i=7,U=1,p=1,c=1,r=1', PNG)
for (let placement = 2; placement <= 257; placement++) {
await writeKitty(h.terminal, `a=p,i=7,U=1,p=${placement},c=1,r=1`)
}
expect(h.kitty._virtualStorageIds.size).toBe(256)
expect(h.kitty._virtualPlacements.get(7).size).toBe(256)
} finally {
h.terminal.dispose()
}
})
it('reads a reduced live budget and safely retires a failed current replacement', async () => {
const h = createPlaceholderTerminal()
try {
await writeKitty(h.terminal, 'a=T,f=100,i=7,U=1,p=10,c=2,r=2', PNG)
const previous = h.bitmaps[0]
const oversized = new TrackedBitmap(400, 400)
let finish
vi.stubGlobal(
'createImageBitmap',
() =>
new Promise((resolve) => {
finish = resolve
})
)
const writing = writeKitty(h.terminal, 'a=T,f=100,i=7,U=1,p=10,c=2,r=2', PNG)
await vi.waitFor(() => expect(finish).toBeTypeOf('function'))
h.addon.storageLimit = 0.5
finish(oversized)
await writing
expect(h.kitty._virtualStorageIds.size).toBe(0)
expect(h.storage._images.size).toBe(0)
expect(previous.close).toHaveBeenCalledOnce()
expect(oversized.close).toHaveBeenCalledOnce()
expect(h.addon.storageUsage).toBeLessThanOrEqual(0.5)
} finally {
h.terminal.dispose()
}
})
})
@@ -0,0 +1,176 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import {
createPlaceholderTerminal,
PLACEHOLDER,
placeholderCells,
PNG,
render,
TrackedBitmap,
write,
writeKitty
} from './xterm-kitty-placeholder-test-terminal.mjs'
afterEach(() => vi.unstubAllGlobals())
const marks = {
0: '\u{305}',
1: '\u{30d}',
128: '\u{82d}',
255: '\u{a8e5}',
256: '\u{a8e6}',
283: '\u{10a0f}',
296: '\u{1d244}'
}
function colors(id, placement = 0) {
const low = id & 0xffffff
return (
`\x1b[38;2;${low >>> 16};${(low >>> 8) & 255};${low & 255}m` +
`\x1b[58;2;${placement >>> 16};${(placement >>> 8) & 255};${placement & 255}m`
)
}
function placeholderAt(id, placement, row, col) {
return colors(id, placement) + PLACEHOLDER + marks[row] + marks[col] + marks[id >>> 24]
}
describe('placeholder coordinate and ID boundaries through real parsing', () => {
it.each([0xff000007, 0xffffffff])(
'resolves maximum high-byte image ID %i as unsigned',
async (id) => {
const h = createPlaceholderTerminal()
try {
await writeKitty(h.terminal, `a=T,f=100,i=${id},U=1,c=2,r=2`, PNG)
await write(h.terminal, placeholderAt(id, 0, 0, 0))
expect(render(h)).toHaveLength(1)
expect(h.kitty._virtualPlacements.has(id)).toBe(true)
expect(h.kitty._virtualPlacements.has(id | 0)).toBe(false)
} finally {
h.terminal.dispose()
}
}
)
it.each([283, 296])(
'decodes non-BMP row/column index %i without UTF-16 splitting',
async (index) => {
const h = createPlaceholderTerminal()
try {
h.addon._handlers.get('kitty')._createBitmap = async () => {
const bitmap = new TrackedBitmap(297, 297)
h.bitmaps.push(bitmap)
return bitmap
}
await writeKitty(h.terminal, 'a=T,f=100,i=7,U=1,c=297,r=297', PNG)
await write(h.terminal, placeholderAt(7, 0, index, index))
const calls = render(h)
expect(calls).toHaveLength(1)
expect(calls[0].slice(1)).toEqual([index, index, 1, 1, 0, 0, 10, 10])
expect(h.terminal.buffer.active.cursorX).toBe(1)
} finally {
h.terminal.dispose()
}
}
)
it.each([256, 296])('rejects table index %i only in the high-byte position', async (index) => {
const h = createPlaceholderTerminal()
try {
await writeKitty(h.terminal, 'a=T,f=100,i=7,U=1,c=2,r=2', PNG)
await write(h.terminal, colors(7) + PLACEHOLDER + marks[0] + marks[0] + marks[index])
expect(render(h)).toHaveLength(0)
} finally {
h.terminal.dispose()
}
})
it('starts a new omitted-coordinate run when the low image ID changes', async () => {
const h = createPlaceholderTerminal()
try {
await writeKitty(h.terminal, 'a=T,f=100,i=7,U=1,c=2,r=2', PNG)
await writeKitty(h.terminal, 'a=T,f=100,i=9,U=1,c=2,r=2', PNG)
await write(h.terminal, placeholderAt(7, 0, 1, 0) + colors(9) + PLACEHOLDER)
const calls = render(h)
expect(calls).toHaveLength(2)
expect(calls[0][0]).toBe(h.bitmaps[0])
expect(calls[0].slice(1, 5)).toEqual([0, 5, 10, 5])
expect(calls[1][0]).toBe(h.bitmaps[1])
expect(calls[1].slice(1, 5)).toEqual([0, 0, 10, 5])
} finally {
h.terminal.dispose()
}
})
it('starts a new omitted-coordinate run when the placement ID changes', async () => {
const h = createPlaceholderTerminal()
try {
await writeKitty(h.terminal, 'a=T,f=100,i=7,U=1,p=10,c=2,r=2', PNG)
await writeKitty(h.terminal, 'a=p,U=1,i=7,p=11,c=2,r=2')
await write(h.terminal, placeholderAt(7, 10, 1, 0) + colors(7, 11) + PLACEHOLDER)
const calls = render(h)
expect(calls).toHaveLength(2)
expect(calls[0][0]).toBe(h.bitmaps[0])
expect(calls[0].slice(1, 5)).toEqual([0, 5, 10, 5])
expect(calls[1][0]).toBe(h.bitmaps[1])
expect(calls[1].slice(1, 5)).toEqual([0, 0, 10, 5])
} finally {
h.terminal.dispose()
}
})
it('does not inherit a high byte across ordinary text', async () => {
const h = createPlaceholderTerminal()
try {
await writeKitty(h.terminal, 'a=T,f=100,i=2147483655,U=1,c=2,r=2', PNG)
await write(h.terminal, `${placeholderAt(0x80000007, 0, 0, 0)} ${PLACEHOLDER}`)
const calls = render(h)
expect(calls).toHaveLength(1)
expect(calls[0].slice(1, 5)).toEqual([0, 0, 10, 5])
} finally {
h.terminal.dispose()
}
})
})
async function prepared() {
const h = createPlaceholderTerminal()
await writeKitty(h.terminal, 'a=T,f=100,i=7,U=1,p=0,c=1,r=1', PNG)
await writeKitty(h.terminal, 'a=p,U=1,i=7,p=10,c=1,r=1')
await write(h.terminal, placeholderCells(7, 10, 1, 1))
return h
}
async function overwriteUnnamed(h) {
await write(h.terminal, `\x1b[0m\x1b[H\x1b[38;2;0;0;7m${PLACEHOLDER}\u0305\u0305`)
const line = h.terminal._core.buffer.lines.get(0)
expect(line.getBg(0) & 0x10000000).toBe(0)
return render(h)
}
describe('placeholder placement IDs after overwriting old extended attributes', () => {
it('selects the default prototype after a named cell is overwritten without underline color', async () => {
const h = await prepared()
try {
const calls = await overwriteUnnamed(h)
expect(calls).toHaveLength(1)
expect(calls[0][0]).toBe(h.bitmaps[0])
expect(calls[0][0]).not.toBe(h.bitmaps[1])
} finally {
h.terminal.dispose()
}
})
it('keeps default image pixels after the old named prototype is deleted', async () => {
const h = await prepared()
try {
await writeKitty(h.terminal, 'a=d,d=I,i=7,p=10,q=2')
expect(h.kitty._virtualPlacements.get(7).has(0)).toBe(true)
expect(h.kitty._virtualPlacements.get(7).has(10)).toBe(false)
const calls = await overwriteUnnamed(h)
expect(calls).toHaveLength(1)
expect(calls[0][0]).toBe(h.bitmaps[0])
} finally {
h.terminal.dispose()
}
})
})
@@ -0,0 +1,308 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import {
createPlaceholderTerminal,
placeholderCells,
PNG,
TrackedBitmap,
write,
writeKitty
} from './xterm-kitty-placeholder-test-terminal.mjs'
const INVISIBLE = 0x40000000
const PLACEHOLDER = String.fromCodePoint(0x10eeee)
afterEach(() => vi.unstubAllGlobals())
const cell = (h, row = 0, col = 0) => h.terminal.buffer.active.getLine(row)?.getCell(col)
async function flush() {
// onWriteParsed fires after the write callback at the end of a parse batch.
await new Promise((resolve) => setTimeout(resolve, 20))
}
async function place(h) {
await writeKitty(h.terminal, 'a=T,f=100,i=7,U=1,p=10,c=2,r=2', PNG)
await write(h.terminal, placeholderCells(7, 10))
await flush()
}
function metadata(line, col) {
return {
words: [...line._data.subarray(col * 3, col * 3 + 3)],
chars: line.getString(col),
width: line.getWidth(col),
code: line.getCodePoint(col),
combined: line.isCombined(col),
extended: line._extendedAttrs[col]
}
}
function reservedRow(h, last = false) {
const buffer = h.terminal._core.buffer
for (
let index = last ? buffer.lines.length - 1 : 0;
last ? index >= 0 : index < buffer.lines.length;
index += last ? -1 : 1
) {
if (buffer.lines.get(index).getString(0).codePointAt(0) === 0x10eeee) {
return index
}
}
throw new Error('Expected reserved text in the real buffer')
}
describe('reserved placeholder glyph visibility', () => {
it('hides an unknown bare reserved character and retains its non-BMP codepoint', async () => {
const h = createPlaceholderTerminal()
try {
await write(h.terminal, `${PLACEHOLDER}😀X`)
await flush()
expect(cell(h).isInvisible()).toBeTruthy()
expect(cell(h).getChars()).toBe(PLACEHOLDER)
expect(cell(h).getCode()).toBe(0x10eeee)
expect(cell(h, 0, 1).getChars()).toBe('😀')
expect(cell(h, 0, 1).getWidth()).toBe(2)
expect(cell(h, 0, 1).isInvisible()).toBeFalsy()
expect(cell(h, 0, 3).getChars()).toBe('X')
} finally {
h.terminal.dispose()
}
})
it('keeps the reserved cells invisible after their image is deleted', async () => {
const h = createPlaceholderTerminal()
try {
await place(h)
await writeKitty(h.terminal, 'a=d,d=I,i=7,p=10,q=2')
await flush()
expect(h.kitty._virtualPlacements.has(7)).toBe(false)
expect(cell(h, 0, 0).isInvisible()).toBeTruthy()
expect(cell(h, 0, 1).isInvisible()).toBeTruthy()
expect(cell(h, 1, 0).getChars().codePointAt(0)).toBe(0x10eeee)
} finally {
h.terminal.dispose()
}
})
it('keeps existing reserved cells hidden while native image publication is pending', async () => {
const h = createPlaceholderTerminal()
let finish
let pending
try {
await write(h.terminal, placeholderCells(7, 10))
await flush()
const handler = h.addon._handlers.get('kitty')
const decode = handler._createBitmap.bind(handler)
handler._createBitmap = async (image) => {
const bitmap = await decode(image)
return new Promise((resolve) => {
finish = () => resolve(bitmap)
})
}
pending = writeKitty(h.terminal, 'a=T,f=100,i=7,U=1,p=10,c=2,r=2', PNG)
await vi.waitFor(() => expect(finish).toBeTypeOf('function'))
expect(h.kitty._virtualPlacements.has(7)).toBe(false)
expect(cell(h).isInvisible()).toBeTruthy()
expect(cell(h, 0, 1).isInvisible()).toBeTruthy()
} finally {
finish?.()
await pending
h.terminal.dispose()
}
})
it('changes only the invisible flag, preserving high image IDs, coordinate text and extended objects', async () => {
const h = createPlaceholderTerminal()
try {
await write(
h.terminal,
`\x1b[48;2;30;40;50m\x1b]8;;https://example.invalid/\x1b\\${placeholderCells(
0x80000007,
10
)}\x1b]8;;\x1b\\`
)
await flush()
const line = h.terminal._core.buffer.lines.get(0)
const before = [0, 1].map((col) => {
line._data[col * 3 + 1] &= ~INVISIBLE
return metadata(line, col)
})
h.kitty.hidePlaceholderGlyphs(h.terminal)
for (const col of [0, 1]) {
const after = metadata(line, col)
expect(after.words).toEqual([
before[col].words[0],
before[col].words[1] | INVISIBLE,
before[col].words[2]
])
expect(after.chars).toBe(before[col].chars)
expect(after.width).toBe(before[col].width)
expect(after.code).toBe(before[col].code)
expect(after.combined).toBe(before[col].combined)
expect(after.extended).toBe(before[col].extended)
}
expect(line.getString(0)).toContain('\u082D')
expect(line.getFg(0) & 0xffffff).toBe(7)
expect(line._extendedAttrs[0].underlineColor & 0xffffff).toBe(10)
} finally {
h.terminal.dispose()
}
})
it('masks a reserved cell without replacing its physical tile payload or linked attributes', async () => {
const h = createPlaceholderTerminal()
try {
await write(
h.terminal,
`\x1b[4;48;2;30;40;50m\x1b]8;;https://example.invalid/\x1b\\${placeholderCells(7, 10, 1, 1)}\x1b]8;;\x1b\\\x1b[H`
)
await flush()
const bitmap = new TrackedBitmap(10, 10)
h.storage.addImage(bitmap, { scrolling: true, layer: 'top', zIndex: 0, cursorPos: 'iip' })
const line = h.terminal._core.buffer.lines.get(0)
const realCell = cell(h)
realCell.fg &= ~INVISIBLE
line.setCell(0, realCell)
const before = metadata(line, 0)
const extended = line.getExtended(0)
const payload = extended.payload
const attrs = [extended.ext, extended.urlId, extended.underlineColor]
expect(extended.urlId).toBeGreaterThan(0)
expect(extended.underlineColor & 0xffffff).toBe(10)
expect(h.storage._images.get(payload.imageId).orig).toBe(bitmap)
h.kitty.hidePlaceholderGlyphs(h.terminal)
const after = metadata(line, 0)
expect(after).toEqual({
...before,
words: [before.words[0], before.words[1] | INVISIBLE, before.words[2]]
})
expect(line.getExtended(0)).toBe(extended)
expect(line.getExtended(0).payload).toBe(payload)
expect([extended.ext, extended.urlId, extended.underlineColor]).toEqual(attrs)
const drawImage = vi.fn()
const canvas = { getContext: () => ({ drawImage }) }
window.document = { createElement: () => canvas }
expect(h.storage.getImageAtBufferCell(0, 0)).toBe(canvas)
expect(drawImage).toHaveBeenCalledWith(bitmap, 0, 0, 10, 10)
} finally {
h.terminal.dispose()
}
})
it('ordinary text overwrites clear invisibility and remain visible', async () => {
const h = createPlaceholderTerminal()
try {
await write(h.terminal, placeholderCells(7, 10))
await flush()
expect(cell(h).isInvisible()).toBeTruthy()
await write(h.terminal, '\x1b[1;1HAB')
await flush()
expect(cell(h).getChars()).toBe('A')
expect(cell(h, 0, 1).getChars()).toBe('B')
expect(cell(h).isInvisible()).toBeFalsy()
expect(cell(h, 0, 1).isInvisible()).toBeFalsy()
} finally {
h.terminal.dispose()
}
})
it('masks offscreen cells synchronously when public scroll and resize reveal them', async () => {
const h = createPlaceholderTerminal()
try {
await write(h.terminal, `${PLACEHOLDER}\r\n${'ROW\r\n'.repeat(24)}`)
await flush()
const buffer = h.terminal._core.buffer
const first = reservedRow(h)
expect(first).toBeLessThan(buffer.ybase)
expect(buffer.lines.get(first).getFg(0) & INVISIBLE).toBe(0)
h.terminal.scrollToTop()
await Promise.resolve()
expect(buffer.lines.get(first).getFg(0) & INVISIBLE).toBeTruthy()
h.terminal.scrollToBottom()
await write(h.terminal, `\x1b[H${PLACEHOLDER}\u030D\r\n${'ROW\r\n'.repeat(24)}`)
await flush()
const last = reservedRow(h, true)
expect(last).toBeLessThan(buffer.ybase)
expect(buffer.lines.get(last).getFg(0) & INVISIBLE).toBe(0)
h.terminal.resize(20, 50)
expect(buffer.lines.get(last).getFg(0) & INVISIBLE).toBeTruthy()
} finally {
h.terminal.dispose()
}
})
it('scans only viewport cells and avoids loading or rewriting ordinary text', async () => {
const h = createPlaceholderTerminal()
try {
await write(h.terminal, `${'ROW\r\n'.repeat(24)}TEXT`)
await flush()
const buffer = h.terminal._core.buffer
const reads = [],
loads = [],
sets = []
for (let row = 0; row < buffer.lines.length; row++) {
const line = buffer.lines.get(row)
reads.push(vi.spyOn(line, 'getFg'))
loads.push(vi.spyOn(line, 'loadCell'))
sets.push(vi.spyOn(line, 'setCell'))
}
h.kitty.hidePlaceholderGlyphs(h.terminal)
expect(reads.reduce((sum, mock) => sum + mock.mock.calls.length, 0)).toBe(120)
expect(
reads
.slice(0, h.terminal.buffer.active.viewportY)
.every((mock) => mock.mock.calls.length === 0)
).toBe(true)
expect(loads.every((mock) => mock.mock.calls.length === 0)).toBe(true)
expect(sets.every((mock) => mock.mock.calls.length === 0)).toBe(true)
} finally {
h.terminal.dispose()
}
})
it('coalesces parser newline scrolling into one viewport scan per write batch', async () => {
const h = createPlaceholderTerminal()
try {
h.terminal.resize(80, 22)
const scans = vi.spyOn(h.kitty, 'hidePlaceholderGlyphs')
await write(h.terminal, 'ROW\r\n'.repeat(10000))
await flush()
expect(scans).toHaveBeenCalledTimes(1)
expect(h.terminal.buffer.active.cursorY).toBe(21)
} finally {
h.terminal.dispose()
}
})
it('cancels a queued scroll scan when the addon is disposed', async () => {
const h = createPlaceholderTerminal()
try {
await write(h.terminal, `${PLACEHOLDER}\r\n${'ROW\r\n'.repeat(24)}`)
await flush()
const scans = vi.spyOn(h.kitty, 'hidePlaceholderGlyphs')
h.terminal.scrollToTop()
h.addon.dispose()
await Promise.resolve()
expect(scans).not.toHaveBeenCalled()
expect(cell(h).getChars()).toBe(PLACEHOLDER)
expect(cell(h).isInvisible()).toBeFalsy()
} finally {
h.terminal.dispose()
}
})
it('addon disposal releases visibility subscriptions while keeping the terminal usable', async () => {
const h = createPlaceholderTerminal()
try {
await write(h.terminal, PLACEHOLDER)
await flush()
expect(cell(h).isInvisible()).toBeTruthy()
h.addon.dispose()
await write(h.terminal, `\x1b[2J\x1b[H${PLACEHOLDER}`)
await flush()
expect(cell(h).getChars()).toBe(PLACEHOLDER)
expect(cell(h).isInvisible()).toBeFalsy()
h.terminal.resize(21, 7)
expect(cell(h).isInvisible()).toBeFalsy()
} finally {
h.terminal.dispose()
}
})
})
@@ -0,0 +1,237 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import {
createPlaceholderTerminal,
DIACRITICS,
placeholderCells,
PLACEHOLDER,
PNG,
render,
write,
writeKitty
} from './xterm-kitty-placeholder-test-terminal.mjs'
afterEach(() => vi.unstubAllGlobals())
describe('Kitty virtual placement ownership and rendering', () => {
it('paints only at the placeholder cells and leaves the cursor unchanged', async () => {
const h = createPlaceholderTerminal()
try {
await write(h.terminal, '\x1b[2;4H')
await writeKitty(h.terminal, 'a=T,f=100,i=7,U=1,c=2,r=2', PNG)
expect([h.terminal.buffer.active.cursorX, h.terminal.buffer.active.cursorY]).toEqual([3, 1])
expect(render(h)).toHaveLength(0)
await write(h.terminal, placeholderCells(7))
const calls = render(h)
expect(calls).toHaveLength(2)
expect(calls[0].slice(1)).toEqual([0, 0, 20, 5, 30, 15, 20, 5])
expect(calls[1].slice(1)).toEqual([0, 5, 20, 5, 0, 20, 20, 5])
} finally {
h.terminal.dispose()
}
})
it('keeps virtual images when an ordinary image is subsequently placed', async () => {
const h = createPlaceholderTerminal()
try {
await writeKitty(h.terminal, 'a=T,f=100,i=7,U=1,c=2,r=2', PNG)
await write(h.terminal, `${placeholderCells(7)}\r\n`)
const virtualBitmap = h.bitmaps[0]
await writeKitty(h.terminal, 'a=T,f=100,i=8', PNG)
expect(render(h).filter((call) => call[0] === virtualBitmap)).toHaveLength(2)
expect(virtualBitmap.close).not.toHaveBeenCalled()
} finally {
h.terminal.dispose()
}
})
it.each(['i', 'I'])('releases the virtual bitmap on d=%s', async (selector) => {
const h = createPlaceholderTerminal()
try {
await writeKitty(h.terminal, 'a=T,f=100,i=7,U=1,c=2,r=2', PNG)
await write(h.terminal, placeholderCells(7))
expect(render(h)).toHaveLength(2)
await writeKitty(h.terminal, `a=d,d=${selector},i=7,q=2`)
expect(render(h)).toHaveLength(0)
expect(h.storage._images.size).toBe(0)
expect(h.bitmaps[0].close).toHaveBeenCalledOnce()
} finally {
h.terminal.dispose()
}
})
it.each(['reset', 'dispose'])('releases markerless virtual bitmaps on %s', async (action) => {
const h = createPlaceholderTerminal()
try {
await writeKitty(h.terminal, 'a=T,f=100,i=7,U=1,c=2,r=2', PNG)
h.addon[action]()
expect(h.storage._images.size).toBe(0)
expect(h.bitmaps[0].close).toHaveBeenCalledOnce()
} finally {
h.terminal.dispose()
}
})
it('retains distinct virtual placement IDs for the same image', async () => {
const h = createPlaceholderTerminal()
try {
await writeKitty(h.terminal, 'a=t,f=100,i=7', PNG)
await writeKitty(h.terminal, 'a=p,U=1,i=7,p=10,c=2,r=2')
await writeKitty(h.terminal, 'a=p,U=1,i=7,p=11,c=1,r=1')
await write(h.terminal, `${placeholderCells(7, 10)}\r\n${placeholderCells(7, 11, 1, 1)}`)
expect(render(h)).toHaveLength(3)
} finally {
h.terminal.dispose()
}
})
it('replaces a prototype without losing its encoded payload or leaking its old bitmap', async () => {
const h = createPlaceholderTerminal()
try {
await writeKitty(h.terminal, 'a=T,f=100,i=7,U=1,p=10,c=2,r=2', PNG)
const oldBitmap = h.bitmaps[0]
await writeKitty(h.terminal, 'a=p,U=1,i=7,p=10,c=1,r=1')
expect(h.kitty.getImage(7)).toBeDefined()
expect(h.storage._images.size).toBe(1)
expect(oldBitmap.close).toHaveBeenCalledOnce()
await write(h.terminal, placeholderCells(7, 10, 1, 1))
expect(render(h)).toHaveLength(1)
} finally {
h.terminal.dispose()
}
})
it('invalidates virtual placement bookkeeping when the pixel budget evicts it', async () => {
const h = createPlaceholderTerminal()
try {
await writeKitty(h.terminal, 'a=T,f=100,i=7,U=1,c=2,r=2', PNG)
await write(h.terminal, placeholderCells(7))
h.storage._pixelLimit = 200
await writeKitty(h.terminal, 'a=T,f=100,i=8,U=1,c=2,r=2', PNG)
expect(render(h)).toHaveLength(0)
expect(h.bitmaps[0].close).toHaveBeenCalledOnce()
expect(h.kitty._virtualPlacements.has(7)).toBe(false)
} finally {
h.terminal.dispose()
}
})
it.each([0x02000007, 0x80000007, 0x80000000])('resolves unsigned image ID %i', async (id) => {
const h = createPlaceholderTerminal()
try {
await writeKitty(h.terminal, `a=T,f=100,i=${id},U=1,c=2,r=2`, PNG)
await write(h.terminal, placeholderCells(id))
expect(render(h)).toHaveLength(2)
} finally {
h.terminal.dispose()
}
})
it.each(['a', 'A'])(
'keeps virtual prototypes when d=%s deletes physical placements',
async (selector) => {
const h = createPlaceholderTerminal()
try {
await writeKitty(h.terminal, 'a=T,f=100,i=7,U=1,c=2,r=2', PNG)
await write(h.terminal, placeholderCells(7))
await writeKitty(h.terminal, `a=d,d=${selector},q=2`)
expect(render(h)).toHaveLength(2)
expect(h.kitty.getImage(7)).toBeDefined()
} finally {
h.terminal.dispose()
}
}
)
it('deletes a named prototype without deleting another placement of the image', async () => {
const h = createPlaceholderTerminal()
try {
await writeKitty(h.terminal, 'a=T,f=100,i=7,U=1,p=10,c=2,r=2', PNG)
await writeKitty(h.terminal, 'a=p,U=1,i=7,p=11,c=1,r=1')
await write(h.terminal, `${placeholderCells(7, 10)}\r\n${placeholderCells(7, 11, 1, 1)}`)
await writeKitty(h.terminal, 'a=d,d=I,i=7,p=10,q=2')
expect(render(h)).toHaveLength(1)
expect(h.bitmaps[0].close).toHaveBeenCalledOnce()
expect(h.bitmaps[1].close).not.toHaveBeenCalled()
expect(h.kitty.getImage(7)).toBeDefined()
} finally {
h.terminal.dispose()
}
})
it('retains image data after deleting placements so a=p can restore a prototype', async () => {
const h = createPlaceholderTerminal()
try {
await writeKitty(h.terminal, 'a=T,f=100,i=7,U=1,c=2,r=2', PNG)
await writeKitty(h.terminal, 'a=d,d=i,i=7,q=2')
expect(h.kitty.getImage(7)).toBeDefined()
await writeKitty(h.terminal, 'a=p,U=1,i=7,c=2,r=2')
await write(h.terminal, placeholderCells(7))
expect(render(h)).toHaveLength(2)
} finally {
h.terminal.dispose()
}
})
it('inherits omitted diacritics only across consecutive matching cells', async () => {
const h = createPlaceholderTerminal()
try {
await writeKitty(h.terminal, 'a=T,f=100,i=7,U=1,c=2,r=2', PNG)
await write(h.terminal, `\x1b[38;5;7m${PLACEHOLDER}${DIACRITICS[1]}${PLACEHOLDER}`)
expect(render(h)).toHaveLength(1)
expect(h.drawImage.mock.calls[0].slice(1, 5)).toEqual([0, 5, 20, 5])
await write(h.terminal, ` ${PLACEHOLDER}`)
const calls = render(h)
expect(calls).toHaveLength(2)
expect(calls[1].slice(1, 5)).toEqual([0, 0, 10, 5])
} finally {
h.terminal.dispose()
}
})
it('ignores invalid row diacritics rather than treating them as omitted', async () => {
const h = createPlaceholderTerminal()
try {
await writeKitty(h.terminal, 'a=T,f=100,i=7,U=1,c=2,r=2', PNG)
await write(h.terminal, `\x1b[38;5;7m${PLACEHOLDER}\u0301${DIACRITICS[0]}`)
expect(render(h)).toHaveLength(0)
} finally {
h.terminal.dispose()
}
})
it('preserves the source aspect ratio when cell proportions change', async () => {
const h = createPlaceholderTerminal()
try {
await writeKitty(h.terminal, 'a=T,f=100,i=7,U=1,c=2,r=2', PNG)
await write(h.terminal, placeholderCells(7))
const createCanvas = vi.fn(() => ({ getContext: () => ({ drawImage: vi.fn() }) }))
vi.stubGlobal('document', { createElement: createCanvas })
vi.spyOn(h.renderer, 'cellSize', 'get').mockReturnValue({ width: 5, height: 10 })
const calls = render(h)
expect(calls[0].slice(1)).toEqual([0, 0, 20, 5, 0, 7.5, 10, 2.5])
expect(calls[1].slice(1)).toEqual([0, 5, 20, 5, 0, 10, 10, 2.5])
expect(h.storage.getUsage()).toBe(0.0008)
expect(createCanvas).not.toHaveBeenCalled()
} finally {
h.terminal.dispose()
}
})
it('follows placeholder cells through scrollback and erasure', async () => {
const h = createPlaceholderTerminal()
try {
await writeKitty(h.terminal, 'a=T,f=100,i=7,U=1,c=2,r=2', PNG)
await write(h.terminal, placeholderCells(7))
await write(h.terminal, '\r\n'.repeat(6))
expect(render(h)).toHaveLength(0)
h.terminal.scrollToTop()
expect(render(h)).toHaveLength(2)
h.terminal.scrollToBottom()
await write(h.terminal, '\x1b[3J')
h.terminal.scrollToTop()
expect(render(h)).toHaveLength(0)
} finally {
h.terminal.dispose()
}
})
})
@@ -0,0 +1,104 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import {
createPlaceholderTerminal,
placeholderCells,
PNG,
render,
TrackedBitmap,
write,
writeKitty
} from './xterm-kitty-placeholder-test-terminal.mjs'
afterEach(() => vi.unstubAllGlobals())
describe('Kitty virtual placement lifecycle', () => {
it('keeps alternate-buffer prototypes across ordinary image placement and releases them on exit', async () => {
const h = createPlaceholderTerminal()
try {
await write(h.terminal, '\x1b[?1049h')
await writeKitty(h.terminal, 'a=T,f=100,i=7,U=1,c=2,r=2', PNG)
await write(h.terminal, `${placeholderCells(7)}\r\n`)
const virtual = h.bitmaps[0]
await writeKitty(h.terminal, 'a=T,f=100,i=8', PNG)
expect(render(h).filter((call) => call[0] === virtual)).toHaveLength(2)
await write(h.terminal, '\x1b[?1049l')
expect(h.storage._images.size).toBe(0)
expect(h.kitty._virtualPlacements.size).toBe(0)
expect(virtual.close).toHaveBeenCalledOnce()
} finally {
h.terminal.dispose()
}
})
it('retransmitting an image ID releases all old virtual placements', async () => {
const h = createPlaceholderTerminal()
try {
await writeKitty(h.terminal, 'a=T,f=100,i=7,U=1,p=10,c=2,r=2', PNG)
await writeKitty(h.terminal, 'a=p,U=1,i=7,p=11,c=1,r=1')
const previous = [...h.bitmaps]
await writeKitty(h.terminal, 'a=T,f=100,i=7,U=1,p=12,c=2,r=2', PNG)
expect(h.storage._images.size).toBe(1)
for (const bitmap of previous) {
expect(bitmap.close).toHaveBeenCalledOnce()
}
await write(h.terminal, `${placeholderCells(7, 10)}\r\n${placeholderCells(7, 12)}`)
expect(render(h)).toHaveLength(2)
} finally {
h.terminal.dispose()
}
})
it('can create a prototype from an encoded image after its physical bitmap is evicted', async () => {
const h = createPlaceholderTerminal()
try {
await writeKitty(h.terminal, 'a=T,f=100,i=7,C=1', PNG)
h.storage._pixelLimit = 200
await writeKitty(h.terminal, 'a=T,f=100,i=8,U=1,c=2,r=2', PNG)
expect(h.kitty.getImage(7)).toBeDefined()
await writeKitty(h.terminal, 'a=p,U=1,i=7,c=2,r=2')
await write(h.terminal, placeholderCells(7))
expect(render(h)).toHaveLength(2)
} finally {
h.terminal.dispose()
}
})
it('deletes physical placements while preserving a prototype of the same image on d=A', async () => {
const h = createPlaceholderTerminal()
try {
await writeKitty(h.terminal, 'a=T,f=100,i=7,U=1,c=2,r=2', PNG)
await writeKitty(h.terminal, 'a=p,i=7,C=1')
expect(h.kitty.kittyIdToStorageId.size).toBe(1)
await writeKitty(h.terminal, 'a=d,d=A,q=2')
expect(h.kitty.kittyIdToStorageId.size).toBe(0)
expect(h.storage._images.size).toBe(1)
expect(h.bitmaps[1].close).toHaveBeenCalledOnce()
await write(h.terminal, placeholderCells(7))
expect(render(h)).toHaveLength(2)
} finally {
h.terminal.dispose()
}
})
it.each(['reset', 'dispose'])('discards a virtual decode completing after %s', async (action) => {
const h = createPlaceholderTerminal()
try {
let finishDecode
h.addon._handlers.get('kitty')._createBitmap = () =>
new Promise((resolve) => {
finishDecode = resolve
})
const decoding = writeKitty(h.terminal, 'a=T,f=100,i=7,U=1,c=2,r=2', PNG)
await vi.waitFor(() => expect(finishDecode).toBeTypeOf('function'))
h.addon[action]()
const bitmap = new TrackedBitmap()
finishDecode(bitmap)
await decoding
expect(h.storage._images.size).toBe(0)
expect(h.kitty._virtualPlacements.size).toBe(0)
expect(bitmap.close).toHaveBeenCalledOnce()
} finally {
h.terminal.dispose()
}
})
})
@@ -0,0 +1,233 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import {
createPlaceholderTerminal,
placeholderCells,
PNG,
render,
TrackedBitmap,
write,
writeKitty
} from './xterm-kitty-placeholder-test-terminal.mjs'
afterEach(() => vi.unstubAllGlobals())
async function prepared() {
const h = createPlaceholderTerminal()
await writeKitty(h.terminal, 'a=T,f=100,i=7,U=1,p=10,c=2,r=2', PNG)
await write(h.terminal, placeholderCells(7, 10))
const first = h.bitmaps[0]
const pending = []
vi.stubGlobal(
'createImageBitmap',
() => new Promise((resolve, reject) => pending.push({ resolve, reject }))
)
return { h, first, pending }
}
describe('placeholder replacement publication boundaries', () => {
it('keeps the existing picture until its replacement finishes decoding', async () => {
const { h, first, pending } = await prepared()
const writing = writeKitty(h.terminal, 'a=T,f=100,i=7,U=1,p=10,c=2,r=2', PNG)
try {
await vi.waitFor(() => expect(pending).toHaveLength(1))
expect(render(h).map((call) => call[0])).toEqual([first, first])
expect(first.close).not.toHaveBeenCalled()
} finally {
pending[0]?.resolve(new TrackedBitmap())
await writing
h.terminal.dispose()
}
})
it('publishes no stale replacement after an addon reset during decoding', async () => {
const { h, first, pending } = await prepared()
const writing = writeKitty(h.terminal, 'a=T,f=100,i=7,U=1,p=10,c=2,r=2', PNG)
const replacement = new TrackedBitmap()
try {
await vi.waitFor(() => expect(pending).toHaveLength(1))
h.addon.reset()
pending[0].resolve(replacement)
await writing
expect(render(h)).toHaveLength(0)
expect(h.kitty._virtualPlacements.size).toBe(0)
expect(h.storage._images.size).toBe(0)
expect(first.close).toHaveBeenCalledOnce()
expect(replacement.close).toHaveBeenCalledOnce()
} finally {
h.terminal.dispose()
}
})
it('does not delete another picture when the replacement fails decoding', async () => {
const h = createPlaceholderTerminal()
try {
await writeKitty(h.terminal, 'a=T,f=100,i=7,U=1,p=10,c=2,r=2', PNG)
await writeKitty(h.terminal, 'a=T,f=100,i=9,U=1,p=11,c=2,r=2', PNG)
const unrelated = [...h.kitty._virtualPlacements.get(9).values()][0]
vi.stubGlobal('createImageBitmap', () => Promise.reject(new Error('decode failure')))
await writeKitty(h.terminal, 'a=T,f=100,i=7,U=1,p=10,c=2,r=2', PNG)
expect(h.kitty._virtualPlacements.has(7)).toBe(false)
expect(h.kitty._virtualPlacements.get(9).get(11)).toBe(unrelated)
expect(h.bitmaps[1].close).not.toHaveBeenCalled()
} finally {
h.terminal.dispose()
}
})
it('drops stale pictures and closes the replacement when storage rejects publication', async () => {
const { h, first, pending } = await prepared()
const writing = writeKitty(h.terminal, 'a=T,f=100,i=7,U=1,p=10,c=2,r=2', PNG)
const replacement = new TrackedBitmap()
try {
await vi.waitFor(() => expect(pending).toHaveLength(1))
vi.spyOn(h.storage, 'addUnplacedImage').mockImplementationOnce(() => {
throw new Error('publication failed')
})
pending[0].resolve(replacement)
await writing
expect(render(h)).toHaveLength(0)
expect(first.close).toHaveBeenCalledOnce()
expect(replacement.close).toHaveBeenCalledOnce()
} finally {
h.terminal.dispose()
}
})
it('retains every old placement until success, then leaves only the replacement placement ID', async () => {
const h = createPlaceholderTerminal()
try {
await writeKitty(h.terminal, 'a=T,f=100,i=7,U=1,p=10,c=2,r=2', PNG)
await writeKitty(h.terminal, 'a=p,U=1,i=7,p=11,c=1,r=1')
const old = [...h.bitmaps]
let resolve
vi.stubGlobal(
'createImageBitmap',
() =>
new Promise((finish) => {
resolve = finish
})
)
const writing = writeKitty(h.terminal, 'a=T,f=100,i=7,U=1,p=12,c=2,r=2', PNG)
await vi.waitFor(() => expect(resolve).toBeTypeOf('function'))
const during = [...(h.kitty._virtualPlacements.get(7)?.keys() ?? [])]
const replacement = new TrackedBitmap()
resolve(replacement)
await writing
expect(during).toEqual([10, 11])
expect([...h.kitty._virtualPlacements.get(7).keys()]).toEqual([12])
expect(h.storage._images.size).toBe(1)
for (const bitmap of old) {
expect(bitmap.close).toHaveBeenCalledOnce()
}
expect(replacement.close).not.toHaveBeenCalled()
} finally {
h.terminal.dispose()
}
})
it.each(['resolve', 'reject'])(
'an unrelated query does not cancel a replacement decode that will %s',
async (completion) => {
const { h, first, pending } = await prepared()
const writing = writeKitty(h.terminal, 'a=T,f=100,i=7,U=1,p=10,c=2,r=2', PNG)
const stale = new TrackedBitmap()
try {
await vi.waitFor(() => expect(pending).toHaveLength(1))
const handler = h.addon._handlers.get('kitty')
const command = Uint32Array.from('a=q,i=9;'.split('').map((char) => char.codePointAt(0)))
handler.start()
handler.put(command, 0, command.length)
await handler.end(true)
if (completion === 'resolve') {
pending[0].resolve(stale)
} else {
pending[0].reject(new Error('late decode failure'))
}
await writing
expect(render(h).map((call) => call[0])).toEqual(
completion === 'resolve' ? [stale, stale] : []
)
expect(first.close).toHaveBeenCalledOnce()
expect(stale.close.mock.calls.length).toBe(0)
} finally {
h.terminal.dispose()
}
}
)
it('cannot resurrect a deleted placement after a newer delete supersedes the decode', async () => {
const { h, first, pending } = await prepared()
const writing = writeKitty(h.terminal, 'a=T,f=100,i=7,U=1,p=10,c=2,r=2', PNG)
const stale = new TrackedBitmap()
try {
await vi.waitFor(() => expect(pending).toHaveLength(1))
const handler = h.addon._handlers.get('kitty')
const command = Uint32Array.from(
'a=d,d=I,i=7,p=10;'.split('').map((char) => char.codePointAt(0))
)
handler.start()
handler.put(command, 0, command.length)
await handler.end(true)
pending[0].resolve(stale)
await writing
expect(h.kitty._virtualPlacements.size).toBe(0)
expect(h.storage._images.size).toBe(0)
expect(first.close).toHaveBeenCalledOnce()
expect(stale.close).toHaveBeenCalledOnce()
} finally {
h.terminal.dispose()
}
})
it.each(['resolve', 'reject'])(
'a newer same-image source preserves the old picture after its superseded decode will %s',
async (completion) => {
const { h, first, pending } = await prepared()
const writing = writeKitty(h.terminal, 'a=T,f=100,i=7,U=1,p=10,c=2,r=2', PNG)
const stale = new TrackedBitmap()
const latest = new TrackedBitmap()
try {
await vi.waitFor(() => expect(pending).toHaveLength(1))
const handler = h.addon._handlers.get('kitty')
const command = Uint32Array.from(
`a=T,f=100,i=7,U=1,p=10,c=2,r=2;${PNG}`.split('').map((char) => char.codePointAt(0))
)
handler.start()
handler.put(command, 0, command.length)
const newer = handler.end(true)
await vi.waitFor(() => expect(pending).toHaveLength(2))
if (completion === 'resolve') {
pending[0].resolve(stale)
} else {
pending[0].reject(new Error('late decode failure'))
}
await writing
expect(render(h).map((call) => call[0])).toEqual([first, first])
expect(first.close).not.toHaveBeenCalled()
expect(stale.close.mock.calls.length).toBe(completion === 'resolve' ? 1 : 0)
pending[1].resolve(latest)
await newer
expect(render(h).map((call) => call[0])).toEqual([latest, latest])
expect(first.close).toHaveBeenCalledOnce()
expect(latest.close).not.toHaveBeenCalled()
} finally {
h.terminal.dispose()
}
}
)
it('does not publish after disposal while decode is pending', async () => {
const { h, first, pending } = await prepared()
const writing = writeKitty(h.terminal, 'a=T,f=100,i=7,U=1,p=10,c=2,r=2', PNG)
const stale = new TrackedBitmap()
await vi.waitFor(() => expect(pending).toHaveLength(1))
h.addon.dispose()
pending[0].resolve(stale)
await writing
expect(h.storage._images.size).toBe(0)
expect(h.kitty._virtualPlacements.size).toBe(0)
expect(first.close).toHaveBeenCalledOnce()
expect(stale.close).toHaveBeenCalledOnce()
h.terminal.dispose()
})
})
@@ -0,0 +1,88 @@
import { createRequire } from 'node:module'
import { vi } from 'vitest'
const require = createRequire(import.meta.url)
const { Terminal } = require(process.env.ORCA_IMAGE_CONTRACT_CORE_PATH ?? '@xterm/xterm')
const { Unicode11Addon } = require('@xterm/addon-unicode11')
const { ImageAddon } = require(process.env.ORCA_IMAGE_CONTRACT_ADDON_PATH ?? '@xterm/addon-image')
export const PLACEHOLDER = '\u{10EEEE}'
export const DIACRITICS = ['\u0305', '\u030D', '\u030E']
DIACRITICS[128] = '\u082D'
export const PNG =
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVQIHWP4z8DwHwAFgAI/ScLbtAAAAABJRU5ErkJggg=='
export class TrackedBitmap {
constructor(width = 20, height = 10) {
this.width = width
this.height = height
this.close = vi.fn()
}
}
export function createPlaceholderTerminal() {
const bitmaps = []
const decode = vi.fn(async () => {
const bitmap = new TrackedBitmap()
bitmaps.push(bitmap)
return bitmap
})
vi.stubGlobal('ImageBitmap', TrackedBitmap)
vi.stubGlobal('createImageBitmap', decode)
vi.stubGlobal('window', { ImageBitmap: TrackedBitmap, createImageBitmap: decode })
const terminal = new Terminal({ allowProposedApi: true, cols: 20, rows: 6 })
terminal.loadAddon(new Unicode11Addon())
terminal.unicode.activeVersion = '11'
const addon = new ImageAddon({
enableSizeReports: false,
storageLimit: 32,
kittySizeLimit: 8 * 1024 * 1024
})
terminal.loadAddon(addon)
const renderer = addon._renderer
vi.spyOn(renderer, 'cellSize', 'get').mockReturnValue({ width: 10, height: 10 })
const drawImage = vi.fn()
renderer._layers.set('top', { drawImage, clearRect() {}, canvas: { remove() {} } })
return {
terminal,
addon,
renderer,
bitmaps,
decode,
drawImage,
storage: addon._storage,
kitty: addon._handlers.get('kitty')._kittyStorage
}
}
export function write(terminal, data) {
return new Promise((resolve) => terminal.write(data, resolve))
}
export function writeKitty(terminal, command, payload = '') {
return write(terminal, `\x1b_G${command};${payload}\x1b\\`)
}
export function placeholderCells(imageId, placementId = 0, cols = 2, rows = 2) {
const id = imageId & 0xffffff
const high = imageId >>> 24
const colors =
`\x1b[38;2;${id >>> 16};${(id >>> 8) & 255};${id & 255}m` +
`\x1b[58;2;${placementId >>> 16};${(placementId >>> 8) & 255};${placementId & 255}m`
return `${
colors +
Array.from({ length: rows }, (_, row) =>
Array.from(
{ length: cols },
(_, col) =>
`${PLACEHOLDER}${DIACRITICS[row]}${DIACRITICS[col]}${high ? DIACRITICS[high] : ''}`
).join('')
).join('\r\n')
}\x1b[0m`
}
export function render(harness) {
harness.drawImage.mockClear()
harness.storage.render({ start: 0, end: 5 })
return harness.drawImage.mock.calls
}
@@ -0,0 +1,27 @@
import { createRequire } from 'node:module'
import { expect, it } from 'vitest'
const require = createRequire(import.meta.url)
const { Terminal } = require('@xterm/xterm')
function write(terminal, text) {
return new Promise((resolve) => terminal.write(text, resolve))
}
it('retains underline color without drawing an underline, including explicit white and reset', async () => {
const terminal = new Terminal({ allowProposedApi: true })
try {
await write(terminal, '\x1b[58;2;0;0;10mA\x1b[58;2;255;255;255mB\x1b[59mC')
const line = terminal._core.buffer.lines.get(0)
expect(line._extendedAttrs[0].underlineColor & 0xffffff).toBe(10)
expect(line._extendedAttrs[0].underlineStyle).toBe(0)
expect(line._extendedAttrs[1].underlineColor & 0xffffff).toBe(0xffffff)
expect(line._extendedAttrs[1].underlineStyle).toBe(0)
expect(line._extendedAttrs[2]).toBeUndefined()
await write(terminal, '\x1b[4mD')
expect(line._extendedAttrs[3].underlineStyle).toBe(1)
expect(line._extendedAttrs[3].underlineColor).toBe(0)
} finally {
terminal.dispose()
}
})
+28 -28
View File
@@ -167,12 +167,12 @@ overrides:
patchedDependencies:
'@vscode/windows-process-tree@0.8.0': 9da74aa3d17243aa53dcdc95c9f06e97437e7fbccf098aeb017579e2d24cbac2
'@xterm/addon-image@0.10.0-beta.301': cb569e3005b47547189ba473280c3ae7cf4040161da538cd998ae6e69ad403f5
'@xterm/addon-image@0.10.0-beta.301': 6849e0a5c060de7052c58310d805004528e6ad028acb48717b947cf63a96f02f
'@xterm/addon-ligatures@0.11.0-beta.301': 47405b9994b5acf1b4e90b49250358c1ca03649854d59560e7732b72fe336920
'@xterm/addon-search@0.17.0-beta.301': eee5338dd2621ece46e79c61ec06766cd7fadaf79ffdb24e2a8ab68e97ef31f0
'@xterm/addon-serialize@0.15.0-beta.301': b35533fe252e7e45433150170348889f4e08a6c17f7017ac34ea694d831fec7f
'@xterm/addon-webgl@0.20.0-beta.300': 5349718f2c19d9319a81552580bf9618ded9b00f3707a5c6cdbbae1910849cd9
'@xterm/xterm@6.1.0-beta.304': 9e1c200b2332745ef9e01e64696e9cda6f629937c37a0ee652323f25ccc8df5c
'@xterm/xterm@6.1.0-beta.304': c2a02250b5bf92e4711d4d7d24a8e7b31dec4f0a163859ed21182b7ce617ffff
i18next-cli@1.74.2: 2ee32eef742615cd133e1a6940f027deb3723a8430bb7953bba3a6769b0aac47
lint-staged@16.4.0: 7333b3837f80a7fbd045964db6d76ba4fc118e49134bdbabb00585b6b7b60673
monaco-editor@0.55.1: 53b71f7d8a7c00b76ffdb73ce83d18e31692f193f5fafa6a390e48f5b3fdd636
@@ -204,7 +204,7 @@ importers:
version: 2.5.6
'@xterm/addon-serialize':
specifier: 0.15.0-beta.301
version: 0.15.0-beta.301(patch_hash=b35533fe252e7e45433150170348889f4e08a6c17f7017ac34ea694d831fec7f)(@xterm/xterm@6.1.0-beta.304(patch_hash=9e1c200b2332745ef9e01e64696e9cda6f629937c37a0ee652323f25ccc8df5c))
version: 0.15.0-beta.301(patch_hash=b35533fe252e7e45433150170348889f4e08a6c17f7017ac34ea694d831fec7f)(@xterm/xterm@6.1.0-beta.304(patch_hash=c2a02250b5bf92e4711d4d7d24a8e7b31dec4f0a163859ed21182b7ce617ffff))
'@xterm/headless':
specifier: 6.1.0-beta.303
version: 6.1.0-beta.303
@@ -412,28 +412,28 @@ importers:
version: 1.18.0
'@xterm/addon-fit':
specifier: 0.12.0-beta.301
version: 0.12.0-beta.301(@xterm/xterm@6.1.0-beta.304(patch_hash=9e1c200b2332745ef9e01e64696e9cda6f629937c37a0ee652323f25ccc8df5c))
version: 0.12.0-beta.301(@xterm/xterm@6.1.0-beta.304(patch_hash=c2a02250b5bf92e4711d4d7d24a8e7b31dec4f0a163859ed21182b7ce617ffff))
'@xterm/addon-image':
specifier: 0.10.0-beta.301
version: 0.10.0-beta.301(patch_hash=cb569e3005b47547189ba473280c3ae7cf4040161da538cd998ae6e69ad403f5)(@xterm/xterm@6.1.0-beta.304(patch_hash=9e1c200b2332745ef9e01e64696e9cda6f629937c37a0ee652323f25ccc8df5c))
version: 0.10.0-beta.301(patch_hash=6849e0a5c060de7052c58310d805004528e6ad028acb48717b947cf63a96f02f)(@xterm/xterm@6.1.0-beta.304(patch_hash=c2a02250b5bf92e4711d4d7d24a8e7b31dec4f0a163859ed21182b7ce617ffff))
'@xterm/addon-ligatures':
specifier: 0.11.0-beta.301
version: 0.11.0-beta.301(patch_hash=47405b9994b5acf1b4e90b49250358c1ca03649854d59560e7732b72fe336920)(@xterm/xterm@6.1.0-beta.304(patch_hash=9e1c200b2332745ef9e01e64696e9cda6f629937c37a0ee652323f25ccc8df5c))
version: 0.11.0-beta.301(patch_hash=47405b9994b5acf1b4e90b49250358c1ca03649854d59560e7732b72fe336920)(@xterm/xterm@6.1.0-beta.304(patch_hash=c2a02250b5bf92e4711d4d7d24a8e7b31dec4f0a163859ed21182b7ce617ffff))
'@xterm/addon-search':
specifier: 0.17.0-beta.301
version: 0.17.0-beta.301(patch_hash=eee5338dd2621ece46e79c61ec06766cd7fadaf79ffdb24e2a8ab68e97ef31f0)(@xterm/xterm@6.1.0-beta.304(patch_hash=9e1c200b2332745ef9e01e64696e9cda6f629937c37a0ee652323f25ccc8df5c))
version: 0.17.0-beta.301(patch_hash=eee5338dd2621ece46e79c61ec06766cd7fadaf79ffdb24e2a8ab68e97ef31f0)(@xterm/xterm@6.1.0-beta.304(patch_hash=c2a02250b5bf92e4711d4d7d24a8e7b31dec4f0a163859ed21182b7ce617ffff))
'@xterm/addon-unicode11':
specifier: 0.10.0-beta.301
version: 0.10.0-beta.301(@xterm/xterm@6.1.0-beta.304(patch_hash=9e1c200b2332745ef9e01e64696e9cda6f629937c37a0ee652323f25ccc8df5c))
version: 0.10.0-beta.301(@xterm/xterm@6.1.0-beta.304(patch_hash=c2a02250b5bf92e4711d4d7d24a8e7b31dec4f0a163859ed21182b7ce617ffff))
'@xterm/addon-web-links':
specifier: 0.13.0-beta.301
version: 0.13.0-beta.301(@xterm/xterm@6.1.0-beta.304(patch_hash=9e1c200b2332745ef9e01e64696e9cda6f629937c37a0ee652323f25ccc8df5c))
version: 0.13.0-beta.301(@xterm/xterm@6.1.0-beta.304(patch_hash=c2a02250b5bf92e4711d4d7d24a8e7b31dec4f0a163859ed21182b7ce617ffff))
'@xterm/addon-webgl':
specifier: 0.20.0-beta.300
version: 0.20.0-beta.300(patch_hash=5349718f2c19d9319a81552580bf9618ded9b00f3707a5c6cdbbae1910849cd9)(@xterm/xterm@6.1.0-beta.304(patch_hash=9e1c200b2332745ef9e01e64696e9cda6f629937c37a0ee652323f25ccc8df5c))
version: 0.20.0-beta.300(patch_hash=5349718f2c19d9319a81552580bf9618ded9b00f3707a5c6cdbbae1910849cd9)(@xterm/xterm@6.1.0-beta.304(patch_hash=c2a02250b5bf92e4711d4d7d24a8e7b31dec4f0a163859ed21182b7ce617ffff))
'@xterm/xterm':
specifier: 6.1.0-beta.304
version: 6.1.0-beta.304(patch_hash=9e1c200b2332745ef9e01e64696e9cda6f629937c37a0ee652323f25ccc8df5c)
version: 6.1.0-beta.304(patch_hash=c2a02250b5bf92e4711d4d7d24a8e7b31dec4f0a163859ed21182b7ce617ffff)
class-variance-authority:
specifier: ^0.7.1
version: 0.7.1
@@ -11246,43 +11246,43 @@ snapshots:
'@xmldom/xmldom@0.8.15': {}
'@xterm/addon-fit@0.12.0-beta.301(@xterm/xterm@6.1.0-beta.304(patch_hash=9e1c200b2332745ef9e01e64696e9cda6f629937c37a0ee652323f25ccc8df5c))':
'@xterm/addon-fit@0.12.0-beta.301(@xterm/xterm@6.1.0-beta.304(patch_hash=c2a02250b5bf92e4711d4d7d24a8e7b31dec4f0a163859ed21182b7ce617ffff))':
dependencies:
'@xterm/xterm': 6.1.0-beta.304(patch_hash=9e1c200b2332745ef9e01e64696e9cda6f629937c37a0ee652323f25ccc8df5c)
'@xterm/xterm': 6.1.0-beta.304(patch_hash=c2a02250b5bf92e4711d4d7d24a8e7b31dec4f0a163859ed21182b7ce617ffff)
'@xterm/addon-image@0.10.0-beta.301(patch_hash=cb569e3005b47547189ba473280c3ae7cf4040161da538cd998ae6e69ad403f5)(@xterm/xterm@6.1.0-beta.304(patch_hash=9e1c200b2332745ef9e01e64696e9cda6f629937c37a0ee652323f25ccc8df5c))':
'@xterm/addon-image@0.10.0-beta.301(patch_hash=6849e0a5c060de7052c58310d805004528e6ad028acb48717b947cf63a96f02f)(@xterm/xterm@6.1.0-beta.304(patch_hash=c2a02250b5bf92e4711d4d7d24a8e7b31dec4f0a163859ed21182b7ce617ffff))':
dependencies:
'@xterm/xterm': 6.1.0-beta.304(patch_hash=9e1c200b2332745ef9e01e64696e9cda6f629937c37a0ee652323f25ccc8df5c)
'@xterm/xterm': 6.1.0-beta.304(patch_hash=c2a02250b5bf92e4711d4d7d24a8e7b31dec4f0a163859ed21182b7ce617ffff)
'@xterm/addon-ligatures@0.11.0-beta.301(patch_hash=47405b9994b5acf1b4e90b49250358c1ca03649854d59560e7732b72fe336920)(@xterm/xterm@6.1.0-beta.304(patch_hash=9e1c200b2332745ef9e01e64696e9cda6f629937c37a0ee652323f25ccc8df5c))':
'@xterm/addon-ligatures@0.11.0-beta.301(patch_hash=47405b9994b5acf1b4e90b49250358c1ca03649854d59560e7732b72fe336920)(@xterm/xterm@6.1.0-beta.304(patch_hash=c2a02250b5bf92e4711d4d7d24a8e7b31dec4f0a163859ed21182b7ce617ffff))':
dependencies:
'@xterm/xterm': 6.1.0-beta.304(patch_hash=9e1c200b2332745ef9e01e64696e9cda6f629937c37a0ee652323f25ccc8df5c)
'@xterm/xterm': 6.1.0-beta.304(patch_hash=c2a02250b5bf92e4711d4d7d24a8e7b31dec4f0a163859ed21182b7ce617ffff)
lru-cache: 11.5.1
opentype.js: 2.0.0
'@xterm/addon-search@0.17.0-beta.301(patch_hash=eee5338dd2621ece46e79c61ec06766cd7fadaf79ffdb24e2a8ab68e97ef31f0)(@xterm/xterm@6.1.0-beta.304(patch_hash=9e1c200b2332745ef9e01e64696e9cda6f629937c37a0ee652323f25ccc8df5c))':
'@xterm/addon-search@0.17.0-beta.301(patch_hash=eee5338dd2621ece46e79c61ec06766cd7fadaf79ffdb24e2a8ab68e97ef31f0)(@xterm/xterm@6.1.0-beta.304(patch_hash=c2a02250b5bf92e4711d4d7d24a8e7b31dec4f0a163859ed21182b7ce617ffff))':
dependencies:
'@xterm/xterm': 6.1.0-beta.304(patch_hash=9e1c200b2332745ef9e01e64696e9cda6f629937c37a0ee652323f25ccc8df5c)
'@xterm/xterm': 6.1.0-beta.304(patch_hash=c2a02250b5bf92e4711d4d7d24a8e7b31dec4f0a163859ed21182b7ce617ffff)
'@xterm/addon-serialize@0.15.0-beta.301(patch_hash=b35533fe252e7e45433150170348889f4e08a6c17f7017ac34ea694d831fec7f)(@xterm/xterm@6.1.0-beta.304(patch_hash=9e1c200b2332745ef9e01e64696e9cda6f629937c37a0ee652323f25ccc8df5c))':
'@xterm/addon-serialize@0.15.0-beta.301(patch_hash=b35533fe252e7e45433150170348889f4e08a6c17f7017ac34ea694d831fec7f)(@xterm/xterm@6.1.0-beta.304(patch_hash=c2a02250b5bf92e4711d4d7d24a8e7b31dec4f0a163859ed21182b7ce617ffff))':
dependencies:
'@xterm/xterm': 6.1.0-beta.304(patch_hash=9e1c200b2332745ef9e01e64696e9cda6f629937c37a0ee652323f25ccc8df5c)
'@xterm/xterm': 6.1.0-beta.304(patch_hash=c2a02250b5bf92e4711d4d7d24a8e7b31dec4f0a163859ed21182b7ce617ffff)
'@xterm/addon-unicode11@0.10.0-beta.301(@xterm/xterm@6.1.0-beta.304(patch_hash=9e1c200b2332745ef9e01e64696e9cda6f629937c37a0ee652323f25ccc8df5c))':
'@xterm/addon-unicode11@0.10.0-beta.301(@xterm/xterm@6.1.0-beta.304(patch_hash=c2a02250b5bf92e4711d4d7d24a8e7b31dec4f0a163859ed21182b7ce617ffff))':
dependencies:
'@xterm/xterm': 6.1.0-beta.304(patch_hash=9e1c200b2332745ef9e01e64696e9cda6f629937c37a0ee652323f25ccc8df5c)
'@xterm/xterm': 6.1.0-beta.304(patch_hash=c2a02250b5bf92e4711d4d7d24a8e7b31dec4f0a163859ed21182b7ce617ffff)
'@xterm/addon-web-links@0.13.0-beta.301(@xterm/xterm@6.1.0-beta.304(patch_hash=9e1c200b2332745ef9e01e64696e9cda6f629937c37a0ee652323f25ccc8df5c))':
'@xterm/addon-web-links@0.13.0-beta.301(@xterm/xterm@6.1.0-beta.304(patch_hash=c2a02250b5bf92e4711d4d7d24a8e7b31dec4f0a163859ed21182b7ce617ffff))':
dependencies:
'@xterm/xterm': 6.1.0-beta.304(patch_hash=9e1c200b2332745ef9e01e64696e9cda6f629937c37a0ee652323f25ccc8df5c)
'@xterm/xterm': 6.1.0-beta.304(patch_hash=c2a02250b5bf92e4711d4d7d24a8e7b31dec4f0a163859ed21182b7ce617ffff)
'@xterm/addon-webgl@0.20.0-beta.300(patch_hash=5349718f2c19d9319a81552580bf9618ded9b00f3707a5c6cdbbae1910849cd9)(@xterm/xterm@6.1.0-beta.304(patch_hash=9e1c200b2332745ef9e01e64696e9cda6f629937c37a0ee652323f25ccc8df5c))':
'@xterm/addon-webgl@0.20.0-beta.300(patch_hash=5349718f2c19d9319a81552580bf9618ded9b00f3707a5c6cdbbae1910849cd9)(@xterm/xterm@6.1.0-beta.304(patch_hash=c2a02250b5bf92e4711d4d7d24a8e7b31dec4f0a163859ed21182b7ce617ffff))':
dependencies:
'@xterm/xterm': 6.1.0-beta.304(patch_hash=9e1c200b2332745ef9e01e64696e9cda6f629937c37a0ee652323f25ccc8df5c)
'@xterm/xterm': 6.1.0-beta.304(patch_hash=c2a02250b5bf92e4711d4d7d24a8e7b31dec4f0a163859ed21182b7ce617ffff)
'@xterm/headless@6.1.0-beta.303': {}
'@xterm/xterm@6.1.0-beta.304(patch_hash=9e1c200b2332745ef9e01e64696e9cda6f629937c37a0ee652323f25ccc8df5c)': {}
'@xterm/xterm@6.1.0-beta.304(patch_hash=c2a02250b5bf92e4711d4d7d24a8e7b31dec4f0a163859ed21182b7ce617ffff)': {}
abbrev@4.0.0: {}
@@ -1,13 +1,26 @@
import { expect, type Page } from '@stablyai/playwright-test'
import { PNG } from 'pngjs'
export function inlineImageProducer(): string {
export function inlineImagePayload(includePlaceholders = false): string {
const png = new PNG({ width: 120, height: 36 })
for (let i = 0; i < png.data.length; i += 4) {
png.data.set([240, 40, 40, 255], i)
}
const encoded = PNG.sync.write(png).toString('base64')
const payload =
let placeholderPayload = ''
if (includePlaceholders) {
for (let i = 0; i < png.data.length; i += 4) {
png.data.set([240, 160, 40, 255], i)
}
const prototype = PNG.sync.write(png).toString('base64')
const rows = ['\u0305', '\u030D', '\u030E']
const cells = rows.map((row) => `\u{10EEEE}${row}${'\u{10EEEE}'.repeat(14)}`).join('\r\n')
placeholderPayload =
`Kitty placeholders: orange\r\n` +
`\x1b_Ga=T,f=100,i=65280,U=1,c=15,r=3,q=2;${prototype}\x1b\\` +
`\x1b[38;2;0;255;0m${cells}\x1b[0m\r\n\r\n`
}
return (
`\x1bcSSH / REMOTE INLINE IMAGE PROOF\r\n\r\n` +
`iTerm2: red\r\n` +
`\x1b]1337;File=inline=1;width=120px;height=36px:${encoded}\x07` +
@@ -17,7 +30,12 @@ export function inlineImageProducer(): string {
`\x1b_Ga=T,f=24,s=120,v=36,q=2;${Buffer.from(
Array.from({ length: 120 * 36 }, () => [40, 40, 240]).flat()
).toString('base64')}\x1b\\` +
`\r\n\r\n`
`\r\n\r\n${placeholderPayload}`
)
}
export function inlineImageProducer(includePlaceholders = false): string {
const payload = inlineImagePayload(includePlaceholders)
return (
`const payload = Buffer.from('${Buffer.from(payload).toString('base64')}', 'base64');\n` +
`process.stdout.write(payload);\n` +
@@ -134,3 +152,30 @@ export async function assertInlineImagePixels(page: Page, screenshotPath: string
)
.toBeGreaterThan(1000)
}
export async function assertKittyPlaceholderPixels(
page: Page,
screenshotPath: string
): Promise<void> {
const screen = page.locator('.pane:visible .xterm-screen').first()
await expect
.poll(
async () => {
const png = PNG.sync.read(await screen.screenshot({ path: screenshotPath }))
let orangePixels = 0
for (let i = 0; i < png.data.length; i += 4) {
if (
png.data[i] > 220 &&
png.data[i + 1] > 140 &&
png.data[i + 1] < 180 &&
png.data[i + 2] < 60
) {
orangePixels++
}
}
return orangePixels
},
{ timeout: 30_000, message: 'Remote Kitty placeholder cells must paint the orange image' }
)
.toBeGreaterThan(1000)
}
@@ -6,6 +6,7 @@ import { toWebTerminalSurfaceTabId } from '../../src/shared/terminal-surface-id'
import { expect, test } from './helpers/orca-app'
import {
assertInlineImagePixels,
assertKittyPlaceholderPixels,
enableInlineImages,
inlineImageProducer,
readInlineImageState
@@ -62,9 +63,10 @@ async function expectProtocolPixels(
)
.toContain(`LIVE:${marker}:`)
await assertInlineImagePixels(client.page, screenshotPath)
await assertKittyPlaceholderPixels(client.page, `${screenshotPath}.placeholders.png`)
await expect
.poll(() => readInlineImageState(client.page))
.toMatchObject({ images: 3, pending: 0, decoderBytes: 0 })
.toMatchObject({ images: 4, pending: 0, decoderBytes: 0 })
}
test('paired runtime paints inline images and resumes the same PTY after host restart', async ({
@@ -104,7 +106,7 @@ test('paired runtime paints inline images and resumes the same PTY after host re
'const record = line => appendFileSync(process.argv[2], line + "\\n")',
'record(`READY:${process.pid}`)',
'process.stdout.write(`READY:${process.pid}\\r\\n`)',
`function emitImages() { ${inlineImageProducer()} }`,
`function emitImages() { ${inlineImageProducer(true)} }`,
'let pending = ""',
'process.stdin.setEncoding("utf8")',
'process.stdin.on("data", data => {',
@@ -0,0 +1,89 @@
import { expect, test } from './helpers/orca-app'
import { ensureTerminalVisible, waitForSessionReady } from './helpers/store'
import {
execInTerminal,
waitForActivePanePtyId,
waitForActiveTerminalManager,
waitForTerminalOutput
} from './helpers/terminal'
import {
assertInlineImagePixels,
assertKittyPlaceholderPixels,
enableInlineImages,
inlineImagePayload
} from './helpers/terminal-inline-image-proof'
import { getFirstWslDistro, useWslRuntimeForActiveProject } from './helpers/wsl-golden-stub-agent'
import { nodeTerminalCommand } from './terminal-node-command'
test.skip(process.platform !== 'win32', 'Native Windows and WSL PTY coverage')
for (const runtime of ['native', 'wsl'] as const) {
for (const acceleration of ['off', 'on'] as const) {
test(`${runtime}, ${acceleration}: PTY images and Kitty placeholders render on Windows`, async ({
orcaPage
}, testInfo) => {
await waitForSessionReady(orcaPage)
await ensureTerminalVisible(orcaPage)
await orcaPage.evaluate(async (policy) => {
await window.__store!.getState().updateSettings({ terminalGpuAcceleration: policy })
}, acceleration)
if (runtime === 'wsl') {
const distro = await getFirstWslDistro(orcaPage)
expect(distro, 'This Windows verification requires an installed WSL distro').not.toBeNull()
if (!distro) {
throw new Error('No WSL distro available')
}
await useWslRuntimeForActiveProject(orcaPage, distro)
} else {
await orcaPage.evaluate(async () => {
await window
.__store!.getState()
.updateSettings({ terminalWindowsShell: 'powershell.exe' })
})
}
const tabId = await orcaPage.evaluate(() => {
const state = window.__store!.getState()
const worktreeId = state.activeWorktreeId
if (!worktreeId) {
throw new Error('Missing verification worktree')
}
const tab = state.createTab(worktreeId)
window.__store!.getState().setActiveTab(tab.id)
window.__store!.getState().setActiveTabType('terminal', worktreeId)
return tab.id
})
await expect(
orcaPage.locator(`[data-tab-id="${tabId}"] [data-shell-icon]`).first()
).toHaveAttribute('data-shell-icon', runtime === 'wsl' ? 'wsl.exe' : 'powershell.exe')
await waitForActiveTerminalManager(orcaPage, 30_000)
await enableInlineImages(orcaPage)
await expect
.poll(() =>
orcaPage.evaluate(() => {
const tab = window.__store!.getState().activeTabId
const manager = tab && window.__paneManagers?.get(tab)
const pane = manager && manager.getActivePane()
return Boolean(manager && pane && manager.hasWebglRenderer(pane.id))
})
)
.toBe(acceleration === 'on')
const ptyId = await waitForActivePanePtyId(orcaPage)
const marker = 'WINDOWS_INLINE_IMAGES_DONE'
const payload = Buffer.from(`${inlineImagePayload(true)}${marker}\r\n`).toString('base64')
const command =
runtime === 'wsl'
? `printf '%s' '${payload}' | base64 -d; printf 'WSL_IMAGE_PROTOCOL=%s\\n' "$ORCA_IMAGE_PROTOCOL"`
: nodeTerminalCommand(['-e', `process.stdout.write(Buffer.from('${payload}', 'base64'))`])
await execInTerminal(orcaPage, ptyId, command)
await waitForTerminalOutput(orcaPage, marker, 30_000)
if (runtime === 'wsl') {
await waitForTerminalOutput(orcaPage, 'WSL_IMAGE_PROTOCOL=kitty', 30_000)
}
await assertInlineImagePixels(orcaPage, testInfo.outputPath('windows-inline-protocols.png'))
await assertKittyPlaceholderPixels(
orcaPage,
testInfo.outputPath('windows-inline-placeholders.png')
)
})
}
}
@@ -0,0 +1,200 @@
import { writeFileSync } from 'node:fs'
import { PNG } from 'pngjs'
import { expect, test } from './helpers/orca-app'
import { ensureTerminalVisible, waitForSessionReady } from './helpers/store'
import {
execInTerminal,
sendToTerminal,
waitForActivePanePtyId,
waitForActiveTerminalManager,
waitForTerminalOutput
} from './helpers/terminal'
import { enableInlineImages } from './helpers/terminal-inline-image-proof'
import { nodeTerminalCommand } from './terminal-node-command'
test.use({
launchEnv: { ORCA_BACKGROUND_LAUNCH: '1' },
orcaAppExtraArgs: ['--force-color-profile=srgb']
})
test.afterEach(async ({ orcaPage }, testInfo) => {
if (testInfo.status === testInfo.expectedStatus) {
return
}
// Read back only after failure so diagnostics cannot supply the missing repaint.
const canvas = await orcaPage
.locator('.pane:visible .xterm-image-layer-top')
.first()
.evaluate(
(layer) =>
layer instanceof HTMLCanvasElement
? { width: layer.width, height: layer.height, png: layer.toDataURL() }
: null,
undefined,
{ timeout: 1000 }
)
.catch(() => null)
writeFileSync(testInfo.outputPath('kitty-failure-canvas.json'), JSON.stringify(canvas))
})
const MARK = '\u{10EEEE}'
const ROWS = ['\u0305', '\u030D', '\u030E', '\u0310']
type ImageKind = 'cursor' | 'placeholder' | 'named' | 'letterbox' | 'transparent'
function imagePayload(kind: ImageKind): string {
const virtual = kind !== 'cursor'
const png = new PNG({ width: 120, height: kind === 'letterbox' ? 60 : 120 })
for (let i = 0; i < png.data.length; i += 4) {
const x = (i / 4) % png.width
const y = Math.floor(i / 4 / png.width)
const hole = kind === 'transparent' && x >= 30 && x < 90 && y >= 30 && y < 90
png.data.set([240, 40, 40, hole ? 0 : 255], i)
}
const encoded = PNG.sync.write(png).toString('base64')
const placement =
kind === 'named'
? `\x1b_Ga=T,f=100,i=65280,p=11,c=1,r=1,q=2,U=1;${encoded}\x1b\\` +
`\x1b_Ga=p,i=65280,p=10,c=8,r=4,q=2,U=1;\x1b\\`
: `\x1b_Ga=T,f=100,i=65280,c=8,r=4,q=2${virtual ? ',U=1' : ''};${encoded}\x1b\\`
const cells = ROWS.map((row) => `${MARK}${row}${'\u{10EEEE}'.repeat(7)}`).join('\r\n')
return `\x1bc${placement}${
virtual
? `\x1b[6;5H\x1b[38;2;0;255;0m${kind === 'named' ? '\x1b[58;2;0;0;10m' : ''}${cells}\x1b[0m`
: ''
}\x1b[12;5H\x1b[38;2;0;255;0mKEEP\x1b[0m\x1b[15;1HINLINE_RENDER_DONE\r\n`
}
for (const acceleration of ['off', 'on'] as const) {
for (const kind of ['cursor', 'placeholder', 'named', 'letterbox', 'transparent'] as const) {
test(`@headful ${acceleration}: Kitty ${kind} image paints at its intended cells`, async ({
orcaPage
}, testInfo) => {
await waitForSessionReady(orcaPage)
await ensureTerminalVisible(orcaPage)
await orcaPage.evaluate(async (policy) => {
await window.__store!.getState().updateSettings({ terminalGpuAcceleration: policy })
}, acceleration)
await waitForActiveTerminalManager(orcaPage, 30_000)
await enableInlineImages(orcaPage)
await expect
.poll(() =>
orcaPage.evaluate(() => {
const tabId = window.__store!.getState().activeTabId
const manager = tabId && window.__paneManagers?.get(tabId)
const pane = manager && manager.getActivePane()
return Boolean(manager && pane && manager.hasWebglRenderer(pane.id))
})
)
.toBe(acceleration === 'on')
const ptyId = await waitForActivePanePtyId(orcaPage)
const producer = testInfo.outputPath('kitty-placeholder-producer.cjs')
writeFileSync(
producer,
`process.stdin.setRawMode(true);
process.stdin.on('data', (data) => {
const key = data.toString();
if (key === 'q') process.exit(0);
const operation = key === 'd' ? 'a=d,d=i,i=65280,q=2' : 'a=p,U=1,i=65280,c=8,r=4,q=2';
process.stdout.write('\\x1b_G' + operation + '${kind === 'named' ? ',p=10' : ''};\\x1b\\\\');
});
process.stdout.write(Buffer.from('${Buffer.from(imagePayload(kind)).toString('base64')}', 'base64'));`
)
await execInTerminal(orcaPage, ptyId, nodeTerminalCommand([producer]))
await waitForTerminalOutput(orcaPage, 'INLINE_RENDER_DONE', 30_000)
const screen = orcaPage.locator('.pane:visible .xterm-screen').first()
await expect(screen).toBeVisible()
const dimensions = await orcaPage.evaluate(() => {
const tabId = window.__store!.getState().activeTabId
const pane = tabId && window.__paneManagers?.get(tabId)?.getActivePane()
if (!pane) {
throw new Error('Missing image terminal')
}
return { cols: pane.terminal.cols, rows: pane.terminal.rows }
})
await expect
.poll(
async () => {
const image = PNG.sync.read(
await screen.screenshot({ path: testInfo.outputPath('kitty-cells.png') })
)
const cellWidth = image.width / dimensions.cols
const cellHeight = image.height / dimensions.rows
const col = kind === 'cursor' ? 0 : 4
const row = kind === 'cursor' ? 0 : 5
let redPixels = 0
let greenPixels = 0
for (let y = Math.ceil(row * cellHeight); y < (row + 4) * cellHeight; y++) {
for (let x = Math.ceil(col * cellWidth); x < (col + 8) * cellWidth; x++) {
const offset = (y * image.width + x) * 4
if (
image.data[offset] > 220 &&
image.data[offset + 1] < 60 &&
image.data[offset + 2] < 60
) {
redPixels++
}
if (
image.data[offset] < 60 &&
image.data[offset + 1] > 220 &&
image.data[offset + 2] < 60
) {
greenPixels++
}
}
}
return { hasImage: redPixels > 1000, greenPixels }
},
{ timeout: 15_000, message: 'Image pixels must appear in the addressed cell rectangle' }
)
.toEqual({ hasImage: true, greenPixels: 0 })
const image = PNG.sync.read(await screen.screenshot())
let redPixels = 0
for (let i = 0; i < image.data.length; i += 4) {
if (image.data[i] > 220 && image.data[i + 1] < 60 && image.data[i + 2] < 60) {
redPixels++
}
}
expect(redPixels).toBeGreaterThan(1000)
if (kind === 'placeholder' || kind === 'named') {
for (const action of ['delete', 'restore'] as const) {
// Only the image command changes: shell echo must not supply the missing repaint.
await sendToTerminal(orcaPage, ptyId, action === 'delete' ? 'd' : 'r')
await expect
.poll(
async () => {
const png = PNG.sync.read(
await screen.screenshot({ path: testInfo.outputPath(`kitty-${action}.png`) })
)
let pixels = 0
for (
let y = Math.ceil((5 * png.height) / dimensions.rows);
y < (9 * png.height) / dimensions.rows;
y++
) {
for (
let x = Math.ceil((4 * png.width) / dimensions.cols);
x < (12 * png.width) / dimensions.cols;
x++
) {
const offset = (y * png.width + x) * 4
if (
png.data[offset] > 220 &&
png.data[offset + 1] < 60 &&
png.data[offset + 2] < 60
) {
pixels++
}
}
}
return pixels > 1000
},
{ timeout: 10_000, message: `${action} must repaint existing placeholder cells` }
)
.toBe(action === 'restore')
}
}
await sendToTerminal(orcaPage, ptyId, 'q')
})
}
}