« get me outta code hell

tui-lib - Pure Node.js library for making visual command-line programs (ala vim, ncdu)
about summary refs log tree commit diff
path: root/ui
diff options
context:
space:
mode:
Diffstat (limited to 'ui')
-rw-r--r--ui/controls/Form.js21
-rw-r--r--ui/controls/ListScrollForm.js153
-rw-r--r--ui/controls/ScrollBar.js121
-rw-r--r--ui/controls/index.js2
-rw-r--r--ui/presentation/Label.js17
-rw-r--r--ui/presentation/WrapLabel.js8
-rw-r--r--ui/primitives/DisplayElement.js6
-rw-r--r--ui/primitives/Element.js2
8 files changed, 190 insertions, 140 deletions
diff --git a/ui/controls/Form.js b/ui/controls/Form.js
index 921096a..0224247 100644
--- a/ui/controls/Form.js
+++ b/ui/controls/Form.js
@@ -81,8 +81,10 @@ export default class Form extends FocusElement {
   }
 
   previousInput() {
-    // TODO: Forms currently assume there is at least one selectable input,
-    // but this isn't necessarily always the case.
+    if (this.inputs.length === 0) {
+      return
+    }
+
     do {
       this.curIndex = (this.curIndex - 1)
       if (this.curIndex < 0) {
@@ -94,7 +96,10 @@ export default class Form extends FocusElement {
   }
 
   nextInput() {
-    // TODO: See previousInput
+    if (this.inputs.length === 0) {
+      return
+    }
+
     do {
       this.curIndex = (this.curIndex + 1) % this.inputs.length
     } while (!this.inputs[this.curIndex].selectable)
@@ -103,9 +108,12 @@ export default class Form extends FocusElement {
   }
 
   firstInput(selectForm = true) {
+    if (this.inputs.length === 0) {
+      return
+    }
+
     this.curIndex = 0
 
-    // TODO: See previousInput
     if (!this.inputs[this.curIndex].selectable) {
       this.nextInput()
     }
@@ -118,9 +126,12 @@ export default class Form extends FocusElement {
   }
 
   lastInput(selectForm = true) {
+    if (this.inputs.length === 0) {
+      return
+    }
+
     this.curIndex = this.inputs.length - 1
 
-    // TODO: See previousInput
     if (!this.inputs[this.curIndex].selectable) {
       this.previousInput()
     }
diff --git a/ui/controls/ListScrollForm.js b/ui/controls/ListScrollForm.js
index 3f75599..f74561e 100644
--- a/ui/controls/ListScrollForm.js
+++ b/ui/controls/ListScrollForm.js
@@ -2,9 +2,8 @@ import * as ansi from 'tui-lib/util/ansi'
 import telc from 'tui-lib/util/telchars'
 import unic from 'tui-lib/util/unichars'
 
-import {DisplayElement} from 'tui-lib/ui/primitives'
-
 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
@@ -17,12 +16,18 @@ export default class ListScrollForm extends Form {
     super()
 
     this.layoutType = layoutType
+    this.wheelMode = 'scroll' // scroll, selection
 
     this.scrollItems = 0
 
     this.scrollBarEnabled = enableScrollBar
 
-    this.scrollBar = new ScrollBar(this)
+    this.scrollBar = new ScrollBar({
+      getLayoutType: () => this.layoutType,
+      getCurrentScroll: () => this.scrollItems,
+      getMaximumScroll: () => this.getScrollItemsLength(),
+      getTotalItems: () => this.inputs.length
+    })
     this.scrollBarShown = false
   }
 
@@ -119,27 +124,26 @@ export default class ListScrollForm extends Form {
   }
 
   clicked(button) {
-    // Old code for changing the actual selected item...maybe an interesting
-    // functionality to explore later?
-    /*
-    if (button === 'scroll-up') {
-      this.previousInput()
-      this.scrollSelectedElementIntoView()
-    } else if (button === 'scroll-down') {
-      this.nextInput()
-      this.scrollSelectedElementIntoView()
-    }
-    */
-
-    // 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 (this.wheelMode === 'selection') {
+      // Change the actual selected item.
       if (button === 'scroll-up') {
-        this.scrollItems--
+        this.previousInput()
+        this.scrollSelectedElementIntoView()
       } else if (button === 'scroll-down') {
-        this.scrollItems++
-      } else {
-        return
+        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
+        }
       }
     }
 
@@ -298,108 +302,3 @@ export default class ListScrollForm extends Form {
     }
   }
 }
-
-class ScrollBar extends DisplayElement {
-  constructor(listScrollForm) {
-    super()
-
-    this.listScrollForm = listScrollForm
-  }
-
-  fixLayout() {
-    // Normally we'd subtract one from contentW/contentH when setting the x/y
-    // position, but the scrollbar is actually displayed OUTSIDE of (adjacent
-    // to) the parent's content area.
-    if (this.listScrollForm.layoutType === 'vertical') {
-      this.h = this.listScrollForm.contentH
-      this.w = 1
-      this.x = this.listScrollForm.contentW
-      this.y = 0
-    } else {
-      this.h = 1
-      this.w = this.listScrollForm.contentW
-      this.x = 0
-      this.y = this.listScrollForm.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.
-
-    const currentScroll = this.listScrollForm.scrollItems
-    const edgeLength = this.listScrollForm.contentH
-    const totalItems = this.listScrollForm.inputs.length
-    const itemsVisibleAtOnce = Math.min(totalItems, edgeLength)
-    const handleLength = itemsVisibleAtOnce / 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.listScrollForm.layoutType === '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.listScrollForm.scrollItems
-    const totalScroll = this.listScrollForm.getScrollItemsLength()
-
-    return {
-      backwards: (currentScroll > 0),
-      forwards: (currentScroll < totalScroll)
-    }
-  }
-
-  canScrollAtAll() {
-    const {backwards, forwards} = this.getScrollableDirections()
-    return backwards || forwards
-  }
-}
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/index.js b/ui/controls/index.js
index e99add1..7f290c2 100644
--- a/ui/controls/index.js
+++ b/ui/controls/index.js
@@ -4,12 +4,14 @@
 //   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'
diff --git a/ui/presentation/Label.js b/ui/presentation/Label.js
index 81223df..ed45601 100644
--- a/ui/presentation/Label.js
+++ b/ui/presentation/Label.js
@@ -4,6 +4,16 @@ 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()
@@ -32,7 +42,12 @@ export default class Label extends DisplayElement {
 
   writeTextTo(writable) {
     writable.write(ansi.moveCursor(this.absTop, this.absLeft))
-    writable.write(this.text)
+    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) {
diff --git a/ui/presentation/WrapLabel.js b/ui/presentation/WrapLabel.js
index 0ecc777..eae8960 100644
--- a/ui/presentation/WrapLabel.js
+++ b/ui/presentation/WrapLabel.js
@@ -1,5 +1,3 @@
-import wrap from 'word-wrap'
-
 import * as ansi from 'tui-lib/util/ansi'
 
 import Label from './Label.js'
@@ -21,7 +19,7 @@ export default class WrapLabel extends Label {
     const lines = this.getWrappedLines()
     for (let i = 0; i < lines.length; i++) {
       writable.write(ansi.moveCursor(this.absTop + i, this.absLeft))
-      writable.write(lines[i])
+      writable.write(this.processFormatting(lines[i]))
     }
   }
 
@@ -30,9 +28,7 @@ export default class WrapLabel extends Label {
       return []
     }
 
-    const options = {width: this.w, indent: ''}
-    return wrap(this.text, options).split('\n')
-      .map(l => l.trim())
+    return ansi.wrapToColumns(this.text, this.w - 1).map(l => l.trim())
   }
 
   get h() {
diff --git a/ui/primitives/DisplayElement.js b/ui/primitives/DisplayElement.js
index d2a0956..6452887 100644
--- a/ui/primitives/DisplayElement.js
+++ b/ui/primitives/DisplayElement.js
@@ -237,7 +237,7 @@ export default class DisplayElement extends Element {
     children.reverse()
 
     for (const el of children) {
-      if (!el.visible || el.clickThrough) {
+      if (!el.visible) {
         continue
       }
 
@@ -246,6 +246,10 @@ export default class DisplayElement extends Element {
         return el2
       }
 
+      if (el.clickThrough) {
+        continue
+      }
+
       const { absX, absY, w, h } = el
       if (absX <= x && absX + w > x) {
         if (absY <= y && absY + h > y) {
diff --git a/ui/primitives/Element.js b/ui/primitives/Element.js
index fea8c03..a5dbea6 100644
--- a/ui/primitives/Element.js
+++ b/ui/primitives/Element.js
@@ -1,5 +1,7 @@
 import EventEmitter from 'node:events'
 
+import exception from 'tui-lib/util/exception'
+
 export default class Element extends EventEmitter {
   // The basic class containing methods for working with an element hierarchy.
   // Generally speaking, you usually want to extend DisplayElement instead of