« 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/form
diff options
context:
space:
mode:
Diffstat (limited to 'ui/form')
-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
9 files changed, 567 insertions, 0 deletions
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--
+    }
+  }
+}