diff options
author | Liam <towerofnix@gmail.com> | 2017-01-07 18:26:02 -0400 |
---|---|---|
committer | Liam <towerofnix@gmail.com> | 2017-01-07 18:26:02 -0400 |
commit | 16da7fb310198851c2e4b02abedfb24979287242 (patch) | |
tree | d7546f7c1a3c3833e6450ea1e10af388f8848bb5 /ui |
Initial commit
Diffstat (limited to 'ui')
-rw-r--r-- | ui/DisplayElement.js | 170 | ||||
-rw-r--r-- | ui/HorizontalBox.js | 13 | ||||
-rw-r--r-- | ui/Label.js | 39 | ||||
-rw-r--r-- | ui/Pane.js | 101 | ||||
-rw-r--r-- | ui/Root.js | 175 | ||||
-rw-r--r-- | ui/Sprite.js | 69 | ||||
-rw-r--r-- | ui/form/Button.js | 49 | ||||
-rw-r--r-- | ui/form/CancelDialog.js | 63 | ||||
-rw-r--r-- | ui/form/ConfirmDialog.js | 79 | ||||
-rw-r--r-- | ui/form/FocusBox.js | 32 | ||||
-rw-r--r-- | ui/form/FocusElement.js | 38 | ||||
-rw-r--r-- | ui/form/Form.js | 51 | ||||
-rw-r--r-- | ui/form/HorizontalForm.js | 4 | ||||
-rw-r--r-- | ui/form/ListScrollForm.js | 137 | ||||
-rw-r--r-- | ui/form/TextInput.js | 114 |
15 files changed, 1134 insertions, 0 deletions
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-- + } + } +} |