« get me outta code hell

tui-lib - Pure Node.js library for making visual command-line programs (ala vim, ncdu)
about summary refs log tree commit diff
path: root/ui
diff options
context:
space:
mode:
Diffstat (limited to 'ui')
-rw-r--r--ui/DisplayElement.js170
-rw-r--r--ui/HorizontalBox.js13
-rw-r--r--ui/Label.js39
-rw-r--r--ui/Pane.js101
-rw-r--r--ui/Root.js175
-rw-r--r--ui/Sprite.js69
-rw-r--r--ui/form/Button.js49
-rw-r--r--ui/form/CancelDialog.js63
-rw-r--r--ui/form/ConfirmDialog.js79
-rw-r--r--ui/form/FocusBox.js32
-rw-r--r--ui/form/FocusElement.js38
-rw-r--r--ui/form/Form.js51
-rw-r--r--ui/form/HorizontalForm.js4
-rw-r--r--ui/form/ListScrollForm.js137
-rw-r--r--ui/form/TextInput.js114
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--
+    }
+  }
+}