Skip to content

Commit 1d0cf32

Browse files
committed
support z-index + dual canvas for DOM. Doesnt work for webgl
1 parent 2c4997f commit 1d0cf32

7 files changed

Lines changed: 235 additions & 50 deletions

File tree

addons/addon-image/src/ImageRenderer.ts

Lines changed: 80 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55

66
import { toRGBA8888 } from 'sixel/lib/Colors';
77
import { IDisposable } from '@xterm/xterm';
8-
import { ICellSize, ITerminalExt, IImageSpec, IRenderDimensions, IRenderService } from './Types';
8+
import { ICellSize, ImageLayer, ITerminalExt, IImageSpec, IRenderDimensions, IRenderService } from './Types';
99
import { Disposable, MutableDisposable, toDisposable } from 'vs/base/common/lifecycle';
1010

1111
const PLACEHOLDER_LENGTH = 4096;
@@ -18,8 +18,12 @@ const PLACEHOLDER_HEIGHT = 24;
1818
* - draw image tiles onRender
1919
*/
2020
export class ImageRenderer extends Disposable implements IDisposable {
21-
public canvas: HTMLCanvasElement | undefined;
22-
private _ctx: CanvasRenderingContext2D | null | undefined;
21+
/** @deprecated Use canvasTop instead. Kept for backward compat — points to canvasTop. */
22+
public get canvas(): HTMLCanvasElement | undefined { return this._canvasTop; }
23+
private _canvasTop: HTMLCanvasElement | undefined;
24+
private _canvasBottom: HTMLCanvasElement | undefined;
25+
private _ctxTop: CanvasRenderingContext2D | null | undefined;
26+
private _ctxBottom: CanvasRenderingContext2D | null | undefined;
2327
private _placeholder: HTMLCanvasElement | undefined;
2428
private _placeholderBitmap: ImageBitmap | undefined;
2529
private _optionsRefresh = this._register(new MutableDisposable());
@@ -86,6 +90,7 @@ export class ImageRenderer extends Disposable implements IDisposable {
8690
});
8791
this._register(toDisposable(() => {
8892
this.removeLayerFromDom();
93+
this.removeLayerFromDom('bottom');
8994
if (this._terminal._core && this._oldOpen) {
9095
this._terminal._core.open = this._oldOpen;
9196
this._oldOpen = undefined;
@@ -95,8 +100,10 @@ export class ImageRenderer extends Disposable implements IDisposable {
95100
this._oldSetRenderer = undefined;
96101
}
97102
this._renderService = undefined;
98-
this.canvas = undefined;
99-
this._ctx = undefined;
103+
this._canvasTop = undefined;
104+
this._canvasBottom = undefined;
105+
this._ctxTop = undefined;
106+
this._ctxBottom = undefined;
100107
this._placeholderBitmap?.close();
101108
this._placeholderBitmap = undefined;
102109
this._placeholder = undefined;
@@ -140,27 +147,36 @@ export class ImageRenderer extends Disposable implements IDisposable {
140147
/**
141148
* Clear a region of the image layer canvas.
142149
*/
143-
public clearLines(start: number, end: number): void {
144-
this._ctx?.clearRect(
145-
0,
146-
start * (this.dimensions?.css.cell.height || 0),
147-
this.dimensions?.css.canvas.width || 0,
148-
(++end - start) * (this.dimensions?.css.cell.height || 0)
149-
);
150+
public clearLines(start: number, end: number, layer?: ImageLayer): void {
151+
const y = start * (this.dimensions?.css.cell.height || 0);
152+
const w = this.dimensions?.css.canvas.width || 0;
153+
const h = (++end - start) * (this.dimensions?.css.cell.height || 0);
154+
if (!layer || layer === 'top') {
155+
this._ctxTop?.clearRect(0, y, w, h);
156+
}
157+
if (!layer || layer === 'bottom') {
158+
this._ctxBottom?.clearRect(0, y, w, h);
159+
}
150160
}
151161

152162
/**
153163
* Clear whole image canvas.
154164
*/
155-
public clearAll(): void {
156-
this._ctx?.clearRect(0, 0, this.canvas?.width || 0, this.canvas?.height || 0);
165+
public clearAll(layer?: ImageLayer): void {
166+
if (!layer || layer === 'top') {
167+
this._ctxTop?.clearRect(0, 0, this._canvasTop?.width || 0, this._canvasTop?.height || 0);
168+
}
169+
if (!layer || layer === 'bottom') {
170+
this._ctxBottom?.clearRect(0, 0, this._canvasBottom?.width || 0, this._canvasBottom?.height || 0);
171+
}
157172
}
158173

159174
/**
160175
* Draw neighboring tiles on the image layer canvas.
161176
*/
162177
public draw(imgSpec: IImageSpec, tileId: number, col: number, row: number, count: number = 1): void {
163-
if (!this._ctx) {
178+
const ctx = imgSpec.layer === 'bottom' ? this._ctxBottom : this._ctxTop;
179+
if (!ctx) {
164180
return;
165181
}
166182
const { width, height } = this.cellSize;
@@ -187,7 +203,7 @@ export class ImageRenderer extends Disposable implements IDisposable {
187203
// Note: For not pixel perfect aligned cells like in the DOM renderer
188204
// this will move a tile slightly to the top/left (subpixel range, thus ignore it).
189205
// FIX #34: avoid striping on displays with pixelDeviceRatio != 1 by ceiling height and width
190-
this._ctx.drawImage(
206+
ctx.drawImage(
191207
img,
192208
Math.floor(sx), Math.floor(sy), Math.ceil(finalWidth), Math.ceil(finalHeight),
193209
Math.floor(dx), Math.floor(dy), Math.ceil(finalWidth), Math.ceil(finalHeight)
@@ -227,7 +243,7 @@ export class ImageRenderer extends Disposable implements IDisposable {
227243
* Draw a line with placeholder on the image layer canvas.
228244
*/
229245
public drawPlaceholder(col: number, row: number, count: number = 1): void {
230-
if (this._ctx) {
246+
if (this._ctxTop) {
231247
const { width, height } = this.cellSize;
232248

233249
// Don't try to draw anything, if we cannot get valid renderer metrics.
@@ -241,7 +257,7 @@ export class ImageRenderer extends Disposable implements IDisposable {
241257
this._createPlaceHolder(height + 1);
242258
}
243259
if (!this._placeholder) return;
244-
this._ctx.drawImage(
260+
this._ctxTop.drawImage(
245261
this._placeholderBitmap ?? this._placeholder!,
246262
col * width,
247263
(row * height) % 2 ? 0 : 1, // needs %2 offset correction
@@ -260,12 +276,15 @@ export class ImageRenderer extends Disposable implements IDisposable {
260276
* Checked once from `ImageStorage.render`.
261277
*/
262278
public rescaleCanvas(): void {
263-
if (!this.canvas) {
264-
return;
279+
const w = this.dimensions?.css.canvas.width || 0;
280+
const h = this.dimensions?.css.canvas.height || 0;
281+
if (this._canvasTop && (this._canvasTop.width !== w || this._canvasTop.height !== h)) {
282+
this._canvasTop.width = w;
283+
this._canvasTop.height = h;
265284
}
266-
if (this.canvas.width !== this.dimensions!.css.canvas.width || this.canvas.height !== this.dimensions!.css.canvas.height) {
267-
this.canvas.width = this.dimensions!.css.canvas.width || 0;
268-
this.canvas.height = this.dimensions!.css.canvas.height || 0;
285+
if (this._canvasBottom && (this._canvasBottom.width !== w || this._canvasBottom.height !== h)) {
286+
this._canvasBottom.width = w;
287+
this._canvasBottom.height = h;
269288
}
270289
}
271290

@@ -305,34 +324,60 @@ export class ImageRenderer extends Disposable implements IDisposable {
305324
this._oldSetRenderer = this._renderService.setRenderer.bind(this._renderService);
306325
this._renderService.setRenderer = (renderer: any) => {
307326
this.removeLayerFromDom();
327+
this.removeLayerFromDom('bottom');
308328
this._oldSetRenderer?.call(this._renderService, renderer);
309329
};
310330
}
311331

312-
public insertLayerToDom(): void {
332+
public insertLayerToDom(layer: ImageLayer = 'top'): void {
313333
// make sure that the terminal is attached to a document and to DOM
314334
if (this.document && this._terminal._core.screenElement) {
315-
if (!this.canvas) {
316-
this.canvas = ImageRenderer.createCanvas(
335+
if (layer === 'top' && !this._canvasTop) {
336+
this._canvasTop = ImageRenderer.createCanvas(
317337
this.document, this.dimensions?.css.canvas.width || 0,
318338
this.dimensions?.css.canvas.height || 0
319339
);
320-
this.canvas.classList.add('xterm-image-layer');
321-
this._terminal._core.screenElement.appendChild(this.canvas);
322-
this._ctx = this.canvas.getContext('2d', { alpha: true, desynchronized: true });
323-
this.clearAll();
340+
this._canvasTop.classList.add('xterm-image-layer-top');
341+
this._terminal._core.screenElement.appendChild(this._canvasTop);
342+
this._ctxTop = this._canvasTop.getContext('2d', { alpha: true, desynchronized: true });
343+
this.clearAll('top');
344+
}
345+
if (layer === 'bottom' && !this._canvasBottom) {
346+
this._canvasBottom = ImageRenderer.createCanvas(
347+
this.document, this.dimensions?.css.canvas.width || 0,
348+
this.dimensions?.css.canvas.height || 0
349+
);
350+
this._canvasBottom.classList.add('xterm-image-layer-bottom');
351+
// Use z-index:-1 so it paints behind non-positioned text elements.
352+
// The screen element needs to be a stacking context to contain the
353+
// negative z-index, otherwise it would go behind the entire terminal.
354+
this._canvasBottom.style.zIndex = '-1';
355+
const screenElement = this._terminal._core.screenElement;
356+
screenElement.style.zIndex = '0';
357+
screenElement.insertBefore(this._canvasBottom, screenElement.firstChild);
358+
this._ctxBottom = this._canvasBottom.getContext('2d', { alpha: true, desynchronized: true });
359+
this.clearAll('bottom');
324360
}
325361
} else {
326362
console.warn('image addon: cannot insert output canvas to DOM, missing document or screenElement');
327363
}
328364
}
329365

330-
public removeLayerFromDom(): void {
331-
if (this.canvas) {
332-
this._ctx = undefined;
333-
this.canvas.remove();
334-
this.canvas = undefined;
366+
public removeLayerFromDom(layer: ImageLayer = 'top'): void {
367+
if (layer === 'top' && this._canvasTop) {
368+
this._ctxTop = undefined;
369+
this._canvasTop.remove();
370+
this._canvasTop = undefined;
335371
}
372+
if (layer === 'bottom' && this._canvasBottom) {
373+
this._ctxBottom = undefined;
374+
this._canvasBottom.remove();
375+
this._canvasBottom = undefined;
376+
}
377+
}
378+
379+
public hasLayer(layer: ImageLayer): boolean {
380+
return layer === 'top' ? !!this._canvasTop : !!this._canvasBottom;
336381
}
337382

338383
private _createPlaceHolder(height: number = PLACEHOLDER_HEIGHT): void {

addons/addon-image/src/ImageStorage.ts

Lines changed: 39 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55

66
import { IDisposable } from '@xterm/xterm';
77
import { ImageRenderer } from './ImageRenderer';
8-
import { ITerminalExt, IExtendedAttrsImage, IImageAddonOptions, IImageSpec, IBufferLineExt, BgFlags, Cell, Content, ICellSize, ExtFlags, Attributes, UnderlineStyle } from './Types';
8+
import { ITerminalExt, IExtendedAttrsImage, IImageAddonOptions, IImageSpec, IBufferLineExt, BgFlags, Cell, Content, ICellSize, ExtFlags, Attributes, UnderlineStyle, ImageLayer } from './Types';
99

1010

1111
// fallback default cell size
@@ -250,7 +250,7 @@ export class ImageStorage implements IDisposable {
250250
* Method to add an image to the storage.
251251
* Returns the internal image ID assigned to the stored image.
252252
*/
253-
public addImage(img: HTMLCanvasElement | ImageBitmap): number {
253+
public addImage(img: HTMLCanvasElement | ImageBitmap, layer: ImageLayer = 'top'): number {
254254
// never allow storage to exceed memory limit
255255
this._evictOldest(img.width * img.height);
256256

@@ -339,7 +339,8 @@ export class ImageStorage implements IDisposable {
339339
actualCellSize: { ...cellSize }, // clone needed, since later modified
340340
marker: endMarker || undefined,
341341
tileCount,
342-
bufferType: this._terminal.buffer.active.type
342+
bufferType: this._terminal.buffer.active.type,
343+
layer
343344
};
344345

345346
// finally add the image
@@ -354,29 +355,56 @@ export class ImageStorage implements IDisposable {
354355
*/
355356
// TODO: Should we move this to the ImageRenderer?
356357
public render(range: { start: number, end: number }): void {
357-
// setup image canvas in case we have none yet, but have images in store
358-
if (!this._renderer.canvas && this._images.size) {
359-
this._renderer.insertLayerToDom();
360-
// safety measure - in case we cannot spawn a canvas at all, just exit
361-
if (!this._renderer.canvas) {
362-
return;
358+
// Determine which layers have images
359+
let hasTopImages = false;
360+
let hasBottomImages = false;
361+
for (const spec of this._images.values()) {
362+
if (spec.layer === 'bottom') {
363+
hasBottomImages = true;
364+
} else {
365+
hasTopImages = true;
363366
}
367+
if (hasTopImages && hasBottomImages) break;
368+
}
369+
370+
// Lazily insert layers that are needed
371+
if (hasTopImages && !this._renderer.hasLayer('top')) {
372+
this._renderer.insertLayerToDom('top');
373+
if (!this._renderer.hasLayer('top')) return;
374+
}
375+
if (hasBottomImages && !this._renderer.hasLayer('bottom')) {
376+
this._renderer.insertLayerToDom('bottom');
364377
}
378+
365379
// rescale if needed
366380
this._renderer.rescaleCanvas();
381+
367382
// exit early if we dont have any images to test for
368383
if (!this._images.size) {
369384
if (!this._fullyCleared) {
370385
this._renderer.clearAll();
371386
this._fullyCleared = true;
372387
this._needsFullClear = false;
373388
}
374-
if (this._renderer.canvas) {
375-
this._renderer.removeLayerFromDom();
389+
if (this._renderer.hasLayer('top')) {
390+
this._renderer.removeLayerFromDom('top');
391+
}
392+
if (this._renderer.hasLayer('bottom')) {
393+
this._renderer.removeLayerFromDom('bottom');
376394
}
377395
return;
378396
}
379397

398+
// Remove layers no longer needed
399+
if (!hasTopImages && this._renderer.hasLayer('top')) {
400+
this._renderer.clearAll('top');
401+
this._renderer.removeLayerFromDom('top');
402+
}
403+
if (!hasBottomImages && this._renderer.hasLayer('bottom')) {
404+
this._renderer.clearAll('bottom');
405+
this._renderer.removeLayerFromDom('bottom');
406+
}
407+
380408
// buffer switches force a full clear
381409
if (this._needsFullClear) {
382410
this._renderer.clearAll();

addons/addon-image/src/Types.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,8 @@ export interface ICellSize {
9999
height: number;
100100
}
101101

102+
export type ImageLayer = 'top' | 'bottom';
103+
102104
export interface IImageSpec {
103105
orig: HTMLCanvasElement | ImageBitmap | undefined;
104106
origCellSize: ICellSize;
@@ -107,4 +109,5 @@ export interface IImageSpec {
107109
marker: IMarker | undefined;
108110
tileCount: number;
109111
bufferType: 'alternate' | 'normal';
112+
layer: ImageLayer;
110113
}

addons/addon-image/src/kitty/KittyGraphicsHandler.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
* @license MIT
44
*/
55

6-
import { IApcHandler, IImageAddonOptions, IResetHandler, ITerminalExt } from '../Types';
6+
import { IApcHandler, IImageAddonOptions, IResetHandler, ITerminalExt, ImageLayer } from '../Types';
77
import { ImageRenderer } from '../ImageRenderer';
88
import { ImageStorage, CELL_SIZE_DEFAULT } from '../ImageStorage';
99
import Base64Decoder, { type DecodeStatus } from 'xterm-wasm-parts/lib/base64/Base64Decoder.wasm';
@@ -504,12 +504,18 @@ export class KittyGraphicsHandler implements IApcHandler, IResetHandler {
504504
const savedY = buffer.y;
505505
const savedYbase = buffer.ybase;
506506

507+
// Determine layer based on z-index: negative = behind text, 0+ = on top.
508+
// Bottom layer only works when allowTransparency is enabled (otherwise text
509+
// canvas background is opaque and hides the bottom canvas). Fall back to top.
510+
const wantsBottom = cmd.zIndex !== undefined && cmd.zIndex < 0;
511+
const layer: ImageLayer = (wantsBottom && this._coreTerminal.options.allowTransparency) ? 'bottom' : 'top';
512+
507513
let storageId: number;
508514
if (w !== bitmap.width || h !== bitmap.height) {
509515
const resized = await createImageBitmap(bitmap, { resizeWidth: w, resizeHeight: h });
510-
storageId = this._storage.addImage(resized);
516+
storageId = this._storage.addImage(resized, layer);
511517
} else {
512-
storageId = this._storage.addImage(bitmap);
518+
storageId = this._storage.addImage(bitmap, layer);
513519
}
514520
this._kittyIdToStorageId.set(image.id, storageId);
515521

addons/addon-image/src/kitty/KittyGraphicsTypes.test.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,5 +86,25 @@ describe('KittyGraphicsTypes', () => {
8686
assert.ok(isNaN(cmd.format!));
8787
assert.strictEqual(cmd.id, 5);
8888
});
89+
90+
it('should parse z-index key with positive value', () => {
91+
const cmd = parseKittyCommand('a=T,f=100,z=10');
92+
assert.strictEqual(cmd.zIndex, 10);
93+
});
94+
95+
it('should parse z-index key with zero', () => {
96+
const cmd = parseKittyCommand('a=T,f=100,z=0');
97+
assert.strictEqual(cmd.zIndex, 0);
98+
});
99+
100+
it('should parse z-index key with negative value', () => {
101+
const cmd = parseKittyCommand('a=T,f=100,z=-1');
102+
assert.strictEqual(cmd.zIndex, -1);
103+
});
104+
105+
it('should leave zIndex undefined when not specified', () => {
106+
const cmd = parseKittyCommand('a=T,f=100');
107+
assert.strictEqual(cmd.zIndex, undefined);
108+
});
89109
});
90110
});

0 commit comments

Comments
 (0)