« 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/presentation
diff options
context:
space:
mode:
Diffstat (limited to 'ui/presentation')
-rw-r--r--ui/presentation/HorizontalBox.js13
-rw-r--r--ui/presentation/Label.js67
-rw-r--r--ui/presentation/Pane.js101
-rw-r--r--ui/presentation/Sprite.js69
-rw-r--r--ui/presentation/WrapLabel.js41
-rw-r--r--ui/presentation/index.js15
6 files changed, 306 insertions, 0 deletions
diff --git a/ui/presentation/HorizontalBox.js b/ui/presentation/HorizontalBox.js
new file mode 100644
index 0000000..d396ec3
--- /dev/null
+++ b/ui/presentation/HorizontalBox.js
@@ -0,0 +1,13 @@
+import {DisplayElement} from 'tui-lib/ui/primitives'
+
+export default class HorizontalBox extends DisplayElement {
+  // A box that will automatically lay out its children in a horizontal row.
+
+  fixLayout() {
+    let nextX = 0
+    for (const child of this.children) {
+      child.x = nextX
+      nextX = child.right + 1
+    }
+  }
+}
diff --git a/ui/presentation/Label.js b/ui/presentation/Label.js
new file mode 100644
index 0000000..ed45601
--- /dev/null
+++ b/ui/presentation/Label.js
@@ -0,0 +1,67 @@
+import {DisplayElement} from 'tui-lib/ui/primitives'
+
+import * as ansi from 'tui-lib/util/ansi'
+
+export default class Label extends DisplayElement {
+  // A simple text display. Automatically adjusts size to fit text.
+  //
+  // Supports formatted text in two ways:
+  // 1) Modify the textAttributes to be an array containing the ANSI numerical
+  //    codes for any wanted attributes, and/or
+  // 2) Supply full ANSI escape codes within the text itself. (The reset
+  //    attributes code, ESC[0m, will be processed to reset to the provided
+  //    values in textAttributes.
+  //
+  // Subclasses overriding the writeTextTo function should be sure to call
+  // processFormatting before actually writing text.
+
+  constructor(text = '') {
+    super()
+
+    this.text = text
+    this.textAttributes = []
+  }
+
+  fixLayout() {
+    this.w = ansi.measureColumns(this.text)
+  }
+
+  drawTo(writable) {
+    if (this.textAttributes.length) {
+      writable.write(ansi.setAttributes(this.textAttributes))
+    }
+
+    this.writeTextTo(writable)
+
+    if (this.textAttributes.length) {
+      writable.write(ansi.resetAttributes())
+    }
+
+    super.drawTo(writable)
+  }
+
+  writeTextTo(writable) {
+    writable.write(ansi.moveCursor(this.absTop, this.absLeft))
+    writable.write(this.processFormatting(this.text))
+  }
+
+  processFormatting(text) {
+    return text.replace(new RegExp(ansi.ESC + '\\[0m', 'g'),
+      ansi.setAttributes([ansi.A_RESET, ...this.textAttributes]))
+  }
+
+  set text(newText) {
+    const ret = this.setDep('text', newText)
+    this.fixLayout()
+    return ret
+  }
+
+  get text() {
+    return this.getDep('text')
+  }
+
+  // Kinda bad, but works as long as you're overwriting the array instead of
+  // mutating it.
+  set textAttributes(val) { return this.setDep('textAttributes', val) }
+  get textAttributes() { return this.getDep('textAttributes') }
+}
diff --git a/ui/presentation/Pane.js b/ui/presentation/Pane.js
new file mode 100644
index 0000000..4769cf9
--- /dev/null
+++ b/ui/presentation/Pane.js
@@ -0,0 +1,101 @@
+import {DisplayElement} from 'tui-lib/ui/primitives'
+
+import * as ansi from 'tui-lib/util/ansi'
+import unic from 'tui-lib/util/unichars'
+
+import Label from './Label.js'
+
+export default 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 = ansi.measureColumns(text) + 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/presentation/Sprite.js b/ui/presentation/Sprite.js
new file mode 100644
index 0000000..49ee450
--- /dev/null
+++ b/ui/presentation/Sprite.js
@@ -0,0 +1,69 @@
+import {DisplayElement} from 'tui-lib/ui/primitives'
+
+import * as ansi from 'tui-lib/util/ansi'
+
+export default 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 => ansi.measureColumns(row)))
+  }
+
+  get textureHeight() {
+    return this.texture.length
+  }
+}
diff --git a/ui/presentation/WrapLabel.js b/ui/presentation/WrapLabel.js
new file mode 100644
index 0000000..eae8960
--- /dev/null
+++ b/ui/presentation/WrapLabel.js
@@ -0,0 +1,41 @@
+import * as ansi from 'tui-lib/util/ansi'
+
+import Label from './Label.js'
+
+export default class WrapLabel extends Label {
+  // A word-wrapping text display. Given a width, wraps text to fit.
+
+  constructor(...args) {
+    super(...args)
+  }
+
+  fixLayout() {
+    // Override Label.fixLayout to do nothing. We don't want to make the
+    // width of this label be set to the content of the text! (That would
+    // defeat the entire point of word wrapping.)
+  }
+
+  writeTextTo(writable) {
+    const lines = this.getWrappedLines()
+    for (let i = 0; i < lines.length; i++) {
+      writable.write(ansi.moveCursor(this.absTop + i, this.absLeft))
+      writable.write(this.processFormatting(lines[i]))
+    }
+  }
+
+  getWrappedLines() {
+    if (this.text.trim().length === 0) {
+      return []
+    }
+
+    return ansi.wrapToColumns(this.text, this.w - 1).map(l => l.trim())
+  }
+
+  get h() {
+    return this.getWrappedLines().length
+  }
+
+  set h(newHeight) {
+    // Do nothing. Height is computed on the fly.
+  }
+}
diff --git a/ui/presentation/index.js b/ui/presentation/index.js
new file mode 100644
index 0000000..9605d25
--- /dev/null
+++ b/ui/presentation/index.js
@@ -0,0 +1,15 @@
+//
+// Import mapping:
+//
+//   primitives ->
+//     HorizontalBox
+//     Sprite
+//
+//     Label -> Pane, WrapLabel
+//
+
+export {default as HorizontalBox} from './HorizontalBox.js'
+export {default as Label} from './Label.js'
+export {default as Pane} from './Pane.js'
+export {default as Sprite} from './Sprite.js'
+export {default as WrapLabel} from './WrapLabel.js'