From 769413468e88acba1a180baa0113139d929a3b9f Mon Sep 17 00:00:00 2001 From: liam4 Date: Mon, 3 Jul 2017 18:59:57 -0300 Subject: A long-due cleanup + examples + things ..Obviously this breaks old things (particularly, see changes in FocusElement). --- Flushable.js | 76 ---------- ansi.js | 257 ---------------------------------- examples/basic-app.js | 51 +++++++ examples/interfacer-command-line.js | 24 ++++ examples/interfacer-telnet.js | 56 ++++++++ examples/label.js | 22 +++ exception.js | 7 - telchars.js | 30 ---- ui/DisplayElement.js | 2 +- ui/Label.js | 2 +- ui/Pane.js | 4 +- ui/Root.js | 104 ++------------ ui/Sprite.js | 2 +- ui/form/Button.js | 6 +- ui/form/CancelDialog.js | 4 +- ui/form/ConfirmDialog.js | 4 +- ui/form/FocusBox.js | 6 +- ui/form/FocusElement.js | 12 +- ui/form/Form.js | 4 +- ui/form/HorizontalForm.js | 4 - ui/form/TextInput.js | 6 +- unichars.js | 17 --- util/CommandLineInterfacer.js | 86 ++++++++++++ util/Flushable.js | 97 +++++++++++++ util/TelnetInterfacer.js | 137 ++++++++++++++++++ util/ansi.js | 272 ++++++++++++++++++++++++++++++++++++ util/exception.js | 7 + util/telchars.js | 30 ++++ util/unichars.js | 17 +++ util/waitForData.js | 9 ++ 30 files changed, 842 insertions(+), 513 deletions(-) delete mode 100644 Flushable.js delete mode 100644 ansi.js create mode 100644 examples/basic-app.js create mode 100644 examples/interfacer-command-line.js create mode 100644 examples/interfacer-telnet.js create mode 100644 examples/label.js delete mode 100644 exception.js delete mode 100644 telchars.js delete mode 100644 ui/form/HorizontalForm.js delete mode 100644 unichars.js create mode 100644 util/CommandLineInterfacer.js create mode 100644 util/Flushable.js create mode 100644 util/TelnetInterfacer.js create mode 100644 util/ansi.js create mode 100644 util/exception.js create mode 100644 util/telchars.js create mode 100644 util/unichars.js create mode 100644 util/waitForData.js diff --git a/Flushable.js b/Flushable.js deleted file mode 100644 index 0e73b6d..0000000 --- a/Flushable.js +++ /dev/null @@ -1,76 +0,0 @@ -const ansi = require('./ansi') - -module.exports = class Flushable { - // A writable that can be used to collect chunks of data before writing - // them. - - constructor(writable, shouldCompress = false) { - this.target = writable - - // Use the magical ANSI self-made compression method that probably - // doesn't *quite* work but should drastically decrease write size? - this.shouldCompress = shouldCompress - - // Update these if you plan on using the ANSI compressor! - this.screenLines = 24 - this.screenCols = 80 - - this.ended = false - - this.chunks = [] - } - - write(what) { - this.chunks.push(what) - } - - flush() { - // Don't write if we've ended. - if (this.ended) { - return - } - - // End if the target is destroyed. - // Yes, this relies on the target having a destroyed property - // Don't worry, it'll still work if there is no destroyed property though - // (I think) - if (this.target.destroyed) { - this.end() - return - } - - let toWrite = this.chunks.join('') - - if (this.shouldCompress) { - toWrite = this.compress(toWrite) - } - - try { - this.target.write(toWrite) - } catch(err) { - console.error('Flushable write error (ending):', err.message) - this.end() - } - - this.chunks = [] - } - - end() { - this.ended = true - } - - compress(toWrite) { - // TODO: customize screen size - const screen = ansi.interpret(toWrite, this.screenLines, this.screenCols) - - /* - const pcSaved = Math.round(100 - (100 / toWrite.length * screen.length)) - console.log( - '\x1b[1A' + - `${toWrite.length} - ${screen.length} ${pcSaved}% saved ` - ) - */ - - return screen - } -} diff --git a/ansi.js b/ansi.js deleted file mode 100644 index 1f9a392..0000000 --- a/ansi.js +++ /dev/null @@ -1,257 +0,0 @@ -const ESC = '\x1b' - -const isDigit = char => '0123456789'.indexOf(char) >= 0 - -const ansi = { - ESC, - - // Attributes - A_RESET: 0, - A_BRIGHT: 1, - A_DIM: 2, - A_INVERT: 7, - C_BLACK: 30, - C_RED: 31, - C_GREEN: 32, - C_YELLOW: 33, - C_BLUE: 34, - C_MAGENTA: 35, - C_CYAN: 36, - C_WHITE: 37, - C_RESET: 39, - - clearScreen() { - // Clears the screen, removing any characters displayed, and resets the - // cursor position. - - return `${ESC}[2J` - }, - - moveCursorRaw(line, col) { - // Moves the cursor to the given line and column on the screen. - // Returns the pure ANSI code, with no modification to line or col. - - return `${ESC}[${line};${col}H` - }, - - moveCursor(line, col) { - // Moves the cursor to the given line and column on the screen. - // Note that since in JavaScript indexes start at 0, but in ANSI codes - // the top left of the screen is (1, 1), this function adjusts the - // arguments to act as if the top left of the screen is (0, 0). - - return `${ESC}[${line + 1};${col + 1}H` - }, - - hideCursor() { - // Makes the cursor invisible. - - return `${ESC}[?25l` - }, - - showCursor() { - // Makes the cursor visible. - - return `${ESC}[?25h` - }, - - resetAttributes() { - // Resets all attributes, including text decorations, foreground and - // background color. - - return `${ESC}[0m` - }, - - setAttributes(attrs) { - // Set some raw attributes. See the attributes section of the ansi.js - // source code for attributes that can be used with this; A_RESET resets - // all attributes. - - return `${ESC}[${attrs.join(';')}m` - }, - - setForeground(color) { - // Sets the foreground color to print text with. See C_(COLOR) for colors - // that can be used with this; C_RESET resets the foreground. - // - // If null or undefined is passed, this function will return a blank - // string (no ANSI escape codes). - - if (typeof color === 'undefined' || color === null) { - return '' - } - - return ansi.setAttributes([color]) - }, - - invert() { - // Inverts the foreground and background colors. - - return `${ESC}[7m` - }, - - - - interpret(text, scrRows, scrCols) { - // Interprets the given ansi code, more or less. - - const blank = { - attributes: [], - char: ' ' - } - - const chars = new Array(scrRows * scrCols).fill(blank) - - let cursorRow = 1 - let cursorCol = 1 - const attributes = [] - const getCursorIndex = () => (cursorRow - 1) * scrCols + (cursorCol - 1) - - for (let charI = 0; charI < text.length; charI++) { - if (text[charI] === ESC) { - charI++ - - if (text[charI] !== '[') { - throw new Error('ESC not followed by [') - } - - charI++ - - const args = [] - let val = '' - while (isDigit(text[charI])) { - val += text[charI] - charI++ - - if (text[charI] === ';') { - charI++ - args.push(val) - val = '' - continue - } - } - args.push(val) - - // CUP - Cursor Position (moveCursor) - if (text[charI] === 'H') { - cursorRow = args[0] - cursorCol = args[1] - } - - // ED - Erase Display (clearScreen) - if (text[charI] === 'J') { - // ESC[2J - erase whole display - if (args[0] === '2') { - chars.fill(blank) - charI += 3 - cursorCol = 1 - cursorRow = 1 - } - - // ESC[1J - erase to beginning - else if (args[0] === '1') { - for (let i = 0; i < getCursorIndex(); i++) { - chars[i] = blank - } - } - - // ESC[0J - erase to end - else if (args.length === 0 || args[0] === '0') { - for (let i = getCursorIndex(); i < chars.length; i++) { - chars[i] = blank - } - } - } - - // SGR - Select Graphic Rendition - if (text[charI] === 'm') { - for (let arg of args) { - if (arg === '0') { - attributes.splice(0, attributes.length) - } else { - attributes.push(arg) - } - } - } - - continue - } - - // debug - /* - if (text[charI] === '.') { - console.log( - `#1-char "${text[charI]}" at ` + - `(${cursorRow},${cursorCol}):${getCursorIndex()} ` + - ` attr:[${attributes.join(';')}]` - ) - } - */ - - chars[getCursorIndex()] = { - char: text[charI], - attributes: attributes.slice() - } - - cursorCol++ - - if (cursorCol > scrCols) { - cursorCol = 1 - cursorRow++ - } - } - - // Character concatenation ----------- - - // Move to the top left of the screen initially. - const result = [ ansi.moveCursorRaw(1, 1) ] - - let lastChar = { - char: '', - attributes: [] - } - - //let n = 1 // debug - - for (let char of chars) { - const newAttributes = ( - char.attributes.filter(attr => !(lastChar.attributes.includes(attr))) - ) - - const removedAttributes = ( - lastChar.attributes.filter(attr => !(char.attributes.includes(attr))) - ) - - // The only way to practically remove any character attribute is to - // reset all of its attributes and then re-add its existing attributes. - // If we do that, there's no need to add new attributes. - if (removedAttributes.length) { - // console.log( - // `removed some attributes "${char.char}"`, removedAttributes - // ) - result.push(ansi.resetAttributes()) - result.push(`${ESC}[${char.attributes.join(';')}m`) - } else if (newAttributes.length) { - result.push(`${ESC}[${newAttributes.join(';')}m`) - } - - // debug - /* - if (char.char !== ' ') { - console.log( - `#2-char ${char.char}; ${chars.indexOf(char) - n} inbetween` - ) - n = chars.indexOf(char) - } - */ - - result.push(char.char) - - lastChar = char - } - - return result.join('') - } -} - -module.exports = ansi diff --git a/examples/basic-app.js b/examples/basic-app.js new file mode 100644 index 0000000..bf8aa41 --- /dev/null +++ b/examples/basic-app.js @@ -0,0 +1,51 @@ +// Basic app demo: +// - Structuring a basic element tree +// - Creating a pane and text input +// - Using content width/height to layout elements +// - Subclassing a FocusElement and using its focused method +// - Sending a quit-app request via Control-C +// +// This script cannot actually be used on its own; see the examples on +// interfacers (interfacer-command-line.js and inerfacer-telnet.js) for a +// working demo. + +const Pane = require('../ui/Pane') +const FocusElement = require('../ui/form/FocusElement') +const TextInput = require('../ui/form/TextInput') + +module.exports = class AppElement extends FocusElement { + constructor() { + super() + + this.pane = new Pane() + this.addChild(this.pane) + + this.textInput = new TextInput() + this.pane.addChild(this.textInput) + } + + fixLayout() { + this.w = this.parent.contentW + this.h = this.parent.contentH + + this.pane.w = this.contentW + this.pane.h = this.contentH + + this.textInput.x = 4 + this.textInput.y = 2 + this.textInput.w = this.pane.contentW - 8 + } + + focused() { + this.root.select(this.textInput) + } + + keyPressed(keyBuf) { + if (keyBuf[0] === 0x03) { // 0x03 is Control-C + this.emit('quitRequested') + return + } + + super.keyPressed(keyBuf) + } +} diff --git a/examples/interfacer-command-line.js b/examples/interfacer-command-line.js new file mode 100644 index 0000000..1da6adf --- /dev/null +++ b/examples/interfacer-command-line.js @@ -0,0 +1,24 @@ +const Root = require('../ui/Root') +const CommandLineInterfacer = require('../util/CommandLineInterfacer') +const AppElement = require('./basic-app') + +const interfacer = new CommandLineInterfacer() + +interfacer.getScreenSize().then(size => { + const root = new Root(interfacer) + root.w = size.width + root.h = size.height + + const appElement = new AppElement() + root.addChild(appElement) + root.select(appElement) + + appElement.on('quitRequested', () => { + process.exit(0) + }) + + setInterval(() => root.render(), 100) +}).catch(error => { + console.error(error) + process.exit(1) +}) diff --git a/examples/interfacer-telnet.js b/examples/interfacer-telnet.js new file mode 100644 index 0000000..8cb804b --- /dev/null +++ b/examples/interfacer-telnet.js @@ -0,0 +1,56 @@ +// Telnet demo: +// - Basic telnet socket handling using the TelnetInterfacer +// - Handling client's screen size +// - Handling socket being closed by client +// - Handling cleanly closing the socket by hand + +const net = require('net') +const Root = require('../ui/Root') +const TelnetInterfacer = require('../TelnetInterfacer') +const AppElement = require('./basic-app') + +const server = new net.Server(socket => { + const interfacer = new TelnetInterfacer(socket) + + interfacer.getScreenSize().then(size => { + const root = new Root(interfacer) + root.w = size.width + root.h = size.height + + interfacer.on('screenSizeUpdated', newSize => { + root.w = newSize.width + root.h = newSize.height + root.fixAllLayout() + }) + + const appElement = new AppElement() + root.addChild(appElement) + root.select(appElement) + + let closed = false + + appElement.on('quitRequested', () => { + if (!closed) { + interfacer.cleanTelnetOptions() + socket.write('Goodbye!\n') + socket.end() + clearInterval(interval) + closed = true + } + }) + + socket.on('close', () => { + if (!closed) { + clearInterval(interval) + closed = true + } + }) + + const interval = setInterval(() => root.render(), 100) + }).catch(error => { + console.error(error) + process.exit(1) + }) +}) + +server.listen(8008) diff --git a/examples/label.js b/examples/label.js new file mode 100644 index 0000000..b8992d2 --- /dev/null +++ b/examples/label.js @@ -0,0 +1,22 @@ +// An example of basic label usage. + +const ansi = require('../util/ansi') +const Label = require('../ui/Label') + +const label1 = new Label('Hello, world!') +const label2 = new Label('I love labels.') + +label1.x = 3 +label1.y = 2 + +label2.x = label1.x +label2.y = label1.y + 1 + +process.stdout.write(ansi.clearScreen()) +label1.drawTo(process.stdout) +label2.drawTo(process.stdout) + +process.stdin.once('data', () => { + process.stdout.write(ansi.clearScreen()) + process.exit(0) +}) diff --git a/exception.js b/exception.js deleted file mode 100644 index e88ff99..0000000 --- a/exception.js +++ /dev/null @@ -1,7 +0,0 @@ -module.exports = function exception(code, message) { - // Makes a custom error with the given code and message. - - const err = new Error(`${code}: ${message}`) - err.code = code - return err -} diff --git a/telchars.js b/telchars.js deleted file mode 100644 index 8cf414c..0000000 --- a/telchars.js +++ /dev/null @@ -1,30 +0,0 @@ -// Useful telnet key detection. - -const telchars = { - isSpace: buf => buf[0] === 0x20, - isEnter: buf => buf[0] === 0x0d && buf[1] === 0x00, - isTab: buf => buf[0] === 0x09, - isBackTab: buf => buf[0] === 0x1b && buf[2] === 0x5A, - - // isEscape is hard because it's just send as ESC (the ANSI escape code), - // so we need to make sure that the escape code is all on its own - // (i.e. the length is 1) - isEscape: buf => buf[0] === 0x1b && buf.length === 1, - - // Use this for when you'd like to detect the user confirming or issuing a - // command, like the X button on your PlayStation controller, or the mouse - // when you click on a button. - isSelect: buf => telchars.isSpace(buf) || telchars.isEnter(buf), - - // Use this for when you'd like to detect the user cancelling an action, - // like the O button on your PlayStation controller, or the Escape key on - // your keyboard. - isCancel: buf => telchars.isEscape(buf), - - isUp: buf => buf[0] === 0x1b && buf[2] === 0x41, - isDown: buf => buf[0] === 0x1b && buf[2] === 0x42, - isRight: buf => buf[0] === 0x1b && buf[2] === 0x43, - isLeft: buf => buf[0] === 0x1b && buf[2] === 0x44, -} - -module.exports = telchars diff --git a/ui/DisplayElement.js b/ui/DisplayElement.js index c8352ed..3a97ed9 100644 --- a/ui/DisplayElement.js +++ b/ui/DisplayElement.js @@ -1,5 +1,5 @@ const EventEmitter = require('events') -const exception = require('../exception') +const exception = require('../util/exception') module.exports = class DisplayElement extends EventEmitter { // A general class that handles dealing with screen coordinates, the tree diff --git a/ui/Label.js b/ui/Label.js index 60ece15..850edc0 100644 --- a/ui/Label.js +++ b/ui/Label.js @@ -1,4 +1,4 @@ -const ansi = require('../ansi') +const ansi = require('../util/ansi') const DisplayElement = require('./DisplayElement') diff --git a/ui/Pane.js b/ui/Pane.js index b4fad57..4e08c55 100644 --- a/ui/Pane.js +++ b/ui/Pane.js @@ -1,5 +1,5 @@ -const ansi = require('../ansi') -const unic = require('../unichars') +const ansi = require('../util/ansi') +const unic = require('../util/unichars') const DisplayElement = require('./DisplayElement') diff --git a/ui/Root.js b/ui/Root.js index 06e3ecd..b170f99 100644 --- a/ui/Root.js +++ b/ui/Root.js @@ -1,6 +1,6 @@ const iac = require('iac') -const ansi = require('../ansi') +const ansi = require('../util/ansi') const DisplayElement = require('./DisplayElement') @@ -10,88 +10,23 @@ module.exports = class Root extends DisplayElement { // An element to be used as the root of a UI. Handles lots of UI and // socket stuff. - constructor(socket) { + constructor(interfacer) { super() - this.socket = socket - this.initTelnetOptions() + this.interfacer = interfacer this.selected = null this.cursorBlinkOffset = Date.now() - socket.on('data', buf => this.handleData(buf)) + interfacer.on('inputData', buf => this.handleData(buf)) } - initTelnetOptions() { - // Initializes various socket options, using telnet magic. - - // Disables linemode. - this.socket.write(Buffer.from([ - 255, 253, 34, // IAC DO LINEMODE - 255, 250, 34, 1, 0, 255, 240, // IAC SB LINEMODE MODE 0 IAC SE - 255, 251, 1 // IAC WILL ECHO - ])) - - // Will SGA. Helps with putty apparently. - this.socket.write(Buffer.from([ - 255, 251, 3 // IAC WILL SGA - ])) - - this.socket.write(ansi.hideCursor()) - } - - cleanTelnetOptions() { - // Resets the telnet options and magic set in initTelnetOptions. - - this.socket.write(ansi.resetAttributes()) - this.socket.write(ansi.showCursor()) - } - - requestTelnetWindowSize() { - // See RFC #1073 - Telnet Window Size Option - - return new Promise((res, rej) => { - this.socket.write(Buffer.from([ - 255, 253, 31 // IAC WILL NAWS - ])) - - this.once('telnetsub', function until(sub) { - if (sub[0] !== 31) { // NAWS - this.once('telnetsub', until) - } else { - res({lines: sub[4], cols: sub[2]}) - } - }) - }) + render() { + this.renderTo(this.interfacer) } handleData(buffer) { - if (buffer[0] === 255) { - // Telnet IAC (Is A Command) - ignore - - // Split the data into multiple IAC commands if more than one IAC was - // sent. - const values = Array.from(buffer.values()) - const commands = [] - const curCmd = [255] - for (let value of values) { - if (value === 255) { // IAC - commands.push(Array.from(curCmd)) - curCmd.splice(1, curCmd.length) - continue - } - curCmd.push(value) - } - commands.push(curCmd) - - for (let command of commands) { - this.interpretTelnetCommand(command) - } - - return - } - if (this.selected) { const els = this.selected.directAncestors.concat([this.selected]) for (let el of els) { @@ -106,29 +41,6 @@ module.exports = class Root extends DisplayElement { } } - interpretTelnetCommand(command) { - if (command[0] !== 255) { // IAC - // First byte isn't IAC, which means this isn't a command, so do - // nothing. - return - } - - if (command[1] === 251) { // WILL - // Do nothing because I'm lazy - const willWhat = command[2] - //console.log('IAC WILL ' + willWhat) - } - - if (command[1] === 250) { // SB - this.telnetSub = command.slice(2) - } - - if (command[1] === 240) { // SE - this.emit('telnetsub', this.telnetSub) - this.telnetSub = null - } - } - drawTo(writable) { writable.write(ansi.moveCursor(0, 0)) writable.write(' '.repeat(this.w * this.h)) @@ -164,11 +76,11 @@ module.exports = class Root extends DisplayElement { // element, if there is one. if (this.selected) { - this.selected.unfocus() + this.selected.unfocused() } this.selected = el - this.selected.focus() + this.selected.focused() this.cursorMoved() } diff --git a/ui/Sprite.js b/ui/Sprite.js index cd6528c..62b0172 100644 --- a/ui/Sprite.js +++ b/ui/Sprite.js @@ -1,4 +1,4 @@ -const ansi = require('../ansi') +const ansi = require('../util/ansi') const DisplayElement = require('./DisplayElement') diff --git a/ui/form/Button.js b/ui/form/Button.js index 9a3d2f7..86347a0 100644 --- a/ui/form/Button.js +++ b/ui/form/Button.js @@ -1,5 +1,5 @@ -const ansi = require('../../ansi') -const telc = require('../../telchars') +const ansi = require('../../util/ansi') +const telc = require('../../util/telchars') const FocusElement = require('./FocusElement') @@ -29,7 +29,7 @@ module.exports = class ButtonInput extends FocusElement { } drawTo(writable) { - if (this.isSelected) { + if (this.isFocused) { writable.write(ansi.invert()) } diff --git a/ui/form/CancelDialog.js b/ui/form/CancelDialog.js index ba9faf8..c5eb7d3 100644 --- a/ui/form/CancelDialog.js +++ b/ui/form/CancelDialog.js @@ -1,4 +1,4 @@ -const telc = require('../../telchars') +const telc = require('../../util/telchars') const FocusElement = require('./FocusElement') @@ -47,7 +47,7 @@ module.exports = class ConfirmDialog extends FocusElement { this.cancelBtn.y = this.pane.contentH - 2 } - focus() { + focused() { this.root.select(this.cancelBtn) } diff --git a/ui/form/ConfirmDialog.js b/ui/form/ConfirmDialog.js index 614dede..3614cf9 100644 --- a/ui/form/ConfirmDialog.js +++ b/ui/form/ConfirmDialog.js @@ -1,4 +1,4 @@ -const telc = require('../../telchars') +const telc = require('../../util/telchars') const FocusElement = require('./FocusElement') @@ -59,7 +59,7 @@ module.exports = class ConfirmDialog extends FocusElement { this.cancelBtn.y = this.form.contentH - 2 } - focus() { + focused() { this.root.select(this.form) } diff --git a/ui/form/FocusBox.js b/ui/form/FocusBox.js index c259f23..51e961b 100644 --- a/ui/form/FocusBox.js +++ b/ui/form/FocusBox.js @@ -1,4 +1,4 @@ -const ansi = require('../../ansi') +const ansi = require('../../util/ansi') const FocusElement = require('./FocusElement') @@ -19,13 +19,13 @@ module.exports = class FocusBox extends FocusElement { } drawTo(writable) { - if (this.isSelected) { + if (this.isFocused) { writable.write(ansi.invert()) } } didRenderTo(writable) { - if (this.isSelected) { + if (this.isFocused) { writable.write(ansi.resetAttributes()) } } diff --git a/ui/form/FocusElement.js b/ui/form/FocusElement.js index 25a0693..5967e26 100644 --- a/ui/form/FocusElement.js +++ b/ui/form/FocusElement.js @@ -9,19 +9,19 @@ module.exports = class FocusElement extends DisplayElement { this.cursorX = 0 this.cursorY = 0 - this.isSelected = false + this.isFocused = false } - focus(socket) { - // Do something with socket. Should be overridden in subclasses. + focused() { + // Should be overridden in subclasses. - this.isSelected = true + this.isFocused = true } - unfocus() { + unfocused() { // Should be overridden in subclasses. - this.isSelected = false + this.isFocused = false } keyPressed(keyBuf) { diff --git a/ui/form/Form.js b/ui/form/Form.js index 49fa075..9274da4 100644 --- a/ui/form/Form.js +++ b/ui/form/Form.js @@ -1,4 +1,4 @@ -const telc = require('../../telchars') +const telc = require('../../util/telchars') const FocusElement = require('./FocusElement') @@ -45,7 +45,7 @@ module.exports = class Form extends FocusElement { } } - focus() { + focused() { this.root.select(this.inputs[this.curIndex]) } } diff --git a/ui/form/HorizontalForm.js b/ui/form/HorizontalForm.js deleted file mode 100644 index 141bb17..0000000 --- a/ui/form/HorizontalForm.js +++ /dev/null @@ -1,4 +0,0 @@ -const Form = require('./DisplayElement') - -module.exports = class HorizontalBox extends Box { -} diff --git a/ui/form/TextInput.js b/ui/form/TextInput.js index d09480f..fc59cbb 100644 --- a/ui/form/TextInput.js +++ b/ui/form/TextInput.js @@ -1,6 +1,6 @@ -const ansi = require('../../ansi') -const unic = require('../../unichars') -const telc = require('../../telchars') +const ansi = require('../../util/ansi') +const unic = require('../../util/unichars') +const telc = require('../../util/telchars') const FocusElement = require('./FocusElement') diff --git a/unichars.js b/unichars.js deleted file mode 100644 index d685890..0000000 --- a/unichars.js +++ /dev/null @@ -1,17 +0,0 @@ -// Useful Unicode characters. - -module.exports = { - /* … */ ELLIPSIS: '\u2026', - - /* ─ */ BOX_H: '\u2500', - /* ━ */ BOX_H_THICK: '\u2501', - /* ═ */ BOX_H_DOUBLE: '\u2550', - /* │ */ BOX_V: '\u2502', - /* ┃ */ BOX_V_THICK: '\u2503', - /* ║ */ BOX_V_DOUBLE: '\u2551', - - /* ┐ */ BOX_CORNER_TR: '\u2510', - /* └ */ BOX_CORNER_BL: '\u2514', - /* ┘ */ BOX_CORNER_BR: '\u2518', - /* ┌ */ BOX_CORNER_TL: '\u250C' -} diff --git a/util/CommandLineInterfacer.js b/util/CommandLineInterfacer.js new file mode 100644 index 0000000..3f9d208 --- /dev/null +++ b/util/CommandLineInterfacer.js @@ -0,0 +1,86 @@ +const EventEmitter = require('events') +const waitForData = require('./waitForData') +const ansi = require('./ansi') + +module.exports = class CommandLineInterfacer extends EventEmitter { + constructor(inStream = process.stdin, outStream = process.stdout) { + super() + + this.inStream = inStream + this.outStream = outStream + + inStream.on('data', buffer => { + this.emit('inputData', buffer) + }) + + inStream.setRawMode(true) + } + + async getScreenSize() { + const waitUntil = cond => waitForData(this.inStream, cond) + + // Get old cursor position.. + this.outStream.write(ansi.requestCursorPosition()) + const { options: oldCoords } = this.parseANSICommand( + await waitUntil(buf => ansi.isANSICommand(buf, 82)) + ) + + // Move far to the bottom right of the screen, then get cursor position.. + // (We could use moveCursor here, but the 0-index offset isn't really + // relevant.) + this.outStream.write(ansi.moveCursorRaw(9999, 9999)) + this.outStream.write(ansi.requestCursorPosition()) + const { options: sizeCoords } = this.parseANSICommand( + await waitUntil(buf => ansi.isANSICommand(buf, 82)) + ) + + // Restore to old cursor position.. (Using moveCursorRaw is actaully + // necessary here, since we'll be passing the coordinates returned from + // another ANSI command.) + this.outStream.write(ansi.moveCursorRaw(oldCoords[0], oldCoords[1])) + + // And return dimensions. + const [ sizeLine, sizeCol ] = sizeCoords + return { + lines: sizeLine, cols: sizeCol, + width: sizeCol, height: sizeLine + } + } + + parseANSICommand(buffer) { + // Typically ANSI commands are written ESC[1;2;3;4C + // ..where ESC is the ANSI escape code, equal to hexadecimal 1B and + // decimal 33 + // ..where [ and ; are the literal strings "[" and ";" + // ..where 1, 2, 3, and 4 are decimal integer arguments written in ASCII + // that may last more than one byte (e.g. "15") + // ..where C is some number representing the code of the command + + if (buffer[0] !== 0x1b || buffer[1] !== 0x5b) { + throw new Error('Not an ANSI command') + } + + const options = [] + let curOption = '' + let commandCode = null + for (let val of buffer.slice(2)) { + if (48 <= val && val <= 57) { // 0124356789 + curOption = curOption.concat(val - 48) + } else { + options.push(parseInt(curOption)) + curOption = '' + + if (val !== 59) { // ; + commandCode = val + break + } + } + } + + return {code: commandCode, options: options} + } + + write(data) { + this.outStream.write(data) + } +} diff --git a/util/Flushable.js b/util/Flushable.js new file mode 100644 index 0000000..b031677 --- /dev/null +++ b/util/Flushable.js @@ -0,0 +1,97 @@ +const ansi = require('./ansi') + +module.exports = class Flushable { + // A writable that can be used to collect chunks of data before writing + // them. + + constructor(writable, shouldCompress = false) { + this.target = writable + + // Use the magical ANSI self-made compression method that probably + // doesn't *quite* work but should drastically decrease write size? + this.shouldCompress = shouldCompress + + // Update these if you plan on using the ANSI compressor! + this.screenLines = 24 + this.screenCols = 80 + + this.ended = false + this.paused = false + this.requestedFlush = false + + this.chunks = [] + } + + write(what) { + this.chunks.push(what) + } + + flush() { + // If we're paused, we don't want to write, but we will keep a note that a + // flush was requested for when we unpause. + if (this.paused) { + this.requestedFlush = true + return + } + + // Don't write if we've ended. + if (this.ended) { + return + } + + // End if the target is destroyed. + // Yes, this relies on the target having a destroyed property + // Don't worry, it'll still work if there is no destroyed property though + // (I think) + if (this.target.destroyed) { + this.end() + return + } + + let toWrite = this.chunks.join('') + + if (this.shouldCompress) { + toWrite = this.compress(toWrite) + } + + try { + this.target.write(toWrite) + } catch(err) { + console.error('Flushable write error (ending):', err.message) + this.end() + } + + this.chunks = [] + } + + pause() { + this.paused = true + } + + resume() { + this.paused = false + + if (this.requestedFlush) { + this.flush() + } + } + + end() { + this.ended = true + } + + compress(toWrite) { + // TODO: customize screen size + const screen = ansi.interpret(toWrite, this.screenLines, this.screenCols) + + /* + const pcSaved = Math.round(100 - (100 / toWrite.length * screen.length)) + console.log( + '\x1b[1A' + + `${toWrite.length} - ${screen.length} ${pcSaved}% saved ` + ) + */ + + return screen + } +} diff --git a/util/TelnetInterfacer.js b/util/TelnetInterfacer.js new file mode 100644 index 0000000..f9b1c23 --- /dev/null +++ b/util/TelnetInterfacer.js @@ -0,0 +1,137 @@ +const ansi = require('./ansi') +const waitForData = require('./waitForData') +const EventEmitter = require('events') + +module.exports = class TelnetInterfacer extends EventEmitter { + constructor(socket) { + super() + + this.socket = socket + + socket.on('data', buffer => { + if (buffer[0] === 255) { + this.handleTelnetData(buffer) + } else { + this.emit('inputData', buffer) + } + }) + + this.initTelnetOptions() + } + + initTelnetOptions() { + // Initializes various socket options, using telnet magic. + + // Disables linemode. + this.socket.write(Buffer.from([ + 255, 253, 34, // IAC DO LINEMODE + 255, 250, 34, 1, 0, 255, 240, // IAC SB LINEMODE MODE 0 IAC SE + 255, 251, 1 // IAC WILL ECHO + ])) + + // Will SGA. Helps with putty apparently. + this.socket.write(Buffer.from([ + 255, 251, 3 // IAC WILL SGA + ])) + + this.socket.write(ansi.hideCursor()) + } + + cleanTelnetOptions() { + // Resets the telnet options and magic set in initTelnetOptions. + + this.socket.write(ansi.resetAttributes()) + this.socket.write(ansi.showCursor()) + } + + async getScreenSize() { + this.socket.write(Buffer.from([255, 253, 31])) // IAC DO NAWS + + let didWillNAWS = false + let didSBNAWS = false + let sb + + inputLoop: while (true) { + const data = await waitForData(this.socket) + + for (let command of this.parseTelnetCommands(data)) { + // WILL NAWS + if (command[1] === 251 && command[2] === 31) { + didWillNAWS = true + continue + } + + // SB NAWS + if (didWillNAWS && command[1] === 250 && command[2] === 31) { + didSBNAWS = true + sb = command.slice(3) + continue + } + + // SE + if (didSBNAWS && command[1] === 240) { // SE + break inputLoop + } + } + } + + return this.parseSBNAWS(sb) + } + + parseTelnetCommands(buffer) { + if (buffer[0] === 255) { + // Telnet IAC (Is A Command) - ignore + + // Split the data into multiple IAC commands if more than one IAC was + // sent. + const values = Array.from(buffer.values()) + const commands = [] + const curCmd = [255] + for (let value of values) { + if (value === 255) { // IAC + commands.push(Array.from(curCmd)) + curCmd.splice(1, curCmd.length) + continue + } + curCmd.push(value) + } + commands.push(curCmd) + + return commands + } else { + return [] + } + } + + write(data) { + this.socket.write(data) + } + + handleTelnetData(buffer) { + let didSBNAWS = false + let sbNAWS + + for (let command of this.parseTelnetCommands(buffer)) { + // SB NAWS + if (command[1] === 250 && command[2] === 31) { + didSBNAWS = true + sbNAWS = command.slice(3) + continue + } + + // SE + if (didSBNAWS && command[1] === 240) { // SE + didSBNAWS = false + this.emit('screenSizeUpdated', this.parseSBNAWS(sbNAWS)) + continue + } + } + } + + parseSBNAWS(sb) { + const cols = (sb[0] << 8) + sb[1] + const lines = (sb[2] << 8) + sb[3] + + return { cols, lines, width: cols, height: lines } + } +} diff --git a/util/ansi.js b/util/ansi.js new file mode 100644 index 0000000..0e6e3fe --- /dev/null +++ b/util/ansi.js @@ -0,0 +1,272 @@ +const ESC = '\x1b' + +const isDigit = char => '0123456789'.indexOf(char) >= 0 + +const ansi = { + ESC, + + // Attributes + A_RESET: 0, + A_BRIGHT: 1, + A_DIM: 2, + A_INVERT: 7, + C_BLACK: 30, + C_RED: 31, + C_GREEN: 32, + C_YELLOW: 33, + C_BLUE: 34, + C_MAGENTA: 35, + C_CYAN: 36, + C_WHITE: 37, + C_RESET: 39, + + clearScreen() { + // Clears the screen, removing any characters displayed, and resets the + // cursor position. + + return `${ESC}[2J` + }, + + moveCursorRaw(line, col) { + // Moves the cursor to the given line and column on the screen. + // Returns the pure ANSI code, with no modification to line or col. + + return `${ESC}[${line};${col}H` + }, + + moveCursor(line, col) { + // Moves the cursor to the given line and column on the screen. + // Note that since in JavaScript indexes start at 0, but in ANSI codes + // the top left of the screen is (1, 1), this function adjusts the + // arguments to act as if the top left of the screen is (0, 0). + + return `${ESC}[${line + 1};${col + 1}H` + }, + + hideCursor() { + // Makes the cursor invisible. + + return `${ESC}[?25l` + }, + + showCursor() { + // Makes the cursor visible. + + return `${ESC}[?25h` + }, + + resetAttributes() { + // Resets all attributes, including text decorations, foreground and + // background color. + + return `${ESC}[0m` + }, + + setAttributes(attrs) { + // Set some raw attributes. See the attributes section of the ansi.js + // source code for attributes that can be used with this; A_RESET resets + // all attributes. + + return `${ESC}[${attrs.join(';')}m` + }, + + setForeground(color) { + // Sets the foreground color to print text with. See C_(COLOR) for colors + // that can be used with this; C_RESET resets the foreground. + // + // If null or undefined is passed, this function will return a blank + // string (no ANSI escape codes). + + if (typeof color === 'undefined' || color === null) { + return '' + } + + return ansi.setAttributes([color]) + }, + + invert() { + // Inverts the foreground and background colors. + + return `${ESC}[7m` + }, + + requestCursorPosition() { + // Requests the position of the cursor. + // Expect a stdin-result '\ESC[l;cR', where l is the line number (1-based), + // c is the column number (also 1-based), and R is the literal character + // 'R' (decimal code 82). + + return `${ESC}[6n` + }, + + isANSICommand(buffer, code = null) { + return ( + buffer[0] === 0x1b && buffer[1] === 0x5b && + (code ? buffer[buffer.length - 1] === code : true) + ) + }, + + + interpret(text, scrRows, scrCols) { + // Interprets the given ansi code, more or less. + + const blank = { + attributes: [], + char: ' ' + } + + const chars = new Array(scrRows * scrCols).fill(blank) + + let cursorRow = 1 + let cursorCol = 1 + const attributes = [] + const getCursorIndex = () => (cursorRow - 1) * scrCols + (cursorCol - 1) + + for (let charI = 0; charI < text.length; charI++) { + if (text[charI] === ESC) { + charI++ + + if (text[charI] !== '[') { + throw new Error('ESC not followed by [') + } + + charI++ + + const args = [] + let val = '' + while (isDigit(text[charI])) { + val += text[charI] + charI++ + + if (text[charI] === ';') { + charI++ + args.push(val) + val = '' + continue + } + } + args.push(val) + + // CUP - Cursor Position (moveCursor) + if (text[charI] === 'H') { + cursorRow = args[0] + cursorCol = args[1] + } + + // ED - Erase Display (clearScreen) + if (text[charI] === 'J') { + // ESC[2J - erase whole display + if (args[0] === '2') { + chars.fill(blank) + charI += 3 + cursorCol = 1 + cursorRow = 1 + } + + // ESC[1J - erase to beginning + else if (args[0] === '1') { + for (let i = 0; i < getCursorIndex(); i++) { + chars[i] = blank + } + } + + // ESC[0J - erase to end + else if (args.length === 0 || args[0] === '0') { + for (let i = getCursorIndex(); i < chars.length; i++) { + chars[i] = blank + } + } + } + + // SGR - Select Graphic Rendition + if (text[charI] === 'm') { + for (let arg of args) { + if (arg === '0') { + attributes.splice(0, attributes.length) + } else { + attributes.push(arg) + } + } + } + + continue + } + + // debug + /* + if (text[charI] === '.') { + console.log( + `#1-char "${text[charI]}" at ` + + `(${cursorRow},${cursorCol}):${getCursorIndex()} ` + + ` attr:[${attributes.join(';')}]` + ) + } + */ + + chars[getCursorIndex()] = { + char: text[charI], + attributes: attributes.slice() + } + + cursorCol++ + + if (cursorCol > scrCols) { + cursorCol = 1 + cursorRow++ + } + } + + // Character concatenation ----------- + + // Move to the top left of the screen initially. + const result = [ ansi.moveCursorRaw(1, 1) ] + + let lastChar = { + char: '', + attributes: [] + } + + //let n = 1 // debug + + for (let char of chars) { + const newAttributes = ( + char.attributes.filter(attr => !(lastChar.attributes.includes(attr))) + ) + + const removedAttributes = ( + lastChar.attributes.filter(attr => !(char.attributes.includes(attr))) + ) + + // The only way to practically remove any character attribute is to + // reset all of its attributes and then re-add its existing attributes. + // If we do that, there's no need to add new attributes. + if (removedAttributes.length) { + // console.log( + // `removed some attributes "${char.char}"`, removedAttributes + // ) + result.push(ansi.resetAttributes()) + result.push(`${ESC}[${char.attributes.join(';')}m`) + } else if (newAttributes.length) { + result.push(`${ESC}[${newAttributes.join(';')}m`) + } + + // debug + /* + if (char.char !== ' ') { + console.log( + `#2-char ${char.char}; ${chars.indexOf(char) - n} inbetween` + ) + n = chars.indexOf(char) + } + */ + + result.push(char.char) + + lastChar = char + } + + return result.join('') + } +} + +module.exports = ansi diff --git a/util/exception.js b/util/exception.js new file mode 100644 index 0000000..e88ff99 --- /dev/null +++ b/util/exception.js @@ -0,0 +1,7 @@ +module.exports = function exception(code, message) { + // Makes a custom error with the given code and message. + + const err = new Error(`${code}: ${message}`) + err.code = code + return err +} diff --git a/util/telchars.js b/util/telchars.js new file mode 100644 index 0000000..8cf414c --- /dev/null +++ b/util/telchars.js @@ -0,0 +1,30 @@ +// Useful telnet key detection. + +const telchars = { + isSpace: buf => buf[0] === 0x20, + isEnter: buf => buf[0] === 0x0d && buf[1] === 0x00, + isTab: buf => buf[0] === 0x09, + isBackTab: buf => buf[0] === 0x1b && buf[2] === 0x5A, + + // isEscape is hard because it's just send as ESC (the ANSI escape code), + // so we need to make sure that the escape code is all on its own + // (i.e. the length is 1) + isEscape: buf => buf[0] === 0x1b && buf.length === 1, + + // Use this for when you'd like to detect the user confirming or issuing a + // command, like the X button on your PlayStation controller, or the mouse + // when you click on a button. + isSelect: buf => telchars.isSpace(buf) || telchars.isEnter(buf), + + // Use this for when you'd like to detect the user cancelling an action, + // like the O button on your PlayStation controller, or the Escape key on + // your keyboard. + isCancel: buf => telchars.isEscape(buf), + + isUp: buf => buf[0] === 0x1b && buf[2] === 0x41, + isDown: buf => buf[0] === 0x1b && buf[2] === 0x42, + isRight: buf => buf[0] === 0x1b && buf[2] === 0x43, + isLeft: buf => buf[0] === 0x1b && buf[2] === 0x44, +} + +module.exports = telchars diff --git a/util/unichars.js b/util/unichars.js new file mode 100644 index 0000000..d685890 --- /dev/null +++ b/util/unichars.js @@ -0,0 +1,17 @@ +// Useful Unicode characters. + +module.exports = { + /* … */ ELLIPSIS: '\u2026', + + /* ─ */ BOX_H: '\u2500', + /* ━ */ BOX_H_THICK: '\u2501', + /* ═ */ BOX_H_DOUBLE: '\u2550', + /* │ */ BOX_V: '\u2502', + /* ┃ */ BOX_V_THICK: '\u2503', + /* ║ */ BOX_V_DOUBLE: '\u2551', + + /* ┐ */ BOX_CORNER_TR: '\u2510', + /* └ */ BOX_CORNER_BL: '\u2514', + /* ┘ */ BOX_CORNER_BR: '\u2518', + /* ┌ */ BOX_CORNER_TL: '\u250C' +} diff --git a/util/waitForData.js b/util/waitForData.js new file mode 100644 index 0000000..bf40c52 --- /dev/null +++ b/util/waitForData.js @@ -0,0 +1,9 @@ +module.exports = function waitForData(stream, cond = null) { + return new Promise(resolve => { + stream.on('data', data => { + if (cond ? cond(data) : true) { + resolve(data) + } + }) + }) +} -- cgit 1.3.0-6-gf8a5