From 16da7fb310198851c2e4b02abedfb24979287242 Mon Sep 17 00:00:00 2001 From: Liam Date: Sat, 7 Jan 2017 18:26:02 -0400 Subject: Initial commit --- .gitignore | 1 + Flushable.js | 76 ++++++++++++++ ansi.js | 257 ++++++++++++++++++++++++++++++++++++++++++++++ exception.js | 7 ++ telchars.js | 30 ++++++ ui/DisplayElement.js | 170 ++++++++++++++++++++++++++++++ ui/HorizontalBox.js | 13 +++ ui/Label.js | 39 +++++++ ui/Pane.js | 101 ++++++++++++++++++ ui/Root.js | 175 +++++++++++++++++++++++++++++++ ui/Sprite.js | 69 +++++++++++++ ui/form/Button.js | 49 +++++++++ ui/form/CancelDialog.js | 63 ++++++++++++ ui/form/ConfirmDialog.js | 79 ++++++++++++++ ui/form/FocusBox.js | 32 ++++++ ui/form/FocusElement.js | 38 +++++++ ui/form/Form.js | 51 +++++++++ ui/form/HorizontalForm.js | 4 + ui/form/ListScrollForm.js | 137 ++++++++++++++++++++++++ ui/form/TextInput.js | 114 ++++++++++++++++++++ unichars.js | 17 +++ util/count.js | 17 +++ util/smoothen.js | 16 +++ util/wrap.js | 22 ++++ 24 files changed, 1577 insertions(+) create mode 100644 .gitignore create mode 100644 Flushable.js create mode 100644 ansi.js create mode 100644 exception.js create mode 100644 telchars.js create mode 100644 ui/DisplayElement.js create mode 100644 ui/HorizontalBox.js create mode 100644 ui/Label.js create mode 100644 ui/Pane.js create mode 100644 ui/Root.js create mode 100644 ui/Sprite.js create mode 100644 ui/form/Button.js create mode 100644 ui/form/CancelDialog.js create mode 100644 ui/form/ConfirmDialog.js create mode 100644 ui/form/FocusBox.js create mode 100644 ui/form/FocusElement.js create mode 100644 ui/form/Form.js create mode 100644 ui/form/HorizontalForm.js create mode 100644 ui/form/ListScrollForm.js create mode 100644 ui/form/TextInput.js create mode 100644 unichars.js create mode 100644 util/count.js create mode 100644 util/smoothen.js create mode 100644 util/wrap.js diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e43b0f9 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +.DS_Store diff --git a/Flushable.js b/Flushable.js new file mode 100644 index 0000000..0e73b6d --- /dev/null +++ b/Flushable.js @@ -0,0 +1,76 @@ +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 new file mode 100644 index 0000000..1f9a392 --- /dev/null +++ b/ansi.js @@ -0,0 +1,257 @@ +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/exception.js b/exception.js new file mode 100644 index 0000000..e88ff99 --- /dev/null +++ b/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/telchars.js b/telchars.js new file mode 100644 index 0000000..11dde94 --- /dev/null +++ b/telchars.js @@ -0,0 +1,30 @@ +// Useful tlelnet 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 new file mode 100644 index 0000000..c8352ed --- /dev/null +++ b/ui/DisplayElement.js @@ -0,0 +1,170 @@ +const EventEmitter = require('events') +const exception = require('../exception') + +module.exports = class DisplayElement extends EventEmitter { + // A general class that handles dealing with screen coordinates, the tree + // of elements, and other common stuff. + // + // This element doesn't handle any real rendering; just layouts. Placing + // characters at specific positions should be implemented in subclasses. + // + // It's a subclass of EventEmitter, so you can make your own events within + // the logic of your subclass. + + constructor() { + super() + + this.visible = true + + this.parent = null + this.children = [] + + this.x = 0 + this.y = 0 + this.w = 0 + this.h = 0 + + this.hPadding = 0 + this.vPadding = 0 + } + + drawTo(writable) { + // Writes text to a "writable" - an object that has a "write" method. + // Custom rendering should be handled as an override of this method in + // subclasses of DisplayElement. + } + + renderTo(writable) { + // Like drawTo, but only calls drawTo if the element is visible. Use this + // with your root element, not drawTo. + + if (this.visible) { + this.drawTo(writable) + this.drawChildrenTo(writable) + this.didRenderTo(writable) + } + } + + didRenderTo(writable) { + // Called immediately after rendering this element AND all of its + // children. If you need to do something when that happens, override this + // method in your subclass. + // + // It's fine to draw more things to the writable here - just keep in mind + // that it'll be drawn over this element and its children, but not any + // elements drawn in the future. + } + + fixLayout() { + // Adjusts the layout of children in this element. If your subclass has + // any children in it, you should override this method. + } + + fixAllLayout() { + // Runs fixLayout on this as well as all children. + + this.fixLayout() + for (let child of this.children) { + child.fixAllLayout() + } + } + + drawChildrenTo(writable) { + // Draws all of the children to a writable. + + for (let child of this.children) { + child.renderTo(writable) + } + } + + addChild(child) { + // TODO Don't let a direct ancestor of this be added as a child. Don't + // let itself be one of its childs either! + + if (child === this) { + throw exception( + 'EINVALIDHIERARCHY', 'An element cannot be a child of itself') + } + + child.parent = this + this.children.push(child) + child.fixLayout() + } + + removeChild(child) { + // Removes the given child element from the children list of this + // element. It won't be rendered in the future. If the given element + // isn't a direct child of this element, nothing will happen. + + if (child.parent !== this) { + return + } + + child.parent = null + this.children.splice(this.children.indexOf(child), 1) + this.fixLayout() + } + + centerInParent() { + // Utility function to center this element in its parent. Must be called + // only when it has a parent. Set the width and height of the element + // before centering it! + + if (this.parent === null) { + throw new Error('Cannot center in parent when parent is null') + } + + this.x = Math.round((this.parent.contentW - this.w) / 2) + this.y = Math.round((this.parent.contentH - this.h) / 2) + } + + get root() { + let el = this + while (el.parent) { + el = el.parent + } + return el + } + + get directAncestors() { + const ancestors = [] + let el = this + while (el.parent) { + el = el.parent + ancestors.push(el) + } + return ancestors + } + + get absX() { + if (this.parent) { + return this.parent.contentX + this.x + } else { + return this.x + } + } + + get absY() { + if (this.parent) { + return this.parent.contentY + this.y + } else { + return this.y + } + } + + // Where contents should be positioned. + get contentX() { return this.absX + this.hPadding } + get contentY() { return this.absY + this.vPadding } + get contentW() { return this.w - this.hPadding * 2 } + get contentH() { return this.h - this.vPadding * 2 } + + get left() { return this.x } + get right() { return this.x + this.w } + get top() { return this.y } + get bottom() { return this.y + this.h } + + get absLeft() { return this.absX } + get absRight() { return this.absX + this.w - 1 } + get absTop() { return this.absY } + get absBottom() { return this.absY + this.h - 1 } +} diff --git a/ui/HorizontalBox.js b/ui/HorizontalBox.js new file mode 100644 index 0000000..fd43f8e --- /dev/null +++ b/ui/HorizontalBox.js @@ -0,0 +1,13 @@ +const DisplayElement = require('./DisplayElement') + +module.exports = class HorizontalBox extends DisplayElement { + // A box that will automatically lay out its children in a horizontal row. + + fixLayout() { + let nextX = 0 + for (let child of this.children) { + child.x = nextX + nextX = child.right + 1 + } + } +} diff --git a/ui/Label.js b/ui/Label.js new file mode 100644 index 0000000..60ece15 --- /dev/null +++ b/ui/Label.js @@ -0,0 +1,39 @@ +const ansi = require('../ansi') + +const DisplayElement = require('./DisplayElement') + +module.exports = class Label extends DisplayElement { + // A simple text display. Automatically adjusts size to fit text. + + constructor(text='') { + super() + + this.text = text + this.textAttributes = [] + } + + drawTo(writable) { + if (this.textAttributes.length) { + writable.write(ansi.setAttributes(this.textAttributes)) + } + + writable.write(ansi.moveCursor(this.absTop, this.absLeft)) + writable.write(this.text) + + if (this.textAttributes.length) { + writable.write(ansi.resetAttributes()) + } + + super.drawTo(writable) + } + + set text(newText) { + this._text = newText + + this.w = newText.length + } + + get text() { + return this._text + } +} diff --git a/ui/Pane.js b/ui/Pane.js new file mode 100644 index 0000000..b4fad57 --- /dev/null +++ b/ui/Pane.js @@ -0,0 +1,101 @@ +const ansi = require('../ansi') +const unic = require('../unichars') + +const DisplayElement = require('./DisplayElement') + +const Label = require('./Label') + +module.exports = class Pane extends DisplayElement { + // A simple rectangular framed pane. + + constructor() { + super() + + this.frameColor = null + + this.hPadding = 1 + this.vPadding = 1 + } + + drawTo(writable) { + this.drawFrame(writable) + super.drawTo(writable) + } + + drawFrame(writable, debug=false) { + writable.write(ansi.setForeground(this.frameColor)) + + const left = this.absLeft + const right = this.absRight + const top = this.absTop + const bottom = this.absBottom + + // Background + // (TODO) Transparent background (that dimmed everything behind it) would + // be cool at some point! + for (let y = top + 1; y <= bottom - 1; y++) { + writable.write(ansi.moveCursor(y, left)) + writable.write(' '.repeat(this.w)) + } + + // Left/right edges + for (let x = left + 1; x <= right - 1; x++) { + writable.write(ansi.moveCursor(top, x)) + writable.write(unic.BOX_H) + writable.write(ansi.moveCursor(bottom, x)) + writable.write(unic.BOX_H) + } + + // Top/bottom edges + for (let y = top + 1; y <= bottom - 1; y++) { + writable.write(ansi.moveCursor(y, left)) + writable.write(unic.BOX_V) + writable.write(ansi.moveCursor(y, right)) + writable.write(unic.BOX_V) + } + + // Corners + writable.write(ansi.moveCursor(top, left)) + writable.write(unic.BOX_CORNER_TL) + writable.write(ansi.moveCursor(top, right)) + writable.write(unic.BOX_CORNER_TR) + writable.write(ansi.moveCursor(bottom, left)) + writable.write(unic.BOX_CORNER_BL) + writable.write(ansi.moveCursor(bottom, right)) + writable.write(unic.BOX_CORNER_BR) + + // Debug info + if (debug) { + writable.write(ansi.moveCursor(6, 8)) + writable.write( + `x: ${this.x}; y: ${this.y}; w: ${this.w}; h: ${this.h}`) + writable.write(ansi.moveCursor(7, 8)) + writable.write(`AbsX: ${this.absX}; AbsY: ${this.absY}`) + writable.write(ansi.moveCursor(8, 8)) + writable.write(`Left: ${this.left}; Right: ${this.right}`) + writable.write(ansi.moveCursor(9, 8)) + writable.write(`Top: ${this.top}; Bottom: ${this.bottom}`) + } + + writable.write(ansi.setForeground(ansi.C_RESET)) + } + + static alert(parent, text) { + // Show an alert pane in the bottom left of the given parent element for + // a couple seconds. + + const pane = new Pane() + pane.frameColor = ansi.C_WHITE + pane.w = text.length + 2 + pane.h = 3 + parent.addChild(pane) + + const label = new Label(text) + label.textAttributes = [ansi.C_WHITE] + pane.addChild(label) + + setTimeout(() => { + parent.removeChild(pane) + }, 2000) + } +} diff --git a/ui/Root.js b/ui/Root.js new file mode 100644 index 0000000..06e3ecd --- /dev/null +++ b/ui/Root.js @@ -0,0 +1,175 @@ +const iac = require('iac') + +const ansi = require('../ansi') + +const DisplayElement = require('./DisplayElement') + +const FocusElement = require('./form/FocusElement') + +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) { + super() + + this.socket = socket + this.initTelnetOptions() + + this.selected = null + + this.cursorBlinkOffset = Date.now() + + socket.on('data', 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]}) + } + }) + }) + } + + 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) { + if (el instanceof FocusElement) { + const shouldBreak = (el.keyPressed(buffer) === false) + if (shouldBreak) { + break + } + el.emit('keypressed', buffer) + } + } + } + } + + 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)) + } + + didRenderTo(writable) { + // Render the cursor, based on the cursorX and cursorY of the currently + // selected element. + if ( + this.selected && + typeof this.selected.cursorX === 'number' && + typeof this.selected.cursorY === 'number' && + (Date.now() - this.cursorBlinkOffset) % 1000 < 500 + ) { + writable.write(ansi.moveCursor( + this.selected.absCursorY, this.selected.absCursorX)) + writable.write(ansi.invert()) + writable.write('I') + writable.write(ansi.resetAttributes()) + } + writable.write(ansi.moveCursor(0, 0)) + } + + cursorMoved() { + // Resets the blinking animation for the cursor. Call this whenever you + // move the cursor. + + this.cursorBlinkOffset = Date.now() + } + + select(el) { + // Select an element. Calls the unfocus method on the already-selected + // element, if there is one. + + if (this.selected) { + this.selected.unfocus() + } + + this.selected = el + this.selected.focus() + + this.cursorMoved() + } +} diff --git a/ui/Sprite.js b/ui/Sprite.js new file mode 100644 index 0000000..cd6528c --- /dev/null +++ b/ui/Sprite.js @@ -0,0 +1,69 @@ +const ansi = require('../ansi') + +const DisplayElement = require('./DisplayElement') + +module.exports = class Sprite extends DisplayElement { + // "A sprite is a two-dimensional bitmap that is integrated into a larger + // scene." - Wikipedia + // + // Sprites are display objects that have a single texture that will not + // render outside of their parent. + // + // Sprites have a "real" position which overrides their "integer" position. + // This is so that motion can be more fluid (i.e., sprites don't need to + // move a whole number of terminal characters at a time). + + constructor() { + super() + + this.texture = [] + + this.realX = 0 + this.realY = 0 + } + + set x(newX) { this.realX = newX } + set y(newY) { this.realY = newY } + get x() { return Math.round(this.realX) } + get y() { return Math.round(this.realY) } + + drawTo(writable) { + if (this.textureAttributes) { + writable.write(ansi.setAttributes(this.textureAttributes)) + } + + for (let y = 0; y < this.textureHeight; y++) { + // Don't render above or below the parent's content area. + if (this.y + y >= this.parent.contentH || this.y + y < 0) continue + + const right = this.x + this.textureWidth + + const start = (this.x < 0) ? -this.x : 0 + const end = ( + (right > this.parent.contentW) + ? this.parent.contentW - right + : right) + const text = this.texture[y].slice(start, end) + + writable.write(ansi.moveCursor(this.absY + y, this.absX + start)) + writable.write(text) + } + + if (this.textureAttributes) { + writable.write(ansi.resetAttributes()) + } + } + + fixLayout() { + this.w = this.textureWidth + this.h = this.textureHeight + } + + get textureWidth() { + return Math.max(...this.texture.map(row => row.length)) + } + + get textureHeight() { + return this.texture.length + } +} diff --git a/ui/form/Button.js b/ui/form/Button.js new file mode 100644 index 0000000..9a3d2f7 --- /dev/null +++ b/ui/form/Button.js @@ -0,0 +1,49 @@ +const ansi = require('../../ansi') +const telc = require('../../telchars') + +const FocusElement = require('./FocusElement') + +module.exports = class ButtonInput extends FocusElement { + // A button. + + constructor(text) { + super() + + this.text = text + + this.cursorX = null + this.cursorY = null + } + + // Setting the text of the button should change the width of the button to + // fit the text. + // + // TODO: Make this happen in fixLayout + set text(newText) { + this._text = newText + this.w = newText.length + } + + get text() { + return this._text + } + + drawTo(writable) { + if (this.isSelected) { + writable.write(ansi.invert()) + } + + writable.write(ansi.moveCursor(this.absTop, this.absLeft)) + writable.write(this.text) + + writable.write(ansi.resetAttributes()) + + super.drawTo(writable) + } + + keyPressed(keyBuf) { + if (telc.isSelect(keyBuf)) { + this.emit('pressed') + } + } +} diff --git a/ui/form/CancelDialog.js b/ui/form/CancelDialog.js new file mode 100644 index 0000000..ba9faf8 --- /dev/null +++ b/ui/form/CancelDialog.js @@ -0,0 +1,63 @@ +const telc = require('../../telchars') + +const FocusElement = require('./FocusElement') + +const Button = require('./Button') +const Form = require('./Form') +const Label = require('../Label') +const Pane = require('../Pane') + +module.exports = class ConfirmDialog extends FocusElement { + // A basic cancel dialog. Has one buttons, cancel, and a label. + // The escape (esc) key can be used to exit the dialog (which sends a + // 'cancelled' event, as the cancel button also does). + + constructor(text) { + super() + + this.pane = new Pane() + this.addChild(this.pane) + + this.cancelBtn = new Button('Cancel') + this.pane.addChild(this.cancelBtn) + + this.label = new Label(text) + this.pane.addChild(this.label) + + this.initEventListeners() + } + + initEventListeners() { + this.cancelBtn.on('pressed', () => this.cancelPressed()) + } + + fixLayout() { + this.w = this.parent.contentW + this.h = this.parent.contentH + + this.pane.w = Math.max(40, 4 + this.label.w) + this.pane.h = 7 + this.pane.centerInParent() + + this.label.x = Math.floor((this.pane.contentW - this.label.w) / 2) + this.label.y = 1 + + this.cancelBtn.x = Math.floor( + (this.pane.contentW - this.cancelBtn.w) / 2) + this.cancelBtn.y = this.pane.contentH - 2 + } + + focus() { + this.root.select(this.cancelBtn) + } + + keyPressed(keyBuf) { + if (telc.isCancel(keyBuf)) { + this.emit('cancelled') + } + } + + cancelPressed() { + this.emit('cancelled') + } +} diff --git a/ui/form/ConfirmDialog.js b/ui/form/ConfirmDialog.js new file mode 100644 index 0000000..614dede --- /dev/null +++ b/ui/form/ConfirmDialog.js @@ -0,0 +1,79 @@ +const telc = require('../../telchars') + +const FocusElement = require('./FocusElement') + +const Button = require('./Button') +const Form = require('./Form') +const Label = require('../Label') +const Pane = require('../Pane') + +module.exports = class ConfirmDialog extends FocusElement { + // A basic yes/no dialog. Has two buttons, confirm/cancel, and a label. + // The escape (esc) key can be used to exit the dialog (which sends a + // 'cancelled' event, as the cancel button also does). + + constructor(text) { + super() + + this.pane = new Pane() + this.addChild(this.pane) + + this.form = new Form() + this.pane.addChild(this.form) + + this.confirmBtn = new Button('Confirm') + this.form.addInput(this.confirmBtn) + + this.cancelBtn = new Button('Cancel') + this.form.addInput(this.cancelBtn) + + this.label = new Label(text) + this.form.addChild(this.label) + + this.initEventListeners() + } + + initEventListeners() { + this.confirmBtn.on('pressed', () => this.confirmPressed()) + this.cancelBtn.on('pressed', () => this.cancelPressed()) + } + + fixLayout() { + this.w = this.parent.contentW + this.h = this.parent.contentH + + this.pane.w = Math.max(40, 2 + this.label.w) + this.pane.h = 7 + this.pane.centerInParent() + + this.form.w = this.pane.contentW + this.form.h = this.pane.contentH + + this.label.x = Math.floor((this.form.contentW - this.label.w) / 2) + this.label.y = 1 + + this.confirmBtn.x = 1 + this.confirmBtn.y = this.form.contentH - 2 + + this.cancelBtn.x = this.form.right - this.cancelBtn.w - 1 + this.cancelBtn.y = this.form.contentH - 2 + } + + focus() { + this.root.select(this.form) + } + + keyPressed(keyBuf) { + if (telc.isCancel(keyBuf)) { + this.emit('cancelled') + } + } + + confirmPressed() { + this.emit('confirmed') + } + + cancelPressed() { + this.emit('cancelled') + } +} diff --git a/ui/form/FocusBox.js b/ui/form/FocusBox.js new file mode 100644 index 0000000..c259f23 --- /dev/null +++ b/ui/form/FocusBox.js @@ -0,0 +1,32 @@ +const ansi = require('../../ansi') + +const FocusElement = require('./FocusElement') + +module.exports = class FocusBox extends FocusElement { + // A box (not to be confused with Pane!) that can be selected. When it's + // selected, it applies an invert effect to its children. (This won't work + // well if you have elements inside of it that have their own attributes, + // since they're likely to reset all effects after drawing - including the + // invert from the FocusBox! Bad ANSI limitations; it's relatively likely + // I'll implement maaaaaagic to help deal with this - maybe something + // similar to 'pushMatrix' from Processing - at some point... [TODO]) + + constructor() { + super() + + this.cursorX = null + this.cursorY = null + } + + drawTo(writable) { + if (this.isSelected) { + writable.write(ansi.invert()) + } + } + + didRenderTo(writable) { + if (this.isSelected) { + writable.write(ansi.resetAttributes()) + } + } +} diff --git a/ui/form/FocusElement.js b/ui/form/FocusElement.js new file mode 100644 index 0000000..25a0693 --- /dev/null +++ b/ui/form/FocusElement.js @@ -0,0 +1,38 @@ +const DisplayElement = require('../DisplayElement') + +module.exports = class FocusElement extends DisplayElement { + // A basic element that can receive cursor focus. + + constructor() { + super() + + this.cursorX = 0 + this.cursorY = 0 + + this.isSelected = false + } + + focus(socket) { + // Do something with socket. Should be overridden in subclasses. + + this.isSelected = true + } + + unfocus() { + // Should be overridden in subclasses. + + this.isSelected = false + } + + keyPressed(keyBuf) { + // Do something with a buffer containing the key pressed (that is, + // telnet data sent). Should be overridden in subclasses. + // + // Keyboard characters are sent as a buffer in the form of + // ESC[# where # is A, B, C or D. See more here: + // http://stackoverflow.com/a/11432632/4633828 + } + + get absCursorX() { return this.absX + this.cursorX } + get absCursorY() { return this.absY + this.cursorY } +} diff --git a/ui/form/Form.js b/ui/form/Form.js new file mode 100644 index 0000000..49fa075 --- /dev/null +++ b/ui/form/Form.js @@ -0,0 +1,51 @@ +const telc = require('../../telchars') + +const FocusElement = require('./FocusElement') + +module.exports = class Form extends FocusElement { + constructor() { + super() + + this.inputs = [] + this.curIndex = 0 + } + + addInput(input, asChild = true) { + // Adds the given input as a child element and pushes it to the input + // list. If the second optional, asChild, is false, it won't add the + // input element as a child of the form. + + this.inputs.push(input) + + if (asChild) { + this.addChild(input) + } + } + + keyPressed(keyBuf) { + if (telc.isTab(keyBuf) || telc.isBackTab(keyBuf)) { + // No inputs to tab through, so do nothing. + if (this.inputs.length < 2) { + return + } + + if (telc.isTab(keyBuf)) { + this.curIndex = (this.curIndex + 1) % this.inputs.length + } else { + this.curIndex = (this.curIndex - 1) + if (this.curIndex < 0) { + this.curIndex = (this.inputs.length - 1) + } + } + + const nextInput = this.inputs[this.curIndex] + this.root.select(nextInput) + + return false + } + } + + focus() { + this.root.select(this.inputs[this.curIndex]) + } +} diff --git a/ui/form/HorizontalForm.js b/ui/form/HorizontalForm.js new file mode 100644 index 0000000..141bb17 --- /dev/null +++ b/ui/form/HorizontalForm.js @@ -0,0 +1,4 @@ +const Form = require('./DisplayElement') + +module.exports = class HorizontalBox extends Box { +} diff --git a/ui/form/ListScrollForm.js b/ui/form/ListScrollForm.js new file mode 100644 index 0000000..b1484b5 --- /dev/null +++ b/ui/form/ListScrollForm.js @@ -0,0 +1,137 @@ +const Form = require('./Form') + +module.exports = class ListScrollForm extends Form { + // A form that lets the user scroll through a list of items. It + // automatically adjusts to always allow the selected item to be visible. + + constructor(layoutType = 'vertical') { + super() + + this.layoutType = layoutType + + this.scrollItems = 0 + } + + fixLayout() { + // The scrollItems property represents the item to the very left of where + // we've scrolled, so we know right away that none of those will be + // visible and we won't bother iterating over them. + const itemsPastScroll = this.inputs.slice(this.scrollItems) + + // This variable stores how far along the respective axis (as defined by + // posProp) the next element should be. + let nextPos = 0 + + for (let item of itemsPastScroll) { + item[this.posProp] = nextPos + nextPos += item[this.sizeProp] + + // By default, the item should be visible.. + item.visible = true + + // ..but the item's far edge is past the form's far edge, it isn't + // fully visible and should be hidden. + if (item[this.posProp] + item[this.sizeProp] > this.formEdge) { + item.visible = false + } + + // Same deal goes for the close edge. We can check it against 0 since + // the close edge of the form's content is going to be 0, of course! + if (item[this.posProp] < 0) { + item.visible = false + } + } + } + + keyPressed(keyBuf) { + super.keyPressed(keyBuf) + + const sel = this.inputs[this.curIndex] + + // If the item is ahead of our view (either to the right of or below), + // we should move the view so that the item is the farthest right (of all + // the visible items). + if (this.getItemPos(sel) > this.formEdge + this.scrollSize) { + // We can decide how many items to scroll past by moving forward until + // our item's far edge is visible. + + let i + let edge = this.formEdge + + for (i = 0; i < this.inputs.length; i++) { + if (this.getItemPos(sel) <= edge) break + edge += this.inputs[i][this.sizeProp] + } + + // Now that we have the right index to scroll to, apply it! + this.scrollItems = i + } + + // Adjusting the number of scroll items is much simpler to deal with if + // the item is behind our view. Since the item's behind, we need to move + // the scroll to be immediately behind it, which is simple since we + // already have its index. + if (this.getItemPos(sel) <= this.scrollSize) { + this.scrollItems = this.curIndex + } + + this.fixLayout() + } + + getItemPos(item) { + // Gets the position of the item in an unscrolled view. + + return this.inputs.slice(0, this.inputs.indexOf(item) + 1) + .reduce((a, b) => a + b[this.sizeProp], 0) + } + + get sizeProp() { + // The property used to measure the size of an item. If the layoutType + // isn't valid (that is, 'horizontal' or 'vertical'), it'll return null. + + return ( + this.layoutType === 'horizontal' ? 'w' : + this.layoutType === 'vertical' ? 'h' : + null + ) + } + + get posProp() { + // The property used to position an item. Like sizeProp, returns null if + // the layoutType isn't valid. + + return ( + this.layoutType === 'horizontal' ? 'x' : + this.layoutType === 'vertical' ? 'y' : + null) + } + + get edgeProp() { + // The property used to get the far edge of the property. As with + // sizeProp, if the layoutType doesn't have an expected value, it'll + // return null. + + return ( + this.layoutType === 'horizontal' ? 'right' : + this.layoutType === 'vertical' ? 'bottom' : + null) + } + + get formEdge() { + // Returns the value of the far edge of this form. Items farther in the + // list (up to the edge) will be closer to this edge. + + return ( + this.layoutType === 'horizontal' ? this.contentW : + this.layoutType === 'vertical' ? this.contentH : + null) + } + + get scrollSize() { + // Gets the actual length made up by all of the items currently scrolled + // past. + + return this.inputs.slice(0, this.scrollItems) + .reduce((a, b) => a + b[this.sizeProp], 0) + } +} diff --git a/ui/form/TextInput.js b/ui/form/TextInput.js new file mode 100644 index 0000000..d09480f --- /dev/null +++ b/ui/form/TextInput.js @@ -0,0 +1,114 @@ +const ansi = require('../../ansi') +const unic = require('../../unichars') +const telc = require('../../telchars') + +const FocusElement = require('./FocusElement') + +module.exports = class TextInput extends FocusElement { + // An element that the user can type in. + + constructor() { + super() + + this.value = '' + this.cursorIndex = 0 + this.scrollChars = 0 + } + + drawTo(writable) { + // There should be room for the cursor so move the "right edge" left a + // single character. + + const startRange = this.scrollChars + const endRange = this.scrollChars + this.w - 3 + + let str = this.value.slice(startRange, endRange) + + writable.write(ansi.moveCursor(this.absTop, this.absLeft + 1)) + writable.write(str) + + // Ellipsis on left side, if there's more characters behind the visible + // area. + if (startRange > 0) { + writable.write(ansi.moveCursor(this.absTop, this.absLeft)) + writable.write(unic.ELLIPSIS) + } + + // Ellipsis on the right side, if there's more characters ahead of the + // visible area. + if (endRange < this.value.length) { + writable.write(ansi.moveCursor(this.absTop, this.absRight - 1)) + writable.write(unic.ELLIPSIS.repeat(2)) + } + + this.cursorX = this.cursorIndex - this.scrollChars + 1 + + super.drawTo(writable) + } + + keyPressed(keyBuf) { + if (keyBuf[0] === 127) { + this.value = ( + this.value.slice(0, this.cursorIndex - 1) + + this.value.slice(this.cursorIndex) + ) + this.cursorIndex-- + this.root.cursorMoved() + } else if (keyBuf[0] === 13) { + this.emit('value', this.value) + } else if (keyBuf[0] === 0x1b && keyBuf[1] === 0x5b) { + // Keyboard navigation + if (keyBuf[2] === 0x44) { + this.cursorIndex-- + this.root.cursorMoved() + } else if (keyBuf[2] === 0x43) { + this.cursorIndex++ + this.root.cursorMoved() + } + } else if (telc.isEscape(keyBuf)) { + // ESC is bad and we don't want that in the text input! + return + } else { + // console.log(keyBuf, keyBuf[0], keyBuf[1], keyBuf[2]) + this.value = ( + this.value.slice(0, this.cursorIndex) + keyBuf.toString() + + this.value.slice(this.cursorIndex) + ) + this.cursorIndex++ + this.root.cursorMoved() + } + + this.keepCursorInRange() + } + + keepCursorInRange() { + // Keep the cursor inside or at the end of the input value. + + if (this.cursorIndex < 0) { + this.cursorIndex = 0 + } + + if (this.cursorIndex > this.value.length) { + this.cursorIndex = this.value.length + } + + // Scroll right, if the cursor is past the right edge of where text is + // displayed. + if (this.cursorIndex - this.scrollChars > this.w - 3) { + this.scrollChars++ + } + + // Scroll left, if the cursor is behind the left edge of where text is + // displayed. + if (this.cursorIndex - this.scrollChars < 0) { + this.scrollChars-- + } + + // Scroll left, if we can see past the end of the text. + if (this.scrollChars > 0 && ( + this.scrollChars + this.w - 3 > this.value.length) + ) { + this.scrollChars-- + } + } +} diff --git a/unichars.js b/unichars.js new file mode 100644 index 0000000..d685890 --- /dev/null +++ b/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/count.js b/util/count.js new file mode 100644 index 0000000..7df97a7 --- /dev/null +++ b/util/count.js @@ -0,0 +1,17 @@ +module.exports = function count(arr) { + // Counts the number of times the items of an array appear (only on the top + // level; it doesn't search through nested arrays!). Returns a map of + // item -> count. + + const map = new Map() + + for (let item of arr) { + if (map.has(item)) { + map.set(item, map.get(item) + 1) + } else { + map.set(item, 1) + } + } + + return map +} diff --git a/util/smoothen.js b/util/smoothen.js new file mode 100644 index 0000000..55ba23c --- /dev/null +++ b/util/smoothen.js @@ -0,0 +1,16 @@ +module.exports = function(tx, x, divisor) { + // Smoothly transitions givens X to TX using a given divisor. Rounds the + // amount moved. + + const move = (tx - x) / divisor + + if (move > 0.5) { + return x + Math.ceil(move) + } else if (move < -0.5) { + return x + Math.floor(move) + } else if (tx > 0) { + return Math.ceil(tx) + } else { + return Math.floor(tx) + } +} diff --git a/util/wrap.js b/util/wrap.js new file mode 100644 index 0000000..78e5233 --- /dev/null +++ b/util/wrap.js @@ -0,0 +1,22 @@ +module.exports = function wrap(str, width) { + // Wraps a string into separate lines. Returns an array of strings, for + // each line of the text. + + const lines = [] + const words = str.split(' ') + + let curLine = words[0] + + for (let word of words.slice(1)) { + if (curLine.length + word.length > width) { + lines.push(curLine) + curLine = word + } else { + curLine += ' ' + word + } + } + + lines.push(curLine) + + return lines +} -- cgit 1.3.0-6-gf8a5