Skip to content

Commit 3a9bfa9

Browse files
authored
Merge pull request xtermjs#5619 from xtermjs/anthonykim1/scaffoldKittyAddon
Support Kitty graphics protocol mvp
2 parents 4388a4a + 209e5aa commit 3a9bfa9

File tree

16 files changed

+3321
-100
lines changed

16 files changed

+3321
-100
lines changed

addons/addon-image/README.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,9 @@ const customSettings: IImageAddonOptions = {
3030
storageLimit: 128, // FIFO storage limit in MB
3131
showPlaceholder: true, // whether to show a placeholder for evicted images
3232
iipSupport: true, // enable iTerm IIP support
33-
iipSizeLimit: 20000000 // size limit of a single IIP sequence
33+
iipSizeLimit: 20000000, // size limit of a single IIP sequence
34+
kittySupport: true, // enable Kitty graphics support
35+
kittySizeLimit: 20000000 // size limit of a single Kitty sequence
3436
}
3537

3638
// initialization
123 Bytes
Loading
449 Bytes
Loading
131 Bytes
Loading

addons/addon-image/src/ImageAddon.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ import type { ImageAddon as IImageApi } from '@xterm/addon-image';
88
import { IIPHandler } from './IIPHandler';
99
import { ImageRenderer } from './ImageRenderer';
1010
import { ImageStorage, CELL_SIZE_DEFAULT } from './ImageStorage';
11+
import { KittyGraphicsHandler } from './kitty/KittyGraphicsHandler';
12+
import { KittyImageStorage } from './kitty/KittyImageStorage';
1113
import { SixelHandler } from './SixelHandler';
1214
import { SixelImageStorage } from './SixelImageStorage';
1315
import { IIPImageStorage } from './IIPImageStorage';
@@ -24,7 +26,9 @@ const DEFAULT_OPTIONS: IImageAddonOptions = {
2426
storageLimit: 128,
2527
showPlaceholder: true,
2628
iipSupport: true,
27-
iipSizeLimit: 20000000
29+
iipSizeLimit: 20000000,
30+
kittySupport: true,
31+
kittySizeLimit: 20000000
2832
};
2933

3034
// max palette size supported by the sixel lib (compile time setting)
@@ -148,6 +152,18 @@ export class ImageAddon implements ITerminalAddon, IImageApi {
148152
terminal._core._inputHandler._parser.registerOscHandler(1337, iipHandler)
149153
);
150154
}
155+
156+
// Kitty graphics handler
157+
if (this._opts.kittySupport) {
158+
const kittyStorage = new KittyImageStorage(this._storage!);
159+
const kittyHandler = new KittyGraphicsHandler(this._opts, this._renderer!, kittyStorage, terminal);
160+
this._handlers.set('kitty', kittyHandler);
161+
this._disposeLater(
162+
kittyStorage,
163+
kittyHandler,
164+
terminal._core._inputHandler._parser.registerApcHandler(0x47, kittyHandler)
165+
);
166+
}
151167
}
152168

153169
// Note: storageLimit is skipped here to not intoduce a surprising side effect.

addons/addon-image/src/ImageRenderer.ts

Lines changed: 84 additions & 43 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 'common/Lifecycle';
1010

1111
const PLACEHOLDER_LENGTH = 4096;
@@ -18,8 +18,9 @@ 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 Kept for backward compat — points to top layer canvas. */
22+
public get canvas(): HTMLCanvasElement | undefined { return this._layers.get('top')?.canvas; }
23+
private _layers = new Map<ImageLayer, CanvasRenderingContext2D>();
2324
private _placeholder: HTMLCanvasElement | undefined;
2425
private _placeholderBitmap: ImageBitmap | undefined;
2526
private _optionsRefresh = this._register(new MutableDisposable());
@@ -86,6 +87,7 @@ export class ImageRenderer extends Disposable implements IDisposable {
8687
});
8788
this._register(toDisposable(() => {
8889
this.removeLayerFromDom();
90+
this.removeLayerFromDom('bottom');
8991
if (this._terminal._core && this._oldOpen) {
9092
this._terminal._core.open = this._oldOpen;
9193
this._oldOpen = undefined;
@@ -95,8 +97,7 @@ export class ImageRenderer extends Disposable implements IDisposable {
9597
this._oldSetRenderer = undefined;
9698
}
9799
this._renderService = undefined;
98-
this.canvas = undefined;
99-
this._ctx = undefined;
100+
this._layers.clear();
100101
this._placeholderBitmap?.close();
101102
this._placeholderBitmap = undefined;
102103
this._placeholder = undefined;
@@ -140,27 +141,38 @@ export class ImageRenderer extends Disposable implements IDisposable {
140141
/**
141142
* Clear a region of the image layer canvas.
142143
*/
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-
);
144+
public clearLines(start: number, end: number, layer?: ImageLayer): void {
145+
const y = start * (this.dimensions?.css.cell.height || 0);
146+
const w = this.dimensions?.css.canvas.width || 0;
147+
const h = (++end - start) * (this.dimensions?.css.cell.height || 0);
148+
if (!layer || layer === 'top') {
149+
this._layers.get('top')?.clearRect(0, y, w, h);
150+
}
151+
if (!layer || layer === 'bottom') {
152+
this._layers.get('bottom')?.clearRect(0, y, w, h);
153+
}
150154
}
151155

152156
/**
153157
* Clear whole image canvas.
154158
*/
155-
public clearAll(): void {
156-
this._ctx?.clearRect(0, 0, this.canvas?.width || 0, this.canvas?.height || 0);
159+
public clearAll(layer?: ImageLayer): void {
160+
if (!layer || layer === 'top') {
161+
const ctx = this._layers.get('top');
162+
ctx?.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);
163+
}
164+
if (!layer || layer === 'bottom') {
165+
const ctx = this._layers.get('bottom');
166+
ctx?.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);
167+
}
157168
}
158169

159170
/**
160171
* Draw neighboring tiles on the image layer canvas.
161172
*/
162173
public draw(imgSpec: IImageSpec, tileId: number, col: number, row: number, count: number = 1): void {
163-
if (!this._ctx) {
174+
const ctx = this._layers.get(imgSpec.layer);
175+
if (!ctx) {
164176
return;
165177
}
166178
const { width, height } = this.cellSize;
@@ -187,7 +199,7 @@ export class ImageRenderer extends Disposable implements IDisposable {
187199
// Note: For not pixel perfect aligned cells like in the DOM renderer
188200
// this will move a tile slightly to the top/left (subpixel range, thus ignore it).
189201
// FIX #34: avoid striping on displays with pixelDeviceRatio != 1 by ceiling height and width
190-
this._ctx.drawImage(
202+
ctx.drawImage(
191203
img,
192204
Math.floor(sx), Math.floor(sy), Math.ceil(finalWidth), Math.ceil(finalHeight),
193205
Math.floor(dx), Math.floor(dy), Math.ceil(finalWidth), Math.ceil(finalHeight)
@@ -227,7 +239,8 @@ export class ImageRenderer extends Disposable implements IDisposable {
227239
* Draw a line with placeholder on the image layer canvas.
228240
*/
229241
public drawPlaceholder(col: number, row: number, count: number = 1): void {
230-
if (this._ctx) {
242+
const ctx = this._layers.get('top');
243+
if (ctx) {
231244
const { width, height } = this.cellSize;
232245

233246
// Don't try to draw anything, if we cannot get valid renderer metrics.
@@ -241,7 +254,7 @@ export class ImageRenderer extends Disposable implements IDisposable {
241254
this._createPlaceHolder(height + 1);
242255
}
243256
if (!this._placeholder) return;
244-
this._ctx.drawImage(
257+
ctx.drawImage(
245258
this._placeholderBitmap ?? this._placeholder!,
246259
col * width,
247260
(row * height) % 2 ? 0 : 1, // needs %2 offset correction
@@ -260,12 +273,13 @@ export class ImageRenderer extends Disposable implements IDisposable {
260273
* Checked once from `ImageStorage.render`.
261274
*/
262275
public rescaleCanvas(): void {
263-
if (!this.canvas) {
264-
return;
265-
}
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;
276+
const w = this.dimensions?.css.canvas.width || 0;
277+
const h = this.dimensions?.css.canvas.height || 0;
278+
for (const ctx of this._layers.values()) {
279+
if (ctx.canvas.width !== w || ctx.canvas.height !== h) {
280+
ctx.canvas.width = w;
281+
ctx.canvas.height = h;
282+
}
269283
}
270284
}
271285

@@ -304,37 +318,64 @@ export class ImageRenderer extends Disposable implements IDisposable {
304318
this._renderService = this._terminal._core._renderService;
305319
this._oldSetRenderer = this._renderService.setRenderer.bind(this._renderService);
306320
this._renderService.setRenderer = (renderer: any) => {
307-
this.removeLayerFromDom();
321+
for (const key of [...this._layers.keys()]) {
322+
this.removeLayerFromDom(key);
323+
}
308324
this._oldSetRenderer?.call(this._renderService, renderer);
309325
};
310326
}
311327

312-
public insertLayerToDom(): void {
328+
public insertLayerToDom(layer: ImageLayer = 'top'): void {
313329
// make sure that the terminal is attached to a document and to DOM
314-
if (this.document && this._terminal._core.screenElement) {
315-
if (!this.canvas) {
316-
this.canvas = ImageRenderer.createCanvas(
317-
this.document, this.dimensions?.css.canvas.width || 0,
318-
this.dimensions?.css.canvas.height || 0
319-
);
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();
324-
}
325-
} else {
330+
if (!this.document || !this._terminal._core.screenElement) {
326331
console.warn('image addon: cannot insert output canvas to DOM, missing document or screenElement');
332+
return;
333+
}
334+
if (this._layers.has(layer)) {
335+
return;
327336
}
337+
const canvas = ImageRenderer.createCanvas(
338+
this.document, this.dimensions?.css.canvas.width || 0,
339+
this.dimensions?.css.canvas.height || 0
340+
);
341+
canvas.classList.add(`xterm-image-layer-${layer}`);
342+
const screenElement = this._terminal._core.screenElement;
343+
if (layer === 'bottom') {
344+
// Use z-index:-1 so it paints behind non-positioned text elements.
345+
// The screen element needs to be a stacking context to contain the
346+
// negative z-index, otherwise it would go behind the entire terminal.
347+
canvas.style.zIndex = '-1';
348+
screenElement.style.zIndex = '0';
349+
screenElement.insertBefore(canvas, screenElement.firstChild);
350+
} else {
351+
// Explicit z-index ensures the image canvas reliably stacks above
352+
// the text layer (DOM renderer rows). z-index: 0 is below the
353+
// selection overlay (z-index: 1).
354+
canvas.style.zIndex = '0';
355+
screenElement.style.zIndex = '0';
356+
screenElement.appendChild(canvas);
357+
}
358+
const ctx = canvas.getContext('2d', { alpha: true, desynchronized: true });
359+
if (!ctx) {
360+
canvas.remove();
361+
return;
362+
}
363+
this._layers.set(layer, ctx);
364+
this.clearAll(layer);
328365
}
329366

330-
public removeLayerFromDom(): void {
331-
if (this.canvas) {
332-
this._ctx = undefined;
333-
this.canvas.remove();
334-
this.canvas = undefined;
367+
public removeLayerFromDom(layer: ImageLayer = 'top'): void {
368+
const ctx = this._layers.get(layer);
369+
if (ctx) {
370+
ctx.canvas.remove();
371+
this._layers.delete(layer);
335372
}
336373
}
337374

375+
public hasLayer(layer: ImageLayer): boolean {
376+
return this._layers.has(layer);
377+
}
378+
338379
private _createPlaceHolder(height: number = PLACEHOLDER_HEIGHT): void {
339380
this._placeholderBitmap?.close();
340381
this._placeholderBitmap = undefined;

0 commit comments

Comments
 (0)