mirror of
https://github.com/wavetermdev/xterm.js.git
synced 2026-08-05 13:43:48 -07:00
support z-index + dual canvas for DOM. Doesnt work for webgl
This commit is contained in:
@@ -5,7 +5,7 @@
|
|||||||
|
|
||||||
import { toRGBA8888 } from 'sixel/lib/Colors';
|
import { toRGBA8888 } from 'sixel/lib/Colors';
|
||||||
import { IDisposable } from '@xterm/xterm';
|
import { IDisposable } from '@xterm/xterm';
|
||||||
import { ICellSize, ITerminalExt, IImageSpec, IRenderDimensions, IRenderService } from './Types';
|
import { ICellSize, ImageLayer, ITerminalExt, IImageSpec, IRenderDimensions, IRenderService } from './Types';
|
||||||
import { Disposable, MutableDisposable, toDisposable } from 'vs/base/common/lifecycle';
|
import { Disposable, MutableDisposable, toDisposable } from 'vs/base/common/lifecycle';
|
||||||
|
|
||||||
const PLACEHOLDER_LENGTH = 4096;
|
const PLACEHOLDER_LENGTH = 4096;
|
||||||
@@ -18,8 +18,12 @@ const PLACEHOLDER_HEIGHT = 24;
|
|||||||
* - draw image tiles onRender
|
* - draw image tiles onRender
|
||||||
*/
|
*/
|
||||||
export class ImageRenderer extends Disposable implements IDisposable {
|
export class ImageRenderer extends Disposable implements IDisposable {
|
||||||
public canvas: HTMLCanvasElement | undefined;
|
/** @deprecated Use canvasTop instead. Kept for backward compat — points to canvasTop. */
|
||||||
private _ctx: CanvasRenderingContext2D | null | undefined;
|
public get canvas(): HTMLCanvasElement | undefined { return this._canvasTop; }
|
||||||
|
private _canvasTop: HTMLCanvasElement | undefined;
|
||||||
|
private _canvasBottom: HTMLCanvasElement | undefined;
|
||||||
|
private _ctxTop: CanvasRenderingContext2D | null | undefined;
|
||||||
|
private _ctxBottom: CanvasRenderingContext2D | null | undefined;
|
||||||
private _placeholder: HTMLCanvasElement | undefined;
|
private _placeholder: HTMLCanvasElement | undefined;
|
||||||
private _placeholderBitmap: ImageBitmap | undefined;
|
private _placeholderBitmap: ImageBitmap | undefined;
|
||||||
private _optionsRefresh = this._register(new MutableDisposable());
|
private _optionsRefresh = this._register(new MutableDisposable());
|
||||||
@@ -86,6 +90,7 @@ export class ImageRenderer extends Disposable implements IDisposable {
|
|||||||
});
|
});
|
||||||
this._register(toDisposable(() => {
|
this._register(toDisposable(() => {
|
||||||
this.removeLayerFromDom();
|
this.removeLayerFromDom();
|
||||||
|
this.removeLayerFromDom('bottom');
|
||||||
if (this._terminal._core && this._oldOpen) {
|
if (this._terminal._core && this._oldOpen) {
|
||||||
this._terminal._core.open = this._oldOpen;
|
this._terminal._core.open = this._oldOpen;
|
||||||
this._oldOpen = undefined;
|
this._oldOpen = undefined;
|
||||||
@@ -95,8 +100,10 @@ export class ImageRenderer extends Disposable implements IDisposable {
|
|||||||
this._oldSetRenderer = undefined;
|
this._oldSetRenderer = undefined;
|
||||||
}
|
}
|
||||||
this._renderService = undefined;
|
this._renderService = undefined;
|
||||||
this.canvas = undefined;
|
this._canvasTop = undefined;
|
||||||
this._ctx = undefined;
|
this._canvasBottom = undefined;
|
||||||
|
this._ctxTop = undefined;
|
||||||
|
this._ctxBottom = undefined;
|
||||||
this._placeholderBitmap?.close();
|
this._placeholderBitmap?.close();
|
||||||
this._placeholderBitmap = undefined;
|
this._placeholderBitmap = undefined;
|
||||||
this._placeholder = undefined;
|
this._placeholder = undefined;
|
||||||
@@ -140,27 +147,36 @@ export class ImageRenderer extends Disposable implements IDisposable {
|
|||||||
/**
|
/**
|
||||||
* Clear a region of the image layer canvas.
|
* Clear a region of the image layer canvas.
|
||||||
*/
|
*/
|
||||||
public clearLines(start: number, end: number): void {
|
public clearLines(start: number, end: number, layer?: ImageLayer): void {
|
||||||
this._ctx?.clearRect(
|
const y = start * (this.dimensions?.css.cell.height || 0);
|
||||||
0,
|
const w = this.dimensions?.css.canvas.width || 0;
|
||||||
start * (this.dimensions?.css.cell.height || 0),
|
const h = (++end - start) * (this.dimensions?.css.cell.height || 0);
|
||||||
this.dimensions?.css.canvas.width || 0,
|
if (!layer || layer === 'top') {
|
||||||
(++end - start) * (this.dimensions?.css.cell.height || 0)
|
this._ctxTop?.clearRect(0, y, w, h);
|
||||||
);
|
}
|
||||||
|
if (!layer || layer === 'bottom') {
|
||||||
|
this._ctxBottom?.clearRect(0, y, w, h);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Clear whole image canvas.
|
* Clear whole image canvas.
|
||||||
*/
|
*/
|
||||||
public clearAll(): void {
|
public clearAll(layer?: ImageLayer): void {
|
||||||
this._ctx?.clearRect(0, 0, this.canvas?.width || 0, this.canvas?.height || 0);
|
if (!layer || layer === 'top') {
|
||||||
|
this._ctxTop?.clearRect(0, 0, this._canvasTop?.width || 0, this._canvasTop?.height || 0);
|
||||||
|
}
|
||||||
|
if (!layer || layer === 'bottom') {
|
||||||
|
this._ctxBottom?.clearRect(0, 0, this._canvasBottom?.width || 0, this._canvasBottom?.height || 0);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Draw neighboring tiles on the image layer canvas.
|
* Draw neighboring tiles on the image layer canvas.
|
||||||
*/
|
*/
|
||||||
public draw(imgSpec: IImageSpec, tileId: number, col: number, row: number, count: number = 1): void {
|
public draw(imgSpec: IImageSpec, tileId: number, col: number, row: number, count: number = 1): void {
|
||||||
if (!this._ctx) {
|
const ctx = imgSpec.layer === 'bottom' ? this._ctxBottom : this._ctxTop;
|
||||||
|
if (!ctx) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const { width, height } = this.cellSize;
|
const { width, height } = this.cellSize;
|
||||||
@@ -187,7 +203,7 @@ export class ImageRenderer extends Disposable implements IDisposable {
|
|||||||
// Note: For not pixel perfect aligned cells like in the DOM renderer
|
// Note: For not pixel perfect aligned cells like in the DOM renderer
|
||||||
// this will move a tile slightly to the top/left (subpixel range, thus ignore it).
|
// this will move a tile slightly to the top/left (subpixel range, thus ignore it).
|
||||||
// FIX #34: avoid striping on displays with pixelDeviceRatio != 1 by ceiling height and width
|
// FIX #34: avoid striping on displays with pixelDeviceRatio != 1 by ceiling height and width
|
||||||
this._ctx.drawImage(
|
ctx.drawImage(
|
||||||
img,
|
img,
|
||||||
Math.floor(sx), Math.floor(sy), Math.ceil(finalWidth), Math.ceil(finalHeight),
|
Math.floor(sx), Math.floor(sy), Math.ceil(finalWidth), Math.ceil(finalHeight),
|
||||||
Math.floor(dx), Math.floor(dy), Math.ceil(finalWidth), Math.ceil(finalHeight)
|
Math.floor(dx), Math.floor(dy), Math.ceil(finalWidth), Math.ceil(finalHeight)
|
||||||
@@ -227,7 +243,7 @@ export class ImageRenderer extends Disposable implements IDisposable {
|
|||||||
* Draw a line with placeholder on the image layer canvas.
|
* Draw a line with placeholder on the image layer canvas.
|
||||||
*/
|
*/
|
||||||
public drawPlaceholder(col: number, row: number, count: number = 1): void {
|
public drawPlaceholder(col: number, row: number, count: number = 1): void {
|
||||||
if (this._ctx) {
|
if (this._ctxTop) {
|
||||||
const { width, height } = this.cellSize;
|
const { width, height } = this.cellSize;
|
||||||
|
|
||||||
// Don't try to draw anything, if we cannot get valid renderer metrics.
|
// Don't try to draw anything, if we cannot get valid renderer metrics.
|
||||||
@@ -241,7 +257,7 @@ export class ImageRenderer extends Disposable implements IDisposable {
|
|||||||
this._createPlaceHolder(height + 1);
|
this._createPlaceHolder(height + 1);
|
||||||
}
|
}
|
||||||
if (!this._placeholder) return;
|
if (!this._placeholder) return;
|
||||||
this._ctx.drawImage(
|
this._ctxTop.drawImage(
|
||||||
this._placeholderBitmap ?? this._placeholder!,
|
this._placeholderBitmap ?? this._placeholder!,
|
||||||
col * width,
|
col * width,
|
||||||
(row * height) % 2 ? 0 : 1, // needs %2 offset correction
|
(row * height) % 2 ? 0 : 1, // needs %2 offset correction
|
||||||
@@ -260,12 +276,15 @@ export class ImageRenderer extends Disposable implements IDisposable {
|
|||||||
* Checked once from `ImageStorage.render`.
|
* Checked once from `ImageStorage.render`.
|
||||||
*/
|
*/
|
||||||
public rescaleCanvas(): void {
|
public rescaleCanvas(): void {
|
||||||
if (!this.canvas) {
|
const w = this.dimensions?.css.canvas.width || 0;
|
||||||
return;
|
const h = this.dimensions?.css.canvas.height || 0;
|
||||||
|
if (this._canvasTop && (this._canvasTop.width !== w || this._canvasTop.height !== h)) {
|
||||||
|
this._canvasTop.width = w;
|
||||||
|
this._canvasTop.height = h;
|
||||||
}
|
}
|
||||||
if (this.canvas.width !== this.dimensions!.css.canvas.width || this.canvas.height !== this.dimensions!.css.canvas.height) {
|
if (this._canvasBottom && (this._canvasBottom.width !== w || this._canvasBottom.height !== h)) {
|
||||||
this.canvas.width = this.dimensions!.css.canvas.width || 0;
|
this._canvasBottom.width = w;
|
||||||
this.canvas.height = this.dimensions!.css.canvas.height || 0;
|
this._canvasBottom.height = h;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -305,34 +324,60 @@ export class ImageRenderer extends Disposable implements IDisposable {
|
|||||||
this._oldSetRenderer = this._renderService.setRenderer.bind(this._renderService);
|
this._oldSetRenderer = this._renderService.setRenderer.bind(this._renderService);
|
||||||
this._renderService.setRenderer = (renderer: any) => {
|
this._renderService.setRenderer = (renderer: any) => {
|
||||||
this.removeLayerFromDom();
|
this.removeLayerFromDom();
|
||||||
|
this.removeLayerFromDom('bottom');
|
||||||
this._oldSetRenderer?.call(this._renderService, renderer);
|
this._oldSetRenderer?.call(this._renderService, renderer);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
public insertLayerToDom(): void {
|
public insertLayerToDom(layer: ImageLayer = 'top'): void {
|
||||||
// make sure that the terminal is attached to a document and to DOM
|
// make sure that the terminal is attached to a document and to DOM
|
||||||
if (this.document && this._terminal._core.screenElement) {
|
if (this.document && this._terminal._core.screenElement) {
|
||||||
if (!this.canvas) {
|
if (layer === 'top' && !this._canvasTop) {
|
||||||
this.canvas = ImageRenderer.createCanvas(
|
this._canvasTop = ImageRenderer.createCanvas(
|
||||||
this.document, this.dimensions?.css.canvas.width || 0,
|
this.document, this.dimensions?.css.canvas.width || 0,
|
||||||
this.dimensions?.css.canvas.height || 0
|
this.dimensions?.css.canvas.height || 0
|
||||||
);
|
);
|
||||||
this.canvas.classList.add('xterm-image-layer');
|
this._canvasTop.classList.add('xterm-image-layer-top');
|
||||||
this._terminal._core.screenElement.appendChild(this.canvas);
|
this._terminal._core.screenElement.appendChild(this._canvasTop);
|
||||||
this._ctx = this.canvas.getContext('2d', { alpha: true, desynchronized: true });
|
this._ctxTop = this._canvasTop.getContext('2d', { alpha: true, desynchronized: true });
|
||||||
this.clearAll();
|
this.clearAll('top');
|
||||||
|
}
|
||||||
|
if (layer === 'bottom' && !this._canvasBottom) {
|
||||||
|
this._canvasBottom = ImageRenderer.createCanvas(
|
||||||
|
this.document, this.dimensions?.css.canvas.width || 0,
|
||||||
|
this.dimensions?.css.canvas.height || 0
|
||||||
|
);
|
||||||
|
this._canvasBottom.classList.add('xterm-image-layer-bottom');
|
||||||
|
// Use z-index:-1 so it paints behind non-positioned text elements.
|
||||||
|
// The screen element needs to be a stacking context to contain the
|
||||||
|
// negative z-index, otherwise it would go behind the entire terminal.
|
||||||
|
this._canvasBottom.style.zIndex = '-1';
|
||||||
|
const screenElement = this._terminal._core.screenElement;
|
||||||
|
screenElement.style.zIndex = '0';
|
||||||
|
screenElement.insertBefore(this._canvasBottom, screenElement.firstChild);
|
||||||
|
this._ctxBottom = this._canvasBottom.getContext('2d', { alpha: true, desynchronized: true });
|
||||||
|
this.clearAll('bottom');
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
console.warn('image addon: cannot insert output canvas to DOM, missing document or screenElement');
|
console.warn('image addon: cannot insert output canvas to DOM, missing document or screenElement');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public removeLayerFromDom(): void {
|
public removeLayerFromDom(layer: ImageLayer = 'top'): void {
|
||||||
if (this.canvas) {
|
if (layer === 'top' && this._canvasTop) {
|
||||||
this._ctx = undefined;
|
this._ctxTop = undefined;
|
||||||
this.canvas.remove();
|
this._canvasTop.remove();
|
||||||
this.canvas = undefined;
|
this._canvasTop = undefined;
|
||||||
}
|
}
|
||||||
|
if (layer === 'bottom' && this._canvasBottom) {
|
||||||
|
this._ctxBottom = undefined;
|
||||||
|
this._canvasBottom.remove();
|
||||||
|
this._canvasBottom = undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public hasLayer(layer: ImageLayer): boolean {
|
||||||
|
return layer === 'top' ? !!this._canvasTop : !!this._canvasBottom;
|
||||||
}
|
}
|
||||||
|
|
||||||
private _createPlaceHolder(height: number = PLACEHOLDER_HEIGHT): void {
|
private _createPlaceHolder(height: number = PLACEHOLDER_HEIGHT): void {
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
|
|
||||||
import { IDisposable } from '@xterm/xterm';
|
import { IDisposable } from '@xterm/xterm';
|
||||||
import { ImageRenderer } from './ImageRenderer';
|
import { ImageRenderer } from './ImageRenderer';
|
||||||
import { ITerminalExt, IExtendedAttrsImage, IImageAddonOptions, IImageSpec, IBufferLineExt, BgFlags, Cell, Content, ICellSize, ExtFlags, Attributes, UnderlineStyle } from './Types';
|
import { ITerminalExt, IExtendedAttrsImage, IImageAddonOptions, IImageSpec, IBufferLineExt, BgFlags, Cell, Content, ICellSize, ExtFlags, Attributes, UnderlineStyle, ImageLayer } from './Types';
|
||||||
|
|
||||||
|
|
||||||
// fallback default cell size
|
// fallback default cell size
|
||||||
@@ -250,7 +250,7 @@ export class ImageStorage implements IDisposable {
|
|||||||
* Method to add an image to the storage.
|
* Method to add an image to the storage.
|
||||||
* Returns the internal image ID assigned to the stored image.
|
* Returns the internal image ID assigned to the stored image.
|
||||||
*/
|
*/
|
||||||
public addImage(img: HTMLCanvasElement | ImageBitmap): number {
|
public addImage(img: HTMLCanvasElement | ImageBitmap, layer: ImageLayer = 'top'): number {
|
||||||
// never allow storage to exceed memory limit
|
// never allow storage to exceed memory limit
|
||||||
this._evictOldest(img.width * img.height);
|
this._evictOldest(img.width * img.height);
|
||||||
|
|
||||||
@@ -339,7 +339,8 @@ export class ImageStorage implements IDisposable {
|
|||||||
actualCellSize: { ...cellSize }, // clone needed, since later modified
|
actualCellSize: { ...cellSize }, // clone needed, since later modified
|
||||||
marker: endMarker || undefined,
|
marker: endMarker || undefined,
|
||||||
tileCount,
|
tileCount,
|
||||||
bufferType: this._terminal.buffer.active.type
|
bufferType: this._terminal.buffer.active.type,
|
||||||
|
layer
|
||||||
};
|
};
|
||||||
|
|
||||||
// finally add the image
|
// finally add the image
|
||||||
@@ -354,16 +355,30 @@ export class ImageStorage implements IDisposable {
|
|||||||
*/
|
*/
|
||||||
// TODO: Should we move this to the ImageRenderer?
|
// TODO: Should we move this to the ImageRenderer?
|
||||||
public render(range: { start: number, end: number }): void {
|
public render(range: { start: number, end: number }): void {
|
||||||
// setup image canvas in case we have none yet, but have images in store
|
// Determine which layers have images
|
||||||
if (!this._renderer.canvas && this._images.size) {
|
let hasTopImages = false;
|
||||||
this._renderer.insertLayerToDom();
|
let hasBottomImages = false;
|
||||||
// safety measure - in case we cannot spawn a canvas at all, just exit
|
for (const spec of this._images.values()) {
|
||||||
if (!this._renderer.canvas) {
|
if (spec.layer === 'bottom') {
|
||||||
return;
|
hasBottomImages = true;
|
||||||
|
} else {
|
||||||
|
hasTopImages = true;
|
||||||
}
|
}
|
||||||
|
if (hasTopImages && hasBottomImages) break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Lazily insert layers that are needed
|
||||||
|
if (hasTopImages && !this._renderer.hasLayer('top')) {
|
||||||
|
this._renderer.insertLayerToDom('top');
|
||||||
|
if (!this._renderer.hasLayer('top')) return;
|
||||||
|
}
|
||||||
|
if (hasBottomImages && !this._renderer.hasLayer('bottom')) {
|
||||||
|
this._renderer.insertLayerToDom('bottom');
|
||||||
|
}
|
||||||
|
|
||||||
// rescale if needed
|
// rescale if needed
|
||||||
this._renderer.rescaleCanvas();
|
this._renderer.rescaleCanvas();
|
||||||
|
|
||||||
// exit early if we dont have any images to test for
|
// exit early if we dont have any images to test for
|
||||||
if (!this._images.size) {
|
if (!this._images.size) {
|
||||||
if (!this._fullyCleared) {
|
if (!this._fullyCleared) {
|
||||||
@@ -371,12 +386,25 @@ export class ImageStorage implements IDisposable {
|
|||||||
this._fullyCleared = true;
|
this._fullyCleared = true;
|
||||||
this._needsFullClear = false;
|
this._needsFullClear = false;
|
||||||
}
|
}
|
||||||
if (this._renderer.canvas) {
|
if (this._renderer.hasLayer('top')) {
|
||||||
this._renderer.removeLayerFromDom();
|
this._renderer.removeLayerFromDom('top');
|
||||||
|
}
|
||||||
|
if (this._renderer.hasLayer('bottom')) {
|
||||||
|
this._renderer.removeLayerFromDom('bottom');
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Remove layers no longer needed
|
||||||
|
if (!hasTopImages && this._renderer.hasLayer('top')) {
|
||||||
|
this._renderer.clearAll('top');
|
||||||
|
this._renderer.removeLayerFromDom('top');
|
||||||
|
}
|
||||||
|
if (!hasBottomImages && this._renderer.hasLayer('bottom')) {
|
||||||
|
this._renderer.clearAll('bottom');
|
||||||
|
this._renderer.removeLayerFromDom('bottom');
|
||||||
|
}
|
||||||
|
|
||||||
// buffer switches force a full clear
|
// buffer switches force a full clear
|
||||||
if (this._needsFullClear) {
|
if (this._needsFullClear) {
|
||||||
this._renderer.clearAll();
|
this._renderer.clearAll();
|
||||||
|
|||||||
@@ -99,6 +99,8 @@ export interface ICellSize {
|
|||||||
height: number;
|
height: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type ImageLayer = 'top' | 'bottom';
|
||||||
|
|
||||||
export interface IImageSpec {
|
export interface IImageSpec {
|
||||||
orig: HTMLCanvasElement | ImageBitmap | undefined;
|
orig: HTMLCanvasElement | ImageBitmap | undefined;
|
||||||
origCellSize: ICellSize;
|
origCellSize: ICellSize;
|
||||||
@@ -107,4 +109,5 @@ export interface IImageSpec {
|
|||||||
marker: IMarker | undefined;
|
marker: IMarker | undefined;
|
||||||
tileCount: number;
|
tileCount: number;
|
||||||
bufferType: 'alternate' | 'normal';
|
bufferType: 'alternate' | 'normal';
|
||||||
|
layer: ImageLayer;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
* @license MIT
|
* @license MIT
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { IApcHandler, IImageAddonOptions, IResetHandler, ITerminalExt } from '../Types';
|
import { IApcHandler, IImageAddonOptions, IResetHandler, ITerminalExt, ImageLayer } from '../Types';
|
||||||
import { ImageRenderer } from '../ImageRenderer';
|
import { ImageRenderer } from '../ImageRenderer';
|
||||||
import { ImageStorage, CELL_SIZE_DEFAULT } from '../ImageStorage';
|
import { ImageStorage, CELL_SIZE_DEFAULT } from '../ImageStorage';
|
||||||
import Base64Decoder, { type DecodeStatus } from 'xterm-wasm-parts/lib/base64/Base64Decoder.wasm';
|
import Base64Decoder, { type DecodeStatus } from 'xterm-wasm-parts/lib/base64/Base64Decoder.wasm';
|
||||||
@@ -504,12 +504,18 @@ export class KittyGraphicsHandler implements IApcHandler, IResetHandler {
|
|||||||
const savedY = buffer.y;
|
const savedY = buffer.y;
|
||||||
const savedYbase = buffer.ybase;
|
const savedYbase = buffer.ybase;
|
||||||
|
|
||||||
|
// Determine layer based on z-index: negative = behind text, 0+ = on top.
|
||||||
|
// Bottom layer only works when allowTransparency is enabled (otherwise text
|
||||||
|
// canvas background is opaque and hides the bottom canvas). Fall back to top.
|
||||||
|
const wantsBottom = cmd.zIndex !== undefined && cmd.zIndex < 0;
|
||||||
|
const layer: ImageLayer = (wantsBottom && this._coreTerminal.options.allowTransparency) ? 'bottom' : 'top';
|
||||||
|
|
||||||
let storageId: number;
|
let storageId: number;
|
||||||
if (w !== bitmap.width || h !== bitmap.height) {
|
if (w !== bitmap.width || h !== bitmap.height) {
|
||||||
const resized = await createImageBitmap(bitmap, { resizeWidth: w, resizeHeight: h });
|
const resized = await createImageBitmap(bitmap, { resizeWidth: w, resizeHeight: h });
|
||||||
storageId = this._storage.addImage(resized);
|
storageId = this._storage.addImage(resized, layer);
|
||||||
} else {
|
} else {
|
||||||
storageId = this._storage.addImage(bitmap);
|
storageId = this._storage.addImage(bitmap, layer);
|
||||||
}
|
}
|
||||||
this._kittyIdToStorageId.set(image.id, storageId);
|
this._kittyIdToStorageId.set(image.id, storageId);
|
||||||
|
|
||||||
|
|||||||
@@ -86,5 +86,25 @@ describe('KittyGraphicsTypes', () => {
|
|||||||
assert.ok(isNaN(cmd.format!));
|
assert.ok(isNaN(cmd.format!));
|
||||||
assert.strictEqual(cmd.id, 5);
|
assert.strictEqual(cmd.id, 5);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should parse z-index key with positive value', () => {
|
||||||
|
const cmd = parseKittyCommand('a=T,f=100,z=10');
|
||||||
|
assert.strictEqual(cmd.zIndex, 10);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should parse z-index key with zero', () => {
|
||||||
|
const cmd = parseKittyCommand('a=T,f=100,z=0');
|
||||||
|
assert.strictEqual(cmd.zIndex, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should parse z-index key with negative value', () => {
|
||||||
|
const cmd = parseKittyCommand('a=T,f=100,z=-1');
|
||||||
|
assert.strictEqual(cmd.zIndex, -1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should leave zIndex undefined when not specified', () => {
|
||||||
|
const cmd = parseKittyCommand('a=T,f=100');
|
||||||
|
assert.strictEqual(cmd.zIndex, undefined);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -70,7 +70,9 @@ export const enum KittyKey {
|
|||||||
// Quiet mode (1=suppress OK responses, 2=suppress error responses)
|
// Quiet mode (1=suppress OK responses, 2=suppress error responses)
|
||||||
QUIET = 'q',
|
QUIET = 'q',
|
||||||
// Cursor movement policy (0=move cursor after image, 1=don't move cursor)
|
// Cursor movement policy (0=move cursor after image, 1=don't move cursor)
|
||||||
CURSOR_MOVEMENT = 'C'
|
CURSOR_MOVEMENT = 'C',
|
||||||
|
// Z-index for image layering (negative = behind text, 0+ = on top)
|
||||||
|
Z_INDEX = 'z'
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pixel format constants
|
// Pixel format constants
|
||||||
@@ -95,6 +97,7 @@ export interface IKittyCommand {
|
|||||||
more?: number;
|
more?: number;
|
||||||
quiet?: number;
|
quiet?: number;
|
||||||
cursorMovement?: number;
|
cursorMovement?: number;
|
||||||
|
zIndex?: number;
|
||||||
compression?: string;
|
compression?: string;
|
||||||
payload?: string;
|
payload?: string;
|
||||||
}
|
}
|
||||||
@@ -164,6 +167,7 @@ export function parseKittyCommand(data: string): IKittyCommand {
|
|||||||
case KittyKey.MORE: cmd.more = numValue; break;
|
case KittyKey.MORE: cmd.more = numValue; break;
|
||||||
case KittyKey.QUIET: cmd.quiet = numValue; break;
|
case KittyKey.QUIET: cmd.quiet = numValue; break;
|
||||||
case KittyKey.CURSOR_MOVEMENT: cmd.cursorMovement = numValue; break;
|
case KittyKey.CURSOR_MOVEMENT: cmd.cursorMovement = numValue; break;
|
||||||
|
case KittyKey.Z_INDEX: cmd.zIndex = numValue; break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -546,6 +546,85 @@ test.describe('Kitty Graphics Protocol', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test.describe('Z-index layer placement', () => {
|
||||||
|
test('default placement (no z key) stores image on top layer', async () => {
|
||||||
|
await ctx.proxy.write(`\x1b_Ga=T,f=100;${KITTY_BLACK_1X1_BASE64}\x1b\\`);
|
||||||
|
await timeout(100);
|
||||||
|
strictEqual(await getImageStorageLength(), 1);
|
||||||
|
strictEqual(await ctx.page.evaluate(`window.imageAddon._storage._images.get(1).layer`), 'top');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('z=0 stores image on top layer', async () => {
|
||||||
|
await ctx.proxy.write(`\x1b_Ga=T,f=100,z=0;${KITTY_BLACK_1X1_BASE64}\x1b\\`);
|
||||||
|
await timeout(100);
|
||||||
|
strictEqual(await getImageStorageLength(), 1);
|
||||||
|
strictEqual(await ctx.page.evaluate(`window.imageAddon._storage._images.get(1).layer`), 'top');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('z=1 (positive) stores image on top layer', async () => {
|
||||||
|
await ctx.proxy.write(`\x1b_Ga=T,f=100,z=1;${KITTY_BLACK_1X1_BASE64}\x1b\\`);
|
||||||
|
await timeout(100);
|
||||||
|
strictEqual(await getImageStorageLength(), 1);
|
||||||
|
strictEqual(await ctx.page.evaluate(`window.imageAddon._storage._images.get(1).layer`), 'top');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('z=-1 falls back to top layer when allowTransparency is disabled', async () => {
|
||||||
|
await ctx.page.evaluate(`window.term.options.allowTransparency = false`);
|
||||||
|
await ctx.proxy.write(`\x1b_Ga=T,f=100,z=-1;${KITTY_BLACK_1X1_BASE64}\x1b\\`);
|
||||||
|
await timeout(100);
|
||||||
|
strictEqual(await getImageStorageLength(), 1);
|
||||||
|
strictEqual(await ctx.page.evaluate(`window.imageAddon._storage._images.get(1).layer`), 'top');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('z=-1 (negative) stores image on bottom layer when allowTransparency is enabled', async () => {
|
||||||
|
await ctx.page.evaluate(`window.term.options.allowTransparency = true`);
|
||||||
|
await ctx.proxy.write(`\x1b_Ga=T,f=100,z=-1;${KITTY_BLACK_1X1_BASE64}\x1b\\`);
|
||||||
|
await timeout(100);
|
||||||
|
strictEqual(await getImageStorageLength(), 1);
|
||||||
|
strictEqual(await ctx.page.evaluate(`window.imageAddon._storage._images.get(1).layer`), 'bottom');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('z=-100 (large negative) stores image on bottom layer when allowTransparency is enabled', async () => {
|
||||||
|
await ctx.page.evaluate(`window.term.options.allowTransparency = true`);
|
||||||
|
await ctx.proxy.write(`\x1b_Ga=T,f=100,z=-100;${KITTY_BLACK_1X1_BASE64}\x1b\\`);
|
||||||
|
await timeout(100);
|
||||||
|
strictEqual(await getImageStorageLength(), 1);
|
||||||
|
strictEqual(await ctx.page.evaluate(`window.imageAddon._storage._images.get(1).layer`), 'bottom');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('top layer canvas has correct CSS class', async () => {
|
||||||
|
await ctx.proxy.write(`\x1b_Ga=T,f=100;${KITTY_BLACK_1X1_BASE64}\x1b\\`);
|
||||||
|
await timeout(100);
|
||||||
|
const hasClass = await ctx.page.evaluate(() => {
|
||||||
|
const el = document.querySelector('.xterm-image-layer-top');
|
||||||
|
return el !== null;
|
||||||
|
});
|
||||||
|
strictEqual(hasClass, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('bottom layer canvas has correct CSS class', async () => {
|
||||||
|
await ctx.page.evaluate(`window.term.options.allowTransparency = true`);
|
||||||
|
await ctx.proxy.write(`\x1b_Ga=T,f=100,z=-1;${KITTY_BLACK_1X1_BASE64}\x1b\\`);
|
||||||
|
await timeout(100);
|
||||||
|
const hasClass = await ctx.page.evaluate(() => {
|
||||||
|
const el = document.querySelector('.xterm-image-layer-bottom');
|
||||||
|
return el !== null;
|
||||||
|
});
|
||||||
|
strictEqual(hasClass, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('bottom layer canvas is before text canvas in DOM order', async () => {
|
||||||
|
await ctx.page.evaluate(`window.term.options.allowTransparency = true`);
|
||||||
|
await ctx.proxy.write(`\x1b_Ga=T,f=100,z=-1;${KITTY_BLACK_1X1_BASE64}\x1b\\`);
|
||||||
|
await timeout(100);
|
||||||
|
const isFirst = await ctx.page.evaluate(() => {
|
||||||
|
const screen = document.querySelector('.xterm-screen');
|
||||||
|
return screen?.firstElementChild?.classList.contains('xterm-image-layer-bottom') ?? false;
|
||||||
|
});
|
||||||
|
strictEqual(isFirst, true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
test.describe('Pixel verification', () => {
|
test.describe('Pixel verification', () => {
|
||||||
test('renders 1x1 black PNG at cursor position', async () => {
|
test('renders 1x1 black PNG at cursor position', async () => {
|
||||||
const seq = `\x1b_Ga=T,f=100;${KITTY_BLACK_1X1_BASE64}\x1b\\`;
|
const seq = `\x1b_Ga=T,f=100;${KITTY_BLACK_1X1_BASE64}\x1b\\`;
|
||||||
|
|||||||
Reference in New Issue
Block a user