From 1f434c1ef11fa55bab1718ea4e3ca8d115c0dfb1 Mon Sep 17 00:00:00 2001 From: Florrie Date: Sat, 8 Dec 2018 02:37:15 -0400 Subject: Mouse support Not exactly the most elegant implementation, but it definitely works and isn't really difficult to code around! --- ui/DisplayElement.js | 26 +++++++++++++++++++++ ui/Root.js | 57 +++++++++++++++++++++++++++++++++++++++-------- ui/form/Button.js | 10 +++++++++ ui/form/Form.js | 2 +- ui/form/ListScrollForm.js | 35 +++++++++++++++++++++++++++++ util/ansi.js | 4 ++++ util/telchars.js | 36 ++++++++++++++++++++++++++++++ 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'), -- cgit 1.3.0-6-gf8a5