// Some non-bold Fraktur symbols are outside the contiguous block const frakturExceptions = { 'C': '\u212d', 'H': '\u210c', 'I': '\u2111', 'R': '\u211c', 'Z': '\u2128' } // constants for decoding the update blob const SEQ_SET_COLOR_ATTR = 1 const SEQ_REPEAT = 2 const SEQ_SET_COLOR = 3 const SEQ_SET_ATTR = 4 const themes = [ [ '#111213', '#CC0000', '#4E9A06', '#C4A000', '#3465A4', '#75507B', '#06989A', '#D3D7CF', '#555753', '#EF2929', '#8AE234', '#FCE94F', '#729FCF', '#AD7FA8', '#34E2E2', '#EEEEEC' ] ] class TermScreen { constructor () { this.canvas = document.createElement('canvas') this.ctx = this.canvas.getContext('2d') if ('AudioContext' in window || 'webkitAudioContext' in window) { this.audioCtx = new (window.AudioContext || window.webkitAudioContext)() } else { console.warn('No AudioContext!') } this.cursor = { x: 0, y: 0, fg: 7, bg: 0, attrs: 0, blinkOn: false, visible: true, hanging: false, blinkInterval: null } this._colors = themes[0] this._window = { width: 0, height: 0, devicePixelRatio: 1, fontFamily: '"DejaVu Sans Mono", "Liberation Mono", "Inconsolata", ' + 'monospace', fontSize: 20, gridScaleX: 1.0, gridScaleY: 1.2, blinkStyleOn: true, blinkInterval: null } this.windowState = { width: 0, height: 0, devicePixelRatio: 0, gridScaleX: 0, gridScaleY: 0, fontFamily: '', fontSize: 0 } const self = this this.window = new Proxy(this._window, { set (target, key, value, receiver) { target[key] = value self.updateSize() self.scheduleDraw() return true } }) this.screen = [] this.screenFG = [] this.screenBG = [] this.screenAttrs = [] this.resetBlink() this.resetCursorBlink() } get colors () { return this._colors } set colors (theme) { this._colors = theme this.scheduleDraw() } // schedule a draw in the next tick scheduleDraw () { clearTimeout(this._scheduledDraw) this._scheduledDraw = setTimeout(() => this.draw(), 1) } getFont (modifiers = {}) { let fontStyle = modifiers.style || 'normal' let fontWeight = modifiers.weight || 'normal' return `${fontStyle} normal ${fontWeight} ${ this.window.fontSize}px ${this.window.fontFamily}` } getCharSize () { this.ctx.font = this.getFont() return { width: this.ctx.measureText(' ').width, height: this.window.fontSize } } updateSize () { this._window.devicePixelRatio = window.devicePixelRatio || 1 let didChange = false for (let key in this.windowState) { if (this.windowState[key] !== this.window[key]) { didChange = true this.windowState[key] = this.window[key] } } if (didChange) { const { width, height, devicePixelRatio, gridScaleX, gridScaleY, fontSize } = this.window const charSize = this.getCharSize() this.canvas.width = width * devicePixelRatio * charSize.width * gridScaleX this.canvas.style.width = `${width * charSize.width * gridScaleX}px` this.canvas.height = height * devicePixelRatio * charSize.height * gridScaleY this.canvas.style.height = `${height * charSize.height * gridScaleY}px` } } resetCursorBlink () { clearInterval(this.cursor.blinkInterval) this.cursor.blinkInterval = setInterval(() => { this.cursor.blinkOn = !this.cursor.blinkOn this.scheduleDraw() }, 500) } resetBlink () { clearInterval(this.window.blinkInterval) let intervals = 0 this.window.blinkInterval = setInterval(() => { intervals++ if (intervals >= 4 && this.window.blinkStyleOn) { this.window.blinkStyleOn = false intervals = 0 } else { this.window.blinkStyleOn = true intervals = 0 } }, 200) } drawCell ({ x, y, charSize, cellWidth, cellHeight, text, fg, bg, attrs }) { const ctx = this.ctx ctx.fillStyle = this.colors[bg] ctx.globalCompositeOperation = 'destination-over' ctx.fillRect(x * cellWidth, y * cellHeight, Math.ceil(cellWidth), Math.ceil(cellHeight)) ctx.globalCompositeOperation = 'source-over' let fontModifiers = {} let underline = false let blink = false let strike = false if (attrs & 1) fontModifiers.weight = 'bold' if (attrs & 1 << 1) ctx.globalAlpha = 0.5 if (attrs & 1 << 2) fontModifiers.style = 'italic' if (attrs & 1 << 3) underline = true if (attrs & 1 << 4) blink = true if (attrs & 1 << 5) text = TermScreen.alphaToFraktur(text) if (attrs & 1 << 6) strike = true if (!blink || this.window.blinkStyleOn) { ctx.font = this.getFont(fontModifiers) ctx.fillStyle = this.colors[fg] ctx.fillText(text, (x + 0.5) * cellWidth, (y + 0.5) * cellHeight) if (underline || strike) { let lineY = underline ? y * cellHeight * charSize.height : (y + 0.5) * cellHeight ctx.strokeStyle = this.colors[fg] ctx.lineWidth = 1 ctx.moveTo(x * cellWidth, lineY) ctx.lineTo((x + 1) * cellWidth, lineY) ctx.stroke() } } ctx.globalAlpha = 1 } draw () { const ctx = this.ctx const { width, height, devicePixelRatio, fontFamily, fontSize, gridScaleX, gridScaleY } = this.window const charSize = this.getCharSize() const cellWidth = charSize.width * gridScaleX const cellHeight = charSize.height * gridScaleY const screenWidth = width * cellWidth const screenHeight = height * cellHeight const screenLength = width * height ctx.setTransform(this.window.devicePixelRatio, 0, 0, this.window.devicePixelRatio, 0, 0) ctx.clearRect(0, 0, screenWidth, screenHeight) ctx.font = this.getFont() ctx.textAlign = 'center' ctx.textBaseline = 'middle' for (let cell = 0; cell < screenLength; cell++) { let x = cell % width let y = Math.floor(cell / width) let isCursor = this.cursor.x === x && this.cursor.y === y let text = this.screen[cell] let fg = isCursor ? this.screenBG[cell] : this.screenFG[cell] let bg = isCursor ? this.screenFG[cell] : this.screenBG[cell] let attrs = this.screenAttrs[cell] if (isCursor && fg === bg) bg = fg === 0 ? 7 : 0 this.drawCell({ x, y, charSize, cellWidth, cellHeight, text, fg, bg, attrs }) } } loadContent (str) { // current index let i = 0 // window size this.window.height = parse2B(str, i) this.window.width = parse2B(str, i + 2) this.updateSize() i += 4 // cursor position let [cursorY, cursorX] = [parse2B(str, i), parse2B(str, i + 2)] i += 4 let cursorMoved = (cursorX !== this.cursor.x || cursorY !== this.cursor.y) this.cursor.x = cursorX this.cursor.y = cursorY if (cursorMoved) this.resetCursorBlink() // attributes let attributes = parse2B(str, i) i += 2 this.cursor.visible = !!(attributes & 1) this.cursor.hanging = !!(attributes & 1 << 0) Input.setAlts( !!(attributes & 1 << 2), // cursors alt !!(attributes & 1 << 3), // numpad alt !!(attributes & 1 << 4) // fn keys alt ) let trackMouseClicks = !!(attributes & 1 << 5) let trackMouseMovement = !!(attributes & 1 << 6) Input.setMouseMode(trackMouseClicks, trackMouseMovement) let showButtons = !!(attributes & 1 << 7) let showConfigLinks = !!(attributes & 1 << 8) $('.x-term-conf-btn').toggleClass('hidden', !showConfigLinks) $('#action-buttons').toggleClass('hidden', !showButtons) // content let fg = 7 let bg = 0 let attrs = 0 let cell = 0 // cell index let text = ' ' let screenLength = this.window.width * this.window.height this.screen = new Array(screenLength).fill(' ') this.screenFG = new Array(screenLength).fill(' ') this.screenBG = new Array(screenLength).fill(' ') this.screenAttrs = new Array(screenLength).fill(' ') while (i < str.length && cell < screenLength) { let character = str[i++] let charCode = character.charCodeAt(0) if (charCode === SEQ_SET_COLOR_ATTR) { let data = parse3B(str, i) i += 3 fg = data & 0xF bg = data >> 4 & 0xF attrs = data >> 8 & 0xFF } else if (charCode == SEQ_SET_COLOR) { let data = parse2B(str, i) i += 2 fg = data & 0xF bg = data >> 4 & 0xF } else if (charCode === SEQ_SET_ATTR) { let data = parse2B(str, i) i += 2 attrs = data & 0xFF } else if (charCode === SEQ_REPEAT) { let count = parse2B(str, i) i += 2 for (let j = 0; j < count; j++) { this.screen[cell] = text this.screenFG[cell] = fg this.screenBG[cell] = bg this.screenAttrs[cell] = attrs if (++cell > screenLength) break } } else { // unique cell character this.screen[cell] = text = character this.screenFG[cell] = fg this.screenBG[cell] = bg this.screenAttrs[cell] = attrs cell++ } } this.scheduleDraw() if (this.onload) this.onload() } /** Apply labels to buttons and screen title (leading T removed already) */ loadLabels (str) { let pieces = str.split('\x01') qs('h1').textContent = pieces[0] $('#action-buttons button').forEach((button, i) => { var label = pieces[i + 1].trim() // if empty string, use the "dim" effect and put nbsp instead to // stretch the button vertically button.innerHTML = label ? e(label) : ' '; button.style.opacity = label ? 1 : 0.2; }) } load (str) { const content = str.substr(1) switch (str[0]) { case 'S': this.loadContent(content) break case 'T': this.loadLabels(content) break case 'B': this.beep() break default: console.warn(`Bad data message type; ignoring.\n${ JSON.stringify(content)}`) } } beep () { const audioCtx = this.audioCtx if (!audioCtx) return let osc, gain // main beep osc = audioCtx.createOscillator() gain = audioCtx.createGain() osc.connect(gain) gain.connect(audioCtx.destination); gain.gain.value = 0.5; osc.frequency.value = 750; osc.type = 'sine'; osc.start(); osc.stop(audioCtx.currentTime + 0.05); // surrogate beep (making it sound like 'oops') osc = audioCtx.createOscillator(); gain = audioCtx.createGain(); osc.connect(gain); gain.connect(audioCtx.destination); gain.gain.value = 0.2; osc.frequency.value = 400; osc.type = 'sine'; osc.start(audioCtx.currentTime + 0.05); osc.stop(audioCtx.currentTime + 0.08); } static alphaToFraktur (character) { if ('a' <= character && character <= 'z') { character = String.fromCodePoint(0x1d51e - 0x61 + character.charCodeAt(0)) } else if ('A' <= character && character <= 'Z') { character = frakturExceptions[character] || String.fromCodePoint( 0x1d504 - 0x41 + character.charCodeAt(0)) } return character } } const Screen = new TermScreen() let didAddScreen = false Screen.onload = function () { if (didAddScreen) return didAddScreen = true qs('#screen').appendChild(Screen.canvas) }