389 lines
14 KiB
TypeScript
389 lines
14 KiB
TypeScript
/**
|
|
* Copyright (c) 2020 The xterm.js authors. All rights reserved.
|
|
* @license MIT
|
|
*/
|
|
|
|
import type { ITerminalAddon, IDisposable } from '@xterm/xterm';
|
|
import type { ImageAddon as IImageApi } from '@xterm/addon-image';
|
|
import { Emitter, type IEvent } from 'common/Event';
|
|
import { IIPHandler } from './IIPHandler';
|
|
import { ImageRenderer } from './ImageRenderer';
|
|
import { ImageStorage, CELL_SIZE_DEFAULT } from './ImageStorage';
|
|
import { KittyGraphicsHandler } from './kitty/KittyGraphicsHandler';
|
|
import { KittyImageStorage } from './kitty/KittyImageStorage';
|
|
import { SixelHandler } from './SixelHandler';
|
|
import { SixelImageStorage } from './SixelImageStorage';
|
|
import { IIPImageStorage } from './IIPImageStorage';
|
|
import { ITerminalExt, IImageAddonOptions, IResetHandler } from './Types';
|
|
import { AnimationManager } from './AnimationManager';
|
|
|
|
|
|
/**
|
|
* Document VT features provided by this addon.
|
|
*
|
|
* @vt: #E[Supported via @xterm/addon-image.] DCS SIXEL "SIXEL Graphics" "DCS Ps ; Ps ; Ps ; q Pt ST" "Draw SIXEL image."
|
|
*
|
|
* Sixel support is provided by the addon @xterm/addon-image with these limitations:
|
|
* - immediate coloring (no shared palette, allows high color settings of `img2sixel`)
|
|
* - max. palette size of 4096 colors
|
|
* - max. pixel width of 16K
|
|
* - max. 25 MB per sixel sequence
|
|
* - VT340 cursor positioning (begin of last sixel data row)
|
|
*
|
|
* See [addon readme](https://github.com/xtermjs/xterm.js/tree/master/addons/addon-image) for more details.
|
|
*
|
|
*
|
|
* @vt: #E[Supported via @xterm/addon-image.] OSC 1337 "iTerm2 Commands" "OSC 1337 ; Pt BEL" "Custom iTerm2 commands."
|
|
*
|
|
* Only the inline image protocol (IIP) is supported by the addon @xterm/addon-image with
|
|
* the following limitations:
|
|
* - sequence:
|
|
* - format: `OSC 1337 ; File=inline=1 ; size=<unencoded size> ; ... : <base64 payload> BEL`
|
|
* - size param must be set and payload may not exceed CEIL(size * 4 / 3)
|
|
* - strict base64 handling as of RFC4648 §4 (standard alphabet, optional padding,
|
|
* no separator bytes allowed)
|
|
* - supported params: size, name, width, height, preserveAspectRatio
|
|
* - image formats: PNG, JPEG and GIF
|
|
* - no animation support (renders first image of a GIF)
|
|
* - no multipart support
|
|
* - VT340 cursor positioning (begin of last sixel data row)
|
|
*
|
|
* See [addon readme](https://github.com/xtermjs/xterm.js/tree/master/addons/addon-image)
|
|
* and [iTerm2 IIP docs](https://iterm2.com/documentation-images.html) for more details.
|
|
*
|
|
*
|
|
* @vt: #E[Supported via @xterm/addon-image.] APC KITTY_GRAPHICS "Kitty Graphics" "APC G Pt ST" "Kitty Graphics Protocol."
|
|
*
|
|
* Kitty graphics support is provided by the addon @xterm/addon-image.
|
|
* Note that while basic image output already works, this is still work in progress.
|
|
*/
|
|
|
|
// default values of addon ctor options
|
|
const DEFAULT_OPTIONS: IImageAddonOptions = {
|
|
enableSizeReports: true,
|
|
pixelLimit: 16777216, // limit to 4096 * 4096 pixels
|
|
sixelSupport: true,
|
|
sixelScrolling: true,
|
|
sixelPaletteLimit: 4096,
|
|
sixelSizeLimit: 33554432,
|
|
storageLimit: 128,
|
|
showPlaceholder: true,
|
|
iipSupport: true,
|
|
iipSizeLimit: 33554432,
|
|
kittySupport: true,
|
|
kittySizeLimit: 33554432,
|
|
animationSupport: true, // alpha
|
|
animationPixelLimit: 4194304 // limit to 2048 * 2048 pixels
|
|
};
|
|
|
|
// max palette size supported by the sixel lib (compile time setting)
|
|
const MAX_SIXEL_PALETTE_SIZE = 4096;
|
|
|
|
// definitions for _xtermGraphicsAttributes sequence
|
|
const enum GaItem {
|
|
COLORS = 1,
|
|
SIXEL_GEO = 2,
|
|
REGIS_GEO = 3
|
|
}
|
|
const enum GaAction {
|
|
READ = 1,
|
|
SET_DEFAULT = 2,
|
|
SET = 3,
|
|
READ_MAX = 4
|
|
}
|
|
const enum GaStatus {
|
|
SUCCESS = 0,
|
|
ITEM_ERROR = 1,
|
|
ACTION_ERROR = 2,
|
|
FAILURE = 3
|
|
}
|
|
|
|
|
|
export class ImageAddon implements ITerminalAddon, IImageApi {
|
|
private _opts: IImageAddonOptions;
|
|
private _defaultOpts: IImageAddonOptions;
|
|
private _storage: ImageStorage | undefined;
|
|
private _renderer: ImageRenderer | undefined;
|
|
private _disposables: IDisposable[] = [];
|
|
private _terminal: ITerminalExt | undefined;
|
|
private _handlers: Map<String, IResetHandler> = new Map();
|
|
private _aniManager: AnimationManager | undefined;
|
|
private readonly _onImageAdded = new Emitter<void>();
|
|
public readonly onImageAdded: IEvent<void> = this._onImageAdded.event;
|
|
|
|
constructor(opts?: Partial<IImageAddonOptions>) {
|
|
this._opts = Object.assign({}, DEFAULT_OPTIONS, opts);
|
|
this._defaultOpts = Object.assign({}, DEFAULT_OPTIONS, opts);
|
|
}
|
|
|
|
public dispose(): void {
|
|
for (const obj of this._disposables) {
|
|
obj.dispose();
|
|
}
|
|
this._disposables.length = 0;
|
|
this._handlers.clear();
|
|
this._onImageAdded.dispose();
|
|
}
|
|
|
|
private _disposeLater(...args: IDisposable[]): void {
|
|
for (const obj of args) {
|
|
this._disposables.push(obj);
|
|
}
|
|
}
|
|
|
|
public activate(terminal: ITerminalExt): void {
|
|
this._terminal = terminal;
|
|
|
|
// internal data structures
|
|
this._renderer = new ImageRenderer(terminal);
|
|
this._storage = new ImageStorage(terminal, this._renderer, this._opts);
|
|
this._storage.onImageAdded = () => this._onImageAdded.fire();
|
|
|
|
// enable size reports
|
|
if (this._opts.enableSizeReports) {
|
|
const windowOps = terminal.options.windowOptions ?? {};
|
|
windowOps.getWinSizePixels = true;
|
|
windowOps.getCellSizePixels = true;
|
|
windowOps.getWinSizeChars = true;
|
|
terminal.options.windowOptions = windowOps;
|
|
}
|
|
|
|
if (this._opts.animationSupport && typeof ImageDecoder === 'function') {
|
|
this._aniManager = new AnimationManager(this._storage, this._renderer, terminal);
|
|
this._disposeLater(terminal.onRender(range => this._aniManager!.viewportUpdated(range)));
|
|
}
|
|
|
|
this._disposeLater(
|
|
this._renderer,
|
|
this._storage,
|
|
|
|
// DECSET/DECRST/DA1/XTSMGRAPHICS handlers
|
|
terminal.parser.registerCsiHandler({ prefix: '?', final: 'h' }, params => this._decset(params)),
|
|
terminal.parser.registerCsiHandler({ prefix: '?', final: 'l' }, params => this._decrst(params)),
|
|
terminal.parser.registerCsiHandler({ final: 'c' }, params => this._da1(params)),
|
|
terminal.parser.registerCsiHandler({ prefix: '?', final: 'S' }, params => this._xtermGraphicsAttributes(params)),
|
|
|
|
// render hook
|
|
terminal.onRender(range => this._storage?.render(range)),
|
|
|
|
/**
|
|
* reset handlers covered:
|
|
* - DECSTR
|
|
* - RIS
|
|
* - Terminal.reset()
|
|
*/
|
|
terminal.parser.registerCsiHandler({ intermediates: '!', final: 'p' }, () => this.reset()),
|
|
terminal.parser.registerEscHandler({ final: 'c' }, () => this.reset()),
|
|
terminal._core._inputHandler.onRequestReset(() => this.reset()),
|
|
|
|
// wipe canvas and delete alternate images on buffer switch
|
|
terminal.buffer.onBufferChange(() => this._storage?.wipeAlternate()),
|
|
|
|
// extend images to the right on resize
|
|
terminal.onResize(metrics => this._storage?.viewportResize(metrics))
|
|
);
|
|
|
|
// SIXEL handler
|
|
if (this._opts.sixelSupport) {
|
|
const sixelStorage = new SixelImageStorage(this._storage!, this._opts, this._renderer!, terminal);
|
|
const sixelHandler = new SixelHandler(this._opts, sixelStorage, terminal);
|
|
this._handlers.set('sixel', sixelHandler);
|
|
this._disposeLater(
|
|
terminal._core._inputHandler._parser.registerDcsHandler({ final: 'q' }, sixelHandler)
|
|
);
|
|
}
|
|
|
|
// iTerm IIP handler
|
|
if (this._opts.iipSupport) {
|
|
const iipStorage = new IIPImageStorage(this._storage!);
|
|
const iipHandler = new IIPHandler(this._opts, this._renderer!, iipStorage, terminal, this._aniManager);
|
|
this._handlers.set('iip', iipHandler);
|
|
this._disposeLater(
|
|
terminal._core._inputHandler._parser.registerOscHandler(1337, iipHandler)
|
|
);
|
|
}
|
|
|
|
// Kitty graphics handler
|
|
if (this._opts.kittySupport) {
|
|
const kittyStorage = new KittyImageStorage(this._storage!);
|
|
const kittyHandler = new KittyGraphicsHandler(this._opts, this._renderer!, kittyStorage, terminal);
|
|
this._handlers.set('kitty', kittyHandler);
|
|
this._disposeLater(
|
|
kittyStorage,
|
|
kittyHandler,
|
|
terminal._core._inputHandler._parser.registerApcHandler({ final: 'G' }, kittyHandler)
|
|
);
|
|
}
|
|
}
|
|
|
|
// Note: storageLimit is skipped here to not intoduce a surprising side effect.
|
|
public reset(): boolean {
|
|
// reset options customizable by sequences to defaults
|
|
this._opts.sixelScrolling = this._defaultOpts.sixelScrolling;
|
|
this._opts.sixelPaletteLimit = this._defaultOpts.sixelPaletteLimit;
|
|
// also clear image storage
|
|
this._storage?.reset();
|
|
// reset protocol handlers
|
|
for (const handler of this._handlers.values()) {
|
|
handler.reset();
|
|
}
|
|
this._aniManager?.reset();
|
|
return false;
|
|
}
|
|
|
|
public get storageLimit(): number {
|
|
return this._storage?.getLimit() || -1;
|
|
}
|
|
|
|
public set storageLimit(limit: number) {
|
|
this._storage?.setLimit(limit);
|
|
this._opts.storageLimit = limit;
|
|
}
|
|
|
|
public get storageUsage(): number {
|
|
if (this._storage) {
|
|
return this._storage.getUsage();
|
|
}
|
|
return -1;
|
|
}
|
|
|
|
public get showPlaceholder(): boolean {
|
|
return this._opts.showPlaceholder;
|
|
}
|
|
|
|
public set showPlaceholder(value: boolean) {
|
|
this._opts.showPlaceholder = value;
|
|
this._renderer?.showPlaceholder(value);
|
|
}
|
|
|
|
public getImageAtBufferCell(x: number, y: number): HTMLCanvasElement | undefined {
|
|
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);
|
|
}
|
|
|
|
private _decset(params: (number | number[])[]): boolean {
|
|
for (let i = 0; i < params.length; ++i) {
|
|
switch (params[i]) {
|
|
case 80:
|
|
this._opts.sixelScrolling = false;
|
|
break;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
private _decrst(params: (number | number[])[]): boolean {
|
|
for (let i = 0; i < params.length; ++i) {
|
|
switch (params[i]) {
|
|
case 80:
|
|
this._opts.sixelScrolling = true;
|
|
break;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
// overload DA to return something more appropriate
|
|
private _da1(params: (number | number[])[]): boolean {
|
|
if (params[0]) {
|
|
return true;
|
|
}
|
|
// reported features:
|
|
// 62 - VT220
|
|
// 4 - SIXEL support
|
|
// 9 - charsets
|
|
// 22 - ANSI colors
|
|
if (this._opts.sixelSupport) {
|
|
this._report(`\x1b[?62;4;9;22c`);
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Implementation of xterm's graphics attribute sequence.
|
|
*
|
|
* Supported features:
|
|
* - read/change palette limits (max 4096 by sixel lib)
|
|
* - read SIXEL canvas geometry (reports current window canvas or
|
|
* squared pixelLimit if canvas > pixel limit)
|
|
*
|
|
* Everything else is deactivated.
|
|
*/
|
|
private _xtermGraphicsAttributes(params: (number | number[])[]): boolean {
|
|
if (params.length < 2) {
|
|
return true;
|
|
}
|
|
if (params[0] === GaItem.COLORS) {
|
|
switch (params[1]) {
|
|
case GaAction.READ:
|
|
this._report(`\x1b[?${params[0]};${GaStatus.SUCCESS};${this._opts.sixelPaletteLimit}S`);
|
|
return true;
|
|
case GaAction.SET_DEFAULT:
|
|
this._opts.sixelPaletteLimit = this._defaultOpts.sixelPaletteLimit;
|
|
this._report(`\x1b[?${params[0]};${GaStatus.SUCCESS};${this._opts.sixelPaletteLimit}S`);
|
|
// also reset protocol handlers for now
|
|
for (const handler of this._handlers.values()) {
|
|
handler.reset();
|
|
}
|
|
return true;
|
|
case GaAction.SET:
|
|
if (params.length > 2 && !(params[2] instanceof Array) && params[2] <= MAX_SIXEL_PALETTE_SIZE) {
|
|
this._opts.sixelPaletteLimit = params[2];
|
|
this._report(`\x1b[?${params[0]};${GaStatus.SUCCESS};${this._opts.sixelPaletteLimit}S`);
|
|
} else {
|
|
this._report(`\x1b[?${params[0]};${GaStatus.ACTION_ERROR}S`);
|
|
}
|
|
return true;
|
|
case GaAction.READ_MAX:
|
|
this._report(`\x1b[?${params[0]};${GaStatus.SUCCESS};${MAX_SIXEL_PALETTE_SIZE}S`);
|
|
return true;
|
|
default:
|
|
this._report(`\x1b[?${params[0]};${GaStatus.ACTION_ERROR}S`);
|
|
return true;
|
|
}
|
|
}
|
|
if (params[0] === GaItem.SIXEL_GEO) {
|
|
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;
|
|
if (!width || !height) {
|
|
// for some reason we have no working image renderer
|
|
// --> fallback to default cell size
|
|
const cellSize = CELL_SIZE_DEFAULT;
|
|
width = (this._terminal?.cols || 80) * cellSize.width;
|
|
height = (this._terminal?.rows || 24) * cellSize.height;
|
|
}
|
|
if (width * height < this._opts.pixelLimit) {
|
|
this._report(`\x1b[?${params[0]};${GaStatus.SUCCESS};${width.toFixed(0)};${height.toFixed(0)}S`);
|
|
} else {
|
|
// if we overflow pixelLimit report that squared instead
|
|
const x = Math.floor(Math.sqrt(this._opts.pixelLimit));
|
|
this._report(`\x1b[?${params[0]};${GaStatus.SUCCESS};${x};${x}S`);
|
|
}
|
|
return true;
|
|
case GaAction.READ_MAX:
|
|
// read_max returns pixelLimit as square area
|
|
const x = Math.floor(Math.sqrt(this._opts.pixelLimit));
|
|
this._report(`\x1b[?${params[0]};${GaStatus.SUCCESS};${x};${x}S`);
|
|
return true;
|
|
default:
|
|
this._report(`\x1b[?${params[0]};${GaStatus.ACTION_ERROR}S`);
|
|
return true;
|
|
}
|
|
}
|
|
// exit with error on ReGIS or any other requests
|
|
this._report(`\x1b[?${params[0]};${GaStatus.ITEM_ERROR}S`);
|
|
return true;
|
|
}
|
|
}
|