« get me outta code hell

use ESM module syntax & minor cleanups - 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
diff options
context:
space:
mode:
author(quasar) nebula <qznebula@protonmail.com>2023-05-12 17:42:09 -0300
committer(quasar) nebula <qznebula@protonmail.com>2023-05-13 12:48:36 -0300
commit6ea74c268a12325296a1d2e7fc31b02030ddb8bc (patch)
tree5da94d93acb64e7ab650d240d6cb23c659ad02ca /ui/primitives
parente783bcf8522fa68e6b221afd18469c3c265b1bb7 (diff)
use ESM module syntax & minor cleanups
The biggest change here is moving various element classes under
more scope-specific directories, which helps to avoid circular
dependencies and is just cleaner to navigate and expand in the
future.

Otherwise this is a largely uncritical port to ESM module syntax!
There are probably a number of changes and other cleanups that
remain much needed.

Whenever I make changes to tui-lib it's hard to believe it's
already been <INSERT COUNTING NUMBER HERE> years since the
previous time. First commits are from January 2017, and the
code originates a month earlier in KAaRMNoD!
Diffstat (limited to 'ui/primitives')
-rw-r--r--ui/primitives/DisplayElement.js305
-rw-r--r--ui/primitives/Element.js80
-rw-r--r--ui/primitives/FocusElement.js45
-rw-r--r--ui/primitives/Root.js284
-rw-r--r--ui/primitives/index.js11
5 files changed, 725 insertions, 0 deletions
diff --git a/ui/primitives/DisplayElement.js b/ui/primitives/DisplayElement.js
new file mode 100644
index 0000000..d2a0956
--- /dev/null
+++ b/ui/primitives/DisplayElement.js
@@ -0,0 +1,305 @@
+import Element from './Element.js'
+
+export default class DisplayElement extends Element {
+  // A general class that handles dealing with screen coordinates, the tree
+  // of elements, and other common stuff.
+  //
+  // This element doesn't handle any real rendering; just layouts. Placing
+  // characters at specific positions should be implemented in subclasses.
+  //
+  // It's a subclass of EventEmitter, so you can make your own events within
+  // the logic of your subclass.
+
+  static drawValues = Symbol('drawValues')
+  static lastDrawValues = Symbol('lastDrawValues')
+  static scheduledDraw = Symbol('scheduledDraw')
+
+  constructor() {
+    super()
+
+    this[DisplayElement.drawValues] = {}
+    this[DisplayElement.lastDrawValues] = {}
+    this[DisplayElement.scheduledDraw] = false
+
+    this.visible = true
+
+    this.x = 0
+    this.y = 0
+    this.w = 0
+    this.h = 0
+
+    this.hPadding = 0
+    this.vPadding = 0
+
+    // Note! This only applies to the parent, not the children. Useful for
+    // when you want an element to cover the whole screen but allow mouse
+    // events to pass through.
+    this.clickThrough = false
+  }
+
+  drawTo(writable) {
+    // Writes text to a "writable" - an object that has a "write" method.
+    // Custom rendering should be handled as an override of this method in
+    // subclasses of DisplayElement.
+  }
+
+  renderTo(writable) {
+    // Like drawTo, but only calls drawTo if the element is visible. Use this
+    // with your root element, not drawTo.
+
+    if (!this.visible) {
+      return
+    }
+
+    const causeRenderEl = this.shouldRender()
+    if (causeRenderEl) {
+      this.drawTo(writable)
+      this.renderChildrenTo(writable)
+      this.didRenderTo(writable)
+    } else {
+      this.renderChildrenTo(writable)
+    }
+  }
+
+  shouldRender() {
+    // WIP! Until this implementation is finished, always return true (or else
+    // lots of rendering breaks).
+    /*
+    return (
+      this[DisplayElement.scheduledDraw] ||
+      [...this.directAncestors].find(el => el.shouldRender())
+    )
+    */
+    return true
+  }
+
+  renderChildrenTo(writable) {
+    // Renders all of the children to a writable.
+
+    for (const child of this.children) {
+      child.renderTo(writable)
+    }
+  }
+
+  didRenderTo(writable) {
+    // Called immediately after rendering this element AND all of its
+    // children. If you need to do something when that happens, override this
+    // method in your subclass.
+    //
+    // It's fine to draw more things to the writable here - just keep in mind
+    // that it'll be drawn over this element and its children, but not any
+    // elements drawn in the future.
+  }
+
+  fixLayout() {
+    // Adjusts the layout of children in this element. If your subclass has
+    // any children in it, you should override this method.
+  }
+
+  fixAllLayout() {
+    // Runs fixLayout on this as well as all children.
+
+    this.fixLayout()
+    for (const child of this.children) {
+      child.fixAllLayout()
+    }
+  }
+
+  confirmDrawValuesExists() {
+    if (!this[DisplayElement.drawValues]) {
+      this[DisplayElement.drawValues] = {}
+    }
+  }
+
+  getDep(key) {
+    this.confirmDrawValuesExists()
+    return this[DisplayElement.drawValues][key]
+  }
+
+  setDep(key, value) {
+    this.confirmDrawValuesExists()
+    const oldValue = this[DisplayElement.drawValues][key]
+    if (value !== this[DisplayElement.drawValues][key]) {
+      this[DisplayElement.drawValues][key] = value
+      this.scheduleDraw()
+      // Grumble: technically it's possible for a root element to not be an
+      // actual Root. While we don't check for this case most of the time (even
+      // though we ought to), we do here because it's not unlikely for draw
+      // dependency values to be changed before the element is actually added
+      // to a Root element.
+      if (this.root.scheduleRender) {
+        this.root.scheduleRender()
+      }
+    }
+    return value
+  }
+
+  scheduleDrawWithoutPropertyChange() {
+    // Utility function for when you need to schedule a draw without updating
+    // any particular draw-dependency property on the element. Works by setting
+    // an otherwise unused dep to a unique object. (We can't use a symbol here,
+    // because then Object.entries doesn't notice it.)
+    this.setDep('drawWithoutProperty', Math.random())
+  }
+
+  scheduleDraw() {
+    this[DisplayElement.scheduledDraw] = true
+  }
+
+  unscheduleDraw() {
+    this[DisplayElement.scheduledDraw] = false
+  }
+
+  hasScheduledDraw() {
+    if (this[DisplayElement.scheduledDraw]) {
+      for (const [ key, value ] of Object.entries(this[DisplayElement.drawValues])) {
+        if (value !== this[DisplayElement.lastDrawValues][key]) {
+          return true
+        }
+      }
+    }
+    return false
+  }
+
+  updateLastDrawValues() {
+    Object.assign(this[DisplayElement.lastDrawValues], this[DisplayElement.drawValues])
+  }
+
+  centerInParent() {
+    // Utility function to center this element in its parent. Must be called
+    // only when it has a parent. Set the width and height of the element
+    // before centering it!
+
+    if (this.parent === null) {
+      throw new Error('Cannot center in parent when parent is null')
+    }
+
+    this.x = Math.round((this.parent.contentW - this.w) / 2)
+    this.y = Math.round((this.parent.contentH - this.h) / 2)
+  }
+
+  fillParent() {
+    // Utility function to fill this element in its parent. Must be called
+    // only when it has a parent.
+
+    if (this.parent === null) {
+      throw new Error('Cannot fill parent when parent is null')
+    }
+
+    this.x = 0
+    this.y = 0
+    this.w = this.parent.contentW
+    this.h = this.parent.contentH
+  }
+
+  fitToParent() {
+    // Utility function to position this element so that it stays within its
+    // parent's bounds. Must be called only when it has a parent.
+    //
+    // This function is useful when (and only when) the right or bottom edge
+    // of this element may be past the right or bottom edge of its parent.
+    // In such a case, the element will first be moved left or up by the
+    // distance that its edge exceeds that of its parent, so that its edge is
+    // no longer past the parent's. Then, if the left or top edge of the
+    // element is less than zero, i.e. outside the parent, it is set to zero
+    // and the element's width or height is adjusted so that it does not go
+    // past the bounds of the parent.
+
+    if (this.x + this.w > this.parent.right) {
+      const offendExtent = (this.x + this.w) - this.parent.contentW
+      this.x -= offendExtent
+      if (this.x < 0) {
+        const offstartExtent = 0 - this.x
+        this.w -= offstartExtent
+        this.x = 0
+      }
+    }
+
+    if (this.y + this.h > this.parent.bottom) {
+      const offendExtent = (this.y + this.h) - this.parent.contentH
+      this.y -= offendExtent
+      if (this.y < 0) {
+        const offstartExtent = 0 - this.y
+        this.h -= offstartExtent
+        this.y = 0
+      }
+    }
+  }
+
+  getElementAt(x, y) {
+    // Gets the topmost element at the provided absolute coordinate.
+    // Note that elements which are not visible or have the clickThrough
+    // property set to true are not considered.
+
+    const children = this.children.slice()
+
+    // Start searching the last- (top-) rendered children first.
+    children.reverse()
+
+    for (const el of children) {
+      if (!el.visible || el.clickThrough) {
+        continue
+      }
+
+      const el2 = el.getElementAt(x, y)
+      if (el2) {
+        return el2
+      }
+
+      const { absX, absY, w, h } = el
+      if (absX <= x && absX + w > x) {
+        if (absY <= y && absY + h > y) {
+          return el
+        }
+      }
+    }
+    return null
+  }
+
+  get x() { return this.getDep('x') }
+  set x(v) { return this.setDep('x', v) }
+  get y() { return this.getDep('y') }
+  set y(v) { return this.setDep('y', v) }
+  get hPadding() { return this.getDep('hPadding') }
+  set hPadding(v) { return this.setDep('hPadding', v) }
+  get vPadding() { return this.getDep('vPadding') }
+  set vPadding(v) { return this.setDep('vPadding', v) }
+  get visible() { return this.getDep('visible') }
+  set visible(v) { return this.setDep('visible', v) }
+
+  // Commented out because this doesn't fix any problems (at least ATM).
+  // get parent() { return this.getDep('parent') }
+  // set parent(v) { return this.setDep('parent', v) }
+
+  get absX() {
+    if (this.parent) {
+      return this.parent.contentX + this.x
+    } else {
+      return this.x
+    }
+  }
+
+  get absY() {
+    if (this.parent) {
+      return this.parent.contentY + this.y
+    } else {
+      return this.y
+    }
+  }
+
+  // Where contents should be positioned.
+  get contentX() { return this.absX + this.hPadding }
+  get contentY() { return this.absY + this.vPadding }
+  get contentW() { return this.w - this.hPadding * 2 }
+  get contentH() { return this.h - this.vPadding * 2 }
+
+  get left()   { return this.x }
+  get right()  { return this.x + this.w }
+  get top()    { return this.y }
+  get bottom() { return this.y + this.h }
+
+  get absLeft()   { return this.absX }
+  get absRight()  { return this.absX + this.w - 1 }
+  get absTop()    { return this.absY }
+  get absBottom() { return this.absY + this.h - 1 }
+}
diff --git a/ui/primitives/Element.js b/ui/primitives/Element.js
new file mode 100644
index 0000000..fea8c03
--- /dev/null
+++ b/ui/primitives/Element.js
@@ -0,0 +1,80 @@
+import EventEmitter from 'node:events'
+
+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
+  // this class.
+
+  constructor() {
+    super()
+
+    this.children = []
+    this.parent = null
+  }
+
+  eachDescendant(fn) {
+    // Run a function on this element, all of its children, all of their
+    // children, etc.
+    fn(this)
+    for (const child of this.children) {
+      child.eachDescendant(fn)
+    }
+  }
+
+  addChild(child, afterIndex = this.children.length, {fixLayout = true} = {}) {
+    // TODO Don't let a direct ancestor of this be added as a child. Don't
+    // let itself be one of its childs either!
+
+    if (child === this) {
+      throw exception(
+        'EINVALIDHIERARCHY', 'An element cannot be a child of itself')
+    }
+
+    child.parent = this
+
+    if (afterIndex === this.children.length) {
+      this.children.push(child)
+    } else {
+      this.children.splice(afterIndex, 0, child)
+    }
+
+    if (fixLayout) {
+      child.fixLayout()
+    }
+  }
+
+  removeChild(child, {fixLayout = true} = {}) {
+    // Removes the given child element from the children list of this
+    // element. It won't be rendered in the future. If the given element
+    // isn't a direct child of this element, nothing will happen.
+
+    if (child.parent !== this) {
+      return
+    }
+
+    child.parent = null
+    this.children.splice(this.children.indexOf(child), 1)
+
+    if (fixLayout) {
+      this.fixLayout()
+    }
+  }
+
+  get root() {
+    let el = this
+    while (el.parent) {
+      el = el.parent
+    }
+    return el
+  }
+
+  get directAncestors() {
+    const ancestors = []
+    let el = this
+    while (el.parent) {
+      el = el.parent
+      ancestors.push(el)
+    }
+    return ancestors
+  }
+}
diff --git a/ui/primitives/FocusElement.js b/ui/primitives/FocusElement.js
new file mode 100644
index 0000000..2c23b1e
--- /dev/null
+++ b/ui/primitives/FocusElement.js
@@ -0,0 +1,45 @@
+import DisplayElement from './DisplayElement.js'
+
+export default class FocusElement extends DisplayElement {
+  // A basic element that can receive cursor focus.
+
+  constructor() {
+    super()
+
+    this.cursorVisible = false
+    this.cursorX = 0
+    this.cursorY = 0
+  }
+
+  selected() {
+    // Should be overridden in subclasses.
+  }
+
+  unselected() {
+    // Should be overridden in subclasses.
+  }
+
+  get selectable() {
+    // Should be overridden if you want to make the element unselectable
+    // (according to particular conditions).
+
+    return true
+  }
+
+  keyPressed(keyBuf) {
+    // Do something with a buffer containing the key pressed (that is,
+    // telnet data sent). Should be overridden in subclasses.
+    //
+    // Arrow keys 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 isSelected() {
+    const selected = this.root.selectedElement
+    return !!(selected && [selected, ...selected.directAncestors].includes(this))
+  }
+
+  get absCursorX() { return this.absX + this.cursorX }
+  get absCursorY() { return this.absY + this.cursorY }
+}
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) }
+}
diff --git a/ui/primitives/index.js b/ui/primitives/index.js
new file mode 100644
index 0000000..4e36452
--- /dev/null
+++ b/ui/primitives/index.js
@@ -0,0 +1,11 @@
+//
+// Import mapping:
+//
+//   Element ->
+//     DisplayElement -> FocusElement, Root
+//
+
+export {default as DisplayElement} from './DisplayElement.js'
+export {default as Element} from './Element.js'
+export {default as FocusElement} from './FocusElement.js'
+export {default as Root} from './Root.js'