156 lines
5.6 KiB
TypeScript
156 lines
5.6 KiB
TypeScript
/**
|
|
* Copyright (c) 2026 The xterm.js authors. All rights reserved.
|
|
* @license MIT
|
|
*/
|
|
|
|
import { IDisposable } from '@xterm/xterm';
|
|
import { ImageStorage } from '../ImageStorage';
|
|
import { ImageLayer, IAddImageOpts } from '../Types';
|
|
import { IKittyImageData } from './KittyGraphicsTypes';
|
|
|
|
// 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
|
|
// - mirrors shared-storage evictions into kitty maps
|
|
// - applies protocol-level undisplayed-image eviction policy
|
|
export class KittyImageStorage implements IDisposable {
|
|
private static readonly _maxStoredImages = 256;
|
|
|
|
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.
|
|
private readonly _kittyIdToStorageId: Map<number, number> = new Map();
|
|
private readonly _storageIdToKittyId: Map<number, number> = new Map();
|
|
|
|
private readonly _previousOnImageDeleted: ((storageId: number) => void) | undefined;
|
|
private readonly _wrappedOnImageDeleted: (storageId: number) => void;
|
|
private readonly _handleStorageImageDeleted = (storageId: number): void => {
|
|
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
|
|
) {
|
|
this._previousOnImageDeleted = this._storage.onImageDeleted;
|
|
this._wrappedOnImageDeleted = (storageId: number) => {
|
|
this._previousOnImageDeleted?.(storageId);
|
|
this._handleStorageImageDeleted(storageId);
|
|
};
|
|
this._storage.onImageDeleted = this._wrappedOnImageDeleted;
|
|
}
|
|
|
|
public reset(): void {
|
|
this._nextImageId = 1;
|
|
this._images.clear();
|
|
this._kittyIdToStorageId.clear();
|
|
this._storageIdToKittyId.clear();
|
|
}
|
|
|
|
public dispose(): void {
|
|
this.reset();
|
|
if (this._storage.onImageDeleted === this._wrappedOnImageDeleted) {
|
|
this._storage.onImageDeleted = this._previousOnImageDeleted;
|
|
}
|
|
}
|
|
|
|
public storeImage(id: number | undefined, imageData: Omit<IKittyImageData, 'id'>): number {
|
|
const imageId = id ?? this._nextImageId++;
|
|
|
|
const oldStorageId = this._kittyIdToStorageId.get(imageId);
|
|
if (oldStorageId !== undefined) {
|
|
this._storage.deleteImage(oldStorageId);
|
|
this._kittyIdToStorageId.delete(imageId);
|
|
this._storageIdToKittyId.delete(oldStorageId);
|
|
}
|
|
|
|
if (!this._images.has(imageId) && this._images.size >= KittyImageStorage._maxStoredImages) {
|
|
this._evictUndisplayedImages();
|
|
}
|
|
|
|
this._images.set(imageId, {
|
|
...imageData,
|
|
id: imageId
|
|
});
|
|
return imageId;
|
|
}
|
|
|
|
public addImage(kittyId: number, image: HTMLCanvasElement | ImageBitmap, scrolling: boolean, layer: ImageLayer, zIndex: number): void {
|
|
// Clean up stale reverse-mapping from a previous placement of the same
|
|
// kitty image. The old shared-storage entry is kept (it may still be
|
|
// visible on screen) but its reverse mapping is removed so that eviction
|
|
// of the old entry won't incorrectly delete the kitty image data.
|
|
const oldStorageId = this._kittyIdToStorageId.get(kittyId);
|
|
if (oldStorageId !== undefined) {
|
|
this._storageIdToKittyId.delete(oldStorageId);
|
|
}
|
|
this._addImageOpts.scrolling = scrolling;
|
|
this._addImageOpts.layer = layer;
|
|
this._addImageOpts.zIndex = zIndex;
|
|
const storageId = this._storage.addImage(image, this._addImageOpts);
|
|
this._kittyIdToStorageId.set(kittyId, storageId);
|
|
this._storageIdToKittyId.set(storageId, kittyId);
|
|
}
|
|
|
|
public getImage(kittyId: number): IKittyImageData | undefined {
|
|
return this._images.get(kittyId);
|
|
}
|
|
|
|
public deleteById(kittyId: number): void {
|
|
this._images.delete(kittyId);
|
|
const storageId = this._kittyIdToStorageId.get(kittyId);
|
|
if (storageId !== undefined) {
|
|
this._storage.deleteImage(storageId);
|
|
this._kittyIdToStorageId.delete(kittyId);
|
|
this._storageIdToKittyId.delete(storageId);
|
|
}
|
|
}
|
|
|
|
public deleteAll(): void {
|
|
this._images.clear();
|
|
for (const storageId of this._kittyIdToStorageId.values()) {
|
|
this._storage.deleteImage(storageId);
|
|
}
|
|
this._kittyIdToStorageId.clear();
|
|
this._storageIdToKittyId.clear();
|
|
}
|
|
|
|
public get images(): ReadonlyMap<number, IKittyImageData> {
|
|
return this._images;
|
|
}
|
|
|
|
public get kittyIdToStorageId(): ReadonlyMap<number, number> {
|
|
return this._kittyIdToStorageId;
|
|
}
|
|
|
|
public get lastImageId(): number {
|
|
return this._nextImageId - 1;
|
|
}
|
|
|
|
private _evictUndisplayedImages(): void {
|
|
for (const [kittyId] of this._images) {
|
|
if (this._images.size <= KittyImageStorage._maxStoredImages / 2) {
|
|
break;
|
|
}
|
|
if (!this._kittyIdToStorageId.has(kittyId)) {
|
|
this._images.delete(kittyId);
|
|
}
|
|
}
|
|
}
|
|
}
|