ascii-ui
Version:
Graphic terminal emulator for HTML canvas elements
574 lines • 20.3 kB
JavaScript
import { isArray } from 'vanilla-type-check/isArray';
import { EventManager } from './EventManager';
import { FocusManager } from './FocusManager';
import { TerminalEvent } from './TerminalEvent';
import { assignCharStyle } from './util/assignCharStyle';
import { clamp } from './util/clamp';
import { deepAssign } from './util/deepAssign';
import { deepAssignAndDiff } from './util/deepAssignAndDiff';
import { emptyArray } from './util/emptyArray';
import { requestAnimationFrame } from './util/requestAnimationFrame';
import { isWidgetContainer } from './WidgetContainer';
export class Terminal {
constructor(canvas, options) {
this.options = {};
this.buffer = [];
this.dirtyTiles = [];
this.decayTiles = {};
this.cursorX = 0;
this.cursorY = 0;
this.cursorVisible = true;
this.lastRenderTime = 0;
this.attachedWidgets = [];
this.focusManager = new FocusManager(this, canvas);
this.eventManager = new EventManager(this);
this.canvas = canvas;
this.ctx = canvas.getContext('2d');
this.setOptions(deepAssign({}, Terminal.defaultOptions, options));
if (this.options.autoSize) {
this.canvas.width = this.options.columns * this.options.tileWidth;
this.canvas.height = this.options.rows * this.options.tileHeight;
}
this.clear();
}
setOptions(options) {
const oldColumns = this.options.columns;
const oldRows = this.options.rows;
const changed = deepAssignAndDiff(this.options, options);
if (changed.columns <= 0 || changed.rows <= 0) {
this.options.columns = oldColumns;
this.options.rows = oldRows;
}
this.options.columns = clamp(this.options.columns, this.options.minColumns, this.options.maxColumns);
this.options.rows = clamp(this.options.rows, this.options.minRows, this.options.maxRows);
this.decayChange = this.options.decayInitialAlpha / this.options.decayTime;
if (changed.commands) {
const commandList = this.options.commands && Object.keys(this.options.commands);
this.escapeCharactersRegExpString = commandList ? `(${commandList.join(')|(')})` : undefined;
}
this.setCursorFrequency(this.options.cursorFrequency);
if (!this.options.cursor && this.buffer.length > 0 && this.cursorVisible) {
this.cursorVisible = false;
this.addDirtyTile(this.buffer[this.cursorY][this.cursorX], this.dirtyTiles);
if (this.options.autoRender) {
this.render();
}
}
if (this.options.columns !== oldColumns || this.options.rows !== oldRows) {
this.resize(this.options.columns, this.options.rows, oldColumns || 0, oldRows || 0);
}
}
clear(col, line, width, height) {
const start = window.performance.now();
const dirtyTiles = this.dirtyTiles;
const buffer = this.buffer;
const options = this.options;
const x0 = col === undefined ? 0 : col;
const y0 = line === undefined ? 0 : line;
const w = width === undefined ? options.columns - x0 : width;
const h = height === undefined ? options.rows - y0 : height;
if (x0 === undefined) {
dirtyTiles.splice(0, dirtyTiles.length);
}
this.setTextStyle(options.clearStyle);
const y1 = y0 + h;
for (let y = y0; y < y1; y++) {
const x1 = x0 + w;
for (let x = x0; x < x1; x++) {
const tile = {
x: x * options.tileWidth,
y: y * options.tileHeight,
offsetX: options.offsetX,
offsetY: options.offsetY,
char: '',
bg: options.bg,
fg: options.fg,
font: options.font,
image: undefined,
dstW: 0,
dstH: 0,
};
buffer[y][x] = tile;
this.addDirtyTile(tile, dirtyTiles);
}
}
this.info(`clear: ${w * h} tiles: ${window.performance.now() - start} ms.`);
if (this.options.autoRender) {
this.render();
}
}
render() {
if (this.dirtyTiles.length === 0) {
return;
}
const start = window.performance.now();
const ctx = this.ctx;
const w = this.options.tileWidth;
const h = this.options.tileHeight;
const nTiles = this.dirtyTiles.length;
const cursorX = this.cursorX * w;
const cursorY = this.cursorY * h;
const drawCursor = this.options.cursor && this.cursorVisible;
const originalAlpha = ctx.globalAlpha;
const tilesToRedraw = [];
const decayChange = this.decayChange * (start - this.lastRenderTime);
ctx.textBaseline = 'bottom';
for (const tile of this.dirtyTiles) {
let x = tile.x;
let y = tile.y;
const isCursor = drawCursor && x === cursorX && y === cursorY;
ctx.fillStyle = isCursor ? tile.fg : tile.bg;
ctx.fillRect(x, y, w, h);
if (tile.image) {
if (tile.offset) {
x += tile.offset.x;
y += tile.offset.y;
}
if (tile.crop) {
ctx.drawImage(tile.image, tile.crop.srcX, tile.crop.srcY, tile.crop.srcW, tile.crop.srcH, x, y, tile.dstW, tile.dstH);
}
else {
ctx.drawImage(tile.image, x, y, tile.dstW, tile.dstH);
}
}
else {
const offsetX = tile.offsetX;
const offsetY = tile.offsetY;
const decayKey = `${x},${y}`;
const decayTile = this.decayTiles[decayKey];
ctx.font = tile.font;
if (isCursor) {
ctx.fillStyle = tile.bg;
ctx.fillText(tile.char, x + offsetX, y + h + offsetY);
}
else {
if (decayTile) {
if (decayTile.alpha > decayChange) {
decayTile.alpha -= decayChange;
ctx.fillStyle = decayTile.fg;
ctx.font = decayTile.font;
ctx.globalAlpha = decayTile.alpha;
ctx.fillText(decayTile.char, x + decayTile.offsetX, y + h + decayTile.offsetY);
ctx.globalAlpha = originalAlpha;
ctx.font = tile.font;
}
else {
this.decayTiles[decayKey] = undefined;
}
this.addDirtyTile(tile, tilesToRedraw);
}
ctx.fillStyle = tile.fg;
ctx.fillText(tile.char, x + offsetX, y + h + offsetY);
}
}
}
this.info(`render: ${nTiles} tiles: ${this.lastRenderTime - start} ms.`);
this.lastRenderTime = start;
this.dirtyTiles = tilesToRedraw;
if (tilesToRedraw.length > 0 && this.options.autoRender) {
requestAnimationFrame(this.render.bind(this));
}
}
renderAll() {
this.dirtyTiles = emptyArray(this.dirtyTiles);
for (let y = 0; y < this.options.rows; y++) {
for (let x = 0; x < this.options.columns; x++) {
this.dirtyTiles.push(this.buffer[y][x]);
}
}
this.render();
}
getViewport() {
return Object.assign({}, this.options.viewport);
}
getSize() {
return {
columns: this.options.columns,
rows: this.options.rows,
};
}
isCursorEnabled() {
return this.options.cursor;
}
getCursor() {
return {
col: this.cursorX,
line: this.cursorY,
};
}
setCursor(col, line) {
const oldTile = this.buffer[this.cursorY][this.cursorX];
let x = col;
let y = line;
if (x >= this.options.columns && y < this.options.rows - 1) {
x = 0;
y++;
}
else if (x < 0 && y > 0) {
x = this.options.columns - 1;
y--;
}
else {
x = Math.max(0, Math.min(x, this.options.columns - 1));
}
if (y >= this.options.rows) {
y = this.options.rows - 1;
}
else if (y < 0) {
y = 0;
}
this.cursorX = x;
this.cursorY = y;
const newTile = this.buffer[y][x];
if (oldTile !== newTile) {
this.addDirtyTile(oldTile, this.dirtyTiles);
this.addDirtyTile(newTile, this.dirtyTiles);
this.cursorVisible = true;
if (this.options.autoRender) {
this.render();
}
}
}
moveCursor(dx, dy) {
this.setCursor(this.cursorX + dx, this.cursorY + dy);
}
getTilePosition(x, y) {
return {
col: Math.floor(x / this.options.tileWidth),
line: Math.floor(y / this.options.tileHeight),
};
}
setTextStyle(style) {
assignCharStyle(this.options, style);
}
getTextStyle() {
const ctx = this.ctx;
return {
font: ctx.font,
offsetX: this.options.offsetX,
offsetY: this.options.offsetY,
fg: this.options.fg,
bg: this.options.bg,
};
}
setText(text, col, line) {
this.info(`setText: ${text}`);
const dirtyTiles = this.dirtyTiles;
const decayTiles = this.decayTiles;
const decayEnabled = !!this.decayChange;
const options = this.options;
const addDirtyTile = this.addDirtyTile.bind(this);
let textOffset = 0;
let regExp;
let match;
const autoRender = this.options.autoRender;
this.options.autoRender = false;
if (this.escapeCharactersRegExpString) {
regExp = new RegExp(this.escapeCharactersRegExpString, 'g');
match = regExp.exec(text);
}
function setTile(tile, i) {
if (decayEnabled && tile.char && tile.char !== ' ') {
decayTiles[`${tile.x},${tile.y}`] = {
char: tile.char,
font: tile.font,
offsetX: tile.offsetX,
offsetY: tile.offsetY,
fg: tile.fg,
alpha: options.decayInitialAlpha,
};
}
delete tile.image;
tile.char = text[i + textOffset];
tile.font = options.font;
tile.offsetX = options.offsetX;
tile.offsetY = options.offsetY;
tile.fg = options.fg;
tile.bg = options.bg;
addDirtyTile(tile, dirtyTiles);
}
if (!match) {
this.iterateTiles(text.length, setTile, col, line);
}
else {
let i = 0;
if (typeof col !== 'undefined') {
this.cursorX = col;
}
if (typeof line !== 'undefined') {
this.cursorY = line;
}
const commandParams = {
text,
terminal: this,
};
while (i < text.length && match) {
textOffset = i;
this.iterateTiles(match.index - i, setTile);
commandParams.index = match.index;
commandParams.match = match[0];
commandParams.col = this.cursorX;
commandParams.line = this.cursorY;
i = this.options.commands[match[0]](commandParams);
match = regExp.exec(text);
}
if (i < text.length) {
textOffset = i;
this.iterateTiles(text.length - i, setTile);
}
}
this.setCursor(this.cursorX, this.cursorY);
this.options.autoRender = autoRender;
if (autoRender) {
this.render();
}
}
setImage(img, col, line, offset, size, crop) {
const setTile = (tile) => {
tile.dstW = size ? size.width : (crop ? crop.srcW : img.width);
tile.dstH = size ? size.height : (crop ? crop.srcH : img.height);
tile.image = img;
tile.crop = crop;
tile.offset = offset;
this.addDirtyTile(tile);
};
this.iterateTiles(1, setTile, col, line);
}
getText(size = 1, col, line) {
let text = '';
const cursorX = this.cursorX;
const cursorY = this.cursorY;
this.iterateTiles(size, (tile) => {
text += tile.char || ' ';
}, col, line);
this.cursorX = cursorX;
this.cursorY = cursorY;
return text;
}
setTiles(tiles, col, line) {
const dirtyTiles = this.dirtyTiles;
const tilesList = isArray(tiles) ? tiles : [tiles];
this.iterateTiles(tilesList.length, (tile, i) => {
Object.assign(tile, tilesList[i]);
this.addDirtyTile(tile, dirtyTiles);
}, col, line);
if (this.options.autoRender) {
this.render();
}
}
getParent() {
return;
}
attachWidget(WidgetClass, options) {
const widget = Reflect.construct(WidgetClass, [
this,
options,
this,
]);
this.attachedWidgets.push(widget);
widget.render();
return widget;
}
dettachWidget(widget) {
const index = this.attachedWidgets.findIndex((w) => w === widget);
if (index !== -1) {
this.attachedWidgets.splice(index, 1);
const position = widget.getPosition();
const size = widget.getSize();
this.clear(position.col, position.line, size.columns, size.rows);
}
return index !== -1;
}
getWidgetAt(column, line) {
for (const widget of this.attachedWidgets) {
if (widget.isAt(column, line)) {
return widget;
}
}
return;
}
getLeafWidgetAt(column, line) {
let container = this;
let widget;
let validWidget;
do {
widget = container.getWidgetAt(column, line);
if (widget) {
validWidget = widget;
container = isWidgetContainer(widget) ? widget : undefined;
}
} while (widget && container);
return validWidget;
}
getWidgetPath(widget) {
const branch = [widget];
let current = widget.getParent();
while (current !== this && current !== undefined) {
branch.push(current);
current = current.getParent();
}
return current === this ? branch : undefined;
}
[Symbol.iterator](startWidget) {
const data = this.attachedWidgets;
let index;
const it = {
next: () => {
index++;
if (index > this.attachedWidgets.length) {
index = this.attachedWidgets.length;
}
return {
value: data[index],
done: !(index in data),
};
},
prev: () => {
index--;
if (index < -1) {
index = -1;
}
return {
value: data[index],
done: !(index in data),
};
},
seek: (value) => {
index = typeof value === 'number'
? (value < 0 ? this.attachedWidgets.length - value - 1 : value)
: this.attachedWidgets.indexOf(value);
},
};
if (startWidget) {
it.seek(startWidget);
}
else {
index = -1;
}
return it;
}
iterateTiles(size, callback, col, line) {
const buffer = this.buffer;
const viewPortTop = this.options.viewport.top || 0;
const viewPortRight = this.options.viewport.right || this.options.columns - 1;
const viewPortBottom = this.options.viewport.bottom || this.options.rows - 1;
const viewPortLeft = this.options.viewport.left || 0;
let x = typeof col === 'undefined' ? this.cursorX : col;
let y = typeof line === 'undefined' ? this.cursorY : line;
for (let i = 0; i < size; i++) {
if (x > viewPortRight) {
x = viewPortLeft;
y++;
}
if (y > viewPortBottom) {
break;
}
if (y < viewPortTop) {
x++;
continue;
}
this.cursorX = x;
this.cursorY = y;
callback(buffer[y][x], i);
x++;
}
if (x >= viewPortRight) {
x = viewPortLeft;
if (y < viewPortBottom) {
this.cursorY++;
}
}
this.cursorX = x;
}
setCursorFrequency(frequency) {
clearInterval(this.updateCursorInterval);
if (this.options.cursor && frequency > 0) {
this.updateCursorInterval = window.setInterval(() => {
this.cursorVisible = !this.cursorVisible;
this.addDirtyTile(this.buffer[this.cursorY][this.cursorX], this.dirtyTiles);
if (this.options.autoRender) {
this.render();
}
}, frequency);
}
}
info(text) {
if (this.options.verbose) {
console.log(`[Terminal] ${text}`);
}
}
addDirtyTile(dirtyTile, container = this.dirtyTiles) {
if (this.options.avoidDoubleRendering) {
for (const tile of container) {
if (tile.x === dirtyTile.x && tile.y === dirtyTile.y) {
return;
}
}
}
container.push(dirtyTile);
}
resize(width, height, oldWidth, oldHeight) {
const buffer = this.buffer;
const autoRender = this.options.autoRender;
this.options.autoRender = !this.options.autoSize;
if (height > oldHeight) {
for (let y = oldHeight; y < height; y++) {
buffer[y] = [];
}
this.clear(0, oldHeight, oldWidth, height - oldHeight);
}
else if (height < oldHeight) {
buffer.splice(height);
}
if (width > oldWidth) {
this.clear(oldWidth, 0, width - oldWidth, height);
}
else if (width < oldWidth) {
for (let y = 0; y < height; y++) {
buffer[y].splice(width);
}
}
if (this.options.autoSize) {
this.canvas.width = this.options.columns * this.options.tileWidth;
this.canvas.height = this.options.rows * this.options.tileHeight;
this.ctx.fillStyle = this.options.bg;
this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height);
}
this.options.autoRender = autoRender;
this.info(`resized to: ${width} x ${height}`);
this.eventManager.trigger(new TerminalEvent('resized', { width, height, oldWidth, oldHeight }));
}
}
Terminal.defaultOptions = {
tileWidth: 18,
tileHeight: 28,
minColumns: 0,
minRows: 0,
maxColumns: Infinity,
maxRows: Infinity,
autoRender: true,
autoSize: true,
cursor: true,
cursorFrequency: 700,
decayTime: 0,
decayInitialAlpha: 0.7,
font: '20pt Terminal_VT220',
offsetX: 1,
offsetY: -1,
fg: '#00ff00',
bg: '#000000',
viewport: {
top: undefined,
right: undefined,
bottom: undefined,
left: undefined,
},
avoidDoubleRendering: true,
verbose: false,
clearStyle: {
char: '',
bg: '#000000',
fg: '#00ff00',
},
};
//# sourceMappingURL=Terminal.js.map