diff options
author | liam4 <towerofnix@gmail.com> | 2017-07-03 18:59:57 -0300 |
---|---|---|
committer | liam4 <towerofnix@gmail.com> | 2017-07-03 19:00:01 -0300 |
commit | 769413468e88acba1a180baa0113139d929a3b9f (patch) | |
tree | f29af36826077178259b7bcc8bf9927cebfe71e3 | |
parent | 489e4d0c78d5f393729cda0e1f6ac9a0a1237b4a (diff) |
A long-due cleanup + examples + things
..Obviously this breaks old things (particularly, see changes in FocusElement).
-rw-r--r-- | examples/basic-app.js | 51 | ||||
-rw-r--r-- | examples/interfacer-command-line.js | 24 | ||||
-rw-r--r-- | examples/interfacer-telnet.js | 56 | ||||
-rw-r--r-- | examples/label.js | 22 | ||||
-rw-r--r-- | ui/DisplayElement.js | 2 | ||||
-rw-r--r-- | ui/Label.js | 2 | ||||
-rw-r--r-- | ui/Pane.js | 4 | ||||
-rw-r--r-- | ui/Root.js | 104 | ||||
-rw-r--r-- | ui/Sprite.js | 2 | ||||
-rw-r--r-- | ui/form/Button.js | 6 | ||||
-rw-r--r-- | ui/form/CancelDialog.js | 4 | ||||
-rw-r--r-- | ui/form/ConfirmDialog.js | 4 | ||||
-rw-r--r-- | ui/form/FocusBox.js | 6 | ||||
-rw-r--r-- | ui/form/FocusElement.js | 12 | ||||
-rw-r--r-- | ui/form/Form.js | 4 | ||||
-rw-r--r-- | ui/form/HorizontalForm.js | 4 | ||||
-rw-r--r-- | ui/form/TextInput.js | 6 | ||||
-rw-r--r-- | util/CommandLineInterfacer.js | 86 | ||||
-rw-r--r-- | util/Flushable.js (renamed from Flushable.js) | 21 | ||||
-rw-r--r-- | util/TelnetInterfacer.js | 137 | ||||
-rw-r--r-- | util/ansi.js (renamed from ansi.js) | 15 | ||||
-rw-r--r-- | util/exception.js (renamed from exception.js) | 0 | ||||
-rw-r--r-- | util/telchars.js (renamed from telchars.js) | 0 | ||||
-rw-r--r-- | util/unichars.js (renamed from unichars.js) | 0 | ||||
-rw-r--r-- | util/waitForData.js | 9 |
25 files changed, 455 insertions, 126 deletions
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/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/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/Flushable.js b/util/Flushable.js index 0e73b6d..b031677 100644 --- a/Flushable.js +++ b/util/Flushable.js @@ -16,6 +16,8 @@ module.exports = class Flushable { this.screenCols = 80 this.ended = false + this.paused = false + this.requestedFlush = false this.chunks = [] } @@ -25,6 +27,13 @@ module.exports = class Flushable { } 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 @@ -55,6 +64,18 @@ module.exports = class Flushable { this.chunks = [] } + pause() { + this.paused = true + } + + resume() { + this.paused = false + + if (this.requestedFlush) { + this.flush() + } + } + end() { this.ended = true } 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/ansi.js b/util/ansi.js index 1f9a392..0e6e3fe 100644 --- a/ansi.js +++ b/util/ansi.js @@ -90,6 +90,21 @@ const ansi = { 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) { diff --git a/exception.js b/util/exception.js index e88ff99..e88ff99 100644 --- a/exception.js +++ b/util/exception.js diff --git a/telchars.js b/util/telchars.js index 8cf414c..8cf414c 100644 --- a/telchars.js +++ b/util/telchars.js diff --git a/unichars.js b/util/unichars.js index d685890..d685890 100644 --- a/unichars.js +++ b/util/unichars.js 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) + } + }) + }) +} |