diff --git a/js/index.js b/js/index.js index 24d776e..eff520a 100644 --- a/js/index.js +++ b/js/index.js @@ -2,7 +2,7 @@ require('./lib/polyfills') require('./modal') require('./notif') require('./appcommon') -try { require('./demo') } catch (err) {} +try { require('./term/demo') } catch (err) {} require('./wifi') const $ = require('./lib/chibi') @@ -13,4 +13,4 @@ window.termInit = require('./term') window.$ = $ window.qs = qs -window.themes = require('./themes') +window.themes = require('./term/themes') diff --git a/js/term_conn.js b/js/term/connection.js similarity index 99% rename from js/term_conn.js rename to js/term/connection.js index e74ade6..1506695 100644 --- a/js/term_conn.js +++ b/js/term/connection.js @@ -1,5 +1,5 @@ const EventEmitter = require('events') -const $ = require('./lib/chibi') +const $ = require('../lib/chibi') let demo try { demo = require('./demo') } catch (err) {} diff --git a/js/debug_screen.js b/js/term/debug_screen.js similarity index 99% rename from js/debug_screen.js rename to js/term/debug_screen.js index 0926a77..d49d747 100644 --- a/js/debug_screen.js +++ b/js/term/debug_screen.js @@ -1,4 +1,4 @@ -const { mk } = require('./utils') +const { mk } = require('../utils') module.exports = function attachDebugScreen (screen) { const debugCanvas = mk('canvas') diff --git a/js/demo.js b/js/term/demo.js similarity index 99% rename from js/demo.js rename to js/term/demo.js index 88811ba..7913157 100644 --- a/js/demo.js +++ b/js/term/demo.js @@ -1,5 +1,5 @@ const EventEmitter = require('events') -const { encode2B, encode3B, parse2B } = require('./utils') +const { encode2B, encode3B, parse2B } = require('../utils') class ANSIParser { constructor (handler) { diff --git a/js/term.js b/js/term/index.js similarity index 94% rename from js/term.js rename to js/term/index.js index 46ecab9..368a29f 100644 --- a/js/term.js +++ b/js/term/index.js @@ -1,9 +1,9 @@ -const { qs, mk } = require('./utils') -const Notify = require('./notif') -const TermScreen = require('./term_screen') -const TermConnection = require('./term_conn') -const TermInput = require('./term_input') -const TermUpload = require('./term_upload') +const { qs, mk } = require('../utils') +const Notify = require('../notif') +const TermScreen = require('./screen') +const TermConnection = require('./connection') +const TermInput = require('./input') +const TermUpload = require('./upload') const initSoftKeyboard = require('./soft_keyboard') const attachDebugScreen = require('./debug_screen') diff --git a/js/term_input.js b/js/term/input.js similarity index 99% rename from js/term_input.js rename to js/term/input.js index e4809ba..2b37f46 100644 --- a/js/term_input.js +++ b/js/term/input.js @@ -1,5 +1,5 @@ -const $ = require('./lib/chibi') -const { encode2B } = require('./utils') +const $ = require('../lib/chibi') +const { encode2B } = require('../utils') /** * User input diff --git a/js/term/screen.js b/js/term/screen.js new file mode 100644 index 0000000..d0bfa86 --- /dev/null +++ b/js/term/screen.js @@ -0,0 +1,577 @@ +const EventEmitter = require('events') +const $ = require('../lib/chibi') +const { mk, qs } = require('../utils') +const notify = require('../notif') +const ScreenParser = require('./screen_parser') +const ScreenRenderer = require('./screen_renderer') + +module.exports = class TermScreen extends EventEmitter { + constructor () { + super() + + this.canvas = mk('canvas') + this.ctx = this.canvas.getContext('2d') + + this.parser = new ScreenParser(this) + this.renderer = new ScreenRenderer(this) + + // debug screen handle + this._debug = null + + if ('AudioContext' in window || 'webkitAudioContext' in window) { + this.audioCtx = new (window.AudioContext || window.webkitAudioContext)() + } else { + console.warn('No AudioContext!') + } + + // dummy. Handle for Input + this.input = new Proxy({}, { + get () { + return () => console.warn('TermScreen#input not set!') + } + }) + + this.cursor = { + x: 0, + y: 0, + blinking: true, + visible: true, + hanging: false, + style: 'block' + } + + 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, + 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.renderer.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 = [] + + let selecting = false + + let selectStart = (x, y) => { + if (selecting) return + selecting = true + this.selection.start = this.selection.end = this.screenToGrid(x, y, true) + this.renderer.scheduleDraw('select-start') + } + + let selectMove = (x, y) => { + if (!selecting) return + this.selection.end = this.screenToGrid(x, y, true) + this.renderer.scheduleDraw('select-move') + } + + let selectEnd = (x, y) => { + if (!selecting) return + selecting = false + this.selection.end = this.screenToGrid(x, y, true) + this.renderer.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.renderer.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) + }) + } + + /** + * Schedule a size update in the next millisecond + */ + scheduleSizeUpdate () { + clearTimeout(this._scheduledSizeUpdate) + this._scheduledSizeUpdate = setTimeout(() => this.updateSize(), 1) + } + + /** + * 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}` + } + + /** + * 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) + } + + /** + * 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.renderer.resetDrawn() + + // draw immediately; the canvas shouldn't flash + this.renderer.draw('update-size') + } + } + + /** + * 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) + } + + /** + * 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) + } + } + } + + /** + * 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() + } + + load (...args) { + this.parser.load(...args) + } +} diff --git a/js/term/screen_parser.js b/js/term/screen_parser.js new file mode 100644 index 0000000..82123ab --- /dev/null +++ b/js/term/screen_parser.js @@ -0,0 +1,259 @@ +const $ = require('../lib/chibi') +const { qs, parse2B, parse3B } = require('../utils') +const { themes } = 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 + +module.exports = class ScreenParser { + constructor (screen) { + this.screen = screen + + // true if TermScreen#load was called at least once + this.contentLoaded = false + } + /** + * 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.screen.window.height !== newHeight) || (this.screen.window.width !== newWidth) + this.screen.window.height = newHeight + this.screen.window.width = newWidth + i += 4 + + // cursor position + let [cursorY, cursorX] = [parse2B(str, i), parse2B(str, i + 2)] + i += 4 + let cursorMoved = (cursorX !== this.screen.cursor.x || cursorY !== this.screen.cursor.y) + this.screen.cursor.x = cursorX + this.screen.cursor.y = cursorY + + if (cursorMoved) { + this.screen.renderer.resetCursorBlink() + this.screen.emit('cursor-moved') + } + + // attributes + let attributes = parse3B(str, i) + i += 3 + + this.screen.cursor.visible = !!(attributes & 1) + this.screen.cursor.hanging = !!(attributes & (1 << 1)) + + this.screen.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.screen.cursor.style = 'block' + else if (cursorStyle === 1) this.screen.cursor.style = 'line' + else if (cursorStyle === 2) this.screen.cursor.style = 'bar' + + if (this.screen.cursor.blinking !== cursorBlinking) { + this.screen.cursor.blinking = cursorBlinking + this.screen.renderer.resetCursorBlink() + } + + this.screen.input.setMouseMode(trackMouseClicks, trackMouseMovement) + this.screen.selection.selectable = !trackMouseClicks && !trackMouseMovement + $(this.screen.canvas).toggleClass('selectable', this.screen.selection.selectable) + this.screen.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.screen.bracketedPaste = !!(attributes & (1 << 13)) + + // content + let fg = 7 + let bg = 0 + let attrs = 0 + let cell = 0 // cell index + let lastChar = ' ' + let screenLength = this.screen.window.width * this.screen.window.height + + if (resized) { + this.screen.updateSize() + this.screen.blinkingCellCount = 0 + this.screen.screen = new Array(screenLength).fill(' ') + this.screen.screenFG = new Array(screenLength).fill(' ') + this.screen.screenBG = new Array(screenLength).fill(' ') + this.screen.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.screen.screenAttrs[cell] & MASK_BLINK) !== (myAttrs & MASK_BLINK)) { + if (myAttrs & MASK_BLINK) this.screen.blinkingCellCount++ + else this.screen.blinkingCellCount-- + } + + this.screen.screen[cell] = lastChar + this.screen.screenFG[cell] = fg + this.screen.screenBG[cell] = bg + this.screen.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.screen.window.debug) console.log(`Blinky cells: ${this.screen.blinkingCellCount}`) + + this.screen.renderer.scheduleDraw('load', 16) + this.screen.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 + }) + } + + /** + * 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.screen.renderer.palette = themes[theme] + } + + switch (str[0]) { + case 'S': + this.loadContent(content) + break + + case 'T': + this.loadLabels(content) + break + + case 'B': + this.screen.beep() + break + + case 'G': + this.screen.showNotification(content) + break + + default: + console.warn(`Bad data message type; ignoring.\n${JSON.stringify(str)}`) + } + } +} diff --git a/js/term/screen_renderer.js b/js/term/screen_renderer.js new file mode 100644 index 0000000..0b3ad4e --- /dev/null +++ b/js/term/screen_renderer.js @@ -0,0 +1,673 @@ +const { themes, buildColorTable, SELECTION_FG, SELECTION_BG } = require('./themes') + +// Some non-bold Fraktur symbols are outside the contiguous block +const frakturExceptions = { + 'C': '\u212d', + 'H': '\u210c', + 'I': '\u2111', + 'R': '\u211c', + 'Z': '\u2128' +} + +module.exports = class ScreenRenderer { + constructor (screen) { + this.screen = screen + this.ctx = screen.ctx + + this._palette = null + + // 256color lookup table + // should not be used to look up 0-15 (will return transparent) + this.colorTable256 = buildColorTable() + + this.resetDrawn() + + this.blinkStyleOn = false + this.blinkInterval = null + this.cursorBlinkOn = false + this.cursorBlinkInterval = null + + // start blink timers + this.resetBlink() + this.resetCursorBlink() + } + + resetDrawn () { + // 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, ''] + } + + /** + * 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') + } + } + + /** + * 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 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)' + } + + /** + * Resets the cursor blink to on and restarts the timer + */ + resetCursorBlink () { + this.cursorBlinkOn = true + clearInterval(this.cursorBlinkInterval) + this.cursorBlinkInterval = setInterval(() => { + this.cursorBlinkOn = this.screen.cursor.blinking + ? !this.cursorBlinkOn + : true + if (this.screen.cursor.blinking) this.scheduleDraw('cursor-blink') + }, 500) + } + + /** + * Resets the blink style to on and restarts the timer + */ + resetBlink () { + this.blinkStyleOn = true + clearInterval(this.blinkInterval) + let intervals = 0 + this.blinkInterval = setInterval(() => { + if (this.screen.blinkingCellCount <= 0) return + + intervals++ + if (intervals >= 4 && this.blinkStyleOn) { + this.blinkStyleOn = false + intervals = 0 + this.scheduleDraw('blink-style') + } else if (intervals >= 1 && !this.blinkStyleOn) { + this.blinkStyleOn = true + intervals = 0 + this.scheduleDraw('blink-style') + } + }, 200) + } + + /** + * 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 = ScreenRenderer.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.screen.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.screen.window + + if (statusScreen) { + // draw status screen instead + this.drawStatus(statusScreen) + this.startDrawLoop() + return + } else this.stopDrawLoop() + + const charSize = this.screen.getCharSize() + const { width: cellWidth, height: cellHeight } = this.screen.getCellSize() + const screenLength = width * height + + ctx.setTransform(devicePixelRatio, 0, 0, devicePixelRatio, 0, 0) + + if (this.screen.window.debug && this.screen._debug) this.screen._debug.drawStart(why) + + ctx.font = this.screen.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.screen.cursor.hanging && + this.screen.cursor.x === x && + this.screen.cursor.y === y && + this.screen.cursor.visible && + this.cursorBlinkOn + + let wasCursor = x === this.drawnCursor[0] && y === this.drawnCursor[1] + + let inSelection = this.screen.isInSelection(x, y) + + let text = this.screen.screen[cell] + let fg = this.screen.screenFG[cell] + let bg = this.screen.screenBG[cell] + let attrs = this.screen.screenAttrs[cell] + + if (attrs & (1 << 4) && !this.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.screen.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.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.screen.window.graphics < 2 || isWideCell || isTextWide(this.screen.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.screen.window.graphics >= 1) { + let debug = this.screen.window.debug && this.screen._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.screen._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.screen._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.screen.window.debug && this.screen._debug) { + // set cell flags + let flags = (+redrawMap.get(cell)) + flags |= (+updateMap.get(cell)) << 1 + flags |= (+isTextWide(text)) << 2 + this.screen._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.screen.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.screen.cursor.style] + + if (isCursor && !inSelection) { + ctx.save() + ctx.beginPath() + if (this.screen.cursor.style === 'block') { + // block + ctx.rect(x * cellWidth, y * cellHeight, cellWidth, cellHeight) + } else if (this.screen.cursor.style === 'bar') { + // vertical bar + let barWidth = 2 + ctx.rect(x * cellWidth, y * cellHeight, barWidth, cellHeight) + } else if (this.screen.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.screen.window.graphics >= 1) ctx.restore() + + if (this.screen.window.debug && this.screen._debug) this.screen._debug.drawEnd() + + this.screen.emit('draw') + } + + drawStatus (statusScreen) { + const ctx = this.ctx + const { + fontFamily, + width, + height, + devicePixelRatio + } = this.screen.window + + // reset drawnScreen to force redraw when statusScreen is disabled + this.drawnScreen = [] + + const cellSize = this.screen.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') + } + + /** + * Converts an alphabetic character to its fraktur variant. + * @param {string} character - the character + * @returns {string} the converted character + */ + static alphaToFraktur (character) { + if (character >= 'a' && character <= 'z') { + character = String.fromCodePoint(0x1d51e - 0x61 + character.charCodeAt(0)) + } else if (character >= 'A' && character <= 'Z') { + character = frakturExceptions[character] || String.fromCodePoint(0x1d504 - 0x41 + character.charCodeAt(0)) + } + return character + } +} diff --git a/js/soft_keyboard.js b/js/term/soft_keyboard.js similarity index 98% rename from js/soft_keyboard.js rename to js/term/soft_keyboard.js index a462180..08299ba 100644 --- a/js/soft_keyboard.js +++ b/js/term/soft_keyboard.js @@ -1,4 +1,4 @@ -const { qs } = require('./utils') +const { qs } = require('../utils') module.exports = function (screen, input) { const keyInput = qs('#softkb-input') diff --git a/js/themes.js b/js/term/themes.js similarity index 98% rename from js/themes.js rename to js/term/themes.js index f957b06..e8323cf 100644 --- a/js/themes.js +++ b/js/term/themes.js @@ -72,6 +72,9 @@ exports.buildColorTable = function () { return colorTable256 } +exports.SELECTION_FG = '#333' +exports.SELECTION_BG = '#b2d7fe' + exports.themePreview = function (n) { document.querySelectorAll('[data-fg]').forEach((elem) => { let shade = +elem.dataset.fg diff --git a/js/term_upload.js b/js/term/upload.js similarity index 97% rename from js/term_upload.js rename to js/term/upload.js index fabd795..6632755 100644 --- a/js/term_upload.js +++ b/js/term/upload.js @@ -1,6 +1,6 @@ -const $ = require('./lib/chibi') -const { qs } = require('./utils') -const modal = require('./modal') +const $ = require('../lib/chibi') +const { qs } = require('../utils') +const modal = require('../modal') /** File upload utility */ module.exports = function (conn, input, screen) { diff --git a/js/term_screen.js b/js/term_screen.js deleted file mode 100644 index c83519c..0000000 --- a/js/term_screen.js +++ /dev/null @@ -1,1483 +0,0 @@ -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 - } -}