« 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/ListScrollForm.js
diff options
context:
space:
mode:
Diffstat (limited to 'ui/controls/ListScrollForm.js')
-rw-r--r--ui/controls/ListScrollForm.js405
1 files changed, 405 insertions, 0 deletions
diff --git a/ui/controls/ListScrollForm.js b/ui/controls/ListScrollForm.js
new file mode 100644
index 0000000..3f75599
--- /dev/null
+++ b/ui/controls/ListScrollForm.js
@@ -0,0 +1,405 @@
+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'
+
+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.scrollItems = 0
+
+    this.scrollBarEnabled = enableScrollBar
+
+    this.scrollBar = new ScrollBar(this)
+    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) {
+    // 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 (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
+    }
+  }
+}
+
+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
+  }
+}