"use strict"; /** * Copyright (c) 2020 The xterm.js authors. All rights reserved. * @license MIT */ Object.defineProperty(exports, "__esModule", { value: true }); exports.ImageStorage = exports.CELL_SIZE_DEFAULT = void 0; const ImageRenderer_1 = require("./ImageRenderer"); // fallback default cell size exports.CELL_SIZE_DEFAULT = { width: 7, height: 14 }; /** * Extend extended attribute to also hold image tile information. * * Object definition is copied from base repo to fully mimick its behavior. * Image data is added as additional public properties `imageId` and `tileId`. */ class ExtendedAttrsImage { get ext() { if (this._urlId) { return ((this._ext & ~469762048 /* ExtFlags.UNDERLINE_STYLE */) | (this.underlineStyle << 26)); } return this._ext; } set ext(value) { this._ext = value; } get underlineStyle() { // Always return the URL style if it has one if (this._urlId) { return 5 /* UnderlineStyle.DASHED */; } return (this._ext & 469762048 /* ExtFlags.UNDERLINE_STYLE */) >> 26; } set underlineStyle(value) { this._ext &= ~469762048 /* ExtFlags.UNDERLINE_STYLE */; this._ext |= (value << 26) & 469762048 /* ExtFlags.UNDERLINE_STYLE */; } get underlineColor() { return this._ext & (50331648 /* Attributes.CM_MASK */ | 16777215 /* Attributes.RGB_MASK */); } set underlineColor(value) { this._ext &= ~(50331648 /* Attributes.CM_MASK */ | 16777215 /* Attributes.RGB_MASK */); this._ext |= value & (50331648 /* Attributes.CM_MASK */ | 16777215 /* Attributes.RGB_MASK */); } get underlineVariantOffset() { const val = (this._ext & 3758096384 /* ExtFlags.VARIANT_OFFSET */) >> 29; if (val < 0) { return val ^ 0xFFFFFFF8; } return val; } set underlineVariantOffset(value) { this._ext &= ~3758096384 /* ExtFlags.VARIANT_OFFSET */; this._ext |= (value << 29) & 3758096384 /* ExtFlags.VARIANT_OFFSET */; } get urlId() { return this._urlId; } set urlId(value) { this._urlId = value; } constructor(ext = 0, urlId = 0, imageId = -1, tileId = -1) { this.imageId = imageId; this.tileId = tileId; this._ext = 0; this._urlId = 0; this._ext = ext; this._urlId = urlId; } clone() { /** * Technically we dont need a clone variant of ExtendedAttrsImage, * as we never clone a cell holding image data. * Note: Clone is only meant to be used by the InputHandler for * sticky attributes, which is never the case for image data. * We still provide a proper clone method to reflect the full ext attr * state in case there are future use cases for clone. */ return new ExtendedAttrsImage(this._ext, this._urlId, this.imageId, this.tileId); } isEmpty() { return this.underlineStyle === 0 /* UnderlineStyle.NONE */ && this._urlId === 0 && this.imageId === -1; } } const EMPTY_ATTRS = new ExtendedAttrsImage(); /** * ImageStorage - extension of CoreTerminal: * - hold image data * - write/read image data to/from buffer * * TODO: image composition for overwrites */ class ImageStorage { constructor(_terminal, _renderer, _opts) { this._terminal = _terminal; this._renderer = _renderer; this._opts = _opts; // storage this._images = new Map(); // last used id this._lastId = 0; // last evicted id this._lowestId = 0; // whether a full clear happened before this._fullyCleared = false; // whether render should do a full clear this._needsFullClear = false; // hard limit of stored pixels (fallback limit of 10 MB) this._pixelLimit = 2500000; try { this.setLimit(this._opts.storageLimit); } catch (e) { console.error(e.message); console.warn(`storageLimit is set to ${this.getLimit()} MB`); } this._viewportMetrics = { cols: this._terminal.cols, rows: this._terminal.rows }; } dispose() { this.reset(); } reset() { var _a; for (const spec of this._images.values()) { (_a = spec.marker) === null || _a === void 0 ? void 0 : _a.dispose(); } // NOTE: marker.dispose above already calls ImageBitmap.close // therefore we can just wipe the map here this._images.clear(); this._renderer.clearAll(); } getLimit() { return this._pixelLimit * 4 / 1000000; } setLimit(value) { if (value < 0.5 || value > 1000) { throw RangeError('invalid storageLimit, should be at least 0.5 MB and not exceed 1G'); } this._pixelLimit = (value / 4 * 1000000) >>> 0; this._evictOldest(0); } getUsage() { return this._getStoredPixels() * 4 / 1000000; } _getStoredPixels() { let storedPixels = 0; for (const spec of this._images.values()) { if (spec.orig) { storedPixels += spec.orig.width * spec.orig.height; if (spec.actual && spec.actual !== spec.orig) { storedPixels += spec.actual.width * spec.actual.height; } } } return storedPixels; } _delImg(id) { const spec = this._images.get(id); this._images.delete(id); // FIXME: really ugly workaround to get bitmaps deallocated :( if (spec && window.ImageBitmap && spec.orig instanceof ImageBitmap) { spec.orig.close(); } } /** * Wipe canvas and images on alternate buffer. */ wipeAlternate() { var _a; // remove all alternate tagged images const zero = []; for (const [id, spec] of this._images.entries()) { if (spec.bufferType === 'alternate') { (_a = spec.marker) === null || _a === void 0 ? void 0 : _a.dispose(); zero.push(id); } } for (const id of zero) { this._delImg(id); } // mark canvas to be wiped on next render this._needsFullClear = true; this._fullyCleared = false; } /** * Only advance text cursor. * This is an edge case from empty sixels carrying only a height but no pixels. * Partially fixes https://github.com/jerch/xterm-addon-image/issues/37. */ advanceCursor(height) { if (this._opts.sixelScrolling) { let cellSize = this._renderer.cellSize; if (cellSize.width === -1 || cellSize.height === -1) { cellSize = exports.CELL_SIZE_DEFAULT; } const rows = Math.ceil(height / cellSize.height); for (let i = 1; i < rows; ++i) { this._terminal._core._inputHandler.lineFeed(); } } } /** * Method to add an image to the storage. */ addImage(img) { var _a; // never allow storage to exceed memory limit this._evictOldest(img.width * img.height); // calc rows x cols needed to display the image let cellSize = this._renderer.cellSize; if (cellSize.width === -1 || cellSize.height === -1) { cellSize = exports.CELL_SIZE_DEFAULT; } const cols = Math.ceil(img.width / cellSize.width); const rows = Math.ceil(img.height / cellSize.height); const imageId = ++this._lastId; const buffer = this._terminal._core.buffer; const termCols = this._terminal.cols; const termRows = this._terminal.rows; const originX = buffer.x; const originY = buffer.y; let offset = originX; let tileCount = 0; if (!this._opts.sixelScrolling) { buffer.x = 0; buffer.y = 0; offset = 0; } this._terminal._core._inputHandler._dirtyRowTracker.markDirty(buffer.y); for (let row = 0; row < rows; ++row) { const line = buffer.lines.get(buffer.y + buffer.ybase); for (let col = 0; col < cols; ++col) { if (offset + col >= termCols) break; this._writeToCell(line, offset + col, imageId, row * cols + col); tileCount++; } if (this._opts.sixelScrolling) { if (row < rows - 1) this._terminal._core._inputHandler.lineFeed(); } else { if (++buffer.y >= termRows) break; } buffer.x = offset; } this._terminal._core._inputHandler._dirtyRowTracker.markDirty(buffer.y); // cursor positioning modes if (this._opts.sixelScrolling) { buffer.x = offset; } else { buffer.x = originX; buffer.y = originY; } // deleted images with zero tile count const zero = []; for (const [id, spec] of this._images.entries()) { if (spec.tileCount < 1) { (_a = spec.marker) === null || _a === void 0 ? void 0 : _a.dispose(); zero.push(id); } } for (const id of zero) { this._delImg(id); } // eviction marker: // delete the image when the marker gets disposed const endMarker = this._terminal.registerMarker(0); endMarker === null || endMarker === void 0 ? void 0 : endMarker.onDispose(() => { const spec = this._images.get(imageId); if (spec) { this._delImg(imageId); } }); // since markers do not work on alternate for some reason, // we evict images here manually if (this._terminal.buffer.active.type === 'alternate') { this._evictOnAlternate(); } // create storage entry const imgSpec = { orig: img, origCellSize: cellSize, actual: img, actualCellSize: Object.assign({}, cellSize), marker: endMarker || undefined, tileCount, bufferType: this._terminal.buffer.active.type }; // finally add the image this._images.set(imageId, imgSpec); } /** * Render method. Collects buffer information and triggers * canvas updates. */ // TODO: Should we move this to the ImageRenderer? render(range) { // setup image canvas in case we have none yet, but have images in store if (!this._renderer.canvas && this._images.size) { this._renderer.insertLayerToDom(); // safety measure - in case we cannot spawn a canvas at all, just exit if (!this._renderer.canvas) { return; } } // rescale if needed this._renderer.rescaleCanvas(); // exit early if we dont have any images to test for if (!this._images.size) { if (!this._fullyCleared) { this._renderer.clearAll(); this._fullyCleared = true; this._needsFullClear = false; } if (this._renderer.canvas) { this._renderer.removeLayerFromDom(); } return; } // buffer switches force a full clear if (this._needsFullClear) { this._renderer.clearAll(); this._fullyCleared = true; this._needsFullClear = false; } const { start, end } = range; const buffer = this._terminal._core.buffer; const cols = this._terminal._core.cols; // clear drawing area this._renderer.clearLines(start, end); // walk all cells in viewport and draw tiles found for (let row = start; row <= end; ++row) { const line = buffer.lines.get(row + buffer.ydisp); if (!line) return; for (let col = 0; col < cols; ++col) { if (line.getBg(col) & 268435456 /* BgFlags.HAS_EXTENDED */) { let e = line._extendedAttrs[col] || EMPTY_ATTRS; const imageId = e.imageId; if (imageId === undefined || imageId === -1) { continue; } const imgSpec = this._images.get(imageId); if (e.tileId !== -1) { const startTile = e.tileId; const startCol = col; let count = 1; /** * merge tiles to the right into a single draw call, if: * - not at end of line * - cell has same image id * - cell has consecutive tile id */ while (++col < cols && (line.getBg(col) & 268435456 /* BgFlags.HAS_EXTENDED */) && (e = line._extendedAttrs[col] || EMPTY_ATTRS) && (e.imageId === imageId) && (e.tileId === startTile + count)) { count++; } col--; if (imgSpec) { if (imgSpec.actual) { this._renderer.draw(imgSpec, startTile, startCol, row, count); } } else if (this._opts.showPlaceholder) { this._renderer.drawPlaceholder(startCol, row, count); } this._fullyCleared = false; } } } } } viewportResize(metrics) { var _a; // exit early if we have nothing in storage if (!this._images.size) { this._viewportMetrics = metrics; return; } // handle only viewport width enlargements, exit all other cases // TODO: needs patch for tile counter if (this._viewportMetrics.cols >= metrics.cols) { this._viewportMetrics = metrics; return; } // walk scrollbuffer at old col width to find all possible expansion matches const buffer = this._terminal._core.buffer; const rows = buffer.lines.length; const oldCol = this._viewportMetrics.cols - 1; for (let row = 0; row < rows; ++row) { const line = buffer.lines.get(row); if (line.getBg(oldCol) & 268435456 /* BgFlags.HAS_EXTENDED */) { const e = line._extendedAttrs[oldCol] || EMPTY_ATTRS; const imageId = e.imageId; if (imageId === undefined || imageId === -1) { continue; } const imgSpec = this._images.get(imageId); if (!imgSpec) { continue; } // found an image tile at oldCol, check if it qualifies for right exapansion const tilesPerRow = Math.ceil((((_a = imgSpec.actual) === null || _a === void 0 ? void 0 : _a.width) || 0) / imgSpec.actualCellSize.width); if ((e.tileId % tilesPerRow) + 1 >= tilesPerRow) { continue; } // expand only if right side is empty (nothing got wrapped from below) let hasData = false; for (let rightCol = oldCol + 1; rightCol > metrics.cols; ++rightCol) { if (line._data[rightCol * 3 /* Cell.SIZE */ + 0 /* Cell.CONTENT */] & 4194303 /* Content.HAS_CONTENT_MASK */) { hasData = true; break; } } if (hasData) { continue; } // do right expansion on terminal buffer const end = Math.min(metrics.cols, tilesPerRow - (e.tileId % tilesPerRow) + oldCol); let lastTile = e.tileId; for (let expandCol = oldCol + 1; expandCol < end; ++expandCol) { this._writeToCell(line, expandCol, imageId, ++lastTile); imgSpec.tileCount++; } } } // store new viewport metrics this._viewportMetrics = metrics; } /** * Retrieve original canvas at buffer position. */ getImageAtBufferCell(x, y) { var _a, _b; const buffer = this._terminal._core.buffer; const line = buffer.lines.get(y); if (line && line.getBg(x) & 268435456 /* BgFlags.HAS_EXTENDED */) { const e = line._extendedAttrs[x] || EMPTY_ATTRS; if (e.imageId && e.imageId !== -1) { const orig = (_a = this._images.get(e.imageId)) === null || _a === void 0 ? void 0 : _a.orig; if (window.ImageBitmap && orig instanceof ImageBitmap) { const canvas = ImageRenderer_1.ImageRenderer.createCanvas(window.document, orig.width, orig.height); (_b = canvas.getContext('2d')) === null || _b === void 0 ? void 0 : _b.drawImage(orig, 0, 0, orig.width, orig.height); return canvas; } return orig; } } } /** * Extract active single tile at buffer position. */ extractTileAtBufferCell(x, y) { const buffer = this._terminal._core.buffer; const line = buffer.lines.get(y); if (line && line.getBg(x) & 268435456 /* BgFlags.HAS_EXTENDED */) { const e = line._extendedAttrs[x] || EMPTY_ATTRS; if (e.imageId && e.imageId !== -1 && e.tileId !== -1) { const spec = this._images.get(e.imageId); if (spec) { return this._renderer.extractTile(spec, e.tileId); } } } } // TODO: Do we need some blob offloading tricks here to avoid early eviction? // also see https://stackoverflow.com/questions/28307789/is-there-any-limitation-on-javascript-max-blob-size _evictOldest(room) { var _a; const used = this._getStoredPixels(); let current = used; while (this._pixelLimit < current + room && this._images.size) { const spec = this._images.get(++this._lowestId); if (spec && spec.orig) { current -= spec.orig.width * spec.orig.height; if (spec.actual && spec.orig !== spec.actual) { current -= spec.actual.width * spec.actual.height; } (_a = spec.marker) === null || _a === void 0 ? void 0 : _a.dispose(); this._delImg(this._lowestId); } } return used - current; } _writeToCell(line, x, imageId, tileId) { if (line._data[x * 3 /* Cell.SIZE */ + 2 /* Cell.BG */] & 268435456 /* BgFlags.HAS_EXTENDED */) { const old = line._extendedAttrs[x]; if (old) { if (old.imageId !== undefined) { // found an old ExtendedAttrsImage, since we know that // they are always isolated instances (single cell usage), // we can re-use it and just update their id entries const oldSpec = this._images.get(old.imageId); if (oldSpec) { // early eviction for in-viewport overwrites oldSpec.tileCount--; } old.imageId = imageId; old.tileId = tileId; return; } // found a plain ExtendedAttrs instance, clone it to new entry line._extendedAttrs[x] = new ExtendedAttrsImage(old.ext, old.urlId, imageId, tileId); return; } } // fall-through: always create new ExtendedAttrsImage entry line._data[x * 3 /* Cell.SIZE */ + 2 /* Cell.BG */] |= 268435456 /* BgFlags.HAS_EXTENDED */; line._extendedAttrs[x] = new ExtendedAttrsImage(0, 0, imageId, tileId); } _evictOnAlternate() { var _a, _b; // nullify tile count of all images on alternate buffer for (const spec of this._images.values()) { if (spec.bufferType === 'alternate') { spec.tileCount = 0; } } // re-count tiles on whole buffer const buffer = this._terminal._core.buffer; for (let y = 0; y < this._terminal.rows; ++y) { const line = buffer.lines.get(y); if (!line) { continue; } for (let x = 0; x < this._terminal.cols; ++x) { if (line._data[x * 3 /* Cell.SIZE */ + 2 /* Cell.BG */] & 268435456 /* BgFlags.HAS_EXTENDED */) { const imgId = (_a = line._extendedAttrs[x]) === null || _a === void 0 ? void 0 : _a.imageId; if (imgId) { const spec = this._images.get(imgId); if (spec) { spec.tileCount++; } } } } } // deleted images with zero tile count const zero = []; for (const [id, spec] of this._images.entries()) { if (spec.bufferType === 'alternate' && !spec.tileCount) { (_b = spec.marker) === null || _b === void 0 ? void 0 : _b.dispose(); zero.push(id); } } for (const id of zero) { this._delImg(id); } } } exports.ImageStorage = ImageStorage;