Compare commits
5
Commits
master
...
image_resizing
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f9c38786bb | ||
|
|
039e103ed8 | ||
|
|
c9aabf9cf5 | ||
|
|
dc1fab5d5d | ||
|
|
b7ef9fa25e |
@@ -3,13 +3,13 @@
|
||||
* @license MIT
|
||||
*/
|
||||
import { IImageAddonOptions, IOscHandler, IResetHandler, ITerminalExt } from './Types';
|
||||
import { ImageRenderer } from './ImageRenderer';
|
||||
import { IIPImageStorage } from './IIPImageStorage';
|
||||
import { CELL_SIZE_DEFAULT } from './ImageStorage';
|
||||
import Base64Decoder from 'xterm-wasm-parts/lib/base64/Base64Decoder.wasm';
|
||||
import QoiDecoder from 'xterm-wasm-parts/lib/qoi/QoiDecoder.wasm';
|
||||
import { HeaderParser, IHeaderFields, HeaderState, SequenceType } from './IIPHeaderParser';
|
||||
import { imageType, UNSUPPORTED_TYPE } from './IIPMetrics';
|
||||
import { imageType, UNSUPPORTED_TYPE } from './Metrics';
|
||||
import { Drawable } from './Primitives';
|
||||
|
||||
// Local const enum mirror - esbuild can't inline const enums from external packages
|
||||
const enum DecoderConst {
|
||||
@@ -44,7 +44,6 @@ export class IIPHandler implements IOscHandler, IResetHandler {
|
||||
|
||||
constructor(
|
||||
private readonly _opts: IImageAddonOptions,
|
||||
private readonly _renderer: ImageRenderer,
|
||||
private readonly _storage: IIPImageStorage,
|
||||
private readonly _coreTerminal: ITerminalExt
|
||||
) {
|
||||
@@ -118,11 +117,13 @@ export class IIPHandler implements IOscHandler, IResetHandler {
|
||||
|
||||
if (seqType === SequenceType.REPORTCELLSIZE) {
|
||||
// OSC 1337 ; ReportCellSize=[height];[width];[scale] ST
|
||||
// IMPORTANT: ReportCellSize uses logical points (CSS pixels)
|
||||
let w = CELL_SIZE_DEFAULT.width;
|
||||
let h = CELL_SIZE_DEFAULT.height;
|
||||
if (this._renderer.dimensions) {
|
||||
w = this._renderer.dimensions.css.canvas.width / this._coreTerminal.cols;
|
||||
h = this._renderer.dimensions.css.canvas.height / this._coreTerminal.rows;
|
||||
const dimensions = this._coreTerminal.dimensions;
|
||||
if (dimensions) {
|
||||
w = dimensions.css.canvas.width / this._coreTerminal.cols;
|
||||
h = dimensions.css.canvas.height / this._coreTerminal.rows;
|
||||
}
|
||||
const scale = this._coreTerminal._core._coreBrowserService?.dpr ?? 1;
|
||||
const report = `\x1b]1337;ReportCellSize=${h.toFixed(3)};${w.toFixed(3)};${scale.toFixed(3)}\x1b\\`;
|
||||
@@ -147,9 +148,6 @@ export class IIPHandler implements IOscHandler, IResetHandler {
|
||||
|
||||
// fallthrough for SequenceType.FILE & SequenceType.FILEEND
|
||||
|
||||
let w = 0;
|
||||
let h = 0;
|
||||
|
||||
// early exit condition chain
|
||||
let cond: number | boolean;
|
||||
let metrics = UNSUPPORTED_TYPE;
|
||||
@@ -157,12 +155,7 @@ export class IIPHandler implements IOscHandler, IResetHandler {
|
||||
if (cond = !this._dec.end()) {
|
||||
metrics = imageType(this._dec.data8);
|
||||
if (cond = metrics.mime !== 'unsupported') {
|
||||
w = metrics.width;
|
||||
h = metrics.height;
|
||||
if (cond = w && h && w * h < this._opts.pixelLimit) {
|
||||
[w, h] = this._resize(w, h).map(Math.floor);
|
||||
cond = w && h && w * h < this._opts.pixelLimit;
|
||||
} else {
|
||||
if (!(cond = metrics.width && metrics.height && metrics.width * metrics.height < this._opts.pixelLimit)) {
|
||||
console.warn(`IIP: image dimension issue ${metrics.width}x${metrics.height}`);
|
||||
}
|
||||
} else {
|
||||
@@ -177,30 +170,24 @@ export class IIPHandler implements IOscHandler, IResetHandler {
|
||||
return true;
|
||||
}
|
||||
|
||||
let blob: Blob | ImageData;
|
||||
let bmSrc: Blob | ImageData;
|
||||
let imgBlob: Blob;
|
||||
if (metrics.mime === 'image/qoi') {
|
||||
const data = this._qoiDec.decode(this._dec.data8);
|
||||
blob = new ImageData(
|
||||
bmSrc = new ImageData(
|
||||
new Uint8ClampedArray(data.buffer, data.byteOffset, data.byteLength),
|
||||
this._qoiDec.width,
|
||||
this._qoiDec.height
|
||||
);
|
||||
this._qoiDec.release();
|
||||
if (w === this._qoiDec.width && h === this._qoiDec.height) {
|
||||
// use fast-path if we don't need to rescale
|
||||
this._dec.release();
|
||||
const canvas = ImageRenderer.createCanvas(undefined, this._qoiDec.width, this._qoiDec.height);
|
||||
canvas.getContext('2d')?.putImageData(blob, 0, 0);
|
||||
this._storage.addImage(canvas);
|
||||
return true;
|
||||
}
|
||||
} else {
|
||||
blob = new Blob([this._dec.data8], { type: metrics.mime });
|
||||
imgBlob = bmSrc = new Blob([this._dec.data8], { type: metrics.mime });
|
||||
}
|
||||
this._dec.release();
|
||||
return createImageBitmap(blob, { resizeWidth: w, resizeHeight: h })
|
||||
return createImageBitmap(bmSrc)
|
||||
.then(bm => {
|
||||
this._storage.addImage(bm);
|
||||
const [w, h] = this._resize(metrics.width, metrics.height);
|
||||
this._storage.addImage(new Drawable(bm), imgBlob, metrics, w / metrics.width, h / metrics.height);
|
||||
return true;
|
||||
})
|
||||
.catch(e => {
|
||||
@@ -210,13 +197,25 @@ export class IIPHandler implements IOscHandler, IResetHandler {
|
||||
}
|
||||
|
||||
private _resize(w: number, h: number): [number, number] {
|
||||
const cw = this._renderer.dimensions?.css.cell.width || CELL_SIZE_DEFAULT.width;
|
||||
const ch = this._renderer.dimensions?.css.cell.height || CELL_SIZE_DEFAULT.height;
|
||||
const width = this._renderer.dimensions?.css.canvas.width || cw * this._coreTerminal.cols;
|
||||
const height = this._renderer.dimensions?.css.canvas.height || ch * this._coreTerminal.rows;
|
||||
let cw;
|
||||
let ch;
|
||||
let width;
|
||||
let height;
|
||||
const dimensions = this._coreTerminal.dimensions;
|
||||
if (dimensions) {
|
||||
width = dimensions.device.canvas.width;
|
||||
height = dimensions.device.canvas.height;
|
||||
cw = width / this._coreTerminal.cols;
|
||||
ch = height / this._coreTerminal.rows;
|
||||
} else {
|
||||
cw = CELL_SIZE_DEFAULT.width;
|
||||
ch = CELL_SIZE_DEFAULT.height;
|
||||
width = cw * this._coreTerminal.cols;
|
||||
height = ch * this._coreTerminal.rows;
|
||||
}
|
||||
|
||||
const rw = this._dim(this._header.width!, width, cw);
|
||||
const rh = this._dim(this._header.height!, height, ch);
|
||||
const rw = this._dim(this._header.width ?? '', width, cw);
|
||||
const rh = this._dim(this._header.height ?? '', height, ch);
|
||||
if (!rw && !rh) {
|
||||
const wf = width / w; // TODO: should this respect initial cursor offset?
|
||||
const hf = (height - ch) / h; // TODO: fix offset issues from float cell height
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* @license MIT
|
||||
*/
|
||||
|
||||
import { IAddImageOpts } from './Types';
|
||||
import { IAddImageOpts, IDrawable, IMetrics } from './Types';
|
||||
import { ImageStorage } from './ImageStorage';
|
||||
|
||||
/**
|
||||
@@ -22,7 +22,9 @@ export class IIPImageStorage {
|
||||
* Add an IIP image to storage.
|
||||
* Always uses scrolling mode — cursor advances past the image.
|
||||
*/
|
||||
public addImage(img: HTMLCanvasElement | ImageBitmap): void {
|
||||
this._storage.addImage(img, this._addImageOpts);
|
||||
public addImage(src: IDrawable, data: Blob | undefined, metrics: IMetrics, prescaleX: number, prescaleY: number): void {
|
||||
this._addImageOpts.prescaleX = prescaleX;
|
||||
this._addImageOpts.prescaleY = prescaleY;
|
||||
this._storage.addImage(src, data, metrics, this._addImageOpts);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -144,6 +144,9 @@ export class ImageAddon implements ITerminalAddon, IImageApi {
|
||||
terminal.options.windowOptions = windowOps;
|
||||
}
|
||||
|
||||
// apply placeholder setting
|
||||
this._renderer.showPlaceholder(this._opts.showPlaceholder);
|
||||
|
||||
this._disposeLater(
|
||||
this._renderer,
|
||||
this._storage,
|
||||
@@ -187,7 +190,7 @@ export class ImageAddon implements ITerminalAddon, IImageApi {
|
||||
// iTerm IIP handler
|
||||
if (this._opts.iipSupport) {
|
||||
const iipStorage = new IIPImageStorage(this._storage!);
|
||||
const iipHandler = new IIPHandler(this._opts, this._renderer!, iipStorage, terminal);
|
||||
const iipHandler = new IIPHandler(this._opts, iipStorage, terminal);
|
||||
this._handlers.set('iip', iipHandler);
|
||||
this._disposeLater(
|
||||
terminal._core._inputHandler._parser.registerOscHandler(1337, iipHandler)
|
||||
@@ -250,10 +253,6 @@ export class ImageAddon implements ITerminalAddon, IImageApi {
|
||||
return this._storage?.getImageAtBufferCell(x, y);
|
||||
}
|
||||
|
||||
public extractTileAtBufferCell(x: number, y: number): HTMLCanvasElement | undefined {
|
||||
return this._storage?.extractTileAtBufferCell(x, y);
|
||||
}
|
||||
|
||||
private _report(s: string): void {
|
||||
this._terminal?._core.input(s, false);
|
||||
}
|
||||
@@ -344,8 +343,8 @@ export class ImageAddon implements ITerminalAddon, IImageApi {
|
||||
switch (params[1]) {
|
||||
// we only implement read and read_max here
|
||||
case GaAction.READ:
|
||||
let width = this._renderer?.dimensions?.css.canvas.width;
|
||||
let height = this._renderer?.dimensions?.css.canvas.height;
|
||||
let width = this._renderer?.dimensions?.device.canvas.width;
|
||||
let height = this._renderer?.dimensions?.device.canvas.height;
|
||||
if (!width || !height) {
|
||||
// for some reason we have no working image renderer
|
||||
// --> fallback to default cell size
|
||||
|
||||
@@ -3,19 +3,14 @@
|
||||
* @license MIT
|
||||
*/
|
||||
|
||||
import { toRGBA8888 } from 'sixel/lib/Colors';
|
||||
import { IDisposable } from '@xterm/xterm';
|
||||
import { ICellSize, ImageLayer, ITerminalExt, IImageSpec, IRenderDimensions, IRenderService } from './Types';
|
||||
import type { ICellSize, ImageLayer, ITerminalExt, IImageSpec, IRenderDimensions, IRenderService } from './Types';
|
||||
import { Disposable, MutableDisposable, toDisposable } from 'common/Lifecycle';
|
||||
import { createCanvas } from './Primitives';
|
||||
|
||||
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
|
||||
*/
|
||||
@@ -24,51 +19,14 @@ export class ImageRenderer extends Disposable implements IDisposable {
|
||||
public get canvas(): HTMLCanvasElement | undefined { return this._layers.get('top')?.canvas; }
|
||||
private _layers = new Map<ImageLayer, CanvasRenderingContext2D>();
|
||||
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<ImageBitmap | undefined> {
|
||||
if (typeof createImageBitmap !== 'function') {
|
||||
return Promise.resolve(undefined);
|
||||
}
|
||||
return createImageBitmap(img);
|
||||
}
|
||||
|
||||
// some local variables for faster access
|
||||
private _dimensions: IRenderDimensions | undefined;
|
||||
private _cellSize: ICellSize | undefined;
|
||||
|
||||
constructor(private _terminal: ITerminalExt) {
|
||||
super();
|
||||
@@ -100,8 +58,6 @@ export class ImageRenderer extends Disposable implements IDisposable {
|
||||
}
|
||||
this._renderService = undefined;
|
||||
this._layers.clear();
|
||||
this._placeholderBitmap?.close();
|
||||
this._placeholderBitmap = undefined;
|
||||
this._placeholder = undefined;
|
||||
}));
|
||||
}
|
||||
@@ -110,15 +66,7 @@ export class ImageRenderer extends Disposable implements IDisposable {
|
||||
* 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._placeholder = value ? this._createPlaceHolder() : undefined;
|
||||
this._renderService?.refreshRows(0, this._terminal.rows);
|
||||
}
|
||||
|
||||
@@ -131,22 +79,27 @@ export class ImageRenderer extends Disposable implements IDisposable {
|
||||
}
|
||||
|
||||
/**
|
||||
* Current cell size (float).
|
||||
* Current cell size.
|
||||
*/
|
||||
public get cellSize(): ICellSize {
|
||||
return {
|
||||
width: this.dimensions?.css.cell.width || -1,
|
||||
height: this.dimensions?.css.cell.height || -1
|
||||
};
|
||||
public getCellSize(dimensions?: IRenderDimensions): ICellSize | undefined {
|
||||
dimensions ??= this._terminal.dimensions;
|
||||
if (dimensions) {
|
||||
return {
|
||||
width: dimensions.device.canvas.width / this._terminal.cols,
|
||||
height: dimensions.device.canvas.height / this._terminal.rows,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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);
|
||||
const deviceGrid = this._cellSize;
|
||||
if (!deviceGrid || !this._dimensions) return;
|
||||
const y = Math.floor(start * deviceGrid.height);
|
||||
const w = this._dimensions.device.canvas.width;
|
||||
const h = Math.ceil((end + 1 - start) * deviceGrid.height);
|
||||
if (!layer || layer === 'top') {
|
||||
this._layers.get('top')?.clearRect(0, y, w, h);
|
||||
}
|
||||
@@ -174,142 +127,103 @@ export class ImageRenderer extends Disposable implements IDisposable {
|
||||
*/
|
||||
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;
|
||||
const initialGrid = imgSpec.cellSize;
|
||||
const deviceGrid = this._cellSize;
|
||||
|
||||
// Don't try to draw anything, if we cannot get valid renderer metrics.
|
||||
if (width === -1 || height === -1) {
|
||||
if (!ctx || !imgSpec.src.native || !deviceGrid) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._rescaleImage(imgSpec, width, height);
|
||||
const img = imgSpec.actual!;
|
||||
const cols = Math.ceil(img.width / width);
|
||||
const img = imgSpec.src;
|
||||
const cols = Math.ceil(img.width * imgSpec.prescaleX / initialGrid.width);
|
||||
|
||||
const sx = (tileId % cols) * width;
|
||||
const sy = Math.floor(tileId / cols) * height;
|
||||
const dx = col * width;
|
||||
const dy = row * height;
|
||||
const gridWidth = initialGrid.width / imgSpec.prescaleX;
|
||||
const gridHeight = initialGrid.height / imgSpec.prescaleY;
|
||||
|
||||
// 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;
|
||||
// crops are initialGrid px
|
||||
const sx = (tileId % cols) * gridWidth + imgSpec.precropX;
|
||||
const sy = Math.floor(tileId / cols) * gridHeight + imgSpec.precropY;
|
||||
const dx = Math.floor(col * deviceGrid.width + imgSpec.offsetX);
|
||||
const dy = Math.floor(row * deviceGrid.height + imgSpec.offsetY);
|
||||
|
||||
// safari bug: never access image source out of bounds, thus we clamp its dimensions
|
||||
const sWidth = Math.min(count * gridWidth, img.width - sx);
|
||||
const sHeight = Math.min(gridHeight, img.height - sy);
|
||||
const dWidth = Math.ceil(sWidth / gridWidth * deviceGrid.width * imgSpec.scaleX);
|
||||
const dHeight = Math.ceil(sHeight / gridHeight * deviceGrid.height * imgSpec.scaleY);
|
||||
|
||||
if (imgSpec.blendMode === 'overwrite') {
|
||||
ctx.clearRect(dx, dy, dWidth, dHeight);
|
||||
}
|
||||
|
||||
// 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)
|
||||
img.native,
|
||||
Math.floor(sx), Math.floor(sy), Math.ceil(sWidth), Math.ceil(sHeight),
|
||||
dx, dy, dWidth, dHeight
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
);
|
||||
const deviceGrid = this._cellSize;
|
||||
if (!ctx || !deviceGrid) {
|
||||
return;
|
||||
}
|
||||
this._placeholder ??= this._createPlaceHolder();
|
||||
if (!this._placeholder) return;
|
||||
ctx.drawImage(
|
||||
this._placeholder,
|
||||
0, 0, 1, 1,
|
||||
col * deviceGrid.width,
|
||||
row * deviceGrid.height,
|
||||
count * deviceGrid.width,
|
||||
deviceGrid.height
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Rescale image layer canvas if needed.
|
||||
* Checked once from `ImageStorage.render`.
|
||||
* NOTE: This method updates dimensions on instance properties
|
||||
* for faster access during draw and clear calls.
|
||||
* So make sure to always call this before doing any draws.
|
||||
*/
|
||||
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;
|
||||
public rescaleCanvas(force: boolean = false): void {
|
||||
const dimensions = this._terminal.dimensions;
|
||||
if (dimensions) {
|
||||
const cssW = dimensions.css.canvas.width;
|
||||
const cssH = dimensions.css.canvas.height;
|
||||
const devW = dimensions.device.canvas.width;
|
||||
const devH = dimensions.device.canvas.height;
|
||||
let recalc = force;
|
||||
for (const ctx of this._layers.values()) {
|
||||
if (ctx.canvas.width !== devW
|
||||
|| ctx.canvas.height !== devH
|
||||
|| ctx.canvas.style.width !== `${cssW}px`
|
||||
|| ctx.canvas.style.height !== `${cssH}px`
|
||||
) {
|
||||
ctx.canvas.width = devW;
|
||||
ctx.canvas.height = devH;
|
||||
ctx.canvas.style.width = `${cssW}px`;
|
||||
ctx.canvas.style.height = `${cssH}px`;
|
||||
recalc = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
if (recalc) {
|
||||
this._dimensions = dimensions;
|
||||
this._cellSize = this.getCellSize(dimensions);
|
||||
}
|
||||
} else {
|
||||
// we were not able to get render dimensions:
|
||||
// set cellsize to undefined so we don't try to draw anything
|
||||
this._cellSize = this.getCellSize();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -336,9 +250,12 @@ export class ImageRenderer extends Disposable implements IDisposable {
|
||||
if (this._layers.has(layer)) {
|
||||
return;
|
||||
}
|
||||
const canvas = ImageRenderer.createCanvas(
|
||||
this.document, this.dimensions?.css.canvas.width || 0,
|
||||
this.dimensions?.css.canvas.height || 0
|
||||
const canvas = createCanvas(
|
||||
this.document,
|
||||
this.dimensions?.device.canvas.width || 0,
|
||||
this.dimensions?.device.canvas.height || 0,
|
||||
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;
|
||||
@@ -366,6 +283,8 @@ export class ImageRenderer extends Disposable implements IDisposable {
|
||||
return;
|
||||
}
|
||||
this._layers.set(layer, ctx);
|
||||
// force rescaling to update stored cellSize (maybe not be populated yet)
|
||||
this.rescaleCanvas(true);
|
||||
this.clearAll(layer);
|
||||
}
|
||||
|
||||
@@ -381,41 +300,17 @@ export class ImageRenderer extends Disposable implements IDisposable {
|
||||
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 });
|
||||
/**
|
||||
* Create a semi-transparent gray 1x1 placeholder
|
||||
*/
|
||||
private _createPlaceHolder(): HTMLCanvasElement | undefined {
|
||||
const imgData = new ImageData(1, 1);
|
||||
new Uint32Array(imgData.data.buffer).fill(128 << 24 | 128 << 16 | 128 << 8 | 128);
|
||||
const canvas = createCanvas(this.document, 1, 1);
|
||||
const ctx = canvas.getContext('2d', { alpha: true });
|
||||
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);
|
||||
return canvas;
|
||||
}
|
||||
|
||||
public get document(): Document | undefined {
|
||||
|
||||
@@ -6,10 +6,13 @@
|
||||
import { IDisposable } from '@xterm/xterm';
|
||||
import { ImageRenderer } from './ImageRenderer';
|
||||
import type {
|
||||
ITerminalExt, IImageAddonOptions, IImageSpec, ICellSize, IAddImageOpts
|
||||
ITerminalExt, IImageAddonOptions, IImageSpec, ICellSize, IAddImageOpts,
|
||||
IDrawable,
|
||||
IMetrics
|
||||
} from './Types';
|
||||
import type { IBufferLine } from 'common/buffer/Types';
|
||||
import { CellData } from 'common/buffer/CellData';
|
||||
import { createCanvas } from './Primitives';
|
||||
|
||||
// fallback default cell size
|
||||
export const CELL_SIZE_DEFAULT: ICellSize = {
|
||||
@@ -43,7 +46,7 @@ export class ImageStorage implements IDisposable {
|
||||
// whether render should do a full clear
|
||||
private _needsFullClear = false;
|
||||
// hard limit of stored pixels (fallback limit of 10 MB)
|
||||
private _pixelLimit: number = 2500000;
|
||||
private _byteLimit: number = 10000000;
|
||||
private _workCell: CellData = new CellData();
|
||||
|
||||
private _viewportMetrics: { cols: number, rows: number };
|
||||
@@ -83,43 +86,54 @@ export class ImageStorage implements IDisposable {
|
||||
this._renderer.clearAll();
|
||||
}
|
||||
|
||||
/**
|
||||
* FIXME: below we need to introduce separation of:
|
||||
* - RAM usage
|
||||
* - VRAM usage
|
||||
* - Blob unsage
|
||||
*/
|
||||
|
||||
|
||||
public getLimit(): number {
|
||||
return this._pixelLimit * 4 / 1000000;
|
||||
return this._byteLimit / 1000000;
|
||||
}
|
||||
|
||||
public setLimit(value: number): void {
|
||||
if (value < 0.5 || value > 1000) {
|
||||
throw RangeError('invalid storageLimit, should be at least 0.5 MB and not exceed 1G');
|
||||
throw RangeError('invalid storageLimit, should be at least 0.5 MB and not exceed 1GB');
|
||||
}
|
||||
this._pixelLimit = (value / 4 * 1000000) >>> 0;
|
||||
this._byteLimit = (value * 1000000) >>> 0;
|
||||
this._evictOldest(0);
|
||||
}
|
||||
|
||||
public getUsage(): number {
|
||||
return this._getStoredPixels() * 4 / 1000000;
|
||||
return this._getVRAMUsage() / 1000000;
|
||||
}
|
||||
|
||||
private _getStoredPixels(): number {
|
||||
let storedPixels = 0;
|
||||
private _getVRAMUsage(): number {
|
||||
let bytes = 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;
|
||||
}
|
||||
bytes += spec.src.bytes;
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
public getBlobUsage(): number {
|
||||
let size = 0;
|
||||
for (const spec of this._images.values()) {
|
||||
if (spec.data instanceof Blob) {
|
||||
size += spec.data.size;
|
||||
}
|
||||
}
|
||||
return storedPixels;
|
||||
return size;
|
||||
}
|
||||
|
||||
private _delImg(id: number): void {
|
||||
const spec = this._images.get(id);
|
||||
if (!spec) return;
|
||||
this._images.delete(id);
|
||||
// FIXME: really ugly workaround to get bitmaps deallocated :(
|
||||
if (window.ImageBitmap && spec.orig instanceof ImageBitmap) {
|
||||
spec.orig.close();
|
||||
}
|
||||
spec.src.close();
|
||||
spec.data = undefined;
|
||||
this.onImageDeleted?.(id);
|
||||
}
|
||||
|
||||
@@ -156,8 +170,10 @@ export class ImageStorage implements IDisposable {
|
||||
}
|
||||
|
||||
/**
|
||||
* Method to add an image to the storage.
|
||||
* @param img - The image to add (canvas or bitmap).
|
||||
* Method to add an drawable to the storage.
|
||||
* @param src - The drawable to add.
|
||||
* @param data - Blob or Uint8Array of the original image data.
|
||||
* @param metrics - IMetrics of the original image.
|
||||
* @param opts - Options for addImage:
|
||||
* - scrolling: When true, cursor advances with the image.
|
||||
* When false, image is placed at ORIGIN and cursor does not move.
|
||||
@@ -166,17 +182,16 @@ export class ImageStorage implements IDisposable {
|
||||
* - cursorPos: 'vt340' for bottom-left, 'iip' for bottom.right.
|
||||
* @returns The internal image ID assigned to the stored image.
|
||||
*/
|
||||
public addImage(img: HTMLCanvasElement | ImageBitmap, opts: IAddImageOpts): number {
|
||||
public addImage(src: IDrawable, data: Blob | undefined, metrics: IMetrics, opts: IAddImageOpts): number {
|
||||
// never allow storage to exceed memory limit
|
||||
this._evictOldest(img.width * img.height);
|
||||
this._evictOldest(src.bytes);
|
||||
|
||||
// calc rows x cols needed to display the image
|
||||
let cellSize = this._renderer.cellSize;
|
||||
if (cellSize.width === -1 || cellSize.height === -1) {
|
||||
cellSize = CELL_SIZE_DEFAULT;
|
||||
}
|
||||
const cols = Math.ceil(img.width / cellSize.width);
|
||||
const rows = Math.ceil(img.height / cellSize.height);
|
||||
const prescaleX = opts.prescaleX ?? 1.0;
|
||||
const prescaleY = opts.prescaleY ?? 1.0;
|
||||
const cellSize = this._renderer.getCellSize() ?? CELL_SIZE_DEFAULT;
|
||||
const cols = Math.ceil(src.width * prescaleX / cellSize.width);
|
||||
const rows = Math.ceil(src.height * prescaleY / cellSize.height);
|
||||
|
||||
const imageId = ++this._lastId;
|
||||
|
||||
@@ -253,15 +268,24 @@ export class ImageStorage implements IDisposable {
|
||||
|
||||
// create storage entry
|
||||
const imgSpec: IImageSpec = {
|
||||
orig: img,
|
||||
origCellSize: cellSize,
|
||||
actual: img,
|
||||
actualCellSize: { ...cellSize }, // clone needed, since later modified
|
||||
src,
|
||||
data,
|
||||
metrics,
|
||||
cellSize,
|
||||
marker: endMarker || undefined,
|
||||
tileCount,
|
||||
bufferType: this._terminal.buffer.active.type,
|
||||
layer: opts.layer,
|
||||
zIndex: opts.zIndex
|
||||
zIndex: opts.zIndex,
|
||||
prescaleX,
|
||||
prescaleY,
|
||||
precropX: 0,
|
||||
precropY: 0,
|
||||
scaleX: 1.0,
|
||||
scaleY: 1.0,
|
||||
offsetX: 0,
|
||||
offsetY: 0,
|
||||
blendMode: 'alpha'
|
||||
};
|
||||
|
||||
// finally add the image
|
||||
@@ -376,9 +400,7 @@ export class ImageStorage implements IDisposable {
|
||||
}
|
||||
col--;
|
||||
if (imgSpec) {
|
||||
if (imgSpec.actual) {
|
||||
drawCalls.push({ imgSpec, tileId: startTile, col: startCol, row, count });
|
||||
}
|
||||
drawCalls.push({ imgSpec, tileId: startTile, col: startCol, row, count });
|
||||
} else if (this._opts.showPlaceholder) {
|
||||
placeholderCalls.push({ col: startCol, row, count });
|
||||
}
|
||||
@@ -433,7 +455,7 @@ export class ImageStorage implements IDisposable {
|
||||
continue;
|
||||
}
|
||||
// found an image tile at oldCol, check if it qualifies for right exapansion
|
||||
const tilesPerRow = Math.ceil((imgSpec.actual?.width || 0) / imgSpec.actualCellSize.width);
|
||||
const tilesPerRow = Math.ceil(imgSpec.src.width * imgSpec.prescaleX / imgSpec.cellSize.width);
|
||||
if ((e.tileId % tilesPerRow) + 1 >= tilesPerRow) {
|
||||
continue;
|
||||
}
|
||||
@@ -470,30 +492,13 @@ export class ImageStorage implements IDisposable {
|
||||
if (line) {
|
||||
const e = line.getExtended(x)?.payload;
|
||||
if (e instanceof ImageTileInfo && e.imageId && e.imageId !== -1) {
|
||||
const orig = this._images.get(e.imageId)?.orig;
|
||||
if (window.ImageBitmap && orig instanceof ImageBitmap) {
|
||||
const canvas = ImageRenderer.createCanvas(window.document, orig.width, orig.height);
|
||||
canvas.getContext('2d')?.drawImage(orig, 0, 0, orig.width, orig.height);
|
||||
const src = this._images.get(e.imageId)?.src;
|
||||
if (src?.native instanceof ImageBitmap || src?.native instanceof VideoFrame) {
|
||||
const canvas = createCanvas(window.document, src.width, src.height);
|
||||
canvas.getContext('2d')?.drawImage(src.native, 0, 0, src.width, src.height);
|
||||
return canvas;
|
||||
}
|
||||
return orig as HTMLCanvasElement;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract active single tile at buffer position.
|
||||
*/
|
||||
public extractTileAtBufferCell(x: number, y: number): HTMLCanvasElement | undefined {
|
||||
const buffer = this._terminal._core.buffer;
|
||||
const line = buffer.lines.get(y);
|
||||
if (line) {
|
||||
const e = line.getExtended(x)?.payload;
|
||||
if (e instanceof ImageTileInfo && e.imageId && e.imageId !== -1 && e.tileId !== -1) {
|
||||
const spec = this._images.get(e.imageId);
|
||||
if (spec) {
|
||||
return this._renderer.extractTile(spec, e.tileId);
|
||||
}
|
||||
return src?.native;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -501,15 +506,12 @@ export class ImageStorage implements IDisposable {
|
||||
// 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
|
||||
private _evictOldest(room: number): number {
|
||||
const used = this._getStoredPixels();
|
||||
const used = this._getVRAMUsage();
|
||||
let current = used;
|
||||
while (this._pixelLimit < current + room && this._images.size) {
|
||||
while (this._byteLimit < 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;
|
||||
}
|
||||
if (spec) {
|
||||
current -= spec.src.bytes;
|
||||
spec.marker?.dispose();
|
||||
this._delImg(this._lowestId);
|
||||
}
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
*/
|
||||
|
||||
import { assert } from 'chai';
|
||||
import { imageType, IMetrics } from './IIPMetrics';
|
||||
import { imageType } from './Metrics';
|
||||
import type { IMetrics } from 'Types';
|
||||
|
||||
// fix missing nodejs decl
|
||||
declare const require: (s: string) => any;
|
||||
@@ -3,14 +3,9 @@
|
||||
* @license MIT
|
||||
*/
|
||||
|
||||
import { IMetrics } from './Types';
|
||||
|
||||
export type ImageType = 'image/png' | 'image/jpeg' | 'image/gif' | 'image/qoi' | 'image/webp' | 'image/avif' | 'unsupported' | '';
|
||||
|
||||
export interface IMetrics {
|
||||
mime: ImageType;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export const UNSUPPORTED_TYPE: IMetrics = {
|
||||
mime: 'unsupported',
|
||||
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* Copyright (c) 2026 The xterm.js authors. All rights reserved.
|
||||
* @license MIT
|
||||
*/
|
||||
|
||||
import { IDrawable } from './Types';
|
||||
|
||||
export function createCanvas(
|
||||
localDocument: Document | undefined,
|
||||
width: number,
|
||||
height: number,
|
||||
cssWidth?: number,
|
||||
cssHeight?: 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;
|
||||
canvas.style.width = `${(cssWidth ?? width) | 0}px`;
|
||||
canvas.style.height = `${(cssHeight ?? height) | 0}px`;
|
||||
return canvas;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Drawable is a resource the ImageRenderer knows to draw on the screen.
|
||||
*/
|
||||
export class Drawable implements IDrawable {
|
||||
public readonly width: number;
|
||||
public readonly height: number;
|
||||
// @readonly: only for memory tracking
|
||||
public bytes: number;
|
||||
|
||||
constructor(public native: ImageBitmap | VideoFrame | HTMLCanvasElement) {
|
||||
if (native instanceof VideoFrame) {
|
||||
this.width = native.displayWidth;
|
||||
this.height = native.displayHeight;
|
||||
// NOTE: native.allocationSize() is too expensive
|
||||
this.bytes = native.codedWidth * native.codedHeight * 4;
|
||||
} else {
|
||||
this.width = native.width;
|
||||
this.height = native.height;
|
||||
this.bytes = native.width * native.height * 4;
|
||||
}
|
||||
}
|
||||
|
||||
public close(): void {
|
||||
if (this.bytes === 0) return;
|
||||
this.bytes = 0;
|
||||
try {
|
||||
if (this.native instanceof HTMLCanvasElement) {
|
||||
this.native.width = 0;
|
||||
this.native.height = 0;
|
||||
} else {
|
||||
this.native.close?.();
|
||||
}
|
||||
} catch {}
|
||||
this.native = null!;
|
||||
}
|
||||
}
|
||||
@@ -7,9 +7,9 @@ import { SixelImageStorage } from './SixelImageStorage';
|
||||
import { IDcsHandler, IParams, IImageAddonOptions, ITerminalExt, AttributeData, IResetHandler, ReadonlyColorSet } from './Types';
|
||||
import { toRGBA8888, BIG_ENDIAN, PALETTE_ANSI_256, PALETTE_VT340_COLOR } from 'sixel/lib/Colors';
|
||||
import { RGBA8888 } from 'sixel/lib/Types';
|
||||
import { ImageRenderer } from './ImageRenderer';
|
||||
|
||||
import { DecoderAsync, Decoder } from 'sixel/lib/Decoder';
|
||||
import { createCanvas, Drawable } from './Primitives';
|
||||
|
||||
// always free decoder ressources after decoding if it exceeds this limit
|
||||
const MEM_PERMA_LIMIT = 4194304; // 1024 pixels * 1024 pixels * 4 channels = 4MB
|
||||
@@ -98,12 +98,12 @@ export class SixelHandler implements IDcsHandler, IResetHandler {
|
||||
return true;
|
||||
}
|
||||
|
||||
const canvas = ImageRenderer.createCanvas(undefined, width, height);
|
||||
const canvas = createCanvas(undefined, width, height);
|
||||
canvas.getContext('2d')?.putImageData(new ImageData(this._dec.data8 as Uint8ClampedArray<ArrayBuffer>, width, height), 0, 0);
|
||||
if (this._dec.memoryUsage > MEM_PERMA_LIMIT) {
|
||||
this._dec.release();
|
||||
}
|
||||
this._storage.addImage(canvas);
|
||||
this._storage.addImage(new Drawable(canvas), undefined, { width, height, mime: 'image/sixel' });
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
*/
|
||||
|
||||
import { ImageStorage, CELL_SIZE_DEFAULT } from './ImageStorage';
|
||||
import { IImageAddonOptions, ITerminalExt, IAddImageOpts } from './Types';
|
||||
import { IImageAddonOptions, ITerminalExt, IAddImageOpts, IDrawable, IMetrics } from './Types';
|
||||
import { ImageRenderer } from './ImageRenderer';
|
||||
|
||||
/**
|
||||
@@ -27,9 +27,9 @@ export class SixelImageStorage {
|
||||
* Add a sixel image to storage.
|
||||
* Cursor behavior depends on the sixelScrolling option (DECSET 80).
|
||||
*/
|
||||
public addImage(img: HTMLCanvasElement | ImageBitmap): void {
|
||||
public addImage(src: IDrawable, data: Blob | undefined, metrics: IMetrics): void {
|
||||
this._addImageOpts.scrolling = this._opts.sixelScrolling;
|
||||
this._storage.addImage(img, this._addImageOpts);
|
||||
this._storage.addImage(src, data, metrics, this._addImageOpts);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -39,10 +39,7 @@ export class SixelImageStorage {
|
||||
*/
|
||||
public advanceCursor(height: number): void {
|
||||
if (this._opts.sixelScrolling) {
|
||||
let cellSize = this._renderer.cellSize;
|
||||
if (cellSize.width === -1 || cellSize.height === -1) {
|
||||
cellSize = CELL_SIZE_DEFAULT;
|
||||
}
|
||||
const cellSize = this._renderer.getCellSize() ?? CELL_SIZE_DEFAULT;
|
||||
const rows = Math.ceil(height / cellSize.height);
|
||||
for (let i = 1; i < rows; ++i) {
|
||||
this._terminal._core._inputHandler.lineFeed();
|
||||
|
||||
@@ -94,16 +94,70 @@ export interface IAddImageOpts {
|
||||
layer: ImageLayer;
|
||||
zIndex: number;
|
||||
cursorPos: CursorPos;
|
||||
prescaleX?: number;
|
||||
prescaleY?: number;
|
||||
}
|
||||
|
||||
export interface IDrawable {
|
||||
native: ImageBitmap | VideoFrame | HTMLCanvasElement;
|
||||
width: number;
|
||||
height: number;
|
||||
close(): void;
|
||||
readonly bytes: number;
|
||||
}
|
||||
|
||||
export type ImageType = 'unsupported'
|
||||
| 'image/png'
|
||||
| 'image/jpeg'
|
||||
| 'image/gif'
|
||||
| 'image/qoi'
|
||||
| 'image/webp'
|
||||
| 'image/avif'
|
||||
// sixel
|
||||
| 'image/sixel'
|
||||
// rgb|a blobs for kitty
|
||||
| 'image/rgb'
|
||||
| 'image/rgba';
|
||||
|
||||
export interface IMetrics {
|
||||
mime: ImageType;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export interface IImageSpec {
|
||||
orig: HTMLCanvasElement | ImageBitmap | undefined;
|
||||
origCellSize: ICellSize;
|
||||
actual: HTMLCanvasElement | ImageBitmap | undefined;
|
||||
actualCellSize: ICellSize;
|
||||
/** entries for a later IImageSource type */
|
||||
/** drawable for screen rendering */
|
||||
src: IDrawable;
|
||||
/** image bytes as blob */
|
||||
data: Blob | undefined;
|
||||
/** metrics about image like mime and dimensions */
|
||||
metrics: IMetrics;
|
||||
|
||||
/** entries for a later IPlacement type */
|
||||
/** cell size at time of insert */
|
||||
cellSize: ICellSize;
|
||||
/** eviction marker */
|
||||
marker: IMarker | undefined;
|
||||
/** used tiles as eviction hint */
|
||||
tileCount: number;
|
||||
/** buffer it was placed on */
|
||||
bufferType: 'alternate' | 'normal';
|
||||
/** layer it was placed on */
|
||||
layer: ImageLayer;
|
||||
zIndex: number;
|
||||
/** scaling factor on source dimensions, default = 1.0 */
|
||||
prescaleX: number;
|
||||
prescaleY: number;
|
||||
/** crop offset into source, default = 0 (currently unused) */
|
||||
precropX: number;
|
||||
precropY: number;
|
||||
/** output scaling, default = 1.0 (currently unused) */
|
||||
scaleX: number;
|
||||
scaleY: number;
|
||||
/** output offset, default = 0 (currently unused) */
|
||||
offsetX: number;
|
||||
offsetY: number;
|
||||
/** compositing mode on output, default = 'alpha' (currently unused) */
|
||||
blendMode: 'overwrite' | 'alpha';
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
KittyPixelConstants,
|
||||
parseKittyCommand
|
||||
} from './KittyGraphicsTypes';
|
||||
import { createCanvas } from '../Primitives';
|
||||
|
||||
const enum Constants {
|
||||
// Memory limit for base64 decoder (4MB, same as IIPHandler)
|
||||
@@ -573,8 +574,8 @@ export class KittyGraphicsHandler implements IApcHandler, IResetHandler, IDispos
|
||||
bitmap = cropped;
|
||||
}
|
||||
|
||||
const cw = this._renderer.dimensions?.css.cell.width || CELL_SIZE_DEFAULT.width;
|
||||
const ch = this._renderer.dimensions?.css.cell.height || CELL_SIZE_DEFAULT.height;
|
||||
const cw = this._renderer.dimensions?.device.cell.width || CELL_SIZE_DEFAULT.width;
|
||||
const ch = this._renderer.dimensions?.device.cell.height || CELL_SIZE_DEFAULT.height;
|
||||
|
||||
// Per spec: c/r default to image's natural cell dimensions.
|
||||
// If only one of c/r is specified, compute the other from image aspect ratio.
|
||||
@@ -636,7 +637,7 @@ export class KittyGraphicsHandler implements IApcHandler, IResetHandler, IDispos
|
||||
// the natural cell dimensions.
|
||||
const canvasW = (cmd.columns !== undefined) ? Math.round(imgCols * cw) : bitmap.width + xOffset;
|
||||
const canvasH = (cmd.rows !== undefined) ? Math.round(imgRows * ch) : bitmap.height + yOffset;
|
||||
const offsetCanvas = ImageRenderer.createCanvas(window.document, canvasW, canvasH);
|
||||
const offsetCanvas = createCanvas(window.document, canvasW, canvasH);
|
||||
const offsetCtx = offsetCanvas.getContext('2d');
|
||||
if (!offsetCtx) {
|
||||
throw new Error('Failed to create offset canvas context');
|
||||
@@ -700,7 +701,7 @@ export class KittyGraphicsHandler implements IApcHandler, IResetHandler, IDispos
|
||||
return new Promise<ImageBitmap>((resolve, reject) => {
|
||||
img.addEventListener('load', () => {
|
||||
URL.revokeObjectURL(url);
|
||||
const canvas = ImageRenderer.createCanvas(window.document, img.width, img.height);
|
||||
const canvas = createCanvas(window.document, img.width, img.height);
|
||||
canvas.getContext('2d')?.drawImage(img, 0, 0);
|
||||
createImageBitmap(canvas).then(resolve).catch(reject);
|
||||
});
|
||||
|
||||
@@ -7,6 +7,8 @@ import { IDisposable } from '@xterm/xterm';
|
||||
import { ImageStorage } from '../ImageStorage';
|
||||
import { ImageLayer, IAddImageOpts } from '../Types';
|
||||
import { IKittyImageData } from './KittyGraphicsTypes';
|
||||
import { Drawable } from '../Primitives';
|
||||
import { UNSUPPORTED_TYPE } from '../Metrics';
|
||||
|
||||
// Kitty-specific image storage controller.
|
||||
//
|
||||
@@ -102,7 +104,8 @@ export class KittyImageStorage implements IDisposable {
|
||||
this._addImageOpts.scrolling = scrolling;
|
||||
this._addImageOpts.layer = layer;
|
||||
this._addImageOpts.zIndex = zIndex;
|
||||
const storageId = this._storage.addImage(image, this._addImageOpts);
|
||||
// FIXME: store image data in blob, fill metrics correctly
|
||||
const storageId = this._storage.addImage(new Drawable(image), undefined, UNSUPPORTED_TYPE, this._addImageOpts);
|
||||
this._kittyIdToStorageId.set(kittyId, storageId);
|
||||
this._storageIdToKittyId.set(storageId, kittyId);
|
||||
}
|
||||
|
||||
@@ -294,10 +294,10 @@ test.describe('ImageAddon', () => {
|
||||
test('size params is optional/informative', async () => {
|
||||
// too small size param
|
||||
await ctx.proxy.write(`\x1b]1337;File=inline=1;size=5:${PALETTE_PNG_BASE64}\x07`);
|
||||
deepStrictEqual(await getOrigSize(1), [640, 80]);
|
||||
deepStrictEqual(await getImageSize(1), [640, 80]);
|
||||
// no size aparm at all
|
||||
await ctx.proxy.write(`\x1b[10H\x1b]1337;File=inline=1:${PALETTE_QOI_BASE64}\x07`);
|
||||
deepStrictEqual(await getOrigSize(2), [640, 80]);
|
||||
deepStrictEqual(await getImageSize(2), [640, 80]);
|
||||
});
|
||||
test.skip('FilePart support - bytewise', async () => {
|
||||
// opener
|
||||
@@ -308,7 +308,7 @@ test.describe('ImageAddon', () => {
|
||||
}
|
||||
// finalizer
|
||||
await ctx.proxy.write(`\x1b]1337;FileEnd\x07`);
|
||||
deepStrictEqual(await getOrigSize(1), [640, 80]);
|
||||
deepStrictEqual(await getImageSize(1), [640, 80]);
|
||||
});
|
||||
test('FilePart support - chunks', async () => {
|
||||
// opener
|
||||
@@ -319,7 +319,7 @@ test.describe('ImageAddon', () => {
|
||||
}
|
||||
// finalizer
|
||||
await ctx.proxy.write(`\x1b]1337;FileEnd\x07`);
|
||||
deepStrictEqual(await getOrigSize(1), [640, 80]);
|
||||
deepStrictEqual(await getImageSize(1), [640, 80]);
|
||||
});
|
||||
test('FilePart support - edgecases', async () => {
|
||||
// multiple opener do not harm
|
||||
@@ -347,39 +347,39 @@ test.describe('ImageAddon', () => {
|
||||
// finalizer skipped
|
||||
// write spinfox.png
|
||||
await ctx.proxy.write(TESTDATA_IIP[1][0]);
|
||||
deepStrictEqual(await getOrigSize(1), TESTDATA_IIP[1][1]);
|
||||
deepStrictEqual(await getImageSize(1), TESTDATA_IIP[1][1]);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('IIP support - testimages', () => {
|
||||
test('palette.png', async () => {
|
||||
await ctx.proxy.write(TESTDATA_IIP[0][0]);
|
||||
deepStrictEqual(await getOrigSize(1), TESTDATA_IIP[0][1]);
|
||||
deepStrictEqual(await getImageSize(1), TESTDATA_IIP[0][1]);
|
||||
});
|
||||
test('spinfox.png', async () => {
|
||||
await ctx.proxy.write(TESTDATA_IIP[1][0]);
|
||||
deepStrictEqual(await getOrigSize(1), TESTDATA_IIP[1][1]);
|
||||
deepStrictEqual(await getImageSize(1), TESTDATA_IIP[1][1]);
|
||||
});
|
||||
test('w3c gif', async () => {
|
||||
await ctx.proxy.write(TESTDATA_IIP[2][0]);
|
||||
deepStrictEqual(await getOrigSize(1), TESTDATA_IIP[2][1]);
|
||||
deepStrictEqual(await getImageSize(1), TESTDATA_IIP[2][1]);
|
||||
});
|
||||
test('w3c jpeg', async () => {
|
||||
await ctx.proxy.write(TESTDATA_IIP[3][0]);
|
||||
deepStrictEqual(await getOrigSize(1), TESTDATA_IIP[3][1]);
|
||||
deepStrictEqual(await getImageSize(1), TESTDATA_IIP[3][1]);
|
||||
});
|
||||
test('w3c png', async () => {
|
||||
await ctx.proxy.write(TESTDATA_IIP[4][0]);
|
||||
deepStrictEqual(await getOrigSize(1), TESTDATA_IIP[4][1]);
|
||||
deepStrictEqual(await getImageSize(1), TESTDATA_IIP[4][1]);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('IIP - QOI support', () => {
|
||||
test('palette should yield same bytes from PNG and QOI', async () => {
|
||||
await ctx.proxy.write(`\x1b]1337;File=inline=1;size=525:${PALETTE_PNG_BASE64}\x07`);
|
||||
deepStrictEqual(await getOrigSize(1), [640, 80]);
|
||||
deepStrictEqual(await getImageSize(1), [640, 80]);
|
||||
await ctx.proxy.write(`\x1b[10H\x1b]1337;File=inline=1;size=${qoiData.length}:${PALETTE_QOI_BASE64}\x07`);
|
||||
deepStrictEqual(await getOrigSize(2), [640, 80]);
|
||||
deepStrictEqual(await getImageSize(2), [640, 80]);
|
||||
const pngScrape = await getImageAtBufferCell(0, 0);
|
||||
const qoiScrape = await getImageAtBufferCell(0, 11);
|
||||
deepStrictEqual(qoiScrape, pngScrape);
|
||||
@@ -406,20 +406,20 @@ test.describe('ImageAddon', () => {
|
||||
const header = 'width=20;height=5;preserveAspectRatio=0';
|
||||
await ctx.proxy.write(`\x1b]1337;File=inline=1;size=${size};${header}:${payload}\x07`);
|
||||
const dim = await getDimensions();
|
||||
deepStrictEqual(await getOrigSize(1), [dim.cellWidth * 20, dim.cellHeight * 5]);
|
||||
deepStrictEqual(await getImageSize(1), [dim.cellWidth * 20, dim.cellHeight * 5]);
|
||||
});
|
||||
test(name + ': Npx --> width=320px height=160px preserveAspectRatio=0', async () => {
|
||||
// pixel based resize
|
||||
const header = 'width=320px;height=160px;preserveAspectRatio=0';
|
||||
await ctx.proxy.write(`\x1b]1337;File=inline=1;size=${size};${header}:${payload}\x07`);
|
||||
deepStrictEqual(await getOrigSize(1), [320, 160]);
|
||||
deepStrictEqual(await getImageSize(1), [320, 160]);
|
||||
});
|
||||
test(name + ': N% --> width=50% height=30% preserveAspectRatio=0', async () => {
|
||||
// % of viewport resize
|
||||
const header = 'width=50%;height=30%;preserveAspectRatio=0';
|
||||
await ctx.proxy.write(`\x1b]1337;File=inline=1;size=${size};${header}:${payload}\x07`);
|
||||
const dim = await getDimensions();
|
||||
deepStrictEqual(await getOrigSize(1), [Math.floor(dim.width * 0.5), Math.floor(dim.height * 0.3)]);
|
||||
deepStrictEqual(await getImageSize(1), [Math.floor(dim.width * 0.5), Math.floor(dim.height * 0.3)]);
|
||||
});
|
||||
test(name + ': ommitted dimension assumes preserveAspectRatio=1', async () => {
|
||||
// width provided in percent
|
||||
@@ -427,11 +427,11 @@ test.describe('ImageAddon', () => {
|
||||
await ctx.proxy.write(`\x1b]1337;File=inline=1;size=${size};${header}:${payload}\x07`);
|
||||
const dim = await getDimensions();
|
||||
const width = Math.floor(dim.width * 0.5);
|
||||
deepStrictEqual(await getOrigSize(1), [width, Math.floor(width * 80 / 640)]);
|
||||
deepStrictEqual(await getImageSize(1), [width, Math.floor(width * 80 / 640)]);
|
||||
// height provided in pixel
|
||||
const header2 = 'height=200px';
|
||||
await ctx.proxy.write(`\x1b]1337;File=inline=1;size=${size};${header2}:${payload}\x07`);
|
||||
deepStrictEqual(await getOrigSize(2), [Math.floor(200 * 640 / 80), 200]);
|
||||
deepStrictEqual(await getImageSize(2), [Math.floor(200 * 640 / 80), 200]);
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -452,10 +452,10 @@ test.describe('ImageAddon', () => {
|
||||
async function getDimensions(): Promise<IDimensions> {
|
||||
const dimensions: any = await ctx.page.evaluate(`term.dimensions`);
|
||||
return {
|
||||
cellWidth: Math.round(dimensions.css.cell.width),
|
||||
cellHeight: Math.round(dimensions.css.cell.height),
|
||||
width: Math.round(dimensions.css.canvas.width),
|
||||
height: Math.round(dimensions.css.canvas.height)
|
||||
cellWidth: Math.round(dimensions.device.cell.width),
|
||||
cellHeight: Math.round(dimensions.device.cell.height),
|
||||
width: Math.round(dimensions.device.canvas.width),
|
||||
height: Math.round(dimensions.device.canvas.height)
|
||||
};
|
||||
}
|
||||
|
||||
@@ -471,11 +471,11 @@ async function getScrollbackPlusRows(): Promise<number> {
|
||||
return ctx.page.evaluate('window.term.options.scrollback + window.term.rows');
|
||||
}
|
||||
|
||||
async function getOrigSize(id: number): Promise<[number, number]> {
|
||||
async function getImageSize(id: number): Promise<[number, number]> {
|
||||
return ctx.page.evaluate<any>(`[
|
||||
window.imageAddon._storage._images.get(${id}).orig.width,
|
||||
window.imageAddon._storage._images.get(${id}).orig.height
|
||||
]`);
|
||||
window.imageAddon._storage._images.get(${id}).src.width * window.imageAddon._storage._images.get(${id}).prescaleX,
|
||||
window.imageAddon._storage._images.get(${id}).src.height * window.imageAddon._storage._images.get(${id}).prescaleY
|
||||
]`).then(r => r.map(Math.floor));
|
||||
}
|
||||
|
||||
async function getImageAtBufferCell(x: number, y: number): Promise<string | undefined> {
|
||||
@@ -483,12 +483,12 @@ async function getImageAtBufferCell(x: number, y: number): Promise<string | unde
|
||||
}
|
||||
|
||||
async function hasTileAtBufferCell(x: number, y: number): Promise<boolean> {
|
||||
return ctx.page.evaluate(`!!window.imageAddon.extractTileAtBufferCell(${x}, ${y})`);
|
||||
return ctx.page.evaluate(`!!window.imageAddon.getImageAtBufferCell(${x}, ${y})`);
|
||||
}
|
||||
|
||||
async function assertTextClearsTiles(imageSeq: string): Promise<void> {
|
||||
await ctx.proxy.write('\x1b[H' + imageSeq);
|
||||
await pollFor(ctx.page, '!!window.imageAddon.extractTileAtBufferCell(5, 1)', true);
|
||||
await pollFor(ctx.page, '!!window.imageAddon.getImageAtBufferCell(5, 1)', true);
|
||||
ok(await hasTileAtBufferCell(0, 1));
|
||||
await ctx.proxy.write('\x1b[2;6H#######');
|
||||
for (let x = 5; x < 12; x++) {
|
||||
|
||||
@@ -123,7 +123,7 @@ test.describe('Kitty Graphics Protocol', () => {
|
||||
await ctx.proxy.write(seq);
|
||||
await timeout(100);
|
||||
strictEqual(await getImageStorageLength(), 1);
|
||||
deepStrictEqual(await getOrigSize(1), [1, 1]);
|
||||
deepStrictEqual(await getImageSize(1), [1, 1]);
|
||||
});
|
||||
|
||||
test('stores 3x1 RGB PNG with a=T', async () => {
|
||||
@@ -131,7 +131,7 @@ test.describe('Kitty Graphics Protocol', () => {
|
||||
await ctx.proxy.write(seq);
|
||||
await timeout(100);
|
||||
strictEqual(await getImageStorageLength(), 1);
|
||||
deepStrictEqual(await getOrigSize(1), [3, 1]);
|
||||
deepStrictEqual(await getImageSize(1), [3, 1]);
|
||||
});
|
||||
|
||||
test('transmit only (a=t) does not display but stores in handler', async () => {
|
||||
@@ -1107,7 +1107,7 @@ test.describe('Kitty Graphics Protocol', () => {
|
||||
await timeout(200);
|
||||
|
||||
strictEqual(await getImageStorageLength(), 1);
|
||||
deepStrictEqual(await getOrigSize(1), [20, 50]);
|
||||
deepStrictEqual(await getImageSize(1), [20, 50]);
|
||||
deepStrictEqual(await getPixel(0, 0, 0, 0), [255, 128, 0, 255]);
|
||||
});
|
||||
|
||||
@@ -1283,7 +1283,7 @@ test.describe('Kitty Graphics Protocol', () => {
|
||||
|
||||
const cursor = await getCursor();
|
||||
const cellDims: number[] = await ctx.page.evaluate(() => {
|
||||
const d = (window as any).term._core._renderService.dimensions.css.cell;
|
||||
const d = (window as any).term._core._renderService.dimensions.device.cell;
|
||||
return [d.width, d.height];
|
||||
});
|
||||
const expectedR = Math.ceil((100 / 200) * 10 * cellDims[0] / cellDims[1]);
|
||||
@@ -1301,7 +1301,7 @@ test.describe('Kitty Graphics Protocol', () => {
|
||||
|
||||
const cursor = await getCursor();
|
||||
const cellDims: number[] = await ctx.page.evaluate(() => {
|
||||
const d = (window as any).term._core._renderService.dimensions.css.cell;
|
||||
const d = (window as any).term._core._renderService.dimensions.device.cell;
|
||||
return [d.width, d.height];
|
||||
});
|
||||
const expectedC = Math.ceil((200 / 100) * 5 * cellDims[1] / cellDims[0]);
|
||||
@@ -1535,7 +1535,7 @@ test.describe('Kitty Graphics Protocol', () => {
|
||||
await ctx.proxy.write(`\x1b_Ga=T,f=100;${KITTY_MULTICOLOR_200X100_BASE64}\x1b\\`);
|
||||
await timeout(200);
|
||||
strictEqual(await getImageStorageLength(), 1);
|
||||
deepStrictEqual(await getOrigSize(1), [200, 100]);
|
||||
deepStrictEqual(await getImageSize(1), [200, 100]);
|
||||
});
|
||||
|
||||
test('transmit only (a=t) stores 200x100 image without display', async () => {
|
||||
@@ -1564,7 +1564,7 @@ test.describe('Kitty Graphics Protocol', () => {
|
||||
await ctx.proxy.write(`\x1b_Ga=T,f=100,i=500;${part2}\x1b\\`);
|
||||
await timeout(200);
|
||||
strictEqual(await getImageStorageLength(), 1);
|
||||
deepStrictEqual(await getOrigSize(1), [200, 100]);
|
||||
deepStrictEqual(await getImageSize(1), [200, 100]);
|
||||
});
|
||||
|
||||
test('handles 3-chunk transmission', async () => {
|
||||
@@ -1580,7 +1580,7 @@ test.describe('Kitty Graphics Protocol', () => {
|
||||
await ctx.proxy.write(`\x1b_Ga=T,f=100,i=501;${p3}\x1b\\`);
|
||||
await timeout(200);
|
||||
strictEqual(await getImageStorageLength(), 1);
|
||||
deepStrictEqual(await getOrigSize(1), [200, 100]);
|
||||
deepStrictEqual(await getImageSize(1), [200, 100]);
|
||||
});
|
||||
|
||||
test('verifies chunked data assembles correctly', async () => {
|
||||
@@ -1713,7 +1713,7 @@ test.describe('Kitty Graphics Protocol', () => {
|
||||
await ctx.proxy.write(`\x1b_Ga=T,f=100,x=20,y=0,w=20,h=50;${KITTY_MULTICOLOR_200X100_BASE64}\x1b\\`);
|
||||
await timeout(200);
|
||||
|
||||
deepStrictEqual(await getOrigSize(1), [20, 50]);
|
||||
deepStrictEqual(await getImageSize(1), [20, 50]);
|
||||
deepStrictEqual(await getPixel(0, 0, 0, 0), [255, 128, 0, 255]);
|
||||
deepStrictEqual(await getPixel(0, 0, 19, 49), [255, 128, 0, 255]);
|
||||
});
|
||||
@@ -1747,7 +1747,7 @@ test.describe('Kitty Graphics Protocol', () => {
|
||||
await ctx.proxy.write(`\x1b_Ga=T,f=100,w=0;${KITTY_MULTICOLOR_200X100_BASE64}\x1b\\`);
|
||||
await timeout(200);
|
||||
|
||||
deepStrictEqual(await getOrigSize(1), [200, 100]);
|
||||
deepStrictEqual(await getImageSize(1), [200, 100]);
|
||||
deepStrictEqual(await getPixel(0, 0, 0, 0), [255, 0, 0, 255]);
|
||||
deepStrictEqual(await getPixel(0, 0, 199, 99), [255, 255, 255, 255]);
|
||||
});
|
||||
@@ -1756,7 +1756,7 @@ test.describe('Kitty Graphics Protocol', () => {
|
||||
await ctx.proxy.write(`\x1b_Ga=T,f=100,h=0;${KITTY_MULTICOLOR_200X100_BASE64}\x1b\\`);
|
||||
await timeout(200);
|
||||
|
||||
deepStrictEqual(await getOrigSize(1), [200, 100]);
|
||||
deepStrictEqual(await getImageSize(1), [200, 100]);
|
||||
deepStrictEqual(await getPixel(0, 0, 0, 0), [255, 0, 0, 255]);
|
||||
deepStrictEqual(await getPixel(0, 0, 0, 50), [255, 192, 203, 255]);
|
||||
});
|
||||
@@ -1772,7 +1772,7 @@ test.describe('Kitty Graphics Protocol', () => {
|
||||
await ctx.proxy.write(`\x1b_Ga=T,f=100,x=-10,y=-10;${KITTY_MULTICOLOR_200X100_BASE64}\x1b\\`);
|
||||
await timeout(200);
|
||||
|
||||
deepStrictEqual(await getOrigSize(1), [200, 100]);
|
||||
deepStrictEqual(await getImageSize(1), [200, 100]);
|
||||
deepStrictEqual(await getPixel(0, 0, 0, 0), [255, 0, 0, 255]);
|
||||
});
|
||||
|
||||
@@ -1893,28 +1893,28 @@ test.describe('Kitty Graphics Protocol', () => {
|
||||
await ctx.proxy.write(`\x1b_Ga=T,f=24,s=3,v=1;${RAW_RGB_3X1}\x1b\\`);
|
||||
await timeout(100);
|
||||
strictEqual(await getImageStorageLength(), 1);
|
||||
deepStrictEqual(await getOrigSize(1), [3, 1]);
|
||||
deepStrictEqual(await getImageSize(1), [3, 1]);
|
||||
});
|
||||
|
||||
test('stores image with correct original dimensions (2x2)', async () => {
|
||||
await ctx.proxy.write(`\x1b_Ga=T,f=24,s=2,v=2;${RAW_RGB_2X2}\x1b\\`);
|
||||
await timeout(100);
|
||||
strictEqual(await getImageStorageLength(), 1);
|
||||
deepStrictEqual(await getOrigSize(1), [2, 2]);
|
||||
deepStrictEqual(await getImageSize(1), [2, 2]);
|
||||
});
|
||||
|
||||
test('stores image with correct original dimensions (5x1)', async () => {
|
||||
await ctx.proxy.write(`\x1b_Ga=T,f=24,s=5,v=1;${RAW_RGB_5X1}\x1b\\`);
|
||||
await timeout(100);
|
||||
strictEqual(await getImageStorageLength(), 1);
|
||||
deepStrictEqual(await getOrigSize(1), [5, 1]);
|
||||
deepStrictEqual(await getImageSize(1), [5, 1]);
|
||||
});
|
||||
|
||||
test('stores image with correct original dimensions (4x2)', async () => {
|
||||
await ctx.proxy.write(`\x1b_Ga=T,f=24,s=4,v=2;${RAW_RGB_4X2}\x1b\\`);
|
||||
await timeout(100);
|
||||
strictEqual(await getImageStorageLength(), 1);
|
||||
deepStrictEqual(await getOrigSize(1), [4, 2]);
|
||||
deepStrictEqual(await getImageSize(1), [4, 2]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2034,21 +2034,21 @@ test.describe('Kitty Graphics Protocol', () => {
|
||||
await ctx.proxy.write(`\x1b_Ga=T,f=32,s=3,v=1;${RAW_RGBA_3X1}\x1b\\`);
|
||||
await timeout(100);
|
||||
strictEqual(await getImageStorageLength(), 1);
|
||||
deepStrictEqual(await getOrigSize(1), [3, 1]);
|
||||
deepStrictEqual(await getImageSize(1), [3, 1]);
|
||||
});
|
||||
|
||||
test('stores image with correct original dimensions (2x2)', async () => {
|
||||
await ctx.proxy.write(`\x1b_Ga=T,f=32,s=2,v=2;${RAW_RGBA_2X2}\x1b\\`);
|
||||
await timeout(100);
|
||||
strictEqual(await getImageStorageLength(), 1);
|
||||
deepStrictEqual(await getOrigSize(1), [2, 2]);
|
||||
deepStrictEqual(await getImageSize(1), [2, 2]);
|
||||
});
|
||||
|
||||
test('stores image with correct original dimensions (5x1)', async () => {
|
||||
await ctx.proxy.write(`\x1b_Ga=T,f=32,s=5,v=1;${RAW_RGBA_5X1}\x1b\\`);
|
||||
await timeout(100);
|
||||
strictEqual(await getImageStorageLength(), 1);
|
||||
deepStrictEqual(await getOrigSize(1), [5, 1]);
|
||||
deepStrictEqual(await getImageSize(1), [5, 1]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2217,7 +2217,7 @@ test.describe('Kitty Graphics Protocol', () => {
|
||||
test.describe('text overwrite removes tiles', () => {
|
||||
test('a=T placement', async () => {
|
||||
await ctx.proxy.write(`\x1b[H\x1b_Ga=T,f=100;${KITTY_MULTICOLOR_200X100_BASE64}\x1b\\`);
|
||||
await pollFor(ctx.page, '!!window.imageAddon.extractTileAtBufferCell(5, 1)', true);
|
||||
await pollFor(ctx.page, '!!window.imageAddon.getImageAtBufferCell(5, 1)', true);
|
||||
ok(await hasTileAtBufferCell(0, 1));
|
||||
await ctx.proxy.write('\x1b[2;6H#######');
|
||||
for (let x = 5; x < 12; x++) {
|
||||
@@ -2236,10 +2236,10 @@ test.describe('Kitty Graphics Protocol', () => {
|
||||
async function getDimensions(): Promise<IDimensions> {
|
||||
const dimensions: any = await ctx.page.evaluate(`term.dimensions`);
|
||||
return {
|
||||
cellWidth: Math.round(dimensions.css.cell.width),
|
||||
cellHeight: Math.round(dimensions.css.cell.height),
|
||||
width: Math.round(dimensions.css.canvas.width),
|
||||
height: Math.round(dimensions.css.canvas.height)
|
||||
cellWidth: Math.round(dimensions.device.cell.width),
|
||||
cellHeight: Math.round(dimensions.device.cell.height),
|
||||
width: Math.round(dimensions.device.canvas.width),
|
||||
height: Math.round(dimensions.device.canvas.height)
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2251,10 +2251,10 @@ async function getImageStorageLength(): Promise<number> {
|
||||
return ctx.page.evaluate('window.imageAddon._storage._images.size');
|
||||
}
|
||||
|
||||
async function getOrigSize(id: number): Promise<[number, number]> {
|
||||
async function getImageSize(id: number): Promise<[number, number]> {
|
||||
return ctx.page.evaluate<any>(`[
|
||||
window.imageAddon._storage._images.get(${id}).orig.width,
|
||||
window.imageAddon._storage._images.get(${id}).orig.height
|
||||
window.imageAddon._storage._images.get(${id}).src.width,
|
||||
window.imageAddon._storage._images.get(${id}).src.height
|
||||
]`);
|
||||
}
|
||||
|
||||
@@ -2279,5 +2279,5 @@ async function getPixels(col: number, row: number, x: number, y: number, w: numb
|
||||
}
|
||||
|
||||
async function hasTileAtBufferCell(x: number, y: number): Promise<boolean> {
|
||||
return ctx.page.evaluate(`!!window.imageAddon.extractTileAtBufferCell(${x}, ${y})`);
|
||||
return ctx.page.evaluate(`!!window.imageAddon.getImageAtBufferCell(${x}, ${y})`);
|
||||
}
|
||||
|
||||
-5
@@ -125,10 +125,5 @@ declare module '@xterm/addon-image' {
|
||||
* Get original image canvas at buffer position.
|
||||
*/
|
||||
public getImageAtBufferCell(x: number, y: number): HTMLCanvasElement | undefined;
|
||||
|
||||
/**
|
||||
* Extract single tile canvas at buffer position.
|
||||
*/
|
||||
public extractTileAtBufferCell(x: number, y: number): HTMLCanvasElement | undefined;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -203,11 +203,8 @@ export class AddonImageWindow extends BaseWindow implements IControlWindow {
|
||||
const pos = (this._terminal as any)._core._mouseCoordsService!.getCoords(ev, (this._terminal as any)._core.screenElement!, this._terminal.cols, this._terminal.rows);
|
||||
const x = pos[0] - 1;
|
||||
const y = pos[1] - 1;
|
||||
const canvas = ev.shiftKey
|
||||
// ctrl+shift+click: get single tile
|
||||
? imageAddon.extractTileAtBufferCell(x, this._terminal.buffer.active.viewportY + y)
|
||||
// ctrl+click: get original image
|
||||
: imageAddon.getImageAtBufferCell(x, this._terminal.buffer.active.viewportY + y);
|
||||
// ctrl+click: get original image
|
||||
const canvas = imageAddon.getImageAtBufferCell(x, this._terminal.buffer.active.viewportY + y);
|
||||
canvas?.toBlob(data => data && window.open(URL.createObjectURL(data), '_blank'));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1137,13 +1137,13 @@ export class CoreBrowserTerminal extends CoreTerminal implements ITerminal {
|
||||
|
||||
switch (type) {
|
||||
case WindowsOptionsReportType.GET_WIN_SIZE_PIXELS:
|
||||
const canvasWidth = this._renderService.dimensions.css.canvas.width.toFixed(0);
|
||||
const canvasHeight = this._renderService.dimensions.css.canvas.height.toFixed(0);
|
||||
const canvasWidth = this._renderService.dimensions.device.canvas.width.toFixed(0);
|
||||
const canvasHeight = this._renderService.dimensions.device.canvas.height.toFixed(0);
|
||||
this.coreService.triggerDataEvent(`${C0.ESC}[4;${canvasHeight};${canvasWidth}t`);
|
||||
break;
|
||||
case WindowsOptionsReportType.GET_CELL_SIZE_PIXELS:
|
||||
const cellWidth = this._renderService.dimensions.css.cell.width.toFixed(0);
|
||||
const cellHeight = this._renderService.dimensions.css.cell.height.toFixed(0);
|
||||
const cellWidth = this._renderService.dimensions.device.cell.width.toFixed(0);
|
||||
const cellHeight = this._renderService.dimensions.device.cell.height.toFixed(0);
|
||||
this.coreService.triggerDataEvent(`${C0.ESC}[6;${cellHeight};${cellWidth}t`);
|
||||
break;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user