« 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
diff options
context:
space:
mode:
-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('')
     }
   }