/** * 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 = 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(); } } }