« 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/controls
diff options
context:
space:
mode:
Diffstat (limited to 'ui/controls')
-rw-r--r--ui/controls/Button.js51
-rw-r--r--ui/controls/FocusBox.js32
-rw-r--r--ui/controls/Form.js154
-rw-r--r--ui/controls/ListScrollForm.js304
-rw-r--r--ui/controls/ScrollBar.js121
-rw-r--r--ui/controls/TextInput.js147
-rw-r--r--ui/controls/index.js18
7 files changed, 827 insertions, 0 deletions
diff --git a/ui/controls/Button.js b/ui/controls/Button.js
new file mode 100644
index 0000000..5be2b2a
--- /dev/null
+++ b/ui/controls/Button.js
@@ -0,0 +1,51 @@
+import {FocusElement} from 'tui-lib/ui/primitives'
+
+import * as ansi from 'tui-lib/util/ansi'
+import telc from 'tui-lib/util/telchars'
+
+export default class Button extends FocusElement {
+  // A button.
+
+  constructor(text) {
+    super()
+
+    this.text = text
+
+    this.cursorX = null
+    this.cursorY = null
+  }
+
+  fixLayout() {
+    this.w = ansi.measureColumns(this.text)
+    this.h = 1
+  }
+
+  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')
+    }
+  }
+
+  clicked(button) {
+    if (button === 'left') {
+      if (this.isSelected) {
+        this.emit('pressed')
+      } else {
+        this.root.select(this)
+      }
+    }
+  }
+}
diff --git a/ui/controls/FocusBox.js b/ui/controls/FocusBox.js
new file mode 100644
index 0000000..64f84c9
--- /dev/null
+++ b/ui/controls/FocusBox.js
@@ -0,0 +1,32 @@
+import {FocusElement} from 'tui-lib/ui/primitives'
+
+import * as ansi from 'tui-lib/util/ansi'
+
+export default 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/controls/Form.js b/ui/controls/Form.js
new file mode 100644
index 0000000..0224247
--- /dev/null
+++ b/ui/controls/Form.js
@@ -0,0 +1,154 @@
+import telc from 'tui-lib/util/telchars'
+
+import {FocusElement} from 'tui-lib/ui/primitives'
+
+export default class Form extends FocusElement {
+  constructor() {
+    super()
+
+    this.inputs = []
+    this.curIndex = 0
+    this.captureTab = true
+  }
+
+  addInput(input, asChild = true, opts = {}) {
+    // Adds the given input as a child element and pushes it to the input
+    // list. If the optional argument 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, this.children.length, opts)
+    }
+  }
+
+  removeInput(input, asChild = true, opts = {}) {
+    // Removes the given input from the form's input list. If the optional
+    // argument asChild is false, it won't try to removeChild the input.
+
+    if (this.inputs.includes(input)) {
+      this.inputs.splice(this.inputs.indexOf(input), 1)
+
+      if (asChild) {
+        this.removeChild(input, opts)
+      }
+    }
+  }
+
+  selectInput(input) {
+    if (this.inputs.includes(input)) {
+      this.curIndex = this.inputs.indexOf(input)
+      this.updateSelectedElement()
+    }
+  }
+
+  keyPressed(keyBuf) {
+    // Don't do anything if captureTab is set to false. This is handy for
+    // nested forms.
+    if (!this.captureTab) {
+      return
+    }
+
+    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.nextInput()
+      } else {
+        this.previousInput()
+      }
+
+      return false
+    }
+  }
+
+  get selectable() {
+    return this.inputs.some(inp => inp.selectable)
+  }
+
+  updateSelectedElement() {
+    if (this.root.select && this.inputs.length) {
+      if (this.curIndex > this.inputs.length - 1) {
+        this.curIndex = this.inputs.length - 1
+      }
+
+      this.root.select(this.inputs[this.curIndex], {fromForm: true})
+    }
+  }
+
+  previousInput() {
+    if (this.inputs.length === 0) {
+      return
+    }
+
+    do {
+      this.curIndex = (this.curIndex - 1)
+      if (this.curIndex < 0) {
+        this.curIndex = (this.inputs.length - 1)
+      }
+    } while (!this.inputs[this.curIndex].selectable)
+
+    this.updateSelectedElement()
+  }
+
+  nextInput() {
+    if (this.inputs.length === 0) {
+      return
+    }
+
+    do {
+      this.curIndex = (this.curIndex + 1) % this.inputs.length
+    } while (!this.inputs[this.curIndex].selectable)
+
+    this.updateSelectedElement()
+  }
+
+  firstInput(selectForm = true) {
+    if (this.inputs.length === 0) {
+      return
+    }
+
+    this.curIndex = 0
+
+    if (!this.inputs[this.curIndex].selectable) {
+      this.nextInput()
+    }
+
+    if (selectForm || (
+      this.root.isChildOrSelfSelected && this.root.isChildOrSelfSelected(this)
+    )) {
+      this.updateSelectedElement()
+    }
+  }
+
+  lastInput(selectForm = true) {
+    if (this.inputs.length === 0) {
+      return
+    }
+
+    this.curIndex = this.inputs.length - 1
+
+    if (!this.inputs[this.curIndex].selectable) {
+      this.previousInput()
+    }
+
+    if (selectForm || (
+      this.root.isChildOrSelfSelected && this.root.isChildOrSelfSelected(this)
+    )) {
+      this.updateSelectedElement()
+    }
+  }
+
+  selected() {
+    if (this.root.selectedElement === this) {
+      this.updateSelectedElement()
+    }
+  }
+
+  get curIndex() { return this.getDep('curIndex') }
+  set curIndex(v) { return this.setDep('curIndex', v) }
+}
diff --git a/ui/controls/ListScrollForm.js b/ui/controls/ListScrollForm.js
new file mode 100644
index 0000000..f74561e
--- /dev/null
+++ b/ui/controls/ListScrollForm.js
@@ -0,0 +1,304 @@
+import * as ansi from 'tui-lib/util/ansi'
+import telc from 'tui-lib/util/telchars'
+import unic from 'tui-lib/util/unichars'
+
+import Form from './Form.js'
+import ScrollBar from './ScrollBar.js'
+
+export default 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.
+  // Unless disabled in the constructor, a scrollbar is automatically displayed
+  // if there are more items than can be shown in the height of the form at a
+  // single time.
+
+  constructor(layoutType = 'vertical', enableScrollBar = true) {
+    super()
+
+    this.layoutType = layoutType
+    this.wheelMode = 'scroll' // scroll, selection
+
+    this.scrollItems = 0
+
+    this.scrollBarEnabled = enableScrollBar
+
+    this.scrollBar = new ScrollBar({
+      getLayoutType: () => this.layoutType,
+      getCurrentScroll: () => this.scrollItems,
+      getMaximumScroll: () => this.getScrollItemsLength(),
+      getTotalItems: () => this.inputs.length
+    })
+    this.scrollBarShown = false
+  }
+
+  fixLayout() {
+    this.keepScrollInBounds()
+
+    const scrollItems = this.scrollItems
+
+    // 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. We do need to hide
+    // them, though.
+    for (let i = 0; i < Math.min(scrollItems, this.inputs.length); i++) {
+      this.inputs[i].visible = false
+    }
+
+    // This variable stores how far along the respective axis (implied by
+    // layoutType) the next element should be.
+    let nextPos = 0
+
+    let formEdge
+    if (this.layoutType === 'horizontal') {
+      formEdge = this.contentW
+    } else {
+      formEdge = this.contentH
+    }
+
+    for (let i = scrollItems; i < this.inputs.length; i++) {
+      const item = this.inputs[i]
+      item.fixLayout()
+
+      const curPos = nextPos
+      let curSize
+      if (this.layoutType === 'horizontal') {
+        item.x = curPos
+        curSize = item.w
+      } else {
+        item.y = curPos
+        curSize = item.h
+      }
+      nextPos += curSize
+
+      // 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 (curPos + curSize > 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 (curPos < 0) {
+        item.visible = false
+      }
+    }
+
+    delete this._scrollItemsLength
+
+    if (this.scrollBarEnabled) {
+      this.showScrollbarIfNecessary()
+    }
+  }
+
+  keyPressed(keyBuf) {
+    let ret
+
+    handleKeyPress: {
+      if (this.layoutType === 'horizontal') {
+        if (telc.isLeft(keyBuf)) {
+          this.previousInput()
+          ret = false; break handleKeyPress
+        } else if (telc.isRight(keyBuf)) {
+          this.nextInput()
+          ret = false; break handleKeyPress
+        }
+      } else if (this.layoutType === 'vertical') {
+        if (telc.isUp(keyBuf)) {
+          this.previousInput()
+          ret = false; break handleKeyPress
+        } else if (telc.isDown(keyBuf)) {
+          this.nextInput()
+          ret = false; break handleKeyPress
+        }
+      }
+
+      ret = super.keyPressed(keyBuf)
+    }
+
+    this.scrollSelectedElementIntoView()
+
+    return ret
+  }
+
+  clicked(button) {
+    if (this.wheelMode === 'selection') {
+      // Change the actual selected item.
+      if (button === 'scroll-up') {
+        this.previousInput()
+        this.scrollSelectedElementIntoView()
+      } else if (button === 'scroll-down') {
+        this.nextInput()
+        this.scrollSelectedElementIntoView()
+      }
+    } else if (this.wheelMode === 'scroll') {
+      // Scrolling is typically pretty slow with a mouse wheel when it's by
+      // a single line, so scroll at 3x that speed.
+      for (let i = 0; i < 3; i++) {
+        if (button === 'scroll-up') {
+          this.scrollItems--
+        } else if (button === 'scroll-down') {
+          this.scrollItems++
+        } else {
+          return
+        }
+      }
+    }
+
+    this.fixLayout()
+  }
+
+  scrollSelectedElementIntoView() {
+    const sel = this.inputs[this.curIndex]
+
+    if (!sel) {
+      return
+    }
+
+    let formEdge
+    if (this.layoutType === 'horizontal') {
+      formEdge = this.contentW
+    } else {
+      formEdge = this.contentH
+    }
+
+    // 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) > formEdge + this.scrollSize) {
+      this.scrollElementIntoEndOfView(sel)
+    }
+
+    // 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()
+  }
+
+  firstInput(...args) {
+    this.scrollItems = 0
+
+    super.firstInput(...args)
+
+    this.fixLayout()
+  }
+
+  getScrollPositionOfElementAtEndOfView(element) {
+    // We can decide how many items to scroll past by moving forward until
+    // the item's far edge is visible.
+    const pos = this.getItemPos(element)
+
+    let edge
+    if (this.layoutType === 'horizontal') {
+      edge = this.contentW
+    } else {
+      edge = this.contentH
+    }
+
+    for (let i = 0; i < this.inputs.length; i++) {
+      if (pos <= edge) {
+        return i
+      }
+
+      if (this.layoutType === 'horizontal') {
+        edge += this.inputs[i].w
+      } else {
+        edge += this.inputs[i].h
+      }
+    }
+    // No result? Well, it's at the end.
+    return this.inputs.length
+  }
+
+  scrollElementIntoEndOfView(element) {
+    this.scrollItems = this.getScrollPositionOfElementAtEndOfView(element)
+  }
+
+  scrollToBeginning() {
+    this.scrollItems = 0
+    this.fixLayout()
+  }
+
+  scrollToEnd() {
+    this.scrollElementIntoEndOfView(this.inputs[this.inputs.length - 1])
+    this.fixLayout()
+  }
+
+  keepScrollInBounds() {
+    this.scrollItems = Math.max(this.scrollItems, 0)
+    this.scrollItems = Math.min(this.scrollItems, this.getScrollItemsLength())
+  }
+
+  getScrollItemsLength() {
+    if (typeof this._scrollItemsLength === 'undefined') {
+      const lastInput = this.inputs[this.inputs.length - 1]
+      this._scrollItemsLength = this.getScrollPositionOfElementAtEndOfView(lastInput)
+    }
+
+    return this._scrollItemsLength
+  }
+
+  getItemPos(item) {
+    // Gets the position of the item in an unscrolled view.
+
+    const index = this.inputs.indexOf(item)
+    let pos = 0
+    for (let i = 0; i <= index; i++) {
+      if (this.layoutType === 'horizontal') {
+        pos += this.inputs[i].w
+      } else {
+        pos += this.inputs[i].h
+      }
+    }
+    return pos
+  }
+
+  showScrollbarIfNecessary() {
+    this.scrollBarShown = this.scrollBar.canScrollAtAll()
+
+    const isChild = this.children.includes(this.scrollBar)
+    if (this.scrollBarShown) {
+      if (!isChild) this.addChild(this.scrollBar)
+    } else {
+      if (isChild) this.removeChild(this.scrollBar)
+    }
+  }
+
+  get scrollSize() {
+    // Gets the actual length made up by all of the items currently scrolled
+    // past.
+
+    let size = 0
+    for (let i = 0; i < Math.min(this.scrollItems, this.inputs.length); i++) {
+      if (this.layoutType === 'horizontal') {
+        size += this.inputs[i].w
+      } else {
+        size += this.inputs[i].h
+      }
+    }
+    return size
+  }
+
+  get contentW() {
+    if (this.scrollBarShown && this.layoutType === 'vertical') {
+      return this.w - 1
+    } else {
+      return this.w
+    }
+  }
+
+  get contentH() {
+    if (this.scrollBarShown && this.layoutType === 'horizontal') {
+      return this.h - 1
+    } else {
+      return this.h
+    }
+  }
+}
diff --git a/ui/controls/ScrollBar.js b/ui/controls/ScrollBar.js
new file mode 100644
index 0000000..4b79d57
--- /dev/null
+++ b/ui/controls/ScrollBar.js
@@ -0,0 +1,121 @@
+import * as ansi from 'tui-lib/util/ansi'
+import unic from 'tui-lib/util/unichars'
+
+import {DisplayElement} from 'tui-lib/ui/primitives'
+
+export default class ScrollBar extends DisplayElement {
+  constructor({
+    getLayoutType,
+    getCurrentScroll,
+    getMaximumScroll,
+    getTotalItems
+  }) {
+    super()
+
+    this.getLayoutType = getLayoutType
+    this.getCurrentScroll = getCurrentScroll
+    this.getMaximumScroll = getMaximumScroll
+    this.getTotalItems = getTotalItems
+  }
+
+  fixLayout() {
+    // Normally we'd subtract one from contentW/contentH when setting the x/y
+    // position, but the scroll-bar is actually displayed OUTSIDE of (adjacent
+    // to) the parent's content area.
+    if (this.getLayoutType() === 'vertical') {
+      this.h = this.parent.contentH
+      this.w = 1
+      this.x = this.parent.contentW
+      this.y = 0
+    } else {
+      this.h = 1
+      this.w = this.parent.contentW
+      this.x = 0
+      this.y = this.parent.contentH
+    }
+  }
+
+  drawTo(writable) {
+    // Uuuurgh
+    this.fixLayout()
+
+    // TODO: Horizontal layout! Not functionally a lot different, but I'm too
+    // lazy to write a test UI for it right now.
+
+    const {
+      backwards: canScrollBackwards,
+      forwards: canScrollForwards
+    } = this.getScrollableDirections()
+
+    // - 2 for extra UI elements (arrows)
+    const totalLength = this.h - 2
+
+    // ..[-----]..
+    //   ^start|
+    //         ^end
+    //
+    // Start and end should correspond to how much of the scroll area
+    // is currently visible. So, if you can see 60% of the full scroll length
+    // at a time, and you are scrolled 10% down, the start position of the
+    // handle should be 10% down, and it should extend 60% of the scrollbar
+    // length, to the 70% mark.
+
+    // NB: I think this math mixes the units for "items" and "lines".
+    // edgeLength is measured in lines, while totalItems is a number of items.
+    // This isn't a problem when the length of an item is equal to one line,
+    // but it's still worth investigating at some point.
+    const currentScroll = this.getCurrentScroll()
+    const totalItems = this.getTotalItems()
+    const edgeLength = this.parent.contentH
+    const visibleAtOnce = Math.min(totalItems, edgeLength)
+    const handleLength = visibleAtOnce / totalItems * totalLength
+    let handlePosition = Math.floor(totalLength / totalItems * currentScroll)
+
+    // Silly peeve of mine: The handle should only be visibly touching the top
+    // or bottom of the scrollbar area if you're actually scrolled all the way
+    // to the start or end. Otherwise, it shouldn't be touching! There should
+    // visible space indicating that you can scroll in that direction
+    // (in addition to the arrows we show at the ends).
+
+    if (canScrollBackwards && handlePosition === 0) {
+      handlePosition = 1
+    }
+
+    if (canScrollForwards && (handlePosition + handleLength) === edgeLength) {
+      handlePosition--
+    }
+
+    if (this.getLayoutType() === 'vertical') {
+      const start = this.absTop + handlePosition + 1
+      for (let i = 0; i < handleLength; i++) {
+        writable.write(ansi.moveCursor(start + i, this.absLeft))
+        writable.write(unic.BOX_V_DOUBLE)
+      }
+
+      if (canScrollBackwards) {
+        writable.write(ansi.moveCursor(this.absTop, this.absLeft))
+        writable.write(unic.ARROW_UP_DOUBLE)
+      }
+
+      if (canScrollForwards) {
+        writable.write(ansi.moveCursor(this.absBottom, this.absLeft))
+        writable.write(unic.ARROW_DOWN_DOUBLE)
+      }
+    }
+  }
+
+  getScrollableDirections() {
+    const currentScroll = this.getCurrentScroll()
+    const maximumScroll = this.getMaximumScroll()
+
+    return {
+      backwards: (currentScroll > 0),
+      forwards: (currentScroll < maximumScroll)
+    }
+  }
+
+  canScrollAtAll() {
+    const {backwards, forwards} = this.getScrollableDirections()
+    return backwards || forwards
+  }
+}
diff --git a/ui/controls/TextInput.js b/ui/controls/TextInput.js
new file mode 100644
index 0000000..1a32605
--- /dev/null
+++ b/ui/controls/TextInput.js
@@ -0,0 +1,147 @@
+import {FocusElement} from 'tui-lib/ui/primitives'
+
+import * as ansi from 'tui-lib/util/ansi'
+import telc from 'tui-lib/util/telchars'
+import unic from 'tui-lib/util/unichars'
+
+export default class TextInput extends FocusElement {
+  // An element that the user can type in.
+
+  constructor() {
+    super()
+
+    this.value = ''
+    this.cursorVisible = true
+    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) {
+    try {
+      if (keyBuf[0] === 127) {
+        this.value = (
+          this.value.slice(0, this.cursorIndex - 1) +
+          this.value.slice(this.cursorIndex)
+        )
+        this.cursorIndex--
+        this.root.cursorMoved()
+        return false
+      } else if (keyBuf[0] === 13) {
+        // These are aliases for each other.
+        this.emit('value', this.value)
+        this.emit('confirm', 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()
+        }
+        return false
+      } else if (telc.isEscape(keyBuf)) {
+        // ESC is bad and we don't want that in the text input!
+        // Also emit a "cancel" event, which doesn't necessarily do anything,
+        // but can be listened to.
+        this.emit('cancel')
+      } else {
+        const isTextInput = keyBuf.toString().split('').every(chr => {
+          const n = chr.charCodeAt(0)
+          return n > 31 && n < 127
+        })
+
+        if (isTextInput) {
+          this.value = (
+            this.value.slice(0, this.cursorIndex) + keyBuf.toString() +
+            this.value.slice(this.cursorIndex)
+          )
+          this.cursorIndex += keyBuf.toString().length
+          this.root.cursorMoved()
+          this.emit('change', this.value)
+
+          return false
+        }
+      }
+    } finally {
+      this.keepCursorInRange()
+    }
+  }
+
+  setValue(value) {
+    this.value = value
+    this.moveToEnd()
+  }
+
+  moveToEnd() {
+    this.cursorIndex = this.value.length
+    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.
+    while (this.cursorIndex - this.scrollChars > this.w - 3) {
+      this.scrollChars++
+    }
+
+    // Scroll left, if the cursor is behind the left edge of where text is
+    // displayed.
+    while (this.cursorIndex - this.scrollChars < 0) {
+      this.scrollChars--
+    }
+
+    // Scroll left, if we can see past the end of the text.
+    while (this.scrollChars > 0 && (
+      this.scrollChars + this.w - 3 > this.value.length)
+    ) {
+      this.scrollChars--
+    }
+  }
+
+  get value() { return this.getDep('value') }
+  set value(v) { return this.setDep('value', v) }
+  get cursorIndex() { return this.getDep('cursorIndex') }
+  set cursorIndex(v) { return this.setDep('cursorIndex', v) }
+}
diff --git a/ui/controls/index.js b/ui/controls/index.js
new file mode 100644
index 0000000..7f290c2
--- /dev/null
+++ b/ui/controls/index.js
@@ -0,0 +1,18 @@
+//
+// Import mapping:
+//
+//   primitives ->
+//     Button
+//     FocusBox
+//     ScrollBar
+//     TextInput
+//
+//     Form -> ListScrollForm
+//
+
+export {default as Button} from './Button.js'
+export {default as ScrollBar} from './ScrollBar.js'
+export {default as FocusBox} from './FocusBox.js'
+export {default as Form} from './Form.js'
+export {default as ListScrollForm} from './ListScrollForm.js'
+export {default as TextInput} from './TextInput.js'