« 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/primitives/Root.js
diff options
context:
space:
mode:
Diffstat (limited to 'ui/primitives/Root.js')
-rw-r--r--ui/primitives/Root.js284
1 files changed, 284 insertions, 0 deletions
diff --git a/ui/primitives/Root.js b/ui/primitives/Root.js
new file mode 100644
index 0000000..a779637
--- /dev/null
+++ b/ui/primitives/Root.js
@@ -0,0 +1,284 @@
+import * as ansi from 'tui-lib/util/ansi'
+import telc from 'tui-lib/util/telchars'
+
+import DisplayElement from './DisplayElement.js'
+
+export default class Root extends DisplayElement {
+  // An element to be used as the root of a UI. Handles lots of UI and
+  // socket stuff.
+
+  constructor(interfaceArg, writable = null) {
+    super()
+
+    this.interface = interfaceArg
+    this.writable = writable || interfaceArg
+
+    this.selectedElement = null
+
+    this.cursorBlinkOffset = Date.now()
+
+    this.oldSelectionStates = []
+
+    this.interface.on('inputData', buf => this.handleData(buf))
+
+    this.renderCount = 0
+  }
+
+  handleData(buffer) {
+    if (telc.isMouse(buffer)) {
+      const allData = telc.parseMouse(buffer)
+      const { button, line, col } = allData
+      const topEl = this.getElementAt(col - 1, line - 1)
+      if (topEl) {
+        //console.log('Clicked', topEl.constructor.name, 'of', topEl.parent.constructor.name)
+        this.eachAncestor(topEl, el => {
+          if (typeof el.clicked === 'function') {
+            return el.clicked(button, allData) === false
+          }
+        })
+      }
+    } else {
+      this.eachAncestor(this.selectedElement, el => {
+        if (typeof el.keyPressed === 'function') {
+          const shouldBreak = (el.keyPressed(buffer) === false)
+          if (shouldBreak) {
+            return true
+          }
+          el.emit('keypressed', buffer)
+        }
+      })
+    }
+  }
+
+  eachAncestor(topEl, func) {
+    // Handy function for doing something to an element and all its ancestors,
+    // allowing for the passed function to return false to break the loop and
+    // stop propagation.
+
+    if (topEl) {
+      const els = [topEl, ...topEl.directAncestors]
+      for (const el of els) {
+        const shouldBreak = func(el)
+        if (shouldBreak) {
+          break
+        }
+      }
+    }
+  }
+
+  drawTo(writable) {
+    writable.write(ansi.moveCursor(0, 0))
+    writable.write(' '.repeat(this.w * this.h))
+  }
+
+  scheduleRender() {
+    if (!this.scheduledRender) {
+      setTimeout(() => {
+        this.scheduledRender = false
+        this.render()
+      })
+      this.scheduledRender = true
+    }
+  }
+
+  render() {
+    this.renderTo(this.writable)
+  }
+
+  renderNow() {
+    this.renderNowTo(this.writable)
+  }
+
+  renderTo(writable) {
+    if (this.anyDescendantShouldRender()) {
+      this.renderNowTo(writable)
+    }
+  }
+
+  renderNowTo(writable) {
+    if (writable) {
+      this.renderCount++
+      super.renderTo(writable)
+      // Since shouldRender is false, super.renderTo won't call didRenderTo for
+      // us. We need to do that ourselves.
+      this.didRenderTo(writable)
+    }
+  }
+
+  anyDescendantShouldRender() {
+    let render = false
+    this.eachDescendant(el => {
+      // If we already know we're going to render, checking the element's
+      // scheduled-draw status (which involves iterating over each of its draw
+      // dependency properties) is redundant.
+      if (render) {
+        return
+      }
+      render = el.hasScheduledDraw()
+    })
+    return render
+  }
+
+  shouldRender() {
+    // We need to return false here because otherwise all children will render,
+    // since they'll see the root as an ancestor who needs to be rendered. Bad!
+    return false
+  }
+
+  didRenderTo(writable) {
+    this.eachDescendant(el => {
+      el.unscheduleDraw()
+      el.updateLastDrawValues()
+    })
+
+    /*
+    writable.write(ansi.moveCursorRaw(1, 1))
+    writable.write('Renders: ' + this.renderCount)
+    */
+
+    // Render the cursor, based on the cursorX and cursorY of the currently
+    // selected element.
+    if (this.selectedElement && this.selectedElement.cursorVisible) {
+      /*
+      if ((Date.now() - this.cursorBlinkOffset) % 1000 < 500) {
+        writable.write(ansi.moveCursor(
+          this.selectedElement.absCursorY, this.selectedElement.absCursorX))
+        writable.write(ansi.invert())
+        writable.write('I')
+        writable.write(ansi.resetAttributes())
+      }
+      */
+
+      writable.write(ansi.showCursor())
+      writable.write(ansi.moveCursorRaw(
+        this.selectedElement.absCursorY, this.selectedElement.absCursorX))
+    } else {
+      writable.write(ansi.hideCursor())
+    }
+
+    this.emit('rendered')
+  }
+
+  cursorMoved() {
+    // Resets the blinking animation for the cursor. Call this whenever you
+    // move the cursor.
+
+    this.cursorBlinkOffset = Date.now()
+  }
+
+  select(el, {fromForm = false} = {}) {
+    // Select an element. Calls the unfocus method on the already-selected
+    // element, if there is one.
+
+    // If the element is part of a form, just be lazy and pass control to that
+    // form...unless the form itself asked us to select the element!
+    //
+    // TODO: This is so that if an element is selected, its parent form will
+    // automatically see that and correctly update its curIndex... but what if
+    // the element is an input of a form which is NOT its parent?
+    //
+    // XXX: We currently use a HUGE HACK instead of `instanceof` to avoid
+    // breaking the rule of import direction (controls -> primitives, never
+    // the other way around). This is bad for obvious reasons, but I haven't
+    // yet looked into what the correct approach would be.
+    const parent = el.parent
+    if (!fromForm && parent.constructor.name === 'Form' && parent.inputs.includes(el)) {
+      parent.selectInput(el)
+      return
+    }
+
+    const oldSelected = this.selectedElement
+    const newSelected = el
+
+    // Relevant elements that COULD have their "isSelected" state change.
+    const relevantElements = ([
+      ...(oldSelected ? [...oldSelected.directAncestors, oldSelected] : []),
+      ...(newSelected ? newSelected.directAncestors : [])
+    ]
+
+      // We ignore elements where isSelected is undefined, because they aren't
+      // built to handle being selected, and they break the compare-old-and-new-
+      // state code below.
+      .filter(el => typeof el.isSelected !== 'undefined')
+
+      // Get rid of duplicates - including any that occurred in the already
+      // existing array of selection states. (We only care about the oldest
+      // selection state, i.e. the one when we did the first .select().)
+      .reduce((acc, el) => {
+        // Duplicates from relevant elements of current .select()
+        if (acc.includes(el)) return acc
+        // Duplicates from already existing selection states
+        if (this.oldSelectionStates.some(x => x[0] === el)) return acc
+        return acc.concat([el])
+      }, []))
+
+    // Keep track of whether those elements were selected before we call the
+    // newly selected element's selected() function. We store these on a
+    // property because we might actually be adding to it from a previous
+    // root.select() call, if that one itself caused this root.select().
+    // One all root.select()s in the "chain" (as it is) have finished, we'll
+    // go through these states and call the appropriate .select/unselect()
+    // functions on each element whose .isSelected changed.
+    const selectionStates = relevantElements.map(el => [el, el.isSelected])
+    this.oldSelectionStates = this.oldSelectionStates.concat(selectionStates)
+
+    this.selectedElement = el
+
+    // Same stuff as in the for loop below. We always call selected() on the
+    // passed element, even if it was already selected before.
+    if (el.selected) el.selected()
+    if (typeof el.focused === 'function') el.focused()
+
+    // If the selection changed as a result of the element's selected()
+    // function, stop here. We will leave calling the appropriate functions on
+    // the elements in the oldSelectionStates array to the final .select(),
+    // i.e. the one which caused no change in selected element.
+    if (this.selectedElement !== newSelected) return
+
+    // Compare the old "isSelected" state of every relevant element with their
+    // current "isSelected" state, and call the respective selected/unselected
+    // functions. (Also call focused and unfocused for some sense of trying to
+    // not break old programs, but, like, old programs are going to be broken
+    // anyways.)
+    const states = this.oldSelectionStates.slice()
+    for (const [ el, wasSelected ] of states) {
+      // Now that we'll have processed it, we don't want it in the array
+      // anymore.
+      this.oldSelectionStates.shift()
+
+      const { isSelected } = el
+      if (isSelected && !wasSelected) {
+        // Don't call these functions if this element is the newly selected
+        // one, because we already called them above!
+        if (el !== newSelected) {
+          if (el.selected) el.selected()
+          if (typeof el.focused === 'function') el.focused()
+        }
+      } else if (wasSelected && !isSelected) {
+        if (el.unselected) el.unselected()
+        if (typeof el.unfocused === 'function') el.unfocused()
+      }
+
+      // If the (un)selected() handler actually selected a different element
+      // itself, then further processing of new selected states is irrelevant,
+      // so stop here. (We return instead of breaking the for loop because
+      // anything after this loop would have already been handled by the call
+      // to Root.select() from the (un)selected() handler.)
+      if (this.selectedElement !== newSelected) {
+        return
+      }
+    }
+
+    this.cursorMoved()
+  }
+
+  isChildOrSelfSelected(el) {
+    if (!this.selectedElement) return false
+    if (this.selectedElement === el) return true
+    if (this.selectedElement.directAncestors.includes(el)) return true
+    return false
+  }
+
+  get selectedElement() { return this.getDep('selectedElement') }
+  set selectedElement(v) { return this.setDep('selectedElement', v) }
+}