« 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/DisplayElement.js
diff options
context:
space:
mode:
Diffstat (limited to 'ui/primitives/DisplayElement.js')
-rw-r--r--ui/primitives/DisplayElement.js305
1 files changed, 305 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 }
+}