« 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.js26
-rw-r--r--ui/Root.js57
-rw-r--r--ui/form/Button.js10
-rw-r--r--ui/form/Form.js2
-rw-r--r--ui/form/ListScrollForm.js35
-rw-r--r--util/ansi.js4
-rw-r--r--util/telchars.js36
7 files changed, 160 insertions, 10 deletions
diff --git a/ui/DisplayElement.js b/ui/DisplayElement.js
index 66d29aa..952c78e 100644
--- a/ui/DisplayElement.js
+++ b/ui/DisplayElement.js
@@ -184,6 +184,32 @@ module.exports = class DisplayElement extends EventEmitter {
     return ancestors
   }
 
+  getElementAt(x, y) {
+    const children = this.children.slice()
+
+    // Start searching the last- (top-) rendered children first.
+    children.reverse()
+
+    for (const el of children) {
+      if (!el.visible) {
+        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 absX() {
     if (this.parent) {
       return this.parent.contentX + this.x
diff --git a/ui/Root.js b/ui/Root.js
index 1933323..3bd4767 100644
--- a/ui/Root.js
+++ b/ui/Root.js
@@ -1,8 +1,9 @@
 const ansi = require('../util/ansi')
+const telc = require('../util/telchars')
 
 const DisplayElement = require('./DisplayElement')
 
-const FocusElement = require('./form/FocusElement')
+const Form = require('./form/Form')
 
 module.exports = class Root extends DisplayElement {
   // An element to be used as the root of a UI. Handles lots of UI and
@@ -27,17 +28,42 @@ module.exports = class Root extends DisplayElement {
   }
 
   handleData(buffer) {
-    if (this.selectedElement) {
-      const els = [
-        this.selectedElement, ...this.selectedElement.directAncestors]
-      for (const el of els) {
-        if (el instanceof FocusElement) {
+    if (telc.isMouse(buffer)) {
+      const { button, line, col } = telc.parseMouse(buffer)
+      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) === false
+          }
+        })
+      }
+    } else {
+      this.eachAncestor(this.selectedElement, el => {
+        if (typeof el.keyPressed === 'function') {
           const shouldBreak = (el.keyPressed(buffer) === false)
           if (shouldBreak) {
-            break
+            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
+        }
       }
     }
   }
@@ -51,6 +77,7 @@ module.exports = class Root extends DisplayElement {
     // 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))
@@ -58,9 +85,10 @@ module.exports = class Root extends DisplayElement {
         writable.write('I')
         writable.write(ansi.resetAttributes())
       }
+      */
 
       writable.write(ansi.showCursor())
-      writable.write(ansi.moveCursor(
+      writable.write(ansi.moveCursorRaw(
         this.selectedElement.absCursorY, this.selectedElement.absCursorX))
     } else {
       writable.write(ansi.hideCursor())
@@ -74,10 +102,21 @@ module.exports = class Root extends DisplayElement {
     this.cursorBlinkOffset = Date.now()
   }
 
-  select(el) {
+  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?
+    const parent = el.parent
+    if (!fromForm && parent instanceof Form && parent.inputs.includes(el)) {
+      parent.selectInput(el)
+      return
+    }
+
     const oldSelected = this.selectedElement
     const newSelected = el
 
diff --git a/ui/form/Button.js b/ui/form/Button.js
index 3a35912..46329a6 100644
--- a/ui/form/Button.js
+++ b/ui/form/Button.js
@@ -38,4 +38,14 @@ module.exports = class Button extends FocusElement {
       this.emit('pressed')
     }
   }
+
+  clicked(button) {
+    if (button === 'left') {
+      if (this.isSelected) {
+        this.emit('pressed')
+      } else {
+        this.root.select(this)
+      }
+    }
+  }
 }
diff --git a/ui/form/Form.js b/ui/form/Form.js
index ac9f1e4..6cdd5a5 100644
--- a/ui/form/Form.js
+++ b/ui/form/Form.js
@@ -76,7 +76,7 @@ module.exports = class Form extends FocusElement {
         this.curIndex = this.inputs.length - 1
       }
 
-      this.root.select(this.inputs[this.curIndex])
+      this.root.select(this.inputs[this.curIndex], {fromForm: true})
     }
   }
 
diff --git a/ui/form/ListScrollForm.js b/ui/form/ListScrollForm.js
index 3f16416..77bebcd 100644
--- a/ui/form/ListScrollForm.js
+++ b/ui/form/ListScrollForm.js
@@ -24,6 +24,8 @@ module.exports = class ListScrollForm extends Form {
   }
 
   fixLayout() {
+    this.keepScrollInBounds()
+
     // The scrollItems property represents the item to the very left of where
     // we've scrolled, so we know right away that none of those will be
     // visible and we won't bother iterating over them.
@@ -96,6 +98,34 @@ module.exports = class ListScrollForm extends Form {
     return ret
   }
 
+  clicked(button) {
+    // Old code for changing the actual selected item...maybe an interesting
+    // functionality to explore later?
+    /*
+    if (button === 'scroll-up') {
+      this.previousInput()
+      this.scrollSelectedElementIntoView()
+    } else if (button === 'scroll-down') {
+      this.nextInput()
+      this.scrollSelectedElementIntoView()
+    }
+    */
+
+    // Scrolling is typically pretty slow with a mouse wheel when it's by
+    // a single line, so scroll at 3x that speed.
+    for (let i = 0; i < 3; i++) {
+      if (button === 'scroll-up') {
+        this.scrollItems--
+      } else if (button === 'scroll-down') {
+        this.scrollItems++
+      } else {
+        return
+      }
+    }
+
+    this.fixLayout()
+  }
+
   scrollSelectedElementIntoView() {
     const sel = this.inputs[this.curIndex]
 
@@ -152,6 +182,11 @@ module.exports = class ListScrollForm extends Form {
     this.fixLayout()
   }
 
+  keepScrollInBounds() {
+    this.scrollItems = Math.max(this.scrollItems, 0)
+    this.scrollItems = Math.min(this.scrollItems, this.getScrollItemsLength())
+  }
+
   getScrollItemsLength() {
     if (typeof this._scrollItemsLength === 'undefined') {
       const lastInput = this.inputs[this.inputs.length - 1]
diff --git a/util/ansi.js b/util/ansi.js
index f976c12..6d26f5d 100644
--- a/util/ansi.js
+++ b/util/ansi.js
@@ -104,6 +104,10 @@ const ansi = {
     return `${ESC}[27m`
   },
 
+  startTrackingMouse() {
+    return `${ESC}[?9h`
+  },
+
   requestCursorPosition() {
     // Requests the position of the cursor.
     // Expect a stdin-result '\ESC[l;cR', where l is the line number (1-based),
diff --git a/util/telchars.js b/util/telchars.js
index dcb840f..b099f65 100644
--- a/util/telchars.js
+++ b/util/telchars.js
@@ -31,6 +31,42 @@ const telchars = {
   isRight: buf => buf[0] === 0x1b && buf[2] === 0x43,
   isLeft: buf => buf[0] === 0x1b && buf[2] === 0x44,
 
+  // Mouse constants!
+  mapMouseActionNum: num => {
+    let button = null
+
+    if (num & 64) {
+      if (num & 1) button = 'scroll-down'
+      else button = 'scroll-up'
+    } else {
+      const bits = num & 3
+      if (bits === 0) button = 'left'
+      else if (bits === 1) button = 'middle'
+      else if (bits === 2) button = 'right'
+      else if (bits === 3) button = 'release'
+    }
+
+    const shift = !!(num & 4)
+    const ctrl = !!(num & 16)
+
+    return {button, shift, ctrl}
+  },
+
+  isMouse: buf => buf[0] === 0x1b && buf[2] === 0x4d,
+  parseMouse: buf => {
+    if (!telchars.isMouse(buf)) {
+      return null
+    }
+
+    const actionNum = buf[3] - 32
+    const col = buf[4] - 32
+    const line = buf[5] - 32
+
+    const { button, shift, ctrl } = telchars.mapMouseActionNum(actionNum)
+
+    return {button, shift, ctrl, col, line, actionNum}
+  },
+
   isShiftUp: buf => compareBufStr(buf, '\x1b[1;2A'),
   isShiftDown: buf => compareBufStr(buf, '\x1b[1;2B'),
   isShiftRight: buf => compareBufStr(buf, '\x1b[1;2C'),