238 lines
6.5 KiB
TypeScript
238 lines
6.5 KiB
TypeScript
/**
|
|
* Copyright (c) 2025 The xterm.js authors. All rights reserved.
|
|
* @license MIT
|
|
*/
|
|
|
|
import type { Terminal, IDisposable } from '@xterm/xterm';
|
|
|
|
/**
|
|
* A placed image in the terminal.
|
|
*/
|
|
export interface IPlacedImage {
|
|
/** The decoded image bitmap */
|
|
bitmap: ImageBitmap;
|
|
/** Column position in terminal */
|
|
col: number;
|
|
/** Row position in terminal (relative to buffer, not viewport) */
|
|
row: number;
|
|
/** Width to render (in pixels, 0 = original) */
|
|
width: number;
|
|
/** Height to render (in pixels, 0 = original) */
|
|
height: number;
|
|
/** The image ID for reference */
|
|
id: number;
|
|
}
|
|
|
|
/**
|
|
* Handles canvas layer management and image rendering for Kitty graphics.
|
|
*
|
|
* Similar to ImageRenderer in addon-image but simplified for Kitty protocol.
|
|
*/
|
|
export class KittyImageRenderer implements IDisposable {
|
|
private _canvas: HTMLCanvasElement | undefined;
|
|
private _ctx: CanvasRenderingContext2D | null | undefined;
|
|
private _terminal: Terminal;
|
|
private _placements: Map<number, IPlacedImage> = new Map();
|
|
private _placementIdCounter = 0;
|
|
private _renderDisposable: IDisposable | undefined;
|
|
private _resizeDisposable: IDisposable | undefined;
|
|
|
|
constructor(terminal: Terminal) {
|
|
this._terminal = terminal;
|
|
}
|
|
|
|
public dispose(): void {
|
|
this._renderDisposable?.dispose();
|
|
this._resizeDisposable?.dispose();
|
|
|
|
// Close all bitmaps
|
|
for (const placement of this._placements.values()) {
|
|
placement.bitmap.close();
|
|
}
|
|
this._placements.clear();
|
|
|
|
if (this._canvas) {
|
|
this._canvas.remove();
|
|
this._canvas = undefined;
|
|
this._ctx = undefined;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Initialize the canvas layer. Called when first image is placed.
|
|
*/
|
|
public ensureCanvasLayer(): void {
|
|
if (this._canvas) {
|
|
return;
|
|
}
|
|
|
|
// Access internal screenElement
|
|
const core = (this._terminal as any)._core;
|
|
const screenElement = core?.screenElement;
|
|
if (!screenElement) {
|
|
console.warn('[KittyGraphicsAddon] Cannot create canvas: no screenElement');
|
|
return;
|
|
}
|
|
|
|
// Get dimensions from terminal
|
|
const dimensions = this._terminal.dimensions;
|
|
const width = dimensions?.css.canvas.width || 800;
|
|
const height = dimensions?.css.canvas.height || 600;
|
|
|
|
// Create canvas
|
|
this._canvas = document.createElement('canvas');
|
|
this._canvas.width = width;
|
|
this._canvas.height = height;
|
|
this._canvas.classList.add('xterm-kitty-graphics-layer');
|
|
|
|
// Position absolutely over the terminal
|
|
this._canvas.style.position = 'absolute';
|
|
this._canvas.style.top = '0';
|
|
this._canvas.style.left = '0';
|
|
this._canvas.style.pointerEvents = 'none';
|
|
this._canvas.style.zIndex = '10';
|
|
|
|
screenElement.appendChild(this._canvas);
|
|
this._ctx = this._canvas.getContext('2d', { alpha: true });
|
|
|
|
// Hook into render events to redraw when terminal scrolls
|
|
this._renderDisposable = this._terminal.onRender(() => this._draw());
|
|
|
|
// Handle resize
|
|
this._resizeDisposable = this._terminal.onResize(() => this._resizeCanvas());
|
|
}
|
|
|
|
/**
|
|
* Get cell dimensions from terminal.
|
|
*/
|
|
public getCellSize(): { width: number, height: number } {
|
|
const dimensions = this._terminal.dimensions;
|
|
return {
|
|
width: dimensions?.css.cell.width || 9,
|
|
height: dimensions?.css.cell.height || 17
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Place a decoded image at cursor position.
|
|
* @param bitmap - The decoded ImageBitmap to place
|
|
* @param id - The image ID this placement belongs to
|
|
* @param col - Optional column position (defaults to cursor X)
|
|
* @param row - Optional row position (defaults to cursor Y + baseY)
|
|
* @param width - Optional width in pixels (0 = original)
|
|
* @param height - Optional height in pixels (0 = original)
|
|
*/
|
|
public placeImage(bitmap: ImageBitmap, id: number, col?: number, row?: number, width?: number, height?: number): number {
|
|
this.ensureCanvasLayer();
|
|
|
|
const buffer = this._terminal.buffer.active;
|
|
const placementId = ++this._placementIdCounter;
|
|
|
|
const placement: IPlacedImage = {
|
|
bitmap,
|
|
col: col ?? buffer.cursorX,
|
|
row: row ?? (buffer.cursorY + buffer.baseY),
|
|
width: width || 0,
|
|
height: height || 0,
|
|
id
|
|
};
|
|
|
|
this._placements.set(placementId, placement);
|
|
this._draw();
|
|
|
|
return placementId;
|
|
}
|
|
|
|
/**
|
|
* Remove a placed image.
|
|
*/
|
|
public removePlacement(placementId: number): void {
|
|
const placement = this._placements.get(placementId);
|
|
if (placement) {
|
|
this._placements.delete(placementId);
|
|
this._draw();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Remove all placements for an image ID.
|
|
*/
|
|
public removeByImageId(imageId: number): void {
|
|
const toDelete: number[] = [];
|
|
for (const [placementId, placement] of this._placements) {
|
|
if (placement.id === imageId) {
|
|
toDelete.push(placementId);
|
|
}
|
|
}
|
|
for (const id of toDelete) {
|
|
this._placements.delete(id);
|
|
}
|
|
if (toDelete.length > 0) {
|
|
this._draw();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Clear all images.
|
|
*/
|
|
public clearAll(): void {
|
|
this._placements.clear();
|
|
if (this._ctx && this._canvas) {
|
|
this._ctx.clearRect(0, 0, this._canvas.width, this._canvas.height);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Redraw all placed images.
|
|
*/
|
|
private _draw(): void {
|
|
if (!this._ctx || !this._canvas) {
|
|
return;
|
|
}
|
|
|
|
// Clear canvas
|
|
this._ctx.clearRect(0, 0, this._canvas.width, this._canvas.height);
|
|
|
|
const buffer = this._terminal.buffer.active;
|
|
const viewportStartRow = buffer.baseY;
|
|
const viewportRows = this._terminal.rows;
|
|
const cellSize = this.getCellSize();
|
|
|
|
// Draw each placement that's visible
|
|
for (const placement of this._placements.values()) {
|
|
// Check if placement is in viewport
|
|
const relativeRow = placement.row - viewportStartRow;
|
|
if (relativeRow < 0 || relativeRow >= viewportRows) {
|
|
continue; // Not in viewport
|
|
}
|
|
|
|
const x = placement.col * cellSize.width;
|
|
const y = relativeRow * cellSize.height;
|
|
|
|
const width = placement.width || placement.bitmap.width;
|
|
const height = placement.height || placement.bitmap.height;
|
|
|
|
this._ctx.drawImage(placement.bitmap, x, y, width, height);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Called when terminal resizes.
|
|
*/
|
|
private _resizeCanvas(): void {
|
|
if (!this._canvas) {
|
|
return;
|
|
}
|
|
|
|
const dimensions = this._terminal.dimensions;
|
|
const width = dimensions?.css.canvas.width || 800;
|
|
const height = dimensions?.css.canvas.height || 600;
|
|
|
|
if (this._canvas.width !== width || this._canvas.height !== height) {
|
|
this._canvas.width = width;
|
|
this._canvas.height = height;
|
|
this._draw();
|
|
}
|
|
}
|
|
}
|