UNPKG

ascii-ui

Version:

Graphic terminal emulator for HTML canvas elements

574 lines 20.3 kB
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