ESPTerm web interface submodule, separated to make testing and development easier
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
espterm-front-end/jssrc/term_screen.js

1067 lines
30 KiB

// Some non-bold Fraktur symbols are outside the contiguous block
const frakturExceptions = {
'C': '\u212d',
'H': '\u210c',
'I': '\u2111',
'R': '\u211c',
'Z': '\u2128'
};
// constants for decoding the update blob
const SEQ_SET_COLOR_ATTR = 1;
const SEQ_REPEAT = 2;
const SEQ_SET_COLOR = 3;
const SEQ_SET_ATTR = 4;
const SELECTION_BG = '#b2d7fe';
const SELECTION_FG = '#333';
const themes = [
[ // Tango
'#111213', '#CC0000', '#4E9A06', '#C4A000', '#3465A4', '#75507B', '#06989A', '#D3D7CF',
'#555753', '#EF2929', '#8AE234', '#FCE94F', '#729FCF', '#AD7FA8', '#34E2E2', '#EEEEEC',
],
[ // Linux
'#000000', '#aa0000', '#00aa00', '#aa5500', '#0000aa', '#aa00aa', '#00aaaa', '#aaaaaa',
'#555555', '#ff5555', '#55ff55', '#ffff55', '#5555ff', '#ff55ff', '#55ffff', '#ffffff',
],
[ // xterm
'#000000', '#cd0000', '#00cd00', '#cdcd00', '#0000ee', '#cd00cd', '#00cdcd', '#e5e5e5',
'#7f7f7f', '#ff0000', '#00ff00', '#ffff00', '#5c5cff', '#ff00ff', '#00ffff', '#ffffff',
],
[ // rxvt
'#000000', '#cd0000', '#00cd00', '#cdcd00', '#0000cd', '#cd00cd', '#00cdcd', '#faebd7',
'#404040', '#ff0000', '#00ff00', '#ffff00', '#0000ff', '#ff00ff', '#00ffff', '#ffffff',
],
[ // Ambience
'#2e3436', '#cc0000', '#4e9a06', '#c4a000', '#3465a4', '#75507b', '#06989a', '#d3d7cf',
'#555753', '#ef2929', '#8ae234', '#fce94f', '#729fcf', '#ad7fa8', '#34e2e2', '#eeeeec',
],
[ // Solarized
'#073642', '#dc322f', '#859900', '#b58900', '#268bd2', '#d33682', '#2aa198', '#eee8d5',
'#002b36', '#cb4b16', '#586e75', '#657b83', '#839496', '#6c71c4', '#93a1a1', '#fdf6e3',
]
];
// 256color lookup table
// should not be used to look up 0-15 (will return transparent)
const colorTable256 = new Array(16).fill('rgba(0, 0, 0, 0)');
{
// fill color table
// colors 16-231 are a 6x6x6 color cube
for (let red = 0; red < 6; red++) {
for (let green = 0; green < 6; green++) {
for (let blue = 0; blue < 6; blue++) {
let redValue = red * 40 + (red ? 55 : 0)
let greenValue = green * 40 + (green ? 55 : 0)
let blueValue = blue * 40 + (blue ? 55 : 0)
colorTable256.push(`rgb(${redValue}, ${greenValue}, ${blueValue})`)
}
}
}
// colors 232-255 are a grayscale ramp, sans black and white
for (let gray = 0; gray < 24; gray++) {
let value = gray * 10 + 8
colorTable256.push(`rgb(${value}, ${value}, ${value})`)
}
}
class TermScreen {
constructor () {
this.canvas = document.createElement('canvas');
this.ctx = this.canvas.getContext('2d');
if ('AudioContext' in window || 'webkitAudioContext' in window) {
this.audioCtx = new (window.AudioContext || window.webkitAudioContext)()
} else {
console.warn('No AudioContext!')
}
this.cursor = {
x: 0,
y: 0,
fg: 7,
bg: 0,
attrs: 0,
blinkOn: false,
blinking: true,
visible: true,
hanging: false,
style: 'block',
blinkEnable: true,
blinkInterval: 0,
};
this._palette = themes[0];
this._window = {
width: 0,
height: 0,
devicePixelRatio: 1,
fontFamily: '"DejaVu Sans Mono", "Liberation Mono", "Inconsolata", "Menlo", monospace',
fontSize: 20,
gridScaleX: 1.0,
gridScaleY: 1.2,
blinkStyleOn: true,
blinkInterval: null,
fitIntoWidth: 0,
fitIntoHeight: 0,
};
// properties of this.window that require updating size and redrawing
this.windowState = {
width: 0,
height: 0,
devicePixelRatio: 0,
gridScaleX: 0,
gridScaleY: 0,
fontFamily: '',
fontSize: 0,
fitIntoWidth: 0,
fitIntoHeight: 0,
};
// current selection
this.selection = {
// when false, this will prevent selection in favor of mouse events,
// though alt can be held to override it
selectable: true,
start: [0, 0],
end: [0, 0],
};
this.mouseMode = { clicks: false, movement: false };
// event listeners
this._listeners = {};
const self = this;
this.window = new Proxy(this._window, {
set (target, key, value, receiver) {
target[key] = value;
self.scheduleSizeUpdate();
self.scheduleDraw();
return true
}
});
this.screen = [];
this.screenFG = [];
this.screenBG = [];
this.screenAttrs = [];
// used to determine if a cell should be redrawn
this.drawnScreen = [];
this.drawnScreenFG = [];
this.drawnScreenBG = [];
this.drawnScreenAttrs = [];
this.resetBlink();
this.resetCursorBlink();
let selecting = false;
let selectStart = (x, y) => {
if (selecting) return;
selecting = true;
this.selection.start = this.selection.end = this.screenToGrid(x, y);
this.scheduleDraw();
};
let selectMove = (x, y) => {
if (!selecting) return;
this.selection.end = this.screenToGrid(x, y);
this.scheduleDraw();
};
let selectEnd = (x, y) => {
if (!selecting) return;
selecting = false;
this.selection.end = this.screenToGrid(x, y);
this.scheduleDraw();
Object.assign(this.selection, this.getNormalizedSelection());
};
this.canvas.addEventListener('mousedown', e => {
if ((this.selection.selectable || e.altKey) && e.button === 0) {
selectStart(e.offsetX, e.offsetY)
} else {
Input.onMouseDown(...this.screenToGrid(e.offsetX, e.offsetY),
e.button + 1)
}
});
window.addEventListener('mousemove', e => {
selectMove(e.offsetX, e.offsetY)
});
window.addEventListener('mouseup', e => {
selectEnd(e.offsetX, e.offsetY)
});
let touchPosition = null;
let touchDownTime = 0;
let touchSelectMinTime = 500;
let touchDidMove = false;
let getTouchPositionOffset = touch => {
let rect = this.canvas.getBoundingClientRect();
return [touch.clientX - rect.left, touch.clientY - rect.top];
};
this.canvas.addEventListener('touchstart', e => {
touchPosition = getTouchPositionOffset(e.touches[0]);
touchDidMove = false;
touchDownTime = Date.now();
});
this.canvas.addEventListener('touchmove', e => {
touchPosition = getTouchPositionOffset(e.touches[0]);
if (!selecting && touchDidMove === false) {
if (touchDownTime < Date.now() - touchSelectMinTime) {
selectStart(...touchPosition);
}
} else if (selecting) {
e.preventDefault();
selectMove(...touchPosition);
}
touchDidMove = true;
});
this.canvas.addEventListener('touchend', e => {
if (e.touches[0]) {
touchPosition = getTouchPositionOffset(e.touches[0]);
}
if (selecting) {
e.preventDefault();
selectEnd(...touchPosition);
let touchSelectMenu = qs('#touch-select-menu');
touchSelectMenu.classList.add('open');
let rect = touchSelectMenu.getBoundingClientRect();
// use middle position for x and one line above for y
let selectionPos = this.gridToScreen(
(this.selection.start[0] + this.selection.end[0]) / 2,
this.selection.start[1] - 1
);
selectionPos[0] -= rect.width / 2;
selectionPos[1] -= rect.height / 2;
touchSelectMenu.style.transform = `translate(${selectionPos[0]}px, ${
selectionPos[1]}px)`
}
if (!touchDidMove) {
this.emit('tap', Object.assign(e, {
x: touchPosition[0],
y: touchPosition[1],
}))
}
touchPosition = null;
});
this.on('tap', e => {
if (this.selection.start[0] !== this.selection.end[0] ||
this.selection.start[1] !== this.selection.end[1]) {
// selection is not empty
// reset selection
this.selection.start = this.selection.end = [0, 0];
qs('#touch-select-menu').classList.remove('open');
this.scheduleDraw();
} else {
e.preventDefault();
this.emit('open-soft-keyboard');
}
});
$.ready(() => {
let copyButton = qs('#touch-select-copy-btn');
if (copyButton) {
copyButton.addEventListener('click', () => {
this.copySelectionToClipboard();
});
}
});
this.canvas.addEventListener('mousemove', e => {
if (!selecting) {
Input.onMouseMove(...this.screenToGrid(e.offsetX, e.offsetY))
}
});
this.canvas.addEventListener('mouseup', e => {
if (!selecting) {
Input.onMouseUp(...this.screenToGrid(e.offsetX, e.offsetY),
e.button + 1)
}
});
this.canvas.addEventListener('wheel', e => {
if (this.mouseMode.clicks) {
Input.onMouseWheel(...this.screenToGrid(e.offsetX, e.offsetY),
e.deltaY > 0 ? 1 : -1);
// prevent page scrolling
e.preventDefault();
}
});
this.canvas.addEventListener('contextmenu', e => {
if (this.mouseMode.clicks) {
// prevent mouse keys getting stuck
e.preventDefault();
}
selectEnd(e.offsetX, e.offsetY);
});
// bind ctrl+shift+c to copy
key('⌃+⇧+c', e => {
e.preventDefault();
this.copySelectionToClipboard()
});
}
on (event, listener) {
if (!this._listeners[event]) this._listeners[event] = [];
this._listeners[event].push({ listener });
}
once (event, listener) {
if (!this._listeners[event]) this._listeners[event] = [];
this._listeners[event].push({ listener, once: true });
}
off (event, listener) {
let listeners = this._listeners[event];
if (listeners) {
for (let i in listeners) {
if (listeners[i].listener === listener) {
listeners.splice(i, 1);
break;
}
}
}
}
emit (event, ...args) {
let listeners = this._listeners[event];
if (listeners) {
let remove = [];
for (let listener of listeners) {
try {
listener.listener(...args);
if (listener.once) remove.push(listener);
} catch (err) {
console.error(err);
}
}
// this needs to be done in this roundabout way because for loops
// do not like arrays with changing lengths
for (let listener of remove) {
listeners.splice(listeners.indexOf(listener), 1);
}
}
}
get palette () { return this._palette }
set palette (palette) {
this._palette = palette;
this.scheduleDraw();
}
getColor (i) {
// return palette color if it exists
if (this.palette[i]) return this.palette[i];
// -1 for selection foreground, -2 for selection background
if (i === -1) return SELECTION_FG;
if (i === -2) return SELECTION_BG;
// 256 color
if (i > 15 && i < 256) return colorTable256[i];
// true color, encoded as (hex) + 256 (such that #000 == 256)
if (i > 255) {
i -= 256;
let red = (i >> 16) & 0xFF;
let green = (i >> 8) & 0xFF;
let blue = i & 0xFF;
return `rgb(${red}, ${green}, ${blue})`;
}
// default to transparent
return 'rgba(0, 0, 0, 0)';
}
// schedule a size update in the next tick
scheduleSizeUpdate () {
clearTimeout(this._scheduledSizeUpdate);
this._scheduledSizeUpdate = setTimeout(() => this.updateSize(), 1)
}
// schedule a draw in the next tick
scheduleDraw (aggregateTime = 1) {
clearTimeout(this._scheduledDraw);
this._scheduledDraw = setTimeout(() => this.draw(), aggregateTime)
}
getFont (modifiers = {}) {
let fontStyle = modifiers.style || 'normal';
let fontWeight = modifiers.weight || 'normal';
return `${fontStyle} normal ${fontWeight} ${this.window.fontSize}px ${this.window.fontFamily}`
}
getCharSize () {
this.ctx.font = this.getFont();
return {
width: Math.floor(this.ctx.measureText(' ').width),
height: this.window.fontSize
}
}
getCellSize () {
let charSize = this.getCharSize();
return {
width: Math.ceil(charSize.width * this.window.gridScaleX),
height: Math.ceil(charSize.height * this.window.gridScaleY)
}
}
updateSize () {
this._window.devicePixelRatio = window.devicePixelRatio || 1;
let didChange = false;
for (let key in this.windowState) {
if (this.windowState.hasOwnProperty(key) && this.windowState[key] !== this.window[key]) {
didChange = true;
this.windowState[key] = this.window[key];
}
}
if (didChange) {
const {
width,
height,
devicePixelRatio,
gridScaleX,
gridScaleY,
fitIntoWidth,
fitIntoHeight
} = this.window;
const cellSize = this.getCellSize();
// real height of the canvas element in pixels
let realWidth = width * cellSize.width;
let realHeight = height * cellSize.height;
if (fitIntoWidth && fitIntoHeight) {
if (realWidth > fitIntoWidth || realHeight > fitIntoHeight) {
let terminalAspect = realWidth / realHeight;
let fitAspect = fitIntoWidth / fitIntoHeight;
if (terminalAspect < fitAspect) {
// align heights
realHeight = fitIntoHeight;
realWidth = realHeight * terminalAspect
}
else {
// align widths
realWidth = fitIntoWidth;
realHeight = realWidth / terminalAspect
}
}
}
else if (fitIntoWidth && realWidth > fitIntoWidth) {
realHeight = fitIntoWidth / (realWidth / realHeight);
realWidth = fitIntoWidth
}
else if (fitIntoHeight && realHeight > fitIntoHeight) {
realWidth = fitIntoHeight * (realWidth / realHeight);
realHeight = fitIntoHeight
}
this.canvas.width = width * devicePixelRatio * cellSize.width;
this.canvas.style.width = `${realWidth}px`;
this.canvas.height = height * devicePixelRatio * cellSize.height;
this.canvas.style.height = `${realHeight}px`;
// the screen has been cleared (by changing canvas width)
this.drawnScreen = [];
this.drawnScreenFG = [];
this.drawnScreenBG = [];
this.drawnScreenAttrs = [];
// draw immediately; the canvas shouldn't flash
this.draw();
}
}
resetCursorBlink () {
this.cursor.blinkOn = true;
clearInterval(this.cursor.blinkInterval);
this.cursor.blinkInterval = setInterval(() => {
this.cursor.blinkOn = this.cursor.blinking
? !this.cursor.blinkOn
: true;
this.scheduleDraw();
}, 500);
}
resetBlink () {
this.window.blinkStyleOn = true;
clearInterval(this.window.blinkInterval);
let intervals = 0;
this.window.blinkInterval = setInterval(() => {
intervals++;
if (intervals >= 4 && this.window.blinkStyleOn) {
this.window.blinkStyleOn = false;
intervals = 0;
} else if (intervals >= 1 && !this.window.blinkStyleOn) {
this.window.blinkStyleOn = true;
intervals = 0;
}
}, 200);
}
getNormalizedSelection () {
let { start, end } = this.selection;
// if the start line is after the end line, or if they're both on the same
// line but the start column comes after the end column, swap
if (start[1] > end[1] || (start[1] === end[1] && start[0] > end[0])) {
[start, end] = [end, start];
}
return { start, end };
}
isInSelection (col, line) {
let { start, end } = this.getNormalizedSelection();
let colAfterStart = start[0] <= col;
let colBeforeEnd = col < end[0];
let onStartLine = line === start[1];
let onEndLine = line === end[1];
if (onStartLine && onEndLine) return colAfterStart && colBeforeEnd;
else if (onStartLine) return colAfterStart;
else if (onEndLine) return colBeforeEnd;
else return start[1] < line && line < end[1];
}
getSelectedText () {
const screenLength = this.window.width * this.window.height;
let lines = [];
let previousLineIndex = -1;
for (let cell = 0; cell < screenLength; cell++) {
let x = cell % this.window.width;
let y = Math.floor(cell / this.window.width);
if (this.isInSelection(x, y)) {
if (previousLineIndex !== y) {
previousLineIndex = y;
lines.push('');
}
lines[lines.length - 1] += this.screen[cell];
}
}
return lines.join('\n');
}
copySelectionToClipboard () {
let selectedText = this.getSelectedText();
// don't copy anything if nothing is selected
if (!selectedText) return;
let textarea = document.createElement('textarea');
document.body.appendChild(textarea);
textarea.value = selectedText;
textarea.select();
if (document.execCommand('copy')) {
Notify.show('Copied to clipboard');
} else {
Notify.show('Failed to copy');
// unsuccessful copy
}
document.body.removeChild(textarea);
}
screenToGrid (x, y) {
let cellSize = this.getCellSize();
return [
Math.floor((x + cellSize.width / 2) / cellSize.width),
Math.floor(y / cellSize.height),
];
}
gridToScreen (x, y) {
let cellSize = this.getCellSize();
return [ x * cellSize.width, y * cellSize.height ];
}
drawCell ({ x, y, charSize, cellWidth, cellHeight, text, fg, bg, attrs }) {
const ctx = this.ctx;
ctx.fillStyle = this.getColor(bg);
ctx.fillRect(x * cellWidth, y * cellHeight,
Math.ceil(cellWidth), Math.ceil(cellHeight));
if (!text) return;
let underline = false;
let blink = false;
let strike = false;
let overline = false;
if (attrs & (1 << 1)) ctx.globalAlpha = 0.5;
if (attrs & (1 << 3)) underline = true;
if (attrs & (1 << 4)) blink = true;
if (attrs & (1 << 5)) text = TermScreen.alphaToFraktur(text);
if (attrs & (1 << 6)) strike = true;
if (attrs & (1 << 7)) overline = true;
if (!blink || this.window.blinkStyleOn) {
ctx.fillStyle = this.getColor(fg);
ctx.fillText(text, (x + 0.5) * cellWidth, (y + 0.5) * cellHeight);
if (underline || strike || overline) {
ctx.strokeStyle = this.getColor(fg);
ctx.lineWidth = 1;
ctx.lineCap = 'round';
ctx.beginPath();
if (underline) {
let lineY = Math.round(y * cellHeight + charSize.height) + 0.5;
ctx.moveTo(x * cellWidth, lineY);
ctx.lineTo((x + 1) * cellWidth, lineY);
}
if (strike) {
let lineY = Math.round((y + 0.5) * cellHeight) + 0.5;
ctx.moveTo(x * cellWidth, lineY);
ctx.lineTo((x + 1) * cellWidth, lineY);
}
if (overline) {
let lineY = Math.round(y * cellHeight) + 0.5;
ctx.moveTo(x * cellWidth, lineY);
ctx.lineTo((x + 1) * cellWidth, lineY);
}
ctx.stroke();
}
}
ctx.globalAlpha = 1;
}
draw () {
const ctx = this.ctx;
const {
width,
height,
devicePixelRatio,
gridScaleX,
gridScaleY
} = this.window;
const charSize = this.getCharSize();
const { width: cellWidth, height: cellHeight } = this.getCellSize();
const screenWidth = width * cellWidth;
const screenHeight = height * cellHeight;
const screenLength = width * height;
ctx.setTransform(devicePixelRatio, 0, 0, devicePixelRatio, 0, 0);
ctx.font = this.getFont();
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
// bits in the attr value that affect the font
const FONT_MASK = 0b101;
// Map of (attrs & FONT_MASK) -> Array of cell indices
const fontGroups = new Map();
// Map of (cell index) -> boolean, whether or not a cell needs to be redrawn
const updateMap = new Map();
for (let cell = 0; cell < screenLength; cell++) {
let x = cell % width;
let y = Math.floor(cell / width);
let isCursor = !this.cursor.hanging
&& this.cursor.x === x
&& this.cursor.y === y;
let invertForCursor = isCursor && this.cursor.blinkOn
&& this.cursor.style === 'block';
let inSelection = this.isInSelection(x, y);
let text = this.screen[cell];
let fg = invertForCursor ? this.screenBG[cell] : this.screenFG[cell];
let bg = invertForCursor ? this.screenFG[cell] : this.screenBG[cell];
let attrs = this.screenAttrs[cell];
// HACK: ensure cursor is visible
if (invertForCursor && fg === bg) bg = fg === 0 ? 7 : 0;
if (inSelection) {
fg = -1;
bg = -2;
}
let needsUpdate = text !== this.drawnScreen[cell] ||
fg !== this.drawnScreenFG[cell] ||
bg !== this.drawnScreenBG[cell] ||
attrs !== this.drawnScreenAttrs[cell] ||
isCursor;
let font = attrs & FONT_MASK;
if (!fontGroups.has(font)) fontGroups.set(font, []);
fontGroups.get(font).push([cell, x, y, text, fg, bg, attrs, isCursor]);
updateMap.set(cell, needsUpdate);
}
for (let font of fontGroups.keys()) {
// set font once because in Firefox, this is a really slow action for some
// reason
let modifiers = {};
if (font & 1) modifiers.weight = 'bold';
if (font & 1 << 2) modifiers.style = 'italic';
ctx.font = this.getFont(modifiers);
for (let data of fontGroups.get(font)) {
let [cell, x, y, text, fg, bg, attrs, isCursor] = data;
// check if this cell or any adjacent cells updated
let needsUpdate = false;
let updateCells = [
cell,
cell - 1,
cell + 1,
cell - width,
cell + width,
// diagonal box drawing characters exist, too
cell - width - 1,
cell - width + 1,
cell + width - 1,
cell + width + 1
];
for (let index of updateCells) {
if (updateMap.has(index) && updateMap.get(index)) {
needsUpdate = true;
break;
}
}
if (needsUpdate) {
this.drawCell({
x, y, charSize, cellWidth, cellHeight, text, fg, bg, attrs
});
this.drawnScreen[cell] = text;
this.drawnScreenFG[cell] = fg;
this.drawnScreenBG[cell] = bg;
this.drawnScreenAttrs[cell] = attrs;
}
if (isCursor && this.cursor.blinkOn && this.cursor.style !== 'block') {
ctx.save();
ctx.beginPath();
if (this.cursor.style === 'bar') {
// vertical bar
let barWidth = 2;
ctx.rect(x * cellWidth, y * cellHeight, barWidth, cellHeight)
} else if (this.cursor.style === 'line') {
// underline
let lineHeight = 2;
ctx.rect(x * cellWidth, y * cellHeight + charSize.height,
cellWidth, lineHeight)
}
ctx.clip();
// swap foreground/background
fg = this.screenBG[cell];
bg = this.screenFG[cell];
// HACK: ensure cursor is visible
if (fg === bg) bg = fg === 0 ? 7 : 0;
this.drawCell({
x, y, charSize, cellWidth, cellHeight, text, fg, bg, attrs
});
ctx.restore();
}
}
}
}
loadContent (str) {
// current index
let i = 0;
// window size
this.window.height = parse2B(str, i);
this.window.width = parse2B(str, i + 2);
this.updateSize();
i += 4;
// cursor position
let [cursorY, cursorX] = [parse2B(str, i), parse2B(str, i + 2)];
i += 4;
let cursorMoved = (cursorX !== this.cursor.x || cursorY !== this.cursor.y);
this.cursor.x = cursorX;
this.cursor.y = cursorY;
if (cursorMoved) {
this.resetCursorBlink();
this.emit('cursor-moved');
}
// attributes
let attributes = parse3B(str, i);
i += 3;
this.cursor.visible = !!(attributes & 1);
this.cursor.hanging = !!(attributes & (1 << 1));
Input.setAlts(
!!(attributes & (1 << 2)), // cursors alt
!!(attributes & (1 << 3)), // numpad alt
!!(attributes & (1 << 4)), // fn keys alt
!!(attributes & (1 << 12)) // crlf mode
);
let trackMouseClicks = !!(attributes & (1 << 5));
let trackMouseMovement = !!(attributes & (1 << 6));
// 0 - Block blink 2 - Block steady (1 is unused)
// 3 - Underline blink 4 - Underline steady
// 5 - I-bar blink 6 - I-bar steady
let cursorShape = (attributes >> 9) & 0x07;
// if it's not zero, decrement such that the two most significant bits
// are the type and the least significant bit is the blink state
if (cursorShape > 0) cursorShape--;
let cursorStyle = cursorShape >> 1;
let cursorBlinking = !(cursorShape & 1);
if (cursorStyle === 0) this.cursor.style = 'block';
else if (cursorStyle === 1) this.cursor.style = 'line';
else if (cursorStyle === 2) this.cursor.style = 'bar';
if (this.cursor.blinking !== cursorBlinking) {
this.cursor.blinking = cursorBlinking;
this.resetCursorBlink();
}
Input.setMouseMode(trackMouseClicks, trackMouseMovement);
this.selection.selectable = !trackMouseMovement;
$(this.canvas).toggleClass('selectable', !trackMouseMovement);
this.mouseMode = {
clicks: trackMouseClicks,
movement: trackMouseMovement
};
let showButtons = !!(attributes & (1 << 7));
let showConfigLinks = !!(attributes & (1 << 8));
$('.x-term-conf-btn').toggleClass('hidden', !showConfigLinks);
$('#action-buttons').toggleClass('hidden', !showButtons);
// content
let fg = 7;
let bg = 0;
let attrs = 0;
let cell = 0; // cell index
let lastChar = ' ';
let screenLength = this.window.width * this.window.height;
this.screen = new Array(screenLength).fill(' ');
this.screenFG = new Array(screenLength).fill(' ');
this.screenBG = new Array(screenLength).fill(' ');
this.screenAttrs = new Array(screenLength).fill(' ');
let strArray = typeof Array.from !== 'undefined'
? Array.from(str)
: str.split('');
while (i < strArray.length && cell < screenLength) {
let character = strArray[i++];
let charCode = character.codePointAt(0);
let data;
switch (charCode) {
case SEQ_SET_COLOR_ATTR:
data = parse3B(strArray[i] + strArray[i + 1] + strArray[i + 2]);
i += 3;
fg = data & 0xF;
bg = data >> 4 & 0xF;
attrs = data >> 8 & 0xFF;
break;
case SEQ_SET_COLOR:
data = parse2B(strArray[i] + strArray[i + 1]);
i += 2;
fg = data & 0xF;
bg = data >> 4 & 0xF;
break;
case SEQ_SET_ATTR:
data = parse2B(strArray[i] + strArray[i + 1]);
i += 2;
attrs = data & 0xFF;
break;
case SEQ_REPEAT:
let count = parse2B(strArray[i] + strArray[i + 1]);
i += 2;
for (let j = 0; j < count; j++) {
this.screen[cell] = lastChar;
this.screenFG[cell] = fg;
this.screenBG[cell] = bg;
this.screenAttrs[cell] = attrs;
if (++cell > screenLength) break;
}
break;
default:
// safety replacement
if (charCode < 32) character = '\ufffd';
// unique cell character
this.screen[cell] = lastChar = character;
this.screenFG[cell] = fg;
this.screenBG[cell] = bg;
this.screenAttrs[cell] = attrs;
cell++;
}
}
this.scheduleDraw(16);
this.emit('load');
}
/** Apply labels to buttons and screen title (leading T removed already) */
loadLabels (str) {
let pieces = str.split('\x01');
qs('h1').textContent = pieces[0];
$('#action-buttons button').forEach((button, i) => {
let label = pieces[i + 1].trim();
// if empty string, use the "dim" effect and put nbsp instead to
// stretch the button vertically
button.innerHTML = label ? e(label) : '&nbsp;';
button.style.opacity = label ? 1 : 0.2;
})
}
showNotification (text) {
console.log(`Notification: ${text}`);
// TODO: request permission earlier
// the requestPermission should be user-triggered; asking upfront seems
// a little awkward
if (Notification && Notification.permission === 'granted') {
let notification = new Notification('ESPTerm', {
body: text
});
notification.addEventListener('click', () => window.focus());
} else {
Notify.show(text);
}
}
load (str) {
const content = str.substr(1);
switch (str[0]) {
case 'S':
this.loadContent(content);
break;
case 'T':
this.loadLabels(content);
break;
case 'B':
this.beep();
break;
case 'G':
this.showNotification(content);
break;
default:
console.warn(`Bad data message type; ignoring.\n${JSON.stringify(content)}`)
}
}
beep () {
const audioCtx = this.audioCtx;
if (!audioCtx) return;
// prevent screeching
if (this._lastBeep && this._lastBeep > Date.now() - 50) return;
this._lastBeep = Date.now();
let osc, gain;
// main beep
osc = audioCtx.createOscillator();
gain = audioCtx.createGain();
osc.connect(gain);
gain.connect(audioCtx.destination);
gain.gain.value = 0.5;
osc.frequency.value = 750;
osc.type = 'sine';
osc.start();
osc.stop(audioCtx.currentTime + 0.05);
// surrogate beep (making it sound like 'oops')
osc = audioCtx.createOscillator();
gain = audioCtx.createGain();
osc.connect(gain);
gain.connect(audioCtx.destination);
gain.gain.value = 0.2;
osc.frequency.value = 400;
osc.type = 'sine';
osc.start(audioCtx.currentTime + 0.05);
osc.stop(audioCtx.currentTime + 0.08);
}
static alphaToFraktur (character) {
if ('a' <= character && character <= 'z') {
character = String.fromCodePoint(0x1d51e - 0x61 + character.charCodeAt(0))
} else if ('A' <= character && character <= 'Z') {
character = frakturExceptions[character] || String.fromCodePoint(
0x1d504 - 0x41 + character.charCodeAt(0))
}
return character
}
}
const Screen = new TermScreen();
Screen.once('load', () => {
qs('#screen').appendChild(Screen.canvas);
for (let item of qs('#screen').classList) {
if (item.startsWith('theme-')) {
Screen.colors = themes[item.substr(6)]
}
}
});
let fitScreen = false;
function fitScreenIfNeeded () {
Screen.window.fitIntoWidth = fitScreen ? window.innerWidth : 0;
Screen.window.fitIntoHeight = fitScreen ? window.innerHeight : 0
}
fitScreenIfNeeded();
window.addEventListener('resize', fitScreenIfNeeded);
window.toggleFitScreen = function () {
fitScreen = !fitScreen;
const resizeButtonIcon = qs('#resize-button-icon');
if (fitScreen) {
resizeButtonIcon.classList.remove('icn-resize-small');
resizeButtonIcon.classList.add('icn-resize-full')
} else {
resizeButtonIcon.classList.remove('icn-resize-full');
resizeButtonIcon.classList.add('icn-resize-small')
}
fitScreenIfNeeded();
};