class ANSIParser { constructor (handler) { this.reset() this.handler = handler this.joinChunks = true } reset () { this.currentSequence = 0 this.sequence = '' } parseSequence (sequence) { if (sequence[0] === '[') { let type = sequence[sequence.length - 1] let content = sequence.substring(1, sequence.length - 1) let numbers = content ? content.split(';').map(i => +i.replace(/\D/g, '')) : [] let numOr1 = numbers.length ? numbers[0] : 1 if (type === 'H') { this.handler('set-cursor', (numbers[0] | 0) - 1, (numbers[1] | 0) - 1) } else if (type >= 'A' && type <= 'D') { this.handler(`move-cursor-${type <= 'B' ? 'y' : 'x'}`, ((type === 'B' || type === 'C') ? 1 : -1) * numOr1) } else if (type === 'E' || type === 'F') { this.handler('move-cursor-line', (type === 'E' ? 1 : -1) * numOr1) } else if (type === 'G') { this.handler('set-cursor-x', numOr1 - 1) } else if (type === 'P') { this.handler('delete', numOr1) } else if (type === '@') { this.handler('insert-blanks', numOr1) } else if (type === 'q') this.handler('set-cursor-style', numOr1) else if (type === 'm') { if (!numbers.length || numbers[0] === 0) { this.handler('reset-style') return } let type = numbers[0] if (type === 1) this.handler('set-attrs', 1) // bold else if (type === 2) this.handler('set-attrs', 1 << 1) // faint else if (type === 3) this.handler('set-attrs', 1 << 2) // italic else if (type === 4) this.handler('set-attrs', 1 << 3) // underline else if (type === 5 || type === 6) this.handler('set-attrs', 1 << 4) // blink else if (type === 7) this.handler('set-attrs', -1) // invert else if (type === 9) this.handler('set-attrs', 1 << 6) // strike else if (type === 20) this.handler('set-attrs', 1 << 5) // fraktur else if (type >= 30 && type <= 37) this.handler('set-color-fg', type % 10) else if (type >= 40 && type <= 47) this.handler('set-color-bg', type % 10) else if (type === 39) this.handler('set-color-fg', 7) else if (type === 49) this.handler('set-color-bg', 0) else if (type >= 90 && type <= 98) this.handler('set-color-fg', (type % 10) + 8) else if (type >= 100 && type <= 108) this.handler('set-color-bg', (type % 10) + 8) else if (type === 38 || type === 48) { if (numbers[1] === 5) { let color = (numbers[2] | 0) & 0xFF if (type === 38) this.handler('set-color-fg', color) if (type === 48) this.handler('set-color-bg', color) } } } } } write (text) { for (let character of text.toString()) { let code = character.codePointAt(0) if (code === 0x1b) this.currentSequence = 1 else if (this.currentSequence === 1 && character === '[') { this.currentSequence = 2 this.sequence += '[' } else if (this.currentSequence && character.match(/[\x40-\x7e]/)) { this.parseSequence(this.sequence + character) this.currentSequence = 0 this.sequence = '' } else if (this.currentSequence > 1) this.sequence += character else if (this.currentSequence === 1) { // something something nothing this.currentSequence = 0 this.handler('write', character) } else if (code === 0x07) this.handler('bell') else if (code === 0x08) this.handler('back') else if (code === 0x0a) this.handler('new-line') else if (code === 0x0d) this.handler('return') else if (code === 0x15) this.handler('delete-line') else if (code === 0x17) this.handler('delete-word') else this.handler('write', character) } if (!this.joinChunks) this.reset() } } const TERM_DEFAULT_STYLE = 7 const TERM_MIN_DRAW_DELAY = 10 class ScrollingTerminal { constructor (screen) { this.width = 80 this.height = 25 this.termScreen = screen this.parser = new ANSIParser((...args) => this.handleParsed(...args)) this.reset() this._lastLoad = Date.now() this.termScreen.load(this.serialize(), 0) } reset () { this.style = TERM_DEFAULT_STYLE this.cursor = { x: 0, y: 0, style: 1 } this.trackMouse = false this.parser.reset() this.clear() } clear () { this.screen = [] for (let i = 0; i < this.width * this.height; i++) { this.screen.push([' ', this.style]) } } scroll () { this.screen.splice(0, this.width) for (let i = 0; i < this.width; i++) { this.screen.push([' ', TERM_DEFAULT_STYLE]) } this.cursor.y-- } newLine () { this.cursor.y++ if (this.cursor.y >= this.height) this.scroll() } writeChar (character) { this.screen[this.cursor.y * this.width + this.cursor.x] = [character, this.style] this.cursor.x++ if (this.cursor.x >= this.width) { this.cursor.x = 0 this.newLine() } } moveBack (n = 1) { for (let i = 0; i < n; i++) { this.cursor.x-- if (this.cursor.x < 0) { if (this.cursor.y > 0) this.cursor.x = this.width - 1 else this.cursor.x = 0 this.cursor.y = Math.max(0, this.cursor.y - 1) } } } moveForward (n = 1) { for (let i = 0; i < n; i++) { this.cursor.x++ if (this.cursor.x >= this.width) { this.cursor.x = 0 this.cursor.y++ if (this.cursor.y >= this.height) this.scroll() } } } deleteChar () { this.moveBack() this.screen.splice((this.cursor.y + 1) * this.width, 0, [' ', TERM_DEFAULT_STYLE]) this.screen.splice(this.cursor.y * this.width + this.cursor.x, 1) } deleteForward (n) { n = Math.min(this.width, n) for (let i = 0; i < n; i++) this.screen.splice((this.cursor.y + 1) * this.width, 0, [' ', TERM_DEFAULT_STYLE]) this.screen.splice(this.cursor.y * this.width + this.cursor.x, n) } clampCursor () { if (this.cursor.x < 0) this.cursor.x = 0 if (this.cursor.y < 0) this.cursor.y = 0 if (this.cursor.x > this.width - 1) this.cursor.x = this.width - 1 if (this.cursor.y > this.height - 1) this.cursor.y = this.height - 1 } handleParsed (action, ...args) { if (action === 'write') { this.writeChar(args[0]) } else if (action === 'delete') { this.deleteForward(args[0]) } else if (action === 'insert-blanks') { this.insertBlanks(args[0]) } else if (action === 'bell') { this.terminal.load('B') } else if (action === 'back') { this.moveBack() } else if (action === 'new-line') { this.newLine() } else if (action === 'return') { this.cursor.x = 0 } else if (action === 'set-cursor') { this.cursor.x = args[0] this.cursor.y = args[1] this.clampCursor() } else if (action === 'move-cursor-y') { this.cursor.y += args[0] this.clampCursor() } else if (action === 'move-cursor-x') { this.cursor.x += args[0] this.clampCursor() } else if (action === 'move-cursor-line') { this.cursor.x = 0 this.cursor.y += args[0] this.clampCursor() } else if (action === 'set-cursor-x') { this.cursor.x = args[0] } else if (action === 'set-cursor-style') { this.cursor.style = Math.max(0, Math.min(6, args[0])) } else if (action === 'reset-style') { this.style = TERM_DEFAULT_STYLE } else if (action === 'set-attrs') { if (args[0] === -1) { this.style = (this.style & 0xFF0000) | ((this.style >> 8) & 0xFF) | ((this.style & 0xFF) << 8) } else { this.style = (this.style & 0x00FFFF) | (args[0] << 16) } } else if (action === 'set-color-fg') { this.style = (this.style & 0xFFFF00) | args[0] } else if (action === 'set-color-bg') { this.style = (this.style & 0xFF00FF) | (args[0] << 8) } } write (text) { this.parser.write(text) this.scheduleLoad() } serialize () { let serialized = 'S' serialized += encode2B(this.height) + encode2B(this.width) serialized += encode2B(this.cursor.y) + encode2B(this.cursor.x) let attributes = 1 // cursor always visible attributes |= (3 << 5) * +this.trackMouse // track mouse controls both attributes |= 3 << 7 // buttons/links always visible attributes |= (this.cursor.style << 9) serialized += encode3B(attributes) let lastStyle = null for (let cell of this.screen) { if (cell[1] !== lastStyle) { let foreground = cell[1] & 0xFF let background = (cell[1] >> 8) & 0xFF let attributes = (cell[1] >> 16) & 0xFF let setForeground = foreground !== (lastStyle & 0xFF) let setBackground = background !== ((lastStyle >> 8) & 0xFF) let setAttributes = attributes !== ((lastStyle >> 16) & 0xFF) if (setForeground && setBackground) serialized += '\x03' + encode3B(cell[1] & 0xFFFF) else if (setForeground) serialized += '\x05' + encode2B(foreground) else if (setBackground) serialized += '\x06' + encode2B(background) if (setAttributes) serialized += '\x04' + encode2B(attributes) lastStyle = cell[1] } serialized += cell[0] } return serialized } scheduleLoad () { clearInterval(this._scheduledLoad) if (this._lastLoad < Date.now() - TERM_MIN_DRAW_DELAY) { this.termScreen.load(this.serialize()) } else { this._scheduledLoad = setTimeout(() => { this.termScreen.load(this.serialize()) }, TERM_MIN_DRAW_DELAY - this._lastLoad) } } } class DemoShell { constructor (terminal) { this.terminal = terminal this.terminal.reset() this.parser = new ANSIParser((...args) => this.handleParsed(...args)) this.prompt() this.input = '' this.cursorPos = 0 this.child = null } write (text) { if (this.child) { if (text.codePointAt(0) === 3) this.child.destroy() else this.child.write(text) } else this.parser.write(text) } prompt () { if (this.terminal.cursor.x !== 0) this.terminal.write('\x1b[38;5;238m⏎\r\n') this.terminal.write('\x1b[38;5;27mdemosh \x1b[m$ ') this.input = '' this.cursorPos = 0 } handleParsed (action, ...args) { this.terminal.write('\b\x1b[P'.repeat(this.cursorPos)) if (action === 'write') { this.input = this.input.substr(0, this.cursorPos) + args[0] + this.input.substr(this.cursorPos) this.cursorPos++ } else if (action === 'back') { this.input = this.input.substr(0, this.cursorPos - 1) + this.input.substr(this.cursorPos) this.cursorPos-- if (this.cursorPos < 0) this.cursorPos = 0 } else if (action === 'move-cursor-x') { this.cursorPos = Math.max(0, Math.min(this.input.length, this.cursorPos + args[0])) } else if (action === 'delete-line') { this.input = '' this.cursorPos = 0 } else if (action === 'delete-word') { let words = this.input.substr(0, this.cursorPos).split(' ') words.pop() this.input = words.join(' ') + this.input.substr(this.cursorPos) this.cursorPos = words.join(' ').length } this.terminal.write(this.input) this.terminal.write('\b'.repeat(this.input.length)) this.terminal.moveForward(this.cursorPos) this.terminal.write('') // dummy. Apply the moveFoward if (action === 'return') { this.prompt() } } } window.demoInterface = { input (data) { let type = data[0] let content = data.substr(1) if (type === 's') { this.shell.write(content) } else if (type === 'b') { let button = content.charCodeAt(0) console.log(`button ${button} pressed`) } else if (type === 'm' || type === 'p' || type === 'r') { console.log(JSON.stringify(data)) } }, init (screen) { this.terminal = new ScrollingTerminal(screen) this.shell = new DemoShell(this.terminal) } }