« 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
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
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.
-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
-rw-r--r--util/ansi.js21
5 files changed, 166 insertions, 11 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) }
 }
diff --git a/util/ansi.js b/util/ansi.js
index c786db5..ac511ed 100644
--- a/util/ansi.js
+++ b/util/ansi.js
@@ -182,7 +182,8 @@ const ansi = {
 
   interpret(text, scrRows, scrCols, {
     oldChars = null, oldLastChar = null,
-    oldCursorRow = 0, oldCursorCol = 0, oldShowCursor = true
+    oldScrRows = null, oldScrCols = null,
+    oldCursorRow = 1, oldCursorCol = 1, oldShowCursor = true
   } = {}) {
     // Interprets the given ansi code, more or less.
 
@@ -193,9 +194,17 @@ const ansi = {
 
     const chars = new Array(scrRows * scrCols).fill(blank)
 
-    let showCursor = true
-    let cursorRow = 1
-    let cursorCol = 1
+    if (oldChars) {
+      for (let row = 0; row < scrRows && row < oldScrRows; row++) {
+        for (let col = 0; col < scrCols && col < oldScrCols; col++) {
+          chars[row * scrCols + col] = oldChars[row * oldScrCols + col]
+        }
+      }
+    }
+
+    let showCursor = oldShowCursor
+    let cursorRow = oldCursorRow
+    let cursorCol = oldCursorCol
     let attributes = []
 
     for (let charI = 0; charI < text.length; charI++) {
@@ -475,10 +484,12 @@ const ansi = {
 
     return {
       oldChars: newChars.slice(),
+      oldLastChar: Object.assign({}, lastChar),
+      oldScrRows: scrRows,
+      oldScrCols: scrCols,
       oldCursorRow: cursorRow,
       oldCursorCol: cursorCol,
       oldShowCursor: showCursor,
-      oldLastChar: Object.assign({}, lastChar),
       screen: result.join('')
     }
   }