const EventEmitter = require('events') const $ = require('./lib/chibi') const { mk, qs, parse2B, parse3B } = require('./utils') const notify = require('./notif') const { themes, buildColorTable } = require('./themes') // constants for decoding the update blob const SEQ_REPEAT = 2 const SEQ_SET_COLORS = 3 const SEQ_SET_ATTRS = 4 const SEQ_SET_FG = 5 const SEQ_SET_BG = 6 const SELECTION_BG = '#b2d7fe' const SELECTION_FG = '#333' module.exports = class TermScreen extends EventEmitter { constructor () { super() // Some non-bold Fraktur symbols are outside the contiguous block this.frakturExceptions = { 'C': '\u212d', 'H': '\u210c', 'I': '\u2111', 'R': '\u211c', 'Z': '\u2128' } // 256color lookup table // should not be used to look up 0-15 (will return transparent) this.colorTable256 = buildColorTable() this._debug = null this.contentLoaded = false this.canvas = mk('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!') } // dummy this.input = new Proxy({}, { get () { return () => console.warn('TermScreen#input not set!') } }) this.cursor = { x: 0, y: 0, blinkOn: false, blinking: true, visible: true, hanging: false, style: 'block', blinkInterval: null } this._palette = null 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, debug: false, graphics: 0, statusScreen: null } // scaling caused by fitIntoWidth/fitIntoHeight this._windowScale = 1 // 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, // selection start and end (x, y) tuples start: [0, 0], end: [0, 0] } // mouse features this.mouseMode = { clicks: false, movement: false } // make writing to window update size and draw const self = this this.window = new Proxy(this._window, { set (target, key, value, receiver) { target[key] = value self.scheduleSizeUpdate() self.scheduleDraw(`window:${key}=${value}`) self.emit(`update-window:${key}`, value) return true } }) this.bracketedPaste = false this.blinkingCellCount = 0 this.screen = [] this.screenFG = [] this.screenBG = [] this.screenAttrs = [] // used to determine if a cell should be redrawn; storing the current state // as it is on screen this.drawnScreen = [] this.drawnScreenFG = [] this.drawnScreenBG = [] this.drawnScreenAttrs = [] this.drawnCursor = [-1, -1, ''] // start blink timers 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, true) this.scheduleDraw('select-start') } let selectMove = (x, y) => { if (!selecting) return this.selection.end = this.screenToGrid(x, y, true) this.scheduleDraw('select-move') } let selectEnd = (x, y) => { if (!selecting) return selecting = false this.selection.end = this.screenToGrid(x, y, true) this.scheduleDraw('select-end') Object.assign(this.selection, this.getNormalizedSelection()) } // bind event listeners this.canvas.addEventListener('mousedown', e => { if ((this.selection.selectable || e.altKey) && e.button === 0) { selectStart(e.offsetX, e.offsetY) } else { this.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) }) // touch event listeners 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) // selection ended; show touch select menu 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('select-reset') } 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) { this.input.onMouseMove(...this.screenToGrid(e.offsetX, e.offsetY)) } }) this.canvas.addEventListener('mouseup', e => { if (!selecting) { this.input.onMouseUp(...this.screenToGrid(e.offsetX, e.offsetY), e.button + 1) } }) this.canvas.addEventListener('wheel', e => { if (this.mouseMode.clicks) { this.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) }) } /** * The color palette. Should define 16 colors in an array. * @type {number[]} */ get palette () { return this._palette || themes[0] } /** @type {number[]} */ set palette (palette) { if (this._palette !== palette) { this._palette = palette this.scheduleDraw('palette') } } /** * Returns the specified color. If `i` is in the palette, it will return the * palette color. If `i` is between 16 and 255, it will return the 256color * value. If `i` is larger than 255, it will return an RGB color value. If `i` * is -1 (foreground) or -2 (background), it will return the selection colors. * @param {number} i - the color * @returns {string} the CSS color */ 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 this.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 millisecond */ scheduleSizeUpdate () { clearTimeout(this._scheduledSizeUpdate) this._scheduledSizeUpdate = setTimeout(() => this.updateSize(), 1) } /** * Schedule a draw in the next millisecond * @param {string} why - the reason why the draw occured (for debugging) * @param {number} [aggregateTime] - time to wait for more scheduleDraw calls * to occur. 1 ms by default. */ scheduleDraw (why, aggregateTime = 1) { clearTimeout(this._scheduledDraw) this._scheduledDraw = setTimeout(() => this.draw(why), aggregateTime) } /** * Returns a CSS font string with this TermScreen's font settings and the * font modifiers. * @param {Object} modifiers * @param {string} [modifiers.style] - the font style * @param {string} [modifiers.weight] - the font weight * @returns {string} a CSS font string */ getFont (modifiers = {}) { let fontStyle = modifiers.style || 'normal' let fontWeight = modifiers.weight || 'normal' return `${fontStyle} normal ${fontWeight} ${this.window.fontSize}px ${this.window.fontFamily}` } /** * The character size, used for calculating the cell size. The space character * is used for measuring. * @returns {Object} the character size with `width` and `height` in pixels */ getCharSize () { this.ctx.font = this.getFont() return { width: Math.floor(this.ctx.measureText(' ').width), height: this.window.fontSize } } /** * The cell size, which is the character size multiplied by the grid scale. * @returns {Object} the cell size with `width` and `height` in pixels */ getCellSize () { let charSize = this.getCharSize() return { width: Math.ceil(charSize.width * this.window.gridScaleX), height: Math.ceil(charSize.height * this.window.gridScaleY) } } /** * Updates the canvas size if it changed */ updateSize () { // see below (this is just updating it) this._window.devicePixelRatio = Math.round(this._windowScale * (window.devicePixelRatio || 1) * 2) / 2 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, 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) { 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) { realHeight = fitIntoWidth / (realWidth / realHeight) realWidth = fitIntoWidth } else if (fitIntoHeight) { realWidth = fitIntoHeight * (realWidth / realHeight) realHeight = fitIntoHeight } // store new window scale this._windowScale = realWidth / (width * cellSize.width) // the DPR must be rounded to a very nice value to prevent gaps between cells let devicePixelRatio = this._window.devicePixelRatio = Math.round(this._windowScale * (window.devicePixelRatio || 1) * 2) / 2 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('update-size') } } /** * Resets the cursor blink to on and restarts the timer */ resetCursorBlink () { this.cursor.blinkOn = true clearInterval(this.cursor.blinkInterval) this.cursor.blinkInterval = setInterval(() => { this.cursor.blinkOn = this.cursor.blinking ? !this.cursor.blinkOn : true if (this.cursor.blinking) this.scheduleDraw('cursor-blink') }, 500) } /** * Resets the blink style to on and restarts the timer */ resetBlink () { this.window.blinkStyleOn = true clearInterval(this.window.blinkInterval) let intervals = 0 this.window.blinkInterval = setInterval(() => { if (this.blinkingCellCount <= 0) return 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) } /** * Returns a normalized version of the current selection, such that `start` * is always before `end`. * @returns {Object} the normalized selection, with `start` and `end` */ 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 } } /** * Returns whether or not a given cell is in the current selection. * @param {number} col - the column (x) * @param {number} line - the line (y) * @returns {boolean} */ 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] } /** * Sweeps for selected cells and joins them in a multiline string. * @returns {string} the selection */ 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') } /** * Copies the selection to clipboard and creates a notification balloon. */ copySelectionToClipboard () { let selectedText = this.getSelectedText() // don't copy anything if nothing is selected if (!selectedText) return let textarea = mk('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') } document.body.removeChild(textarea) } /** * Converts screen coordinates to grid coordinates. * @param {number} x - x in pixels * @param {number} y - y in pixels * @param {boolean} rounded - whether to round the coord, used for select highlighting * @returns {number[]} a tuple of (x, y) in cells */ screenToGrid (x, y, rounded = false) { let cellSize = this.getCellSize() return [ Math.floor((x + (rounded ? cellSize.width / 2 : 0)) / cellSize.width), Math.floor(y / cellSize.height) ] } /** * Converts grid coordinates to screen coordinates. * @param {number} x - x in cells * @param {number} y - y in cells * @param {boolean} [withScale] - when true, will apply window scale * @returns {number[]} a tuple of (x, y) in pixels */ gridToScreen (x, y, withScale = false) { let cellSize = this.getCellSize() return [x * cellSize.width, y * cellSize.height].map(v => withScale ? v * this._windowScale : v) } /** * Draws a cell's background with the given parameters. * @param {Object} options * @param {number} options.x - x in cells * @param {number} options.y - y in cells * @param {number} options.cellWidth - cell width in pixels * @param {number} options.cellHeight - cell height in pixels * @param {number} options.bg - the background color */ drawBackground ({ x, y, cellWidth, cellHeight, bg }) { const ctx = this.ctx ctx.fillStyle = this.getColor(bg) ctx.clearRect(x * cellWidth, y * cellHeight, cellWidth, cellHeight) ctx.fillRect(x * cellWidth, y * cellHeight, cellWidth, cellHeight) } /** * Draws a cell's character with the given parameters. Won't do anything if * text is an empty string. * @param {Object} options * @param {number} options.x - x in cells * @param {number} options.y - y in cells * @param {Object} options.charSize - the character size, an object with * `width` and `height` in pixels * @param {number} options.cellWidth - cell width in pixels * @param {number} options.cellHeight - cell height in pixels * @param {string} options.text - the cell content * @param {number} options.fg - the foreground color * @param {number} options.attrs - the cell's attributes */ drawCharacter ({ x, y, charSize, cellWidth, cellHeight, text, fg, attrs }) { if (!text) return const ctx = this.ctx let underline = false let strike = false let overline = false if (attrs & (1 << 1)) ctx.globalAlpha = 0.5 if (attrs & (1 << 3)) underline = true if (attrs & (1 << 5)) text = this.alphaToFraktur(text) if (attrs & (1 << 6)) strike = true if (attrs & (1 << 7)) overline = true ctx.fillStyle = this.getColor(fg) let codePoint = text.codePointAt(0) if (codePoint >= 0x2580 && codePoint <= 0x259F) { // block elements ctx.beginPath() const left = x * cellWidth const top = y * cellHeight const cw = cellWidth const ch = cellHeight const c2w = cellWidth / 2 const c2h = cellHeight / 2 // http://www.fileformat.info/info/unicode/block/block_elements/utf8test.htm // 0x00 0x01 0x02 0x03 0x04 0x05 0x06 0x07 0x08 0x09 0x0A 0x0B 0x0C 0x0D 0x0E 0x0F // 0x2580 ▀ ▁ ▂ ▃ ▄ ▅ ▆ ▇ █ ▉ ▊ ▋ ▌ ▍ ▎ ▏ // 0x2590 ▐ ░ ▒ ▓ ▔ ▕ ▖ ▗ ▘ ▙ ▚ ▛ ▜ ▝ ▞ ▟ if (codePoint === 0x2580) { // upper half block >▀< ctx.rect(left, top, cw, c2h) } else if (codePoint <= 0x2588) { // lower n eighth block (increasing) >▁< to >█< let offset = (1 - (codePoint - 0x2580) / 8) * ch ctx.rect(left, top + offset, cw, ch - offset) } else if (codePoint <= 0x258F) { // left n eighth block (decreasing) >▉< to >▏< let offset = (codePoint - 0x2588) / 8 * cw ctx.rect(left, top, cw - offset, ch) } else if (codePoint === 0x2590) { // right half block >▐< ctx.rect(left + c2w, top, c2w, ch) } else if (codePoint <= 0x2593) { // shading >░< >▒< >▓< // dot spacing by dividing cell size by a constant. This could be // reworked to always return a whole number, but that would require // prime factorization, and doing that without a loop would let you // take over the world, which is not within the scope of this project. let dotSpacingX, dotSpacingY, dotSize if (codePoint === 0x2591) { dotSpacingX = cw / 4 dotSpacingY = ch / 10 dotSize = 1 } else if (codePoint === 0x2592) { dotSpacingX = cw / 6 dotSpacingY = cw / 10 dotSize = 1 } else if (codePoint === 0x2593) { dotSpacingX = cw / 4 dotSpacingY = cw / 7 dotSize = 2 } let alignRight = false for (let dy = 0; dy < ch; dy += dotSpacingY) { for (let dx = 0; dx < cw; dx += dotSpacingX) { // prevent overflow let dotSizeY = Math.min(dotSize, ch - dy) ctx.rect(x * cw + (alignRight ? cw - dx - dotSize : dx), y * ch + dy, dotSize, dotSizeY) } alignRight = !alignRight } } else if (codePoint === 0x2594) { // upper one eighth block >▔< ctx.rect(x * cw, y * ch, cw, ch / 8) } else if (codePoint === 0x2595) { // right one eighth block >▕< ctx.rect((x + 7 / 8) * cw, y * ch, cw / 8, ch) } else if (codePoint === 0x2596) { // left bottom quadrant >▖< ctx.rect(left, top + c2h, c2w, c2h) } else if (codePoint === 0x2597) { // right bottom quadrant >▗< ctx.rect(left + c2w, top + c2h, c2w, c2h) } else if (codePoint === 0x2598) { // left top quadrant >▘< ctx.rect(left, top, c2w, c2h) } else if (codePoint === 0x2599) { // left chair >▙< ctx.rect(left, top, c2w, ch) ctx.rect(left + c2w, top + c2h, c2w, c2h) } else if (codePoint === 0x259A) { // quadrants lt rb >▚< ctx.rect(left, top, c2w, c2h) ctx.rect(left + c2w, top + c2h, c2w, c2h) } else if (codePoint === 0x259B) { // left chair upside down >▛< ctx.rect(left, top, c2w, ch) ctx.rect(left + c2w, top, c2w, c2h) } else if (codePoint === 0x259C) { // right chair upside down >▜< ctx.rect(left, top, cw, c2h) ctx.rect(left + c2w, top + c2h, c2w, c2h) } else if (codePoint === 0x259D) { // right top quadrant >▝< ctx.rect(left + c2w, top, c2w, c2h) } else if (codePoint === 0x259E) { // quadrants lb rt >▞< ctx.rect(left, top + c2h, c2w, c2h) ctx.rect(left + c2w, top, c2w, c2h) } else if (codePoint === 0x259F) { // right chair upside down >▟< ctx.rect(left, top + c2h, c2w, c2h) ctx.rect(left + c2w, top, c2w, ch) } ctx.fill() } else { // Draw other characters using the text renderer ctx.fillText(text, (x + 0.5) * cellWidth, (y + 0.5) * cellHeight) } // -- line drawing - a reference for a possible future rect/line implementation --- // http://www.fileformat.info/info/unicode/block/box_drawing/utf8test.htm // 0x00 0x01 0x02 0x03 0x04 0x05 0x06 0x07 0x08 0x09 0x0A 0x0B 0x0C 0x0D 0x0E 0x0F // 0x2500 ─ ━ │ ┃ ┄ ┅ ┆ ┇ ┈ ┉ ┊ ┋ ┌ ┍ ┎ ┏ // 0x2510 ┐ ┑ ┒ ┓ └ ┕ ┖ ┗ ┘ ┙ ┚ ┛ ├ ┝ ┞ ┟ // 0x2520 ┠ ┡ ┢ ┣ ┤ ┥ ┦ ┧ ┨ ┩ ┪ ┫ ┬ ┭ ┮ ┯ // 0x2530 ┰ ┱ ┲ ┳ ┴ ┵ ┶ ┷ ┸ ┹ ┺ ┻ ┼ ┽ ┾ ┿ // 0x2540 ╀ ╁ ╂ ╃ ╄ ╅ ╆ ╇ ╈ ╉ ╊ ╋ ╌ ╍ ╎ ╏ // 0x2550 ═ ║ ╒ ╓ ╔ ╕ ╖ ╗ ╘ ╙ ╚ ╛ ╜ ╝ ╞ ╟ // 0x2560 ╠ ╡ ╢ ╣ ╤ ╥ ╦ ╧ ╨ ╩ ╪ ╫ ╬ ╭ ╮ ╯ // 0x2570 ╰ ╱ ╲ ╳ ╴ ╵ ╶ ╷ ╸ ╹ ╺ ╻ ╼ ╽ ╾ ╿ 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 } /** * Returns all adjacent cell indices given a radius. * @param {number} cell - the center cell index * @param {number} [radius] - the radius. 1 by default * @returns {number[]} an array of cell indices */ getAdjacentCells (cell, radius = 1) { const { width, height } = this.window const screenLength = width * height let cells = [] for (let x = -radius; x <= radius; x++) { for (let y = -radius; y <= radius; y++) { if (x === 0 && y === 0) continue cells.push(cell + x + y * width) } } return cells.filter(cell => cell >= 0 && cell < screenLength) } /** * Updates the screen. * @param {string} why - the draw reason (for debugging) */ draw (why) { const ctx = this.ctx const { width, height, devicePixelRatio, statusScreen } = this.window if (statusScreen) { // draw status screen instead this.drawStatus(statusScreen) this.startDrawLoop() return } else this.stopDrawLoop() const charSize = this.getCharSize() const { width: cellWidth, height: cellHeight } = this.getCellSize() const screenLength = width * height ctx.setTransform(devicePixelRatio, 0, 0, devicePixelRatio, 0, 0) if (this.window.debug && this._debug) this._debug.drawStart(why) 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 let fontGroups = new Map() // Map of (cell index) -> boolean, whether or not a cell has updated let 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 && this.cursor.blinkOn && this.cursor.visible let wasCursor = x === this.drawnCursor[0] && y === this.drawnCursor[1] let inSelection = this.isInSelection(x, y) let text = this.screen[cell] let fg = this.screenFG[cell] let bg = this.screenBG[cell] let attrs = this.screenAttrs[cell] if (attrs & (1 << 4) && !this.window.blinkStyleOn) { // blinking is enabled and blink style is off // set text to nothing so drawCharacter doesn't draw anything text = '' } if (inSelection) { fg = -1 bg = -2 } let didUpdate = text !== this.drawnScreen[cell] || // text updated fg !== this.drawnScreenFG[cell] || // foreground updated, and this cell has text bg !== this.drawnScreenBG[cell] || // background updated attrs !== this.drawnScreenAttrs[cell] || // attributes updated isCursor !== wasCursor || // cursor blink/position updated (isCursor && this.cursor.style !== this.drawnCursor[2]) // cursor style updated let font = attrs & FONT_MASK if (!fontGroups.has(font)) fontGroups.set(font, []) fontGroups.get(font).push([cell, x, y, text, fg, bg, attrs, isCursor, inSelection]) updateMap.set(cell, didUpdate) } // Map of (cell index) -> boolean, whether or not a cell should be redrawn const redrawMap = new Map() let isTextWide = text => text !== ' ' && ctx.measureText(text).width >= (cellWidth + 0.05) // decide for each cell if it should be redrawn let updateRedrawMapAt = cell => { let shouldUpdate = updateMap.get(cell) || redrawMap.get(cell) || false // TODO: fonts (necessary?) let text = this.screen[cell] let isWideCell = isTextWide(text) let checkRadius = isWideCell ? 2 : 1 if (!shouldUpdate) { // check adjacent cells let adjacentDidUpdate = false for (let adjacentCell of this.getAdjacentCells(cell, checkRadius)) { // update this cell if: // - the adjacent cell updated (For now, this'll always be true because characters can be slightly larger than they say they are) // - the adjacent cell updated and this cell or the adjacent cell is wide if (updateMap.get(adjacentCell) && (this.window.graphics < 2 || isWideCell || isTextWide(this.screen[adjacentCell]))) { adjacentDidUpdate = true break } } if (adjacentDidUpdate) shouldUpdate = true } redrawMap.set(cell, shouldUpdate) } for (let cell of updateMap.keys()) updateRedrawMapAt(cell) // mask to redrawing regions only if (this.window.graphics >= 1) { let debug = this.window.debug && this._debug ctx.save() ctx.beginPath() for (let y = 0; y < height; y++) { let regionStart = null for (let x = 0; x < width; x++) { let cell = y * width + x let redrawing = redrawMap.get(cell) if (redrawing && regionStart === null) regionStart = x if (!redrawing && regionStart !== null) { ctx.rect(regionStart * cellWidth, y * cellHeight, (x - regionStart) * cellWidth, cellHeight) if (debug) this._debug.clipRect(regionStart * cellWidth, y * cellHeight, (x - regionStart) * cellWidth, cellHeight) regionStart = null } } if (regionStart !== null) { ctx.rect(regionStart * cellWidth, y * cellHeight, (width - regionStart) * cellWidth, cellHeight) if (debug) this._debug.clipRect(regionStart * cellWidth, y * cellHeight, (width - regionStart) * cellWidth, cellHeight) } } ctx.clip() } // pass 1: backgrounds for (let font of fontGroups.keys()) { for (let data of fontGroups.get(font)) { let [cell, x, y, text, , bg] = data if (redrawMap.get(cell)) { this.drawBackground({ x, y, cellWidth, cellHeight, bg }) if (this.window.debug && this._debug) { // set cell flags let flags = (+redrawMap.get(cell)) flags |= (+updateMap.get(cell)) << 1 flags |= (+isTextWide(text)) << 2 this._debug.setCell(cell, flags) } } } } // reset drawn cursor this.drawnCursor = [-1, -1, -1] // pass 2: characters 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, inSelection] = data if (redrawMap.get(cell)) { this.drawCharacter({ x, y, charSize, cellWidth, cellHeight, text, fg, attrs }) this.drawnScreen[cell] = text this.drawnScreenFG[cell] = fg this.drawnScreenBG[cell] = bg this.drawnScreenAttrs[cell] = attrs if (isCursor) this.drawnCursor = [x, y, this.cursor.style] if (isCursor && !inSelection) { ctx.save() ctx.beginPath() if (this.cursor.style === 'block') { // block ctx.rect(x * cellWidth, y * cellHeight, cellWidth, cellHeight) } else 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, bg] = [bg, fg] // HACK: ensure cursor is visible if (fg === bg) bg = fg === 0 ? 7 : 0 this.drawBackground({ x, y, cellWidth, cellHeight, bg }) this.drawCharacter({ x, y, charSize, cellWidth, cellHeight, text, fg, attrs }) ctx.restore() } } } } if (this.window.graphics >= 1) ctx.restore() if (this.window.debug && this._debug) this._debug.drawEnd() this.emit('draw') } drawStatus (statusScreen) { const ctx = this.ctx const { fontFamily, width, height, devicePixelRatio } = this.window // reset drawnScreen to force redraw when statusScreen is disabled this.drawnScreen = [] const cellSize = this.getCellSize() const screenWidth = width * cellSize.width const screenHeight = height * cellSize.height ctx.setTransform(devicePixelRatio, 0, 0, devicePixelRatio, 0, 0) ctx.clearRect(0, 0, screenWidth, screenHeight) ctx.font = `24px ${fontFamily}` ctx.fillStyle = '#fff' ctx.textAlign = 'center' ctx.textBaseline = 'middle' ctx.fillText(statusScreen.title || '', screenWidth / 2, screenHeight / 2 - 50) if (statusScreen.loading) { // show loading spinner ctx.save() ctx.translate(screenWidth / 2, screenHeight / 2 + 20) ctx.strokeStyle = '#fff' ctx.lineWidth = 5 ctx.lineCap = 'round' let t = Date.now() / 1000 for (let i = 0; i < 12; i++) { ctx.rotate(Math.PI / 6) let offset = ((t * 12) - i) % 12 ctx.globalAlpha = Math.max(0.2, 1 - offset / 3) ctx.beginPath() ctx.moveTo(0, 15) ctx.lineTo(0, 30) ctx.stroke() } ctx.restore() } } startDrawLoop () { if (this._drawTimerThread) return let threadID = Math.random().toString(36) this._drawTimerThread = threadID this.drawTimerLoop(threadID) } stopDrawLoop () { this._drawTimerThread = null } drawTimerLoop (threadID) { if (!threadID || threadID !== this._drawTimerThread) return window.requestAnimationFrame(() => this.drawTimerLoop(threadID)) this.draw('draw-loop') } /** * Parses the content of an `S` message and schedules a draw * @param {string} str - the message content */ loadContent (str) { // current index let i = 0 // Uncomment to capture screen content for the demo page // console.log(JSON.stringify(`S${str}`)) if (!this.contentLoaded) { let errmsg = qs('#load-failed') if (errmsg) errmsg.parentNode.removeChild(errmsg) this.contentLoaded = true } // window size const newHeight = parse2B(str, i) const newWidth = parse2B(str, i + 2) const resized = (this.window.height !== newHeight) || (this.window.width !== newWidth) this.window.height = newHeight this.window.width = newWidth 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)) this.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() } this.input.setMouseMode(trackMouseClicks, trackMouseMovement) this.selection.selectable = !trackMouseClicks && !trackMouseMovement $(this.canvas).toggleClass('selectable', this.selection.selectable) 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) this.bracketedPaste = !!(attributes & (1 << 13)) // 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 if (resized) { this.updateSize() this.blinkingCellCount = 0 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 = Array.from ? Array.from(str) : str.split('') const MASK_LINE_ATTR = 0xC8 const MASK_BLINK = 1 << 4 let setCellContent = () => { // Remove blink attribute if it wouldn't have any effect let myAttrs = attrs if ((myAttrs & MASK_BLINK) !== 0 && ((lastChar === ' ' && ((myAttrs & MASK_LINE_ATTR) === 0)) || // no line styles fg === bg // invisible text ) ) { myAttrs ^= MASK_BLINK } // update blinking cells counter if blink state changed if ((this.screenAttrs[cell] & MASK_BLINK) !== (myAttrs & MASK_BLINK)) { if (myAttrs & MASK_BLINK) this.blinkingCellCount++ else this.blinkingCellCount-- } this.screen[cell] = lastChar this.screenFG[cell] = fg this.screenBG[cell] = bg this.screenAttrs[cell] = myAttrs } while (i < strArray.length && cell < screenLength) { let character = strArray[i++] let charCode = character.codePointAt(0) let data switch (charCode) { case SEQ_REPEAT: let count = parse2B(strArray[i] + strArray[i + 1]) i += 2 for (let j = 0; j < count; j++) { setCellContent(cell) if (++cell > screenLength) break } break case SEQ_SET_COLORS: data = parse3B(strArray[i] + strArray[i + 1] + strArray[i + 2]) i += 3 fg = data & 0xFF bg = (data >> 8) & 0xFF break case SEQ_SET_ATTRS: data = parse2B(strArray[i] + strArray[i + 1]) i += 2 attrs = data & 0xFF break case SEQ_SET_FG: data = parse2B(strArray[i] + strArray[i + 1]) i += 2 fg = data & 0xFF break case SEQ_SET_BG: data = parse2B(strArray[i] + strArray[i + 1]) i += 2 bg = data & 0xFF break default: if (charCode < 32) character = '\ufffd' lastChar = character setCellContent(cell) cell++ } } if (this.window.debug) console.log(`Blinky cells = ${this.blinkingCellCount}`) this.scheduleDraw('load', 16) this.emit('load') } /** * Parses the content of a `T` message and updates the screen title and button * labels. * @param {string} str - the message content */ loadLabels (str) { let pieces = str.split('\x01') let screenTitle = pieces[0] qs('#screen-title').textContent = screenTitle if (screenTitle.length === 0) screenTitle = 'Terminal' qs('title').textContent = `${screenTitle} :: ESPTerm` $('#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 ? $.htmlEscape(label) : ' ' button.style.opacity = label ? 1 : 0.2 }) } /** * Shows an actual notification (if possible) or a notification balloon. * @param {string} text - the notification content */ showNotification (text) { console.info(`Notification: ${text}`) if (window.Notification && window.Notification.permission === 'granted') { let notification = new window.Notification('ESPTerm', { body: text }) notification.addEventListener('click', () => window.focus()) } else { if (window.Notification && window.Notification.permission !== 'denied') { window.Notification.requestPermission() } else { // Fallback using the built-in notification balloon notify.show(text) } } } /** * Loads a message from the server, and optionally a theme. * @param {string} str - the message * @param {number} [theme] - the new theme index */ load (str, theme = -1) { const content = str.substr(1) if (theme >= 0 && theme < themes.length) { this.palette = themes[theme] } 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(str)}`) } } /** * Creates a beep sound. */ beep () { const audioCtx = this.audioCtx if (!audioCtx) return // prevent screeching if (this._lastBeep && this._lastBeep > Date.now() - 50) return this._lastBeep = Date.now() if (!this._convolver) { this._convolver = audioCtx.createConvolver() let impulseLength = audioCtx.sampleRate * 0.8 let impulse = audioCtx.createBuffer(2, impulseLength, audioCtx.sampleRate) for (let i = 0; i < impulseLength; i++) { impulse.getChannelData(0)[i] = (1 - i / impulseLength) ** (7 + Math.random()) impulse.getChannelData(1)[i] = (1 - i / impulseLength) ** (7 + Math.random()) } this._convolver.buffer = impulse this._convolver.connect(audioCtx.destination) } // main beep const mainOsc = audioCtx.createOscillator() const mainGain = audioCtx.createGain() mainOsc.connect(mainGain) mainGain.gain.value = 6 mainOsc.frequency.value = 750 mainOsc.type = 'sine' // surrogate beep (making it sound like 'oops') const surrOsc = audioCtx.createOscillator() const surrGain = audioCtx.createGain() surrOsc.connect(surrGain) surrGain.gain.value = 4 surrOsc.frequency.value = 400 surrOsc.type = 'sine' mainGain.connect(this._convolver) surrGain.connect(this._convolver) let startTime = audioCtx.currentTime mainOsc.start() mainOsc.stop(startTime + 0.5) surrOsc.start(startTime + 0.05) surrOsc.stop(startTime + 0.8) let loop = function () { if (audioCtx.currentTime < startTime + 0.8) window.requestAnimationFrame(loop) mainGain.gain.value *= 0.8 surrGain.gain.value *= 0.8 } loop() } /** * Converts an alphabetic character to its fraktur variant. * @param {string} character - the character * @returns {string} the converted character */ alphaToFraktur (character) { if (character >= 'a' && character <= 'z') { character = String.fromCodePoint(0x1d51e - 0x61 + character.charCodeAt(0)) } else if (character >= 'A' && character <= 'Z') { character = this.frakturExceptions[character] || String.fromCodePoint( 0x1d504 - 0x41 + character.charCodeAt(0)) } return character } }