/** * Copyright (c) 2020 The xterm.js authors. All rights reserved. * @license MIT */ import { toRGBA8888 } from 'sixel/lib/Colors'; import { IDisposable } from '@xterm/xterm'; import { ICellSize, ImageLayer, ITerminalExt, IImageSpec, IRenderDimensions, IRenderService } from './Types'; import { Disposable, MutableDisposable, toDisposable } from 'common/Lifecycle'; const enum Constants { PLACEHOLDER_LENGTH = 4096, PLACEHOLDER_HEIGHT = 24 } /** * ImageRenderer - terminal frontend extension: * - provide primitives for canvas, ImageData, Bitmap (static) * - add canvas layer to DOM (browser only for now) * - draw image tiles onRender */ export class ImageRenderer extends Disposable implements IDisposable { /** @deprecated Kept for backward compat — points to top layer canvas. */ public get canvas(): HTMLCanvasElement | undefined { return this._layers.get('top')?.canvas; } private _layers = new Map(); private _placeholder: HTMLCanvasElement | undefined; private _placeholderBitmap: ImageBitmap | undefined; private _optionsRefresh = this._register(new MutableDisposable()); private _oldOpen: ((parent: HTMLElement) => void) | undefined; private _renderService: IRenderService | undefined; private _oldSetRenderer: ((renderer: any) => void) | undefined; // drawing primitive - canvas public static createCanvas(localDocument: Document | undefined, width: number, height: number): HTMLCanvasElement { /** * NOTE: We normally dont care, from which document the canvas * gets created, so we can fall back to global document, * if the terminal has no document associated yet. * This way early image loads before calling .open keep working * (still discouraged though, as the metrics will be screwed up). * Only the DOM output canvas should be on the terminal's document, * which gets explicitly checked in `insertLayerToDom`. */ const canvas = (localDocument ?? document).createElement('canvas'); canvas.width = width | 0; canvas.height = height | 0; return canvas; } // drawing primitive - ImageData with optional buffer public static createImageData(ctx: CanvasRenderingContext2D, width: number, height: number, buffer?: ArrayBuffer): ImageData { if (typeof ImageData !== 'function') { const imgData = ctx.createImageData(width, height); if (buffer) { imgData.data.set(new Uint8ClampedArray(buffer, 0, width * height * 4)); } return imgData; } return buffer ? new ImageData(new Uint8ClampedArray(buffer, 0, width * height * 4), width, height) : new ImageData(width, height); } // drawing primitive - ImageBitmap public static createImageBitmap(img: ImageBitmapSource): Promise { if (typeof createImageBitmap !== 'function') { return Promise.resolve(undefined); } return createImageBitmap(img); } constructor(private _terminal: ITerminalExt) { super(); this._oldOpen = this._terminal._core.open; this._terminal._core.open = (parent: HTMLElement): void => { this._oldOpen?.call(this._terminal._core, parent); this._open(); }; if (this._terminal._core.screenElement) { this._open(); } // hack to spot fontSize changes this._optionsRefresh.value = this._terminal._core.optionsService.onOptionChange(option => { if (option === 'fontSize') { this.rescaleCanvas(); this._renderService?.refreshRows(0, this._terminal.rows); } }); this._register(toDisposable(() => { this.removeLayerFromDom(); this.removeLayerFromDom('bottom'); if (this._terminal._core && this._oldOpen) { this._terminal._core.open = this._oldOpen; this._oldOpen = undefined; } if (this._renderService && this._oldSetRenderer) { this._renderService.setRenderer = this._oldSetRenderer; this._oldSetRenderer = undefined; } this._renderService = undefined; this._layers.clear(); this._placeholderBitmap?.close(); this._placeholderBitmap = undefined; this._placeholder = undefined; })); } /** * Enable the placeholder. */ public showPlaceholder(value: boolean): void { if (value) { if (!this._placeholder && this.cellSize.height !== -1) { this._createPlaceHolder(Math.max(this.cellSize.height + 1, Constants.PLACEHOLDER_HEIGHT)); } } else { this._placeholderBitmap?.close(); this._placeholderBitmap = undefined; this._placeholder = undefined; } this._renderService?.refreshRows(0, this._terminal.rows); } /** * Dimensions of the terminal. * Forwarded from internal render service. */ public get dimensions(): IRenderDimensions | undefined { return this._terminal.dimensions; } /** * Current cell size (float). */ public get cellSize(): ICellSize { return { width: this.dimensions?.css.cell.width || -1, height: this.dimensions?.css.cell.height || -1 }; } /** * Clear a region of the image layer canvas. */ 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 + 1 - start) * (this.dimensions?.css.cell.height || 0); if (!layer || layer === 'top') { this._layers.get('top')?.clearRect(0, y, w, h); } if (!layer || layer === 'bottom') { this._layers.get('bottom')?.clearRect(0, y, w, h); } } /** * Clear whole image canvas. */ public clearAll(layer?: ImageLayer): void { if (!layer || layer === 'top') { const ctx = this._layers.get('top'); ctx?.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height); } if (!layer || layer === 'bottom') { const ctx = this._layers.get('bottom'); ctx?.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height); } } /** * Draw neighboring tiles on the image layer canvas. */ public draw(imgSpec: IImageSpec, tileId: number, col: number, row: number, count: number = 1): void { const ctx = this._layers.get(imgSpec.layer); if (!ctx) { return; } const { width, height } = this.cellSize; // Don't try to draw anything, if we cannot get valid renderer metrics. if (width === -1 || height === -1) { return; } this._rescaleImage(imgSpec, width, height); const img = imgSpec.actual!; const cols = Math.ceil(img.width / width); const sx = (tileId % cols) * width; const sy = Math.floor(tileId / cols) * height; const dx = col * width; const dy = row * height; // safari bug: never access image source out of bounds const finalWidth = count * width + sx > img.width ? img.width - sx : count * width; const finalHeight = sy + height > img.height ? img.height - sy : height; // Floor all pixel offsets to get stable tile mapping without any overflows. // 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 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) ); } /** * Extract a single tile from an image. */ public extractTile(imgSpec: IImageSpec, tileId: number): HTMLCanvasElement | undefined { const { width, height } = this.cellSize; // Don't try to draw anything, if we cannot get valid renderer metrics. if (width === -1 || height === -1) { return; } this._rescaleImage(imgSpec, width, height); const img = imgSpec.actual!; const cols = Math.ceil(img.width / width); const sx = (tileId % cols) * width; const sy = Math.floor(tileId / cols) * height; const finalWidth = width + sx > img.width ? img.width - sx : width; const finalHeight = sy + height > img.height ? img.height - sy : height; const canvas = ImageRenderer.createCanvas(this.document, finalWidth, finalHeight); const ctx = canvas.getContext('2d'); if (ctx) { ctx.drawImage( img, Math.floor(sx), Math.floor(sy), Math.floor(finalWidth), Math.floor(finalHeight), 0, 0, Math.floor(finalWidth), Math.floor(finalHeight) ); return canvas; } } /** * Draw a line with placeholder on the image layer canvas. */ public drawPlaceholder(col: number, row: number, count: number = 1): void { const ctx = this._layers.get('top'); if (ctx) { const { width, height } = this.cellSize; // Don't try to draw anything, if we cannot get valid renderer metrics. if (width === -1 || height === -1) { return; } if (!this._placeholder) { this._createPlaceHolder(Math.max(height + 1, Constants.PLACEHOLDER_HEIGHT)); } else if (height >= this._placeholder!.height) { this._createPlaceHolder(height + 1); } if (!this._placeholder) return; ctx.drawImage( this._placeholderBitmap ?? this._placeholder!, col * width, (row * height) % 2 ? 0 : 1, // needs %2 offset correction width * count, height, col * width, row * height, width * count, height ); } } /** * Rescale image layer canvas if needed. * Checked once from `ImageStorage.render`. */ public rescaleCanvas(): void { const w = this.dimensions?.css.canvas.width || 0; const h = this.dimensions?.css.canvas.height || 0; for (const ctx of this._layers.values()) { if (ctx.canvas.width !== w || ctx.canvas.height !== h) { ctx.canvas.width = w; ctx.canvas.height = h; } } } /** * Rescale image in storage if needed. */ private _rescaleImage(spec: IImageSpec, currentWidth: number, currentHeight: number): void { if (currentWidth === spec.actualCellSize.width && currentHeight === spec.actualCellSize.height) { return; } const { width: originalWidth, height: originalHeight } = spec.origCellSize; if (currentWidth === originalWidth && currentHeight === originalHeight) { spec.actual = spec.orig; spec.actualCellSize.width = originalWidth; spec.actualCellSize.height = originalHeight; return; } const canvas = ImageRenderer.createCanvas( this.document, Math.ceil(spec.orig!.width * currentWidth / originalWidth), Math.ceil(spec.orig!.height * currentHeight / originalHeight) ); const ctx = canvas.getContext('2d'); if (ctx) { ctx.drawImage(spec.orig!, 0, 0, canvas.width, canvas.height); spec.actual = canvas; spec.actualCellSize.width = currentWidth; spec.actualCellSize.height = currentHeight; } } /** * Lazy init for the renderer. */ private _open(): void { this._renderService = this._terminal._core._renderService; this._oldSetRenderer = this._renderService.setRenderer.bind(this._renderService); this._renderService.setRenderer = (renderer: any) => { for (const key of [...this._layers.keys()]) { this.removeLayerFromDom(key); } this._oldSetRenderer?.call(this._renderService, renderer); }; } 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) { console.warn('image addon: cannot insert output canvas to DOM, missing document or screenElement'); return; } if (this._layers.has(layer)) { return; } const canvas = ImageRenderer.createCanvas( this.document, this.dimensions?.css.canvas.width || 0, this.dimensions?.css.canvas.height || 0 ); canvas.classList.add(`xterm-image-layer-${layer}`); const screenElement = this._terminal._core.screenElement; // Use isolation to create a stacking context without overriding z-index, // which would conflict with integrators (e.g. VS Code) that set their // own z-index on the screen element. screenElement.style.isolation = 'isolate'; if (layer === 'bottom') { // Use z-index:-1 so it paints behind non-positioned text elements. // The screen element needs to be a stacking context (via isolation) // to contain the negative z-index, otherwise it would go behind the // entire terminal. canvas.style.zIndex = '-1'; screenElement.insertBefore(canvas, screenElement.firstChild); } else { // Explicit z-index ensures the image canvas reliably stacks above // the text layer (DOM renderer rows). z-index: 0 is below the // selection overlay (z-index: 1). canvas.style.zIndex = '0'; screenElement.appendChild(canvas); } const ctx = canvas.getContext('2d', { alpha: true }); if (!ctx) { canvas.remove(); return; } this._layers.set(layer, ctx); this.clearAll(layer); } public removeLayerFromDom(layer: ImageLayer = 'top'): void { const ctx = this._layers.get(layer); if (ctx) { ctx.canvas.remove(); this._layers.delete(layer); } } public hasLayer(layer: ImageLayer): boolean { return this._layers.has(layer); } private _createPlaceHolder(height: number = Constants.PLACEHOLDER_HEIGHT): void { this._placeholderBitmap?.close(); this._placeholderBitmap = undefined; // create blueprint to fill placeholder with const bWidth = 32; // must be 2^n const blueprint = ImageRenderer.createCanvas(this.document, bWidth, height); const ctx = blueprint.getContext('2d', { alpha: false }); if (!ctx) return; const imgData = ImageRenderer.createImageData(ctx, bWidth, height); const d32 = new Uint32Array(imgData.data.buffer); const black = toRGBA8888(0, 0, 0); const white = toRGBA8888(255, 255, 255); d32.fill(black); for (let y = 0; y < height; ++y) { const shift = y % 2; const offset = y * bWidth; for (let x = 0; x < bWidth; x += 2) { d32[offset + x + shift] = white; } } ctx.putImageData(imgData, 0, 0); // create placeholder line, width aligned to blueprint width const width = (screen.width + bWidth - 1) & ~(bWidth - 1) || Constants.PLACEHOLDER_LENGTH; this._placeholder = ImageRenderer.createCanvas(this.document, width, height); const ctx2 = this._placeholder.getContext('2d', { alpha: false }); if (!ctx2) { this._placeholder = undefined; return; } for (let i = 0; i < width; i += bWidth) { ctx2.drawImage(blueprint, i, 0); } ImageRenderer.createImageBitmap(this._placeholder).then(bitmap => this._placeholderBitmap = bitmap); } public get document(): Document | undefined { return this._terminal._core._coreBrowserService?.window.document; } }