« 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
diff options
context:
space:
mode:
-rw-r--r--.gitignore1
-rw-r--r--Flushable.js76
-rw-r--r--ansi.js257
-rw-r--r--exception.js7
-rw-r--r--telchars.js30
-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
-rw-r--r--unichars.js17
-rw-r--r--util/count.js17
-rw-r--r--util/smoothen.js16
-rw-r--r--util/wrap.js22
24 files changed, 1577 insertions, 0 deletions
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
+}