mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 08:37:49 +00:00
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:
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()
|
||||
}
|
||||
})
|
||||
Generated
+28
-28
@@ -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')
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user