support z-index + dual canvas for DOM. Doesnt work for webgl

This commit is contained in:
Anthony Kim
2026-02-11 14:19:27 -08:00
parent 2c4997f529
commit 1d0cf32b28
7 changed files with 235 additions and 50 deletions
+80 -35
View File
@@ -5,7 +5,7 @@
import { toRGBA8888 } from 'sixel/lib/Colors';
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';
const PLACEHOLDER_LENGTH = 4096;
@@ -18,8 +18,12 @@ const PLACEHOLDER_HEIGHT = 24;
* - draw image tiles onRender
*/
export class ImageRenderer extends Disposable implements IDisposable {
public canvas: HTMLCanvasElement | undefined;
private _ctx: CanvasRenderingContext2D | null | undefined;
/** @deprecated Use canvasTop instead. Kept for backward compat — points to canvasTop. */
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 _placeholderBitmap: ImageBitmap | undefined;
private _optionsRefresh = this._register(new MutableDisposable());
@@ -86,6 +90,7 @@ export class ImageRenderer extends Disposable implements IDisposable {
});
this._register(toDisposable(() => {
this.removeLayerFromDom();
this.removeLayerFromDom('bottom');
if (this._terminal._core && this._oldOpen) {
this._terminal._core.open = this._oldOpen;
this._oldOpen = undefined;
@@ -95,8 +100,10 @@ export class ImageRenderer extends Disposable implements IDisposable {
this._oldSetRenderer = undefined;
}
this._renderService = undefined;
this.canvas = undefined;
this._ctx = undefined;
this._canvasTop = undefined;
this._canvasBottom = undefined;
this._ctxTop = undefined;
this._ctxBottom = undefined;
this._placeholderBitmap?.close();
this._placeholderBitmap = undefined;
this._placeholder = undefined;
@@ -140,27 +147,36 @@ export class ImageRenderer extends Disposable implements IDisposable {
/**
* Clear a region of the image layer canvas.
*/
public clearLines(start: number, end: number): void {
this._ctx?.clearRect(
0,
start * (this.dimensions?.css.cell.height || 0),
this.dimensions?.css.canvas.width || 0,
(++end - start) * (this.dimensions?.css.cell.height || 0)
);
public clearLines(start: number, end: number, layer?: ImageLayer): void {
const y = start * (this.dimensions?.css.cell.height || 0);
const w = this.dimensions?.css.canvas.width || 0;
const h = (++end - start) * (this.dimensions?.css.cell.height || 0);
if (!layer || layer === 'top') {
this._ctxTop?.clearRect(0, y, w, h);
}
if (!layer || layer === 'bottom') {
this._ctxBottom?.clearRect(0, y, w, h);
}
}
/**
* Clear whole image canvas.
*/
public clearAll(): void {
this._ctx?.clearRect(0, 0, this.canvas?.width || 0, this.canvas?.height || 0);
public clearAll(layer?: ImageLayer): void {
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.
*/
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;
}
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
// 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
this._ctx.drawImage(
ctx.drawImage(
img,
Math.floor(sx), Math.floor(sy), 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.
*/
public drawPlaceholder(col: number, row: number, count: number = 1): void {
if (this._ctx) {
if (this._ctxTop) {
const { width, height } = this.cellSize;
// 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);
}
if (!this._placeholder) return;
this._ctx.drawImage(
this._ctxTop.drawImage(
this._placeholderBitmap ?? this._placeholder!,
col * width,
(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`.
*/
public rescaleCanvas(): void {
if (!this.canvas) {
return;
const w = this.dimensions?.css.canvas.width || 0;
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) {
this.canvas.width = this.dimensions!.css.canvas.width || 0;
this.canvas.height = this.dimensions!.css.canvas.height || 0;
if (this._canvasBottom && (this._canvasBottom.width !== w || this._canvasBottom.height !== h)) {
this._canvasBottom.width = w;
this._canvasBottom.height = h;
}
}
@@ -305,34 +324,60 @@ export class ImageRenderer extends Disposable implements IDisposable {
this._oldSetRenderer = this._renderService.setRenderer.bind(this._renderService);
this._renderService.setRenderer = (renderer: any) => {
this.removeLayerFromDom();
this.removeLayerFromDom('bottom');
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
if (this.document && this._terminal._core.screenElement) {
if (!this.canvas) {
this.canvas = ImageRenderer.createCanvas(
if (layer === 'top' && !this._canvasTop) {
this._canvasTop = ImageRenderer.createCanvas(
this.document, this.dimensions?.css.canvas.width || 0,
this.dimensions?.css.canvas.height || 0
);
this.canvas.classList.add('xterm-image-layer');
this._terminal._core.screenElement.appendChild(this.canvas);
this._ctx = this.canvas.getContext('2d', { alpha: true, desynchronized: true });
this.clearAll();
this._canvasTop.classList.add('xterm-image-layer-top');
this._terminal._core.screenElement.appendChild(this._canvasTop);
this._ctxTop = this._canvasTop.getContext('2d', { alpha: true, desynchronized: true });
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 {
console.warn('image addon: cannot insert output canvas to DOM, missing document or screenElement');
}
}
public removeLayerFromDom(): void {
if (this.canvas) {
this._ctx = undefined;
this.canvas.remove();
this.canvas = undefined;
public removeLayerFromDom(layer: ImageLayer = 'top'): void {
if (layer === 'top' && this._canvasTop) {
this._ctxTop = undefined;
this._canvasTop.remove();
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 {
+39 -11
View File
@@ -5,7 +5,7 @@
import { IDisposable } from '@xterm/xterm';
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
@@ -250,7 +250,7 @@ export class ImageStorage implements IDisposable {
* Method to add an image to the storage.
* 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
this._evictOldest(img.width * img.height);
@@ -339,7 +339,8 @@ export class ImageStorage implements IDisposable {
actualCellSize: { ...cellSize }, // clone needed, since later modified
marker: endMarker || undefined,
tileCount,
bufferType: this._terminal.buffer.active.type
bufferType: this._terminal.buffer.active.type,
layer
};
// finally add the image
@@ -354,16 +355,30 @@ export class ImageStorage implements IDisposable {
*/
// TODO: Should we move this to the ImageRenderer?
public render(range: { start: number, end: number }): void {
// setup image canvas in case we have none yet, but have images in store
if (!this._renderer.canvas && this._images.size) {
this._renderer.insertLayerToDom();
// safety measure - in case we cannot spawn a canvas at all, just exit
if (!this._renderer.canvas) {
return;
// Determine which layers have images
let hasTopImages = false;
let hasBottomImages = false;
for (const spec of this._images.values()) {
if (spec.layer === 'bottom') {
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
this._renderer.rescaleCanvas();
// exit early if we dont have any images to test for
if (!this._images.size) {
if (!this._fullyCleared) {
@@ -371,12 +386,25 @@ export class ImageStorage implements IDisposable {
this._fullyCleared = true;
this._needsFullClear = false;
}
if (this._renderer.canvas) {
this._renderer.removeLayerFromDom();
if (this._renderer.hasLayer('top')) {
this._renderer.removeLayerFromDom('top');
}
if (this._renderer.hasLayer('bottom')) {
this._renderer.removeLayerFromDom('bottom');
}
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
if (this._needsFullClear) {
this._renderer.clearAll();
+3
View File
@@ -99,6 +99,8 @@ export interface ICellSize {
height: number;
}
export type ImageLayer = 'top' | 'bottom';
export interface IImageSpec {
orig: HTMLCanvasElement | ImageBitmap | undefined;
origCellSize: ICellSize;
@@ -107,4 +109,5 @@ export interface IImageSpec {
marker: IMarker | undefined;
tileCount: number;
bufferType: 'alternate' | 'normal';
layer: ImageLayer;
}
@@ -3,7 +3,7 @@
* @license MIT
*/
import { IApcHandler, IImageAddonOptions, IResetHandler, ITerminalExt } from '../Types';
import { IApcHandler, IImageAddonOptions, IResetHandler, ITerminalExt, ImageLayer } from '../Types';
import { ImageRenderer } from '../ImageRenderer';
import { ImageStorage, CELL_SIZE_DEFAULT } from '../ImageStorage';
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 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;
if (w !== bitmap.width || h !== bitmap.height) {
const resized = await createImageBitmap(bitmap, { resizeWidth: w, resizeHeight: h });
storageId = this._storage.addImage(resized);
storageId = this._storage.addImage(resized, layer);
} else {
storageId = this._storage.addImage(bitmap);
storageId = this._storage.addImage(bitmap, layer);
}
this._kittyIdToStorageId.set(image.id, storageId);
@@ -86,5 +86,25 @@ describe('KittyGraphicsTypes', () => {
assert.ok(isNaN(cmd.format!));
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 = 'q',
// 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
@@ -95,6 +97,7 @@ export interface IKittyCommand {
more?: number;
quiet?: number;
cursorMovement?: number;
zIndex?: number;
compression?: string;
payload?: string;
}
@@ -164,6 +167,7 @@ export function parseKittyCommand(data: string): IKittyCommand {
case KittyKey.MORE: cmd.more = numValue; break;
case KittyKey.QUIET: cmd.quiet = 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('renders 1x1 black PNG at cursor position', async () => {
const seq = `\x1b_Ga=T,f=100;${KITTY_BLACK_1X1_BASE64}\x1b\\`;