« get me outta code hell

(!!) Only render when draw-dependency props change - 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:
authorFlorrie <towerofnix@gmail.com>2019-09-15 16:55:53 -0300
committerFlorrie <towerofnix@gmail.com>2019-09-15 17:09:31 -0300
commit3f76094c554c23ee3519f41458a04d348f4f75a3 (patch)
tree5b87c588651b52aec6136e651e73d9f1d747c638 /ui
parent878e55e7c2a203d89fb1dad83ba6d6d8751b521a (diff)
(!!) Only render when draw-dependency props change
This is a very large change and probably breaks most applications not
built to work with it. (Obviously, I'm not really being that responsible
with this sort of thing.) I've tested with mtui and it works fine, but
some elements may need tweaks before being 100% adjusted to the new
scheduled-render system we're using with this commit. Also, any elements
which have custom draw behavior will likely need updating so that they
appropriately schedule renders.
Diffstat (limited to 'ui')
-rw-r--r--ui/DisplayElement.js85
-rw-r--r--ui/Root.js65
-rw-r--r--ui/form/Form.js3
-rw-r--r--ui/form/TextInput.js3
4 files changed, 150 insertions, 6 deletions
diff --git a/ui/DisplayElement.js b/ui/DisplayElement.js
index b7e07b9..87a9cb5 100644
--- a/ui/DisplayElement.js
+++ b/ui/DisplayElement.js
@@ -14,10 +14,14 @@ module.exports = class DisplayElement extends EventEmitter {
   constructor() {
     super()
 
-    this.visible = true
+    this[DisplayElement.drawValues] = {}
+    this[DisplayElement.lastDrawValues] = {}
+    this[DisplayElement.scheduledDraw] = false
 
-    this.parent = null
     this.children = []
+    this.parent = null
+
+    this.visible = true
 
     this.x = 0
     this.y = 0
@@ -82,6 +86,68 @@ module.exports = class DisplayElement extends EventEmitter {
     }
   }
 
+  getDep(key) {
+    return this[DisplayElement.drawValues][key]
+  }
+
+  setDep(key, value) {
+    const oldValue = this[DisplayElement.drawValues][key]
+    // TODO: new map for old values. we only compare value !== oldValue LATER, at render time, not when choosing to schedule - otherwise intermediate sets e.g. f.y = 1; f.y++, which always has a net effect of f.y = 2, will count as a redraw even though the final value isn't changing between frames
+    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])
+  }
+
+  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!
@@ -230,6 +296,17 @@ module.exports = class DisplayElement extends EventEmitter {
     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) }
+
   get absX() {
     if (this.parent) {
       return this.parent.contentX + this.x
@@ -262,3 +339,7 @@ module.exports = class DisplayElement extends EventEmitter {
   get absTop()    { return this.absY }
   get absBottom() { return this.absY + this.h - 1 }
 }
+
+module.exports.drawValues = Symbol('drawValues')
+module.exports.lastDrawValues = Symbol('lastDrawValues')
+module.exports.scheduledDraw = Symbol('scheduledDraw')
diff --git a/ui/Root.js b/ui/Root.js
index 8242f7a..fd81fed 100644
--- a/ui/Root.js
+++ b/ui/Root.js
@@ -9,10 +9,11 @@ module.exports = class Root extends DisplayElement {
   // An element to be used as the root of a UI. Handles lots of UI and
   // socket stuff.
 
-  constructor(interfacer) {
+  constructor(interfacer, writable = null) {
     super()
 
     this.interfacer = interfacer
+    this.writable = writable || interfacer
 
     this.selectedElement = null
 
@@ -21,10 +22,8 @@ module.exports = class Root extends DisplayElement {
     this.oldSelectionStates = []
 
     interfacer.on('inputData', buf => this.handleData(buf))
-  }
 
-  render() {
-    this.renderTo(this.interfacer)
+    this.renderCount = 0
   }
 
   handleData(buffer) {
@@ -74,7 +73,60 @@ module.exports = class Root extends DisplayElement {
     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.shouldRenderTo(writable)) {
+      this.renderNowTo(writable)
+    }
+  }
+
+  renderNowTo(writable) {
+    if (writable) {
+      this.renderCount++
+      super.renderTo(writable)
+    }
+  }
+
+  shouldRenderTo(writable) {
+    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) {
+        el.unscheduleDraw()
+      } else if (el.hasScheduledDraw()) {
+        render = true
+        el.unscheduleDraw()
+      }
+      el.updateLastDrawValues()
+    })
+    return render
+  }
+
   didRenderTo(writable) {
+    /*
+    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) {
@@ -94,6 +146,8 @@ module.exports = class Root extends DisplayElement {
     } else {
       writable.write(ansi.hideCursor())
     }
+
+    this.emit('rendered')
   }
 
   cursorMoved() {
@@ -209,4 +263,7 @@ module.exports = class Root extends DisplayElement {
     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/form/Form.js b/ui/form/Form.js
index 3c59cf5..74a1065 100644
--- a/ui/form/Form.js
+++ b/ui/form/Form.js
@@ -122,4 +122,7 @@ module.exports = class Form extends FocusElement {
       this.updateSelectedElement()
     }
   }
+
+  get curIndex() { return this.getDep('curIndex') }
+  set curIndex(v) { return this.setDep('curIndex', v) }
 }
diff --git a/ui/form/TextInput.js b/ui/form/TextInput.js
index 08bbbb6..78d3b6d 100644
--- a/ui/form/TextInput.js
+++ b/ui/form/TextInput.js
@@ -139,4 +139,7 @@ module.exports = class TextInput extends FocusElement {
       this.scrollChars--
     }
   }
+
+  get value() { return this.getDep('value') }
+  set value(v) { return this.setDep('value', v) }
 }