From 6ea74c268a12325296a1d2e7fc31b02030ddb8bc Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Fri, 12 May 2023 17:42:09 -0300 Subject: use ESM module syntax & minor cleanups The biggest change here is moving various element classes under more scope-specific directories, which helps to avoid circular dependencies and is just cleaner to navigate and expand in the future. Otherwise this is a largely uncritical port to ESM module syntax! There are probably a number of changes and other cleanups that remain much needed. Whenever I make changes to tui-lib it's hard to believe it's already been years since the previous time. First commits are from January 2017, and the code originates a month earlier in KAaRMNoD! --- ui/Dialog.js | 56 ------ ui/DisplayElement.js | 306 ----------------------------- ui/Element.js | 80 -------- ui/HorizontalBox.js | 13 -- ui/Label.js | 52 ----- ui/Pane.js | 101 ---------- ui/Root.js | 280 --------------------------- ui/Sprite.js | 69 ------- ui/WrapLabel.js | 44 ----- ui/controls/Button.js | 51 +++++ ui/controls/FocusBox.js | 32 ++++ ui/controls/Form.js | 143 ++++++++++++++ ui/controls/ListScrollForm.js | 405 +++++++++++++++++++++++++++++++++++++++ ui/controls/TextInput.js | 147 ++++++++++++++ ui/controls/index.js | 16 ++ ui/dialogs/CancelDialog.js | 60 ++++++ ui/dialogs/ConfirmDialog.js | 76 ++++++++ ui/dialogs/Dialog.js | 55 ++++++ ui/dialogs/FilePickerForm.js | 79 ++++++++ ui/dialogs/OpenFileDialog.js | 108 +++++++++++ ui/dialogs/index.js | 16 ++ ui/form/Button.js | 51 ----- ui/form/CancelDialog.js | 63 ------ ui/form/ConfirmDialog.js | 79 -------- ui/form/FocusBox.js | 32 ---- ui/form/FocusElement.js | 45 ----- ui/form/Form.js | 143 -------------- ui/form/ListScrollForm.js | 404 -------------------------------------- ui/form/TextInput.js | 145 -------------- ui/index.js | 4 + ui/presentation/HorizontalBox.js | 13 ++ ui/presentation/Label.js | 52 +++++ ui/presentation/Pane.js | 101 ++++++++++ ui/presentation/Sprite.js | 69 +++++++ ui/presentation/WrapLabel.js | 45 +++++ ui/presentation/index.js | 15 ++ ui/primitives/DisplayElement.js | 305 +++++++++++++++++++++++++++++ ui/primitives/Element.js | 80 ++++++++ ui/primitives/FocusElement.js | 45 +++++ ui/primitives/Root.js | 284 +++++++++++++++++++++++++++ ui/primitives/index.js | 11 ++ ui/tools/FilePickerForm.js | 88 --------- ui/tools/OpenFileDialog.js | 110 ----------- 43 files changed, 2212 insertions(+), 2161 deletions(-) delete mode 100644 ui/Dialog.js delete mode 100644 ui/DisplayElement.js delete mode 100644 ui/Element.js delete mode 100644 ui/HorizontalBox.js delete mode 100644 ui/Label.js delete mode 100644 ui/Pane.js delete mode 100644 ui/Root.js delete mode 100644 ui/Sprite.js delete mode 100644 ui/WrapLabel.js create mode 100644 ui/controls/Button.js create mode 100644 ui/controls/FocusBox.js create mode 100644 ui/controls/Form.js create mode 100644 ui/controls/ListScrollForm.js create mode 100644 ui/controls/TextInput.js create mode 100644 ui/controls/index.js create mode 100644 ui/dialogs/CancelDialog.js create mode 100644 ui/dialogs/ConfirmDialog.js create mode 100644 ui/dialogs/Dialog.js create mode 100644 ui/dialogs/FilePickerForm.js create mode 100644 ui/dialogs/OpenFileDialog.js create mode 100644 ui/dialogs/index.js delete mode 100644 ui/form/Button.js delete mode 100644 ui/form/CancelDialog.js delete mode 100644 ui/form/ConfirmDialog.js delete mode 100644 ui/form/FocusBox.js delete mode 100644 ui/form/FocusElement.js delete mode 100644 ui/form/Form.js delete mode 100644 ui/form/ListScrollForm.js delete mode 100644 ui/form/TextInput.js create mode 100644 ui/index.js create mode 100644 ui/presentation/HorizontalBox.js create mode 100644 ui/presentation/Label.js create mode 100644 ui/presentation/Pane.js create mode 100644 ui/presentation/Sprite.js create mode 100644 ui/presentation/WrapLabel.js create mode 100644 ui/presentation/index.js create mode 100644 ui/primitives/DisplayElement.js create mode 100644 ui/primitives/Element.js create mode 100644 ui/primitives/FocusElement.js create mode 100644 ui/primitives/Root.js create mode 100644 ui/primitives/index.js delete mode 100644 ui/tools/FilePickerForm.js delete mode 100644 ui/tools/OpenFileDialog.js (limited to 'ui') diff --git a/ui/Dialog.js b/ui/Dialog.js deleted file mode 100644 index 0b77b12..0000000 --- a/ui/Dialog.js +++ /dev/null @@ -1,56 +0,0 @@ -const FocusElement = require('./form/FocusElement') - -const Pane = require('./Pane') - -const telc = require('../util/telchars') - -module.exports = class Dialog extends FocusElement { - // A simple base dialog. - // - // Emits the 'cancelled' event when the cancel key (escape) is pressed, - // which should (probably) be handled by the dialog's creator. - // - // Doesn't do anything when focused by default - this should be overridden - // in subclasses. - // - // Automatically adjusts to fill its parent. Has a pane child (this.pane), - // but the pane isn't adjusted at all (you should change its size and - // likely center it in your subclass). - - constructor() { - super() - - this.pane = new Pane() - this.addChild(this.pane) - } - - fixLayout() { - this.w = this.parent.contentW - this.h = this.parent.contentH - } - - open() { - this.oldSelectedElement = this.root.selectedElement - this.opened() - this.visible = true - this.root.select(this) - this.fixLayout() - } - - close() { - this.closed() - this.visible = false - this.root.select(this.oldSelectedElement) - } - - opened() {} - - closed() {} - - keyPressed(keyBuf) { - if (telc.isCancel(keyBuf)) { - this.emit('cancelled') - return false - } - } -} diff --git a/ui/DisplayElement.js b/ui/DisplayElement.js deleted file mode 100644 index 8720142..0000000 --- a/ui/DisplayElement.js +++ /dev/null @@ -1,306 +0,0 @@ -const Element = require('./Element') -const exception = require('../util/exception') - -module.exports = class DisplayElement extends Element { - // A general class that handles dealing with screen coordinates, the tree - // of elements, and other common stuff. - // - // This element doesn't handle any real rendering; just layouts. Placing - // characters at specific positions should be implemented in subclasses. - // - // It's a subclass of EventEmitter, so you can make your own events within - // the logic of your subclass. - - constructor() { - super() - - this[DisplayElement.drawValues] = {} - this[DisplayElement.lastDrawValues] = {} - this[DisplayElement.scheduledDraw] = false - - this.visible = true - - this.x = 0 - this.y = 0 - this.w = 0 - this.h = 0 - - this.hPadding = 0 - this.vPadding = 0 - - // Note! This only applies to the parent, not the children. Useful for - // when you want an element to cover the whole screen but allow mouse - // events to pass through. - this.clickThrough = false - } - - drawTo(writable) { - // Writes text to a "writable" - an object that has a "write" method. - // Custom rendering should be handled as an override of this method in - // subclasses of DisplayElement. - } - - renderTo(writable) { - // Like drawTo, but only calls drawTo if the element is visible. Use this - // with your root element, not drawTo. - - if (!this.visible) { - return - } - - const causeRenderEl = this.shouldRender() - if (causeRenderEl) { - this.drawTo(writable) - this.renderChildrenTo(writable) - this.didRenderTo(writable) - } else { - this.renderChildrenTo(writable) - } - } - - shouldRender() { - // WIP! Until this implementation is finished, always return true (or else - // lots of rendering breaks). - /* - return ( - this[DisplayElement.scheduledDraw] || - [...this.directAncestors].find(el => el.shouldRender()) - ) - */ - return true - } - - renderChildrenTo(writable) { - // Renders all of the children to a writable. - - for (const child of this.children) { - child.renderTo(writable) - } - } - - didRenderTo(writable) { - // Called immediately after rendering this element AND all of its - // children. If you need to do something when that happens, override this - // method in your subclass. - // - // It's fine to draw more things to the writable here - just keep in mind - // that it'll be drawn over this element and its children, but not any - // elements drawn in the future. - } - - fixLayout() { - // Adjusts the layout of children in this element. If your subclass has - // any children in it, you should override this method. - } - - fixAllLayout() { - // Runs fixLayout on this as well as all children. - - this.fixLayout() - for (const child of this.children) { - child.fixAllLayout() - } - } - - confirmDrawValuesExists() { - if (!this[DisplayElement.drawValues]) { - this[DisplayElement.drawValues] = {} - } - } - - getDep(key) { - this.confirmDrawValuesExists() - return this[DisplayElement.drawValues][key] - } - - setDep(key, value) { - this.confirmDrawValuesExists() - const oldValue = this[DisplayElement.drawValues][key] - 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]) - } - - centerInParent() { - // Utility function to center this element in its parent. Must be called - // only when it has a parent. Set the width and height of the element - // before centering it! - - if (this.parent === null) { - throw new Error('Cannot center in parent when parent is null') - } - - this.x = Math.round((this.parent.contentW - this.w) / 2) - this.y = Math.round((this.parent.contentH - this.h) / 2) - } - - fillParent() { - // Utility function to fill this element in its parent. Must be called - // only when it has a parent. - - if (this.parent === null) { - throw new Error('Cannot fill parent when parent is null') - } - - this.x = 0 - this.y = 0 - this.w = this.parent.contentW - this.h = this.parent.contentH - } - - fitToParent() { - // Utility function to position this element so that it stays within its - // parent's bounds. Must be called only when it has a parent. - // - // This function is useful when (and only when) the right or bottom edge - // of this element may be past the right or bottom edge of its parent. - // In such a case, the element will first be moved left or up by the - // distance that its edge exceeds that of its parent, so that its edge is - // no longer past the parent's. Then, if the left or top edge of the - // element is less than zero, i.e. outside the parent, it is set to zero - // and the element's width or height is adjusted so that it does not go - // past the bounds of the parent. - - if (this.x + this.w > this.parent.right) { - const offendExtent = (this.x + this.w) - this.parent.contentW - this.x -= offendExtent - if (this.x < 0) { - const offstartExtent = 0 - this.x - this.w -= offstartExtent - this.x = 0 - } - } - - if (this.y + this.h > this.parent.bottom) { - const offendExtent = (this.y + this.h) - this.parent.contentH - this.y -= offendExtent - if (this.y < 0) { - const offstartExtent = 0 - this.y - this.h -= offstartExtent - this.y = 0 - } - } - } - - getElementAt(x, y) { - // Gets the topmost element at the provided absolute coordinate. - // Note that elements which are not visible or have the clickThrough - // property set to true are not considered. - - const children = this.children.slice() - - // Start searching the last- (top-) rendered children first. - children.reverse() - - for (const el of children) { - if (!el.visible || el.clickThrough) { - 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 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) } - - // Commented out because this doesn't fix any problems (at least ATM). - // get parent() { return this.getDep('parent') } - // set parent(v) { return this.setDep('parent', v) } - - get absX() { - if (this.parent) { - return this.parent.contentX + this.x - } else { - return this.x - } - } - - get absY() { - if (this.parent) { - return this.parent.contentY + this.y - } else { - return this.y - } - } - - // Where contents should be positioned. - get contentX() { return this.absX + this.hPadding } - get contentY() { return this.absY + this.vPadding } - get contentW() { return this.w - this.hPadding * 2 } - get contentH() { return this.h - this.vPadding * 2 } - - get left() { return this.x } - get right() { return this.x + this.w } - get top() { return this.y } - get bottom() { return this.y + this.h } - - get absLeft() { return this.absX } - get absRight() { return this.absX + this.w - 1 } - 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/Element.js b/ui/Element.js deleted file mode 100644 index c5beb59..0000000 --- a/ui/Element.js +++ /dev/null @@ -1,80 +0,0 @@ -const EventEmitter = require('events') - -module.exports = class Element extends EventEmitter { - // The basic class containing methods for working with an element hierarchy. - // Generally speaking, you usually want to extend DisplayElement instead of - // this class. - - constructor() { - super() - - this.children = [] - this.parent = null - } - - 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! - - if (child === this) { - throw exception( - 'EINVALIDHIERARCHY', 'An element cannot be a child of itself') - } - - child.parent = this - - if (afterIndex === this.children.length) { - this.children.push(child) - } else { - this.children.splice(afterIndex, 0, child) - } - - if (fixLayout) { - child.fixLayout() - } - } - - removeChild(child, {fixLayout = true} = {}) { - // Removes the given child element from the children list of this - // element. It won't be rendered in the future. If the given element - // isn't a direct child of this element, nothing will happen. - - if (child.parent !== this) { - return - } - - child.parent = null - this.children.splice(this.children.indexOf(child), 1) - - if (fixLayout) { - this.fixLayout() - } - } - - get root() { - let el = this - while (el.parent) { - el = el.parent - } - return el - } - - get directAncestors() { - const ancestors = [] - let el = this - while (el.parent) { - el = el.parent - ancestors.push(el) - } - return ancestors - } -} diff --git a/ui/HorizontalBox.js b/ui/HorizontalBox.js deleted file mode 100644 index f92bf10..0000000 --- a/ui/HorizontalBox.js +++ /dev/null @@ -1,13 +0,0 @@ -const DisplayElement = require('./DisplayElement') - -module.exports = class HorizontalBox extends DisplayElement { - // A box that will automatically lay out its children in a horizontal row. - - fixLayout() { - let nextX = 0 - for (const child of this.children) { - child.x = nextX - nextX = child.right + 1 - } - } -} diff --git a/ui/Label.js b/ui/Label.js deleted file mode 100644 index f2cd405..0000000 --- a/ui/Label.js +++ /dev/null @@ -1,52 +0,0 @@ -const ansi = require('../util/ansi') - -const DisplayElement = require('./DisplayElement') - -module.exports = class Label extends DisplayElement { - // A simple text display. Automatically adjusts size to fit text. - - constructor(text = '') { - super() - - this.text = text - this.textAttributes = [] - } - - fixLayout() { - this.w = ansi.measureColumns(this.text) - } - - drawTo(writable) { - if (this.textAttributes.length) { - writable.write(ansi.setAttributes(this.textAttributes)) - } - - this.writeTextTo(writable) - - if (this.textAttributes.length) { - writable.write(ansi.resetAttributes()) - } - - super.drawTo(writable) - } - - writeTextTo(writable) { - writable.write(ansi.moveCursor(this.absTop, this.absLeft)) - writable.write(this.text) - } - - set text(newText) { - const ret = this.setDep('text', newText) - this.fixLayout() - return ret - } - - get text() { - return this.getDep('text') - } - - // Kinda bad, but works as long as you're overwriting the array instead of - // mutating it. - set textAttributes(val) { return this.setDep('textAttributes', val) } - get textAttributes() { return this.getDep('textAttributes') } -} diff --git a/ui/Pane.js b/ui/Pane.js deleted file mode 100644 index b33a1b7..0000000 --- a/ui/Pane.js +++ /dev/null @@ -1,101 +0,0 @@ -const ansi = require('../util/ansi') -const unic = require('../util/unichars') - -const DisplayElement = require('./DisplayElement') - -const Label = require('./Label') - -module.exports = class Pane extends DisplayElement { - // A simple rectangular framed pane. - - constructor() { - super() - - this.frameColor = null - - this.hPadding = 1 - this.vPadding = 1 - } - - drawTo(writable) { - this.drawFrame(writable) - super.drawTo(writable) - } - - drawFrame(writable, debug=false) { - writable.write(ansi.setForeground(this.frameColor)) - - const left = this.absLeft - const right = this.absRight - const top = this.absTop - const bottom = this.absBottom - - // Background - // (TODO) Transparent background (that dimmed everything behind it) would - // be cool at some point! - for (let y = top + 1; y <= bottom - 1; y++) { - writable.write(ansi.moveCursor(y, left)) - writable.write(' '.repeat(this.w)) - } - - // Left/right edges - for (let x = left + 1; x <= right - 1; x++) { - writable.write(ansi.moveCursor(top, x)) - writable.write(unic.BOX_H) - writable.write(ansi.moveCursor(bottom, x)) - writable.write(unic.BOX_H) - } - - // Top/bottom edges - for (let y = top + 1; y <= bottom - 1; y++) { - writable.write(ansi.moveCursor(y, left)) - writable.write(unic.BOX_V) - writable.write(ansi.moveCursor(y, right)) - writable.write(unic.BOX_V) - } - - // Corners - writable.write(ansi.moveCursor(top, left)) - writable.write(unic.BOX_CORNER_TL) - writable.write(ansi.moveCursor(top, right)) - writable.write(unic.BOX_CORNER_TR) - writable.write(ansi.moveCursor(bottom, left)) - writable.write(unic.BOX_CORNER_BL) - writable.write(ansi.moveCursor(bottom, right)) - writable.write(unic.BOX_CORNER_BR) - - // Debug info - if (debug) { - writable.write(ansi.moveCursor(6, 8)) - writable.write( - `x: ${this.x}; y: ${this.y}; w: ${this.w}; h: ${this.h}`) - writable.write(ansi.moveCursor(7, 8)) - writable.write(`AbsX: ${this.absX}; AbsY: ${this.absY}`) - writable.write(ansi.moveCursor(8, 8)) - writable.write(`Left: ${this.left}; Right: ${this.right}`) - writable.write(ansi.moveCursor(9, 8)) - writable.write(`Top: ${this.top}; Bottom: ${this.bottom}`) - } - - writable.write(ansi.setForeground(ansi.C_RESET)) - } - - static alert(parent, text) { - // Show an alert pane in the bottom left of the given parent element for - // a couple seconds. - - const pane = new Pane() - pane.frameColor = ansi.C_WHITE - pane.w = ansi.measureColumns(text) + 2 - pane.h = 3 - parent.addChild(pane) - - const label = new Label(text) - label.textAttributes = [ansi.C_WHITE] - pane.addChild(label) - - setTimeout(() => { - parent.removeChild(pane) - }, 2000) - } -} diff --git a/ui/Root.js b/ui/Root.js deleted file mode 100644 index 2b13203..0000000 --- a/ui/Root.js +++ /dev/null @@ -1,280 +0,0 @@ -const ansi = require('../util/ansi') -const telc = require('../util/telchars') - -const DisplayElement = require('./DisplayElement') - -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 - // socket stuff. - - constructor(interfacer, writable = null) { - super() - - this.interfacer = interfacer - this.writable = writable || interfacer - - this.selectedElement = null - - this.cursorBlinkOffset = Date.now() - - this.oldSelectionStates = [] - - interfacer.on('inputData', buf => this.handleData(buf)) - - this.renderCount = 0 - } - - handleData(buffer) { - if (telc.isMouse(buffer)) { - const allData = telc.parseMouse(buffer) - const { button, line, col } = allData - 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, allData) === false - } - }) - } - } else { - this.eachAncestor(this.selectedElement, el => { - if (typeof el.keyPressed === 'function') { - const shouldBreak = (el.keyPressed(buffer) === false) - if (shouldBreak) { - 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 - } - } - } - } - - drawTo(writable) { - writable.write(ansi.moveCursor(0, 0)) - 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.anyDescendantShouldRender()) { - this.renderNowTo(writable) - } - } - - renderNowTo(writable) { - if (writable) { - this.renderCount++ - super.renderTo(writable) - // Since shouldRender is false, super.renderTo won't call didRenderTo for - // us. We need to do that ourselves. - this.didRenderTo(writable) - } - } - - anyDescendantShouldRender() { - 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) { - return - } - render = el.hasScheduledDraw() - }) - return render - } - - shouldRender() { - // We need to return false here because otherwise all children will render, - // since they'll see the root as an ancestor who needs to be rendered. Bad! - return false - } - - didRenderTo(writable) { - this.eachDescendant(el => { - el.unscheduleDraw() - el.updateLastDrawValues() - }) - - /* - 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) { - /* - if ((Date.now() - this.cursorBlinkOffset) % 1000 < 500) { - writable.write(ansi.moveCursor( - this.selectedElement.absCursorY, this.selectedElement.absCursorX)) - writable.write(ansi.invert()) - writable.write('I') - writable.write(ansi.resetAttributes()) - } - */ - - writable.write(ansi.showCursor()) - writable.write(ansi.moveCursorRaw( - this.selectedElement.absCursorY, this.selectedElement.absCursorX)) - } else { - writable.write(ansi.hideCursor()) - } - - this.emit('rendered') - } - - cursorMoved() { - // Resets the blinking animation for the cursor. Call this whenever you - // move the cursor. - - this.cursorBlinkOffset = Date.now() - } - - 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 - - // Relevant elements that COULD have their "isSelected" state change. - const relevantElements = ([ - ...(oldSelected ? [...oldSelected.directAncestors, oldSelected] : []), - ...(newSelected ? newSelected.directAncestors : []) - ] - - // We ignore elements where isSelected is undefined, because they aren't - // built to handle being selected, and they break the compare-old-and-new- - // state code below. - .filter(el => typeof el.isSelected !== 'undefined') - - // Get rid of duplicates - including any that occurred in the already - // existing array of selection states. (We only care about the oldest - // selection state, i.e. the one when we did the first .select().) - .reduce((acc, el) => { - // Duplicates from relevant elements of current .select() - if (acc.includes(el)) return acc - // Duplicates from already existing selection states - if (this.oldSelectionStates.some(x => x[0] === el)) return acc - return acc.concat([el]) - }, [])) - - // Keep track of whether those elements were selected before we call the - // newly selected element's selected() function. We store these on a - // property because we might actually be adding to it from a previous - // root.select() call, if that one itself caused this root.select(). - // One all root.select()s in the "chain" (as it is) have finished, we'll - // go through these states and call the appropriate .select/unselect() - // functions on each element whose .isSelected changed. - const selectionStates = relevantElements.map(el => [el, el.isSelected]) - this.oldSelectionStates = this.oldSelectionStates.concat(selectionStates) - - this.selectedElement = el - - // Same stuff as in the for loop below. We always call selected() on the - // passed element, even if it was already selected before. - if (el.selected) el.selected() - if (typeof el.focused === 'function') el.focused() - - // If the selection changed as a result of the element's selected() - // function, stop here. We will leave calling the appropriate functions on - // the elements in the oldSelectionStates array to the final .select(), - // i.e. the one which caused no change in selected element. - if (this.selectedElement !== newSelected) return - - // Compare the old "isSelected" state of every relevant element with their - // current "isSelected" state, and call the respective selected/unselected - // functions. (Also call focused and unfocused for some sense of trying to - // not break old programs, but, like, old programs are going to be broken - // anyways.) - const states = this.oldSelectionStates.slice() - for (const [ el, wasSelected ] of states) { - // Now that we'll have processed it, we don't want it in the array - // anymore. - this.oldSelectionStates.shift() - - const { isSelected } = el - if (isSelected && !wasSelected) { - // Don't call these functions if this element is the newly selected - // one, because we already called them above! - if (el !== newSelected) { - if (el.selected) el.selected() - if (typeof el.focused === 'function') el.focused() - } - } else if (wasSelected && !isSelected) { - if (el.unselected) el.unselected() - if (typeof el.unfocused === 'function') el.unfocused() - } - - // If the (un)selected() handler actually selected a different element - // itself, then further processing of new selected states is irrelevant, - // so stop here. (We return instead of breaking the for loop because - // anything after this loop would have already been handled by the call - // to Root.select() from the (un)selected() handler.) - if (this.selectedElement !== newSelected) { - return - } - } - - this.cursorMoved() - } - - isChildOrSelfSelected(el) { - if (!this.selectedElement) return false - if (this.selectedElement === el) return true - 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/Sprite.js b/ui/Sprite.js deleted file mode 100644 index 701f1b8..0000000 --- a/ui/Sprite.js +++ /dev/null @@ -1,69 +0,0 @@ -const ansi = require('../util/ansi') - -const DisplayElement = require('./DisplayElement') - -module.exports = class Sprite extends DisplayElement { - // "A sprite is a two-dimensional bitmap that is integrated into a larger - // scene." - Wikipedia - // - // Sprites are display objects that have a single texture that will not - // render outside of their parent. - // - // Sprites have a "real" position which overrides their "integer" position. - // This is so that motion can be more fluid (i.e., sprites don't need to - // move a whole number of terminal characters at a time). - - constructor() { - super() - - this.texture = [] - - this.realX = 0 - this.realY = 0 - } - - set x(newX) { this.realX = newX } - set y(newY) { this.realY = newY } - get x() { return Math.round(this.realX) } - get y() { return Math.round(this.realY) } - - drawTo(writable) { - if (this.textureAttributes) { - writable.write(ansi.setAttributes(this.textureAttributes)) - } - - for (let y = 0; y < this.textureHeight; y++) { - // Don't render above or below the parent's content area. - if (this.y + y >= this.parent.contentH || this.y + y < 0) continue - - const right = this.x + this.textureWidth - - const start = (this.x < 0) ? -this.x : 0 - const end = ( - (right > this.parent.contentW) - ? this.parent.contentW - right - : right) - const text = this.texture[y].slice(start, end) - - writable.write(ansi.moveCursor(this.absY + y, this.absX + start)) - writable.write(text) - } - - if (this.textureAttributes) { - writable.write(ansi.resetAttributes()) - } - } - - fixLayout() { - this.w = this.textureWidth - this.h = this.textureHeight - } - - get textureWidth() { - return Math.max(...this.texture.map(row => ansi.measureColumns(row))) - } - - get textureHeight() { - return this.texture.length - } -} diff --git a/ui/WrapLabel.js b/ui/WrapLabel.js deleted file mode 100644 index babf462..0000000 --- a/ui/WrapLabel.js +++ /dev/null @@ -1,44 +0,0 @@ -const ansi = require('../util/ansi') -const wrap = require('word-wrap') - -const Label = require('./Label') - -module.exports = class WrapLabel extends Label { - // A word-wrapping text display. Given a width, wraps text to fit. - - constructor(...args) { - super(...args) - } - - fixLayout() { - // Override Label.fixLayout to do nothing. We don't want to make the - // width of this label be set to the content of the text! (That would - // defeat the entire point of word wrapping.) - } - - writeTextTo(writable) { - const lines = this.getWrappedLines() - for (let i = 0; i < lines.length; i++) { - writable.write(ansi.moveCursor(this.absTop + i, this.absLeft)) - writable.write(lines[i]) - } - } - - getWrappedLines() { - if (this.text.trim().length === 0) { - return [] - } - - const options = {width: this.w, indent: ''} - return wrap(this.text, options).split('\n') - .map(l => l.trim()) - } - - get h() { - return this.getWrappedLines().length - } - - set h(newHeight) { - // Do nothing. Height is computed on the fly. - } -} diff --git a/ui/controls/Button.js b/ui/controls/Button.js new file mode 100644 index 0000000..5be2b2a --- /dev/null +++ b/ui/controls/Button.js @@ -0,0 +1,51 @@ +import {FocusElement} from 'tui-lib/ui/primitives' + +import * as ansi from 'tui-lib/util/ansi' +import telc from 'tui-lib/util/telchars' + +export default class Button extends FocusElement { + // A button. + + constructor(text) { + super() + + this.text = text + + this.cursorX = null + this.cursorY = null + } + + fixLayout() { + this.w = ansi.measureColumns(this.text) + this.h = 1 + } + + drawTo(writable) { + if (this.isSelected) { + writable.write(ansi.invert()) + } + + writable.write(ansi.moveCursor(this.absTop, this.absLeft)) + writable.write(this.text) + + writable.write(ansi.resetAttributes()) + + super.drawTo(writable) + } + + keyPressed(keyBuf) { + if (telc.isSelect(keyBuf)) { + this.emit('pressed') + } + } + + clicked(button) { + if (button === 'left') { + if (this.isSelected) { + this.emit('pressed') + } else { + this.root.select(this) + } + } + } +} diff --git a/ui/controls/FocusBox.js b/ui/controls/FocusBox.js new file mode 100644 index 0000000..64f84c9 --- /dev/null +++ b/ui/controls/FocusBox.js @@ -0,0 +1,32 @@ +import {FocusElement} from 'tui-lib/ui/primitives' + +import * as ansi from 'tui-lib/util/ansi' + +export default class FocusBox extends FocusElement { + // A box (not to be confused with Pane!) that can be selected. When it's + // selected, it applies an invert effect to its children. (This won't work + // well if you have elements inside of it that have their own attributes, + // since they're likely to reset all effects after drawing - including the + // invert from the FocusBox! Bad ANSI limitations; it's relatively likely + // I'll implement maaaaaagic to help deal with this - maybe something + // similar to 'pushMatrix' from Processing - at some point... [TODO]) + + constructor() { + super() + + this.cursorX = null + this.cursorY = null + } + + drawTo(writable) { + if (this.isSelected) { + writable.write(ansi.invert()) + } + } + + didRenderTo(writable) { + if (this.isSelected) { + writable.write(ansi.resetAttributes()) + } + } +} diff --git a/ui/controls/Form.js b/ui/controls/Form.js new file mode 100644 index 0000000..921096a --- /dev/null +++ b/ui/controls/Form.js @@ -0,0 +1,143 @@ +import telc from 'tui-lib/util/telchars' + +import {FocusElement} from 'tui-lib/ui/primitives' + +export default class Form extends FocusElement { + constructor() { + super() + + this.inputs = [] + this.curIndex = 0 + this.captureTab = true + } + + addInput(input, asChild = true, opts = {}) { + // Adds the given input as a child element and pushes it to the input + // list. If the optional argument asChild is false, it won't add the + // input element as a child of the form. + + this.inputs.push(input) + + if (asChild) { + this.addChild(input, this.children.length, opts) + } + } + + removeInput(input, asChild = true, opts = {}) { + // Removes the given input from the form's input list. If the optional + // argument asChild is false, it won't try to removeChild the input. + + if (this.inputs.includes(input)) { + this.inputs.splice(this.inputs.indexOf(input), 1) + + if (asChild) { + this.removeChild(input, opts) + } + } + } + + selectInput(input) { + if (this.inputs.includes(input)) { + this.curIndex = this.inputs.indexOf(input) + this.updateSelectedElement() + } + } + + keyPressed(keyBuf) { + // Don't do anything if captureTab is set to false. This is handy for + // nested forms. + if (!this.captureTab) { + return + } + + if (telc.isTab(keyBuf) || telc.isBackTab(keyBuf)) { + // No inputs to tab through, so do nothing. + if (this.inputs.length < 2) { + return + } + + if (telc.isTab(keyBuf)) { + this.nextInput() + } else { + this.previousInput() + } + + return false + } + } + + get selectable() { + return this.inputs.some(inp => inp.selectable) + } + + updateSelectedElement() { + if (this.root.select && this.inputs.length) { + if (this.curIndex > this.inputs.length - 1) { + this.curIndex = this.inputs.length - 1 + } + + this.root.select(this.inputs[this.curIndex], {fromForm: true}) + } + } + + previousInput() { + // TODO: Forms currently assume there is at least one selectable input, + // but this isn't necessarily always the case. + do { + this.curIndex = (this.curIndex - 1) + if (this.curIndex < 0) { + this.curIndex = (this.inputs.length - 1) + } + } while (!this.inputs[this.curIndex].selectable) + + this.updateSelectedElement() + } + + nextInput() { + // TODO: See previousInput + do { + this.curIndex = (this.curIndex + 1) % this.inputs.length + } while (!this.inputs[this.curIndex].selectable) + + this.updateSelectedElement() + } + + firstInput(selectForm = true) { + this.curIndex = 0 + + // TODO: See previousInput + if (!this.inputs[this.curIndex].selectable) { + this.nextInput() + } + + if (selectForm || ( + this.root.isChildOrSelfSelected && this.root.isChildOrSelfSelected(this) + )) { + this.updateSelectedElement() + } + } + + lastInput(selectForm = true) { + this.curIndex = this.inputs.length - 1 + + // TODO: See previousInput + if (!this.inputs[this.curIndex].selectable) { + this.previousInput() + } + + if (selectForm || ( + this.root.isChildOrSelfSelected && this.root.isChildOrSelfSelected(this) + )) { + this.updateSelectedElement() + } + } + + selected() { + if (this.root.selectedElement === this) { + this.updateSelectedElement() + } + } + + get curIndex() { return this.getDep('curIndex') } + set curIndex(v) { return this.setDep('curIndex', v) } +} diff --git a/ui/controls/ListScrollForm.js b/ui/controls/ListScrollForm.js new file mode 100644 index 0000000..3f75599 --- /dev/null +++ b/ui/controls/ListScrollForm.js @@ -0,0 +1,405 @@ +import * as ansi from 'tui-lib/util/ansi' +import telc from 'tui-lib/util/telchars' +import unic from 'tui-lib/util/unichars' + +import {DisplayElement} from 'tui-lib/ui/primitives' + +import Form from './Form.js' + +export default class ListScrollForm extends Form { + // A form that lets the user scroll through a list of items. It + // automatically adjusts to always allow the selected item to be visible. + // Unless disabled in the constructor, a scrollbar is automatically displayed + // if there are more items than can be shown in the height of the form at a + // single time. + + constructor(layoutType = 'vertical', enableScrollBar = true) { + super() + + this.layoutType = layoutType + + this.scrollItems = 0 + + this.scrollBarEnabled = enableScrollBar + + this.scrollBar = new ScrollBar(this) + this.scrollBarShown = false + } + + fixLayout() { + this.keepScrollInBounds() + + const scrollItems = this.scrollItems + + // 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. We do need to hide + // them, though. + for (let i = 0; i < Math.min(scrollItems, this.inputs.length); i++) { + this.inputs[i].visible = false + } + + // This variable stores how far along the respective axis (implied by + // layoutType) the next element should be. + let nextPos = 0 + + let formEdge + if (this.layoutType === 'horizontal') { + formEdge = this.contentW + } else { + formEdge = this.contentH + } + + for (let i = scrollItems; i < this.inputs.length; i++) { + const item = this.inputs[i] + item.fixLayout() + + const curPos = nextPos + let curSize + if (this.layoutType === 'horizontal') { + item.x = curPos + curSize = item.w + } else { + item.y = curPos + curSize = item.h + } + nextPos += curSize + + // By default, the item should be visible.. + item.visible = true + + // ..but the item's far edge is past the form's far edge, it isn't + // fully visible and should be hidden. + if (curPos + curSize > formEdge) { + item.visible = false + } + + // Same deal goes for the close edge. We can check it against 0 since + // the close edge of the form's content is going to be 0, of course! + if (curPos < 0) { + item.visible = false + } + } + + delete this._scrollItemsLength + + if (this.scrollBarEnabled) { + this.showScrollbarIfNecessary() + } + } + + keyPressed(keyBuf) { + let ret + + handleKeyPress: { + if (this.layoutType === 'horizontal') { + if (telc.isLeft(keyBuf)) { + this.previousInput() + ret = false; break handleKeyPress + } else if (telc.isRight(keyBuf)) { + this.nextInput() + ret = false; break handleKeyPress + } + } else if (this.layoutType === 'vertical') { + if (telc.isUp(keyBuf)) { + this.previousInput() + ret = false; break handleKeyPress + } else if (telc.isDown(keyBuf)) { + this.nextInput() + ret = false; break handleKeyPress + } + } + + ret = super.keyPressed(keyBuf) + } + + this.scrollSelectedElementIntoView() + + 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] + + if (!sel) { + return + } + + let formEdge + if (this.layoutType === 'horizontal') { + formEdge = this.contentW + } else { + formEdge = this.contentH + } + + // If the item is ahead of our view (either to the right of or below), + // we should move the view so that the item is the farthest right (of all + // the visible items). + if (this.getItemPos(sel) > formEdge + this.scrollSize) { + this.scrollElementIntoEndOfView(sel) + } + + // Adjusting the number of scroll items is much simpler to deal with if + // the item is behind our view. Since the item's behind, we need to move + // the scroll to be immediately behind it, which is simple since we + // already have its index. + if (this.getItemPos(sel) <= this.scrollSize) { + this.scrollItems = this.curIndex + } + + this.fixLayout() + } + + firstInput(...args) { + this.scrollItems = 0 + + super.firstInput(...args) + + this.fixLayout() + } + + getScrollPositionOfElementAtEndOfView(element) { + // We can decide how many items to scroll past by moving forward until + // the item's far edge is visible. + const pos = this.getItemPos(element) + + let edge + if (this.layoutType === 'horizontal') { + edge = this.contentW + } else { + edge = this.contentH + } + + for (let i = 0; i < this.inputs.length; i++) { + if (pos <= edge) { + return i + } + + if (this.layoutType === 'horizontal') { + edge += this.inputs[i].w + } else { + edge += this.inputs[i].h + } + } + // No result? Well, it's at the end. + return this.inputs.length + } + + scrollElementIntoEndOfView(element) { + this.scrollItems = this.getScrollPositionOfElementAtEndOfView(element) + } + + scrollToBeginning() { + this.scrollItems = 0 + this.fixLayout() + } + + scrollToEnd() { + this.scrollElementIntoEndOfView(this.inputs[this.inputs.length - 1]) + 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] + this._scrollItemsLength = this.getScrollPositionOfElementAtEndOfView(lastInput) + } + + return this._scrollItemsLength + } + + getItemPos(item) { + // Gets the position of the item in an unscrolled view. + + const index = this.inputs.indexOf(item) + let pos = 0 + for (let i = 0; i <= index; i++) { + if (this.layoutType === 'horizontal') { + pos += this.inputs[i].w + } else { + pos += this.inputs[i].h + } + } + return pos + } + + showScrollbarIfNecessary() { + this.scrollBarShown = this.scrollBar.canScrollAtAll() + + const isChild = this.children.includes(this.scrollBar) + if (this.scrollBarShown) { + if (!isChild) this.addChild(this.scrollBar) + } else { + if (isChild) this.removeChild(this.scrollBar) + } + } + + get scrollSize() { + // Gets the actual length made up by all of the items currently scrolled + // past. + + let size = 0 + for (let i = 0; i < Math.min(this.scrollItems, this.inputs.length); i++) { + if (this.layoutType === 'horizontal') { + size += this.inputs[i].w + } else { + size += this.inputs[i].h + } + } + return size + } + + get contentW() { + if (this.scrollBarShown && this.layoutType === 'vertical') { + return this.w - 1 + } else { + return this.w + } + } + + get contentH() { + if (this.scrollBarShown && this.layoutType === 'horizontal') { + return this.h - 1 + } else { + return this.h + } + } +} + +class ScrollBar extends DisplayElement { + constructor(listScrollForm) { + super() + + this.listScrollForm = listScrollForm + } + + fixLayout() { + // Normally we'd subtract one from contentW/contentH when setting the x/y + // position, but the scrollbar is actually displayed OUTSIDE of (adjacent + // to) the parent's content area. + if (this.listScrollForm.layoutType === 'vertical') { + this.h = this.listScrollForm.contentH + this.w = 1 + this.x = this.listScrollForm.contentW + this.y = 0 + } else { + this.h = 1 + this.w = this.listScrollForm.contentW + this.x = 0 + this.y = this.listScrollForm.contentH + } + } + + drawTo(writable) { + // Uuuurgh + this.fixLayout() + + // TODO: Horizontal layout! Not functionally a lot different, but I'm too + // lazy to write a test UI for it right now. + + const { + backwards: canScrollBackwards, + forwards: canScrollForwards + } = this.getScrollableDirections() + + // - 2 for extra UI elements (arrows) + const totalLength = this.h - 2 + + // ..[-----].. + // ^start| + // ^end + // + // Start and end should correspond to how much of the scroll area + // is currently visible. So, if you can see 60% of the full scroll length + // at a time, and you are scrolled 10% down, the start position of the + // handle should be 10% down, and it should extend 60% of the scrollbar + // length, to the 70% mark. + + const currentScroll = this.listScrollForm.scrollItems + const edgeLength = this.listScrollForm.contentH + const totalItems = this.listScrollForm.inputs.length + const itemsVisibleAtOnce = Math.min(totalItems, edgeLength) + const handleLength = itemsVisibleAtOnce / totalItems * totalLength + let handlePosition = Math.floor(totalLength / totalItems * currentScroll) + + // Silly peeve of mine: The handle should only be visibly touching the top + // or bottom of the scrollbar area if you're actually scrolled all the way + // to the start or end. Otherwise, it shouldn't be touching! There should + // visible space indicating that you can scroll in that direction + // (in addition to the arrows we show at the ends). + + if (canScrollBackwards && handlePosition === 0) { + handlePosition = 1 + } + + if (canScrollForwards && (handlePosition + handleLength) === edgeLength) { + handlePosition-- + } + + if (this.listScrollForm.layoutType === 'vertical') { + const start = this.absTop + handlePosition + 1 + for (let i = 0; i < handleLength; i++) { + writable.write(ansi.moveCursor(start + i, this.absLeft)) + writable.write(unic.BOX_V_DOUBLE) + } + + if (canScrollBackwards) { + writable.write(ansi.moveCursor(this.absTop, this.absLeft)) + writable.write(unic.ARROW_UP_DOUBLE) + } + + if (canScrollForwards) { + writable.write(ansi.moveCursor(this.absBottom, this.absLeft)) + writable.write(unic.ARROW_DOWN_DOUBLE) + } + } + } + + getScrollableDirections() { + const currentScroll = this.listScrollForm.scrollItems + const totalScroll = this.listScrollForm.getScrollItemsLength() + + return { + backwards: (currentScroll > 0), + forwards: (currentScroll < totalScroll) + } + } + + canScrollAtAll() { + const {backwards, forwards} = this.getScrollableDirections() + return backwards || forwards + } +} diff --git a/ui/controls/TextInput.js b/ui/controls/TextInput.js new file mode 100644 index 0000000..1a32605 --- /dev/null +++ b/ui/controls/TextInput.js @@ -0,0 +1,147 @@ +import {FocusElement} from 'tui-lib/ui/primitives' + +import * as ansi from 'tui-lib/util/ansi' +import telc from 'tui-lib/util/telchars' +import unic from 'tui-lib/util/unichars' + +export default class TextInput extends FocusElement { + // An element that the user can type in. + + constructor() { + super() + + this.value = '' + this.cursorVisible = true + this.cursorIndex = 0 + this.scrollChars = 0 + } + + drawTo(writable) { + // There should be room for the cursor so move the "right edge" left a + // single character. + + const startRange = this.scrollChars + const endRange = this.scrollChars + this.w - 3 + + let str = this.value.slice(startRange, endRange) + + writable.write(ansi.moveCursor(this.absTop, this.absLeft + 1)) + writable.write(str) + + // Ellipsis on left side, if there's more characters behind the visible + // area. + if (startRange > 0) { + writable.write(ansi.moveCursor(this.absTop, this.absLeft)) + writable.write(unic.ELLIPSIS) + } + + // Ellipsis on the right side, if there's more characters ahead of the + // visible area. + if (endRange < this.value.length) { + writable.write(ansi.moveCursor(this.absTop, this.absRight - 1)) + writable.write(unic.ELLIPSIS.repeat(2)) + } + + this.cursorX = this.cursorIndex - this.scrollChars + 1 + + super.drawTo(writable) + } + + keyPressed(keyBuf) { + try { + if (keyBuf[0] === 127) { + this.value = ( + this.value.slice(0, this.cursorIndex - 1) + + this.value.slice(this.cursorIndex) + ) + this.cursorIndex-- + this.root.cursorMoved() + return false + } else if (keyBuf[0] === 13) { + // These are aliases for each other. + this.emit('value', this.value) + this.emit('confirm', this.value) + } else if (keyBuf[0] === 0x1b && keyBuf[1] === 0x5b) { + // Keyboard navigation + if (keyBuf[2] === 0x44) { + this.cursorIndex-- + this.root.cursorMoved() + } else if (keyBuf[2] === 0x43) { + this.cursorIndex++ + this.root.cursorMoved() + } + return false + } else if (telc.isEscape(keyBuf)) { + // ESC is bad and we don't want that in the text input! + // Also emit a "cancel" event, which doesn't necessarily do anything, + // but can be listened to. + this.emit('cancel') + } else { + const isTextInput = keyBuf.toString().split('').every(chr => { + const n = chr.charCodeAt(0) + return n > 31 && n < 127 + }) + + if (isTextInput) { + this.value = ( + this.value.slice(0, this.cursorIndex) + keyBuf.toString() + + this.value.slice(this.cursorIndex) + ) + this.cursorIndex += keyBuf.toString().length + this.root.cursorMoved() + this.emit('change', this.value) + + return false + } + } + } finally { + this.keepCursorInRange() + } + } + + setValue(value) { + this.value = value + this.moveToEnd() + } + + moveToEnd() { + this.cursorIndex = this.value.length + this.keepCursorInRange() + } + + keepCursorInRange() { + // Keep the cursor inside or at the end of the input value. + + if (this.cursorIndex < 0) { + this.cursorIndex = 0 + } + + if (this.cursorIndex > this.value.length) { + this.cursorIndex = this.value.length + } + + // Scroll right, if the cursor is past the right edge of where text is + // displayed. + while (this.cursorIndex - this.scrollChars > this.w - 3) { + this.scrollChars++ + } + + // Scroll left, if the cursor is behind the left edge of where text is + // displayed. + while (this.cursorIndex - this.scrollChars < 0) { + this.scrollChars-- + } + + // Scroll left, if we can see past the end of the text. + while (this.scrollChars > 0 && ( + this.scrollChars + this.w - 3 > this.value.length) + ) { + this.scrollChars-- + } + } + + get value() { return this.getDep('value') } + set value(v) { return this.setDep('value', v) } + get cursorIndex() { return this.getDep('cursorIndex') } + set cursorIndex(v) { return this.setDep('cursorIndex', v) } +} diff --git a/ui/controls/index.js b/ui/controls/index.js new file mode 100644 index 0000000..e99add1 --- /dev/null +++ b/ui/controls/index.js @@ -0,0 +1,16 @@ +// +// Import mapping: +// +// primitives -> +// Button +// FocusBox +// TextInput +// +// Form -> ListScrollForm +// + +export {default as Button} from './Button.js' +export {default as FocusBox} from './FocusBox.js' +export {default as Form} from './Form.js' +export {default as ListScrollForm} from './ListScrollForm.js' +export {default as TextInput} from './TextInput.js' diff --git a/ui/dialogs/CancelDialog.js b/ui/dialogs/CancelDialog.js new file mode 100644 index 0000000..9069d43 --- /dev/null +++ b/ui/dialogs/CancelDialog.js @@ -0,0 +1,60 @@ +import {Button, Form} from 'tui-lib/ui/controls' +import {Label, Pane} from 'tui-lib/ui/presentation' +import {FocusElement} from 'tui-lib/ui/primitives' + +import telc from 'tui-lib/util/telchars' + +export default class CancelDialog extends FocusElement { + // A basic cancel dialog. Has one buttons, cancel, and a label. + // The escape (esc) key can be used to exit the dialog (which sends a + // 'cancelled' event, as the cancel button also does). + + constructor(text) { + super() + + this.pane = new Pane() + this.addChild(this.pane) + + this.cancelBtn = new Button('Cancel') + this.pane.addChild(this.cancelBtn) + + this.label = new Label(text) + this.pane.addChild(this.label) + + this.initEventListeners() + } + + initEventListeners() { + this.cancelBtn.on('pressed', () => this.cancelPressed()) + } + + fixLayout() { + this.w = this.parent.contentW + this.h = this.parent.contentH + + this.pane.w = Math.max(40, 4 + this.label.w) + this.pane.h = 7 + this.pane.centerInParent() + + this.label.x = Math.floor((this.pane.contentW - this.label.w) / 2) + this.label.y = 1 + + this.cancelBtn.x = Math.floor( + (this.pane.contentW - this.cancelBtn.w) / 2) + this.cancelBtn.y = this.pane.contentH - 2 + } + + selected() { + this.root.select(this.cancelBtn) + } + + keyPressed(keyBuf) { + if (telc.isCancel(keyBuf)) { + this.emit('cancelled') + } + } + + cancelPressed() { + this.emit('cancelled') + } +} diff --git a/ui/dialogs/ConfirmDialog.js b/ui/dialogs/ConfirmDialog.js new file mode 100644 index 0000000..c0bcfae --- /dev/null +++ b/ui/dialogs/ConfirmDialog.js @@ -0,0 +1,76 @@ +import {Button, Form} from 'tui-lib/ui/controls' +import {Label, Pane} from 'tui-lib/ui/presentation' +import {FocusElement} from 'tui-lib/ui/primitives' + +import telc from 'tui-lib/util/telchars' + +export default class ConfirmDialog extends FocusElement { + // A basic yes/no dialog. Has two buttons, confirm/cancel, and a label. + // The escape (esc) key can be used to exit the dialog (which sends a + // 'cancelled' event, as the cancel button also does). + + constructor(text) { + super() + + this.pane = new Pane() + this.addChild(this.pane) + + this.form = new Form() + this.pane.addChild(this.form) + + this.confirmBtn = new Button('Confirm') + this.form.addInput(this.confirmBtn) + + this.cancelBtn = new Button('Cancel') + this.form.addInput(this.cancelBtn) + + this.label = new Label(text) + this.form.addChild(this.label) + + this.initEventListeners() + } + + initEventListeners() { + this.confirmBtn.on('pressed', () => this.confirmPressed()) + this.cancelBtn.on('pressed', () => this.cancelPressed()) + } + + fixLayout() { + this.w = this.parent.contentW + this.h = this.parent.contentH + + this.pane.w = Math.max(40, 2 + this.label.w) + this.pane.h = 7 + this.pane.centerInParent() + + this.form.w = this.pane.contentW + this.form.h = this.pane.contentH + + this.label.x = Math.floor((this.form.contentW - this.label.w) / 2) + this.label.y = 1 + + this.confirmBtn.x = 1 + this.confirmBtn.y = this.form.contentH - 2 + + this.cancelBtn.x = this.form.right - this.cancelBtn.w - 1 + this.cancelBtn.y = this.form.contentH - 2 + } + + selected() { + this.root.select(this.form) + } + + keyPressed(keyBuf) { + if (telc.isCancel(keyBuf)) { + this.emit('cancelled') + } + } + + confirmPressed() { + this.emit('confirmed') + } + + cancelPressed() { + this.emit('cancelled') + } +} diff --git a/ui/dialogs/Dialog.js b/ui/dialogs/Dialog.js new file mode 100644 index 0000000..19565f5 --- /dev/null +++ b/ui/dialogs/Dialog.js @@ -0,0 +1,55 @@ +import {Pane} from 'tui-lib/ui/presentation' +import {FocusElement} from 'tui-lib/ui/primitives' + +import telc from 'tui-lib/util/telchars' + +export default class Dialog extends FocusElement { + // A simple base dialog. + // + // Emits the 'cancelled' event when the cancel key (escape) is pressed, + // which should (probably) be handled by the dialog's creator. + // + // Doesn't do anything when focused by default - this should be overridden + // in subclasses. + // + // Automatically adjusts to fill its parent. Has a pane child (this.pane), + // but the pane isn't adjusted at all (you should change its size and + // likely center it in your subclass). + + constructor() { + super() + + this.pane = new Pane() + this.addChild(this.pane) + } + + fixLayout() { + this.w = this.parent.contentW + this.h = this.parent.contentH + } + + open() { + this.oldSelectedElement = this.root.selectedElement + this.opened() + this.visible = true + this.root.select(this) + this.fixLayout() + } + + close() { + this.closed() + this.visible = false + this.root.select(this.oldSelectedElement) + } + + opened() {} + + closed() {} + + keyPressed(keyBuf) { + if (telc.isCancel(keyBuf)) { + this.emit('cancelled') + return false + } + } +} diff --git a/ui/dialogs/FilePickerForm.js b/ui/dialogs/FilePickerForm.js new file mode 100644 index 0000000..6414818 --- /dev/null +++ b/ui/dialogs/FilePickerForm.js @@ -0,0 +1,79 @@ +import {readdir, stat} from 'node:fs/promises' +import path from 'node:path' + +import {compare as naturalCompare} from 'natural-orderby' + +import {Button, ListScrollForm} from 'tui-lib/ui/controls' + +export default class FilePickerForm extends ListScrollForm { + fillItems(dirPath) { + this.inputs = [] + this.children = [] + + const button = new Button('..Loading..') + this.addInput(button) + this.firstInput(false) + + readdir(dirPath).then( + async items => { + this.removeInput(button) + + const processedItems = await Promise.all(items.map(item => { + const itemPath = path.resolve(dirPath, item) + return stat(itemPath).then(s => { + return { + path: itemPath, + label: item + (s.isDirectory() ? '/' : ''), + isDirectory: s.isDirectory() + } + }) + })) + + const compare = naturalCompare() + processedItems.sort((a, b) => { + if (a.isDirectory === b.isDirectory) { + return compare(a.label, b.label) + } else { + if (a.isDirectory) { + return -1 + } else { + return +1 + } + } + }) + + processedItems.unshift({ + path: path.resolve(dirPath, '..'), + label: '../', + isDirectory: true + }) + + let y = 0 + for (const item of processedItems) { + const itemButton = new Button(item.label) + itemButton.y = y + y++ + this.addInput(itemButton) + + itemButton.on('pressed', () => { + if (item.isDirectory) { + this.emit('browsingDirectory', item.path) + this.fillItems(item.path) + } else { + this.emit('selected', item.path) + } + }) + } + + console.log('HALLO.', false) + this.firstInput(false) + this.fixLayout() + }, + () => { + button.text = 'Failed to read path! (Cancel)' + button.on('pressed', () => { + this.emit('canceled') + }) + }) + } +} diff --git a/ui/dialogs/OpenFileDialog.js b/ui/dialogs/OpenFileDialog.js new file mode 100644 index 0000000..970e291 --- /dev/null +++ b/ui/dialogs/OpenFileDialog.js @@ -0,0 +1,108 @@ +import path from 'node:path' + +import {Button, Form, TextInput} from 'tui-lib/ui/controls' +import {Label} from 'tui-lib/ui/presentation' + +import Dialog from './Dialog.js' +import FilePickerForm from './FilePickerForm.js' + +export default class OpenFileDialog extends Dialog { + constructor() { + super() + + this.visible = false + + this.form = new Form() + this.pane.addChild(this.form) + + this.filePathLabel = new Label('Enter file path:') + this.filePathInput = new TextInput() + this.openButton = new Button('Open') + this.cancelButton = new Button('Cancel') + + this.filePickerForm = new FilePickerForm() + this.filePickerForm.captureTab = false + + this.form.addChild(this.filePathLabel) + this.form.addInput(this.filePathInput) + this.form.addInput(this.filePickerForm) + this.form.addInput(this.openButton) + this.form.addInput(this.cancelButton) + + this._resolve = null + + this.openButton.on('pressed', () => { + this._resolve(this.filePathInput.value) + }) + + this.filePathInput.on('value', () => { + this._resolve(this.filePathInput.value) + }) + + { + const cb = append => p => { + this.filePathInput.setValue((path.relative(process.cwd(), p) || '.') + append) + } + + this.filePickerForm.on('selected', cb('')) + this.filePickerForm.on('browsingDirectory', cb('/')) + } + + this.cancelButton.on('pressed', () => { + this._resolve(null) + }) + + const dir = (this.lastFilePath + ? path.relative(process.cwd(), path.dirname(this.lastFilePath)) + '/' + : './') + + this.filePathInput.setValue(dir) + this.filePickerForm.fillItems(dir) + } + + fixLayout() { + super.fixLayout() + + this.pane.w = Math.min(this.contentW, 40) + this.pane.h = Math.min(this.contentH, 20) + this.pane.centerInParent() + + this.form.w = this.pane.contentW + this.form.h = this.pane.contentH + + this.filePathLabel.x = 0 + this.filePathLabel.y = 0 + + this.filePathInput.x = this.filePathLabel.right + 2 + this.filePathInput.y = this.filePathLabel.y + this.filePathInput.w = this.form.contentW - this.filePathInput.x + + this.filePickerForm.x = 0 + this.filePickerForm.y = this.filePathInput.y + 2 + this.filePickerForm.w = this.form.contentW + this.filePickerForm.h = this.form.contentH - this.filePickerForm.y - 2 + + this.openButton.x = 0 + this.openButton.y = this.form.contentH - 1 + + this.cancelButton.x = this.openButton.right + 2 + this.cancelButton.y = this.openButton.y + } + + selected() { + this.form.firstInput() + } + + go() { + this.visible = true + this.root.select(this) + + return new Promise(resolve => { + this._resolve = resolve + }).then(filePath => { + this.visible = false + this.lastFilePath = filePath + return filePath + }) + } +} diff --git a/ui/dialogs/index.js b/ui/dialogs/index.js new file mode 100644 index 0000000..5cb9f04 --- /dev/null +++ b/ui/dialogs/index.js @@ -0,0 +1,16 @@ +// +// Import mapping: +// +// controls, presentation, primitives -> +// CancelDialog +// ConfirmDialog +// +// Dialog -> OpenFileDialog +// FilePickerForm -> OpenFileDialog +// + +export {default as CancelDialog} from './CancelDialog.js' +export {default as ConfirmDialog} from './ConfirmDialog.js' +export {default as Dialog} from './Dialog.js' +export {default as FilePickerForm} from './FilePickerForm.js' +export {default as OpenFileDialog} from './OpenFileDialog.js' diff --git a/ui/form/Button.js b/ui/form/Button.js deleted file mode 100644 index 46329a6..0000000 --- a/ui/form/Button.js +++ /dev/null @@ -1,51 +0,0 @@ -const ansi = require('../../util/ansi') -const telc = require('../../util/telchars') - -const FocusElement = require('./FocusElement') - -module.exports = class Button extends FocusElement { - // A button. - - constructor(text) { - super() - - this.text = text - - this.cursorX = null - this.cursorY = null - } - - fixLayout() { - this.w = ansi.measureColumns(this.text) - this.h = 1 - } - - drawTo(writable) { - if (this.isSelected) { - writable.write(ansi.invert()) - } - - writable.write(ansi.moveCursor(this.absTop, this.absLeft)) - writable.write(this.text) - - writable.write(ansi.resetAttributes()) - - super.drawTo(writable) - } - - keyPressed(keyBuf) { - if (telc.isSelect(keyBuf)) { - this.emit('pressed') - } - } - - clicked(button) { - if (button === 'left') { - if (this.isSelected) { - this.emit('pressed') - } else { - this.root.select(this) - } - } - } -} diff --git a/ui/form/CancelDialog.js b/ui/form/CancelDialog.js deleted file mode 100644 index 21ff6df..0000000 --- a/ui/form/CancelDialog.js +++ /dev/null @@ -1,63 +0,0 @@ -const telc = require('../../util/telchars') - -const FocusElement = require('./FocusElement') - -const Button = require('./Button') -const Form = require('./Form') -const Label = require('../Label') -const Pane = require('../Pane') - -module.exports = class ConfirmDialog extends FocusElement { - // A basic cancel dialog. Has one buttons, cancel, and a label. - // The escape (esc) key can be used to exit the dialog (which sends a - // 'cancelled' event, as the cancel button also does). - - constructor(text) { - super() - - this.pane = new Pane() - this.addChild(this.pane) - - this.cancelBtn = new Button('Cancel') - this.pane.addChild(this.cancelBtn) - - this.label = new Label(text) - this.pane.addChild(this.label) - - this.initEventListeners() - } - - initEventListeners() { - this.cancelBtn.on('pressed', () => this.cancelPressed()) - } - - fixLayout() { - this.w = this.parent.contentW - this.h = this.parent.contentH - - this.pane.w = Math.max(40, 4 + this.label.w) - this.pane.h = 7 - this.pane.centerInParent() - - this.label.x = Math.floor((this.pane.contentW - this.label.w) / 2) - this.label.y = 1 - - this.cancelBtn.x = Math.floor( - (this.pane.contentW - this.cancelBtn.w) / 2) - this.cancelBtn.y = this.pane.contentH - 2 - } - - selected() { - this.root.select(this.cancelBtn) - } - - keyPressed(keyBuf) { - if (telc.isCancel(keyBuf)) { - this.emit('cancelled') - } - } - - cancelPressed() { - this.emit('cancelled') - } -} diff --git a/ui/form/ConfirmDialog.js b/ui/form/ConfirmDialog.js deleted file mode 100644 index 230230d..0000000 --- a/ui/form/ConfirmDialog.js +++ /dev/null @@ -1,79 +0,0 @@ -const telc = require('../../util/telchars') - -const FocusElement = require('./FocusElement') - -const Button = require('./Button') -const Form = require('./Form') -const Label = require('../Label') -const Pane = require('../Pane') - -module.exports = class ConfirmDialog extends FocusElement { - // A basic yes/no dialog. Has two buttons, confirm/cancel, and a label. - // The escape (esc) key can be used to exit the dialog (which sends a - // 'cancelled' event, as the cancel button also does). - - constructor(text) { - super() - - this.pane = new Pane() - this.addChild(this.pane) - - this.form = new Form() - this.pane.addChild(this.form) - - this.confirmBtn = new Button('Confirm') - this.form.addInput(this.confirmBtn) - - this.cancelBtn = new Button('Cancel') - this.form.addInput(this.cancelBtn) - - this.label = new Label(text) - this.form.addChild(this.label) - - this.initEventListeners() - } - - initEventListeners() { - this.confirmBtn.on('pressed', () => this.confirmPressed()) - this.cancelBtn.on('pressed', () => this.cancelPressed()) - } - - fixLayout() { - this.w = this.parent.contentW - this.h = this.parent.contentH - - this.pane.w = Math.max(40, 2 + this.label.w) - this.pane.h = 7 - this.pane.centerInParent() - - this.form.w = this.pane.contentW - this.form.h = this.pane.contentH - - this.label.x = Math.floor((this.form.contentW - this.label.w) / 2) - this.label.y = 1 - - this.confirmBtn.x = 1 - this.confirmBtn.y = this.form.contentH - 2 - - this.cancelBtn.x = this.form.right - this.cancelBtn.w - 1 - this.cancelBtn.y = this.form.contentH - 2 - } - - selected() { - this.root.select(this.form) - } - - keyPressed(keyBuf) { - if (telc.isCancel(keyBuf)) { - this.emit('cancelled') - } - } - - confirmPressed() { - this.emit('confirmed') - } - - cancelPressed() { - this.emit('cancelled') - } -} diff --git a/ui/form/FocusBox.js b/ui/form/FocusBox.js deleted file mode 100644 index 69b5bf5..0000000 --- a/ui/form/FocusBox.js +++ /dev/null @@ -1,32 +0,0 @@ -const ansi = require('../../util/ansi') - -const FocusElement = require('./FocusElement') - -module.exports = class FocusBox extends FocusElement { - // A box (not to be confused with Pane!) that can be selected. When it's - // selected, it applies an invert effect to its children. (This won't work - // well if you have elements inside of it that have their own attributes, - // since they're likely to reset all effects after drawing - including the - // invert from the FocusBox! Bad ANSI limitations; it's relatively likely - // I'll implement maaaaaagic to help deal with this - maybe something - // similar to 'pushMatrix' from Processing - at some point... [TODO]) - - constructor() { - super() - - this.cursorX = null - this.cursorY = null - } - - drawTo(writable) { - if (this.isSelected) { - writable.write(ansi.invert()) - } - } - - didRenderTo(writable) { - if (this.isSelected) { - writable.write(ansi.resetAttributes()) - } - } -} diff --git a/ui/form/FocusElement.js b/ui/form/FocusElement.js deleted file mode 100644 index 23c2e02..0000000 --- a/ui/form/FocusElement.js +++ /dev/null @@ -1,45 +0,0 @@ -const DisplayElement = require('../DisplayElement') - -module.exports = class FocusElement extends DisplayElement { - // A basic element that can receive cursor focus. - - constructor() { - super() - - this.cursorVisible = false - this.cursorX = 0 - this.cursorY = 0 - } - - selected() { - // Should be overridden in subclasses. - } - - unselected() { - // Should be overridden in subclasses. - } - - get selectable() { - // Should be overridden if you want to make the element unselectable - // (according to particular conditions). - - return true - } - - keyPressed(keyBuf) { - // Do something with a buffer containing the key pressed (that is, - // telnet data sent). Should be overridden in subclasses. - // - // Arrow keys are sent as a buffer in the form of - // ESC[# where # is A, B, C or D. See more here: - // http://stackoverflow.com/a/11432632/4633828 - } - - get isSelected() { - const selected = this.root.selectedElement - return !!(selected && [selected, ...selected.directAncestors].includes(this)) - } - - get absCursorX() { return this.absX + this.cursorX } - get absCursorY() { return this.absY + this.cursorY } -} diff --git a/ui/form/Form.js b/ui/form/Form.js deleted file mode 100644 index 451baa4..0000000 --- a/ui/form/Form.js +++ /dev/null @@ -1,143 +0,0 @@ -const telc = require('../../util/telchars') - -const FocusElement = require('./FocusElement') - -module.exports = class Form extends FocusElement { - constructor() { - super() - - this.inputs = [] - this.curIndex = 0 - this.captureTab = true - } - - addInput(input, asChild = true, opts = {}) { - // Adds the given input as a child element and pushes it to the input - // list. If the optional argument asChild is false, it won't add the - // input element as a child of the form. - - this.inputs.push(input) - - if (asChild) { - this.addChild(input, this.children.length, opts) - } - } - - removeInput(input, asChild = true, opts = {}) { - // Removes the given input from the form's input list. If the optional - // argument asChild is false, it won't try to removeChild the input. - - if (this.inputs.includes(input)) { - this.inputs.splice(this.inputs.indexOf(input), 1) - - if (asChild) { - this.removeChild(input, opts) - } - } - } - - selectInput(input) { - if (this.inputs.includes(input)) { - this.curIndex = this.inputs.indexOf(input) - this.updateSelectedElement() - } - } - - keyPressed(keyBuf) { - // Don't do anything if captureTab is set to false. This is handy for - // nested forms. - if (!this.captureTab) { - return - } - - if (telc.isTab(keyBuf) || telc.isBackTab(keyBuf)) { - // No inputs to tab through, so do nothing. - if (this.inputs.length < 2) { - return - } - - if (telc.isTab(keyBuf)) { - this.nextInput() - } else { - this.previousInput() - } - - return false - } - } - - get selectable() { - return this.inputs.some(inp => inp.selectable) - } - - updateSelectedElement() { - if (this.root.select && this.inputs.length) { - if (this.curIndex > this.inputs.length - 1) { - this.curIndex = this.inputs.length - 1 - } - - this.root.select(this.inputs[this.curIndex], {fromForm: true}) - } - } - - previousInput() { - // TODO: Forms currently assume there is at least one selectable input, - // but this isn't necessarily always the case. - do { - this.curIndex = (this.curIndex - 1) - if (this.curIndex < 0) { - this.curIndex = (this.inputs.length - 1) - } - } while (!this.inputs[this.curIndex].selectable) - - this.updateSelectedElement() - } - - nextInput() { - // TODO: See previousInput - do { - this.curIndex = (this.curIndex + 1) % this.inputs.length - } while (!this.inputs[this.curIndex].selectable) - - this.updateSelectedElement() - } - - firstInput(selectForm = true) { - this.curIndex = 0 - - // TODO: See previousInput - if (!this.inputs[this.curIndex].selectable) { - this.nextInput() - } - - if (selectForm || ( - this.root.isChildOrSelfSelected && this.root.isChildOrSelfSelected(this) - )) { - this.updateSelectedElement() - } - } - - lastInput(selectForm = true) { - this.curIndex = this.inputs.length - 1 - - // TODO: See previousInput - if (!this.inputs[this.curIndex].selectable) { - this.previousInput() - } - - if (selectForm || ( - this.root.isChildOrSelfSelected && this.root.isChildOrSelfSelected(this) - )) { - this.updateSelectedElement() - } - } - - selected() { - if (this.root.selectedElement === this) { - this.updateSelectedElement() - } - } - - get curIndex() { return this.getDep('curIndex') } - set curIndex(v) { return this.setDep('curIndex', v) } -} diff --git a/ui/form/ListScrollForm.js b/ui/form/ListScrollForm.js deleted file mode 100644 index 78c376f..0000000 --- a/ui/form/ListScrollForm.js +++ /dev/null @@ -1,404 +0,0 @@ -const ansi = require('../../util/ansi') -const telc = require('../../util/telchars') -const unic = require('../../util/unichars') - -const DisplayElement = require('../DisplayElement') -const Form = require('./Form') - -module.exports = class ListScrollForm extends Form { - // A form that lets the user scroll through a list of items. It - // automatically adjusts to always allow the selected item to be visible. - // Unless disabled in the constructor, a scrollbar is automatically displayed - // if there are more items than can be shown in the height of the form at a - // single time. - - constructor(layoutType = 'vertical', enableScrollBar = true) { - super() - - this.layoutType = layoutType - - this.scrollItems = 0 - - this.scrollBarEnabled = enableScrollBar - - this.scrollBar = new ScrollBar(this) - this.scrollBarShown = false - } - - fixLayout() { - this.keepScrollInBounds() - - const scrollItems = this.scrollItems - - // 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. We do need to hide - // them, though. - for (let i = 0; i < Math.min(scrollItems, this.inputs.length); i++) { - this.inputs[i].visible = false - } - - // This variable stores how far along the respective axis (implied by - // layoutType) the next element should be. - let nextPos = 0 - - let formEdge - if (this.layoutType === 'horizontal') { - formEdge = this.contentW - } else { - formEdge = this.contentH - } - - for (let i = scrollItems; i < this.inputs.length; i++) { - const item = this.inputs[i] - item.fixLayout() - - const curPos = nextPos - let curSize - if (this.layoutType === 'horizontal') { - item.x = curPos - curSize = item.w - } else { - item.y = curPos - curSize = item.h - } - nextPos += curSize - - // By default, the item should be visible.. - item.visible = true - - // ..but the item's far edge is past the form's far edge, it isn't - // fully visible and should be hidden. - if (curPos + curSize > formEdge) { - item.visible = false - } - - // Same deal goes for the close edge. We can check it against 0 since - // the close edge of the form's content is going to be 0, of course! - if (curPos < 0) { - item.visible = false - } - } - - delete this._scrollItemsLength - - if (this.scrollBarEnabled) { - this.showScrollbarIfNecessary() - } - } - - keyPressed(keyBuf) { - let ret - - handleKeyPress: { - if (this.layoutType === 'horizontal') { - if (telc.isLeft(keyBuf)) { - this.previousInput() - ret = false; break handleKeyPress - } else if (telc.isRight(keyBuf)) { - this.nextInput() - ret = false; break handleKeyPress - } - } else if (this.layoutType === 'vertical') { - if (telc.isUp(keyBuf)) { - this.previousInput() - ret = false; break handleKeyPress - } else if (telc.isDown(keyBuf)) { - this.nextInput() - ret = false; break handleKeyPress - } - } - - ret = super.keyPressed(keyBuf) - } - - this.scrollSelectedElementIntoView() - - 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] - - if (!sel) { - return - } - - let formEdge - if (this.layoutType === 'horizontal') { - formEdge = this.contentW - } else { - formEdge = this.contentH - } - - // If the item is ahead of our view (either to the right of or below), - // we should move the view so that the item is the farthest right (of all - // the visible items). - if (this.getItemPos(sel) > formEdge + this.scrollSize) { - this.scrollElementIntoEndOfView(sel) - } - - // Adjusting the number of scroll items is much simpler to deal with if - // the item is behind our view. Since the item's behind, we need to move - // the scroll to be immediately behind it, which is simple since we - // already have its index. - if (this.getItemPos(sel) <= this.scrollSize) { - this.scrollItems = this.curIndex - } - - this.fixLayout() - } - - firstInput(...args) { - this.scrollItems = 0 - - super.firstInput(...args) - - this.fixLayout() - } - - getScrollPositionOfElementAtEndOfView(element) { - // We can decide how many items to scroll past by moving forward until - // the item's far edge is visible. - const pos = this.getItemPos(element) - - let edge - if (this.layoutType === 'horizontal') { - edge = this.contentW - } else { - edge = this.contentH - } - - for (let i = 0; i < this.inputs.length; i++) { - if (pos <= edge) { - return i - } - - if (this.layoutType === 'horizontal') { - edge += this.inputs[i].w - } else { - edge += this.inputs[i].h - } - } - // No result? Well, it's at the end. - return this.inputs.length - } - - scrollElementIntoEndOfView(element) { - this.scrollItems = this.getScrollPositionOfElementAtEndOfView(element) - } - - scrollToBeginning() { - this.scrollItems = 0 - this.fixLayout() - } - - scrollToEnd() { - this.scrollElementIntoEndOfView(this.inputs[this.inputs.length - 1]) - 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] - this._scrollItemsLength = this.getScrollPositionOfElementAtEndOfView(lastInput) - } - - return this._scrollItemsLength - } - - getItemPos(item) { - // Gets the position of the item in an unscrolled view. - - const index = this.inputs.indexOf(item) - let pos = 0 - for (let i = 0; i <= index; i++) { - if (this.layoutType === 'horizontal') { - pos += this.inputs[i].w - } else { - pos += this.inputs[i].h - } - } - return pos - } - - showScrollbarIfNecessary() { - this.scrollBarShown = this.scrollBar.canScrollAtAll() - - const isChild = this.children.includes(this.scrollBar) - if (this.scrollBarShown) { - if (!isChild) this.addChild(this.scrollBar) - } else { - if (isChild) this.removeChild(this.scrollBar) - } - } - - get scrollSize() { - // Gets the actual length made up by all of the items currently scrolled - // past. - - let size = 0 - for (let i = 0; i < Math.min(this.scrollItems, this.inputs.length); i++) { - if (this.layoutType === 'horizontal') { - size += this.inputs[i].w - } else { - size += this.inputs[i].h - } - } - return size - } - - get contentW() { - if (this.scrollBarShown && this.layoutType === 'vertical') { - return this.w - 1 - } else { - return this.w - } - } - - get contentH() { - if (this.scrollBarShown && this.layoutType === 'horizontal') { - return this.h - 1 - } else { - return this.h - } - } -} - -class ScrollBar extends DisplayElement { - constructor(listScrollForm) { - super() - - this.listScrollForm = listScrollForm - } - - fixLayout() { - // Normally we'd subtract one from contentW/contentH when setting the x/y - // position, but the scrollbar is actually displayed OUTSIDE of (adjacent - // to) the parent's content area. - if (this.listScrollForm.layoutType === 'vertical') { - this.h = this.listScrollForm.contentH - this.w = 1 - this.x = this.listScrollForm.contentW - this.y = 0 - } else { - this.h = 1 - this.w = this.listScrollForm.contentW - this.x = 0 - this.y = this.listScrollForm.contentH - } - } - - drawTo(writable) { - // Uuuurgh - this.fixLayout() - - // TODO: Horizontal layout! Not functionally a lot different, but I'm too - // lazy to write a test UI for it right now. - - const { - backwards: canScrollBackwards, - forwards: canScrollForwards - } = this.getScrollableDirections() - - // - 2 for extra UI elements (arrows) - const totalLength = this.h - 2 - - // ..[-----].. - // ^start| - // ^end - // - // Start and end should correspond to how much of the scroll area - // is currently visible. So, if you can see 60% of the full scroll length - // at a time, and you are scrolled 10% down, the start position of the - // handle should be 10% down, and it should extend 60% of the scrollbar - // length, to the 70% mark. - - const currentScroll = this.listScrollForm.scrollItems - const edgeLength = this.listScrollForm.contentH - const totalItems = this.listScrollForm.inputs.length - const itemsVisibleAtOnce = Math.min(totalItems, edgeLength) - const handleLength = itemsVisibleAtOnce / totalItems * totalLength - let handlePosition = Math.floor(totalLength / totalItems * currentScroll) - - // Silly peeve of mine: The handle should only be visibly touching the top - // or bottom of the scrollbar area if you're actually scrolled all the way - // to the start or end. Otherwise, it shouldn't be touching! There should - // visible space indicating that you can scroll in that direction - // (in addition to the arrows we show at the ends). - - if (canScrollBackwards && handlePosition === 0) { - handlePosition = 1 - } - - if (canScrollForwards && (handlePosition + handleLength) === edgeLength) { - handlePosition-- - } - - if (this.listScrollForm.layoutType === 'vertical') { - const start = this.absTop + handlePosition + 1 - for (let i = 0; i < handleLength; i++) { - writable.write(ansi.moveCursor(start + i, this.absLeft)) - writable.write(unic.BOX_V_DOUBLE) - } - - if (canScrollBackwards) { - writable.write(ansi.moveCursor(this.absTop, this.absLeft)) - writable.write(unic.ARROW_UP_DOUBLE) - } - - if (canScrollForwards) { - writable.write(ansi.moveCursor(this.absBottom, this.absLeft)) - writable.write(unic.ARROW_DOWN_DOUBLE) - } - } - } - - getScrollableDirections() { - const currentScroll = this.listScrollForm.scrollItems - const totalScroll = this.listScrollForm.getScrollItemsLength() - - return { - backwards: (currentScroll > 0), - forwards: (currentScroll < totalScroll) - } - } - - canScrollAtAll() { - const {backwards, forwards} = this.getScrollableDirections() - return backwards || forwards - } -} diff --git a/ui/form/TextInput.js b/ui/form/TextInput.js deleted file mode 100644 index 78d3b6d..0000000 --- a/ui/form/TextInput.js +++ /dev/null @@ -1,145 +0,0 @@ -const ansi = require('../../util/ansi') -const unic = require('../../util/unichars') -const telc = require('../../util/telchars') - -const FocusElement = require('./FocusElement') - -module.exports = class TextInput extends FocusElement { - // An element that the user can type in. - - constructor() { - super() - - this.value = '' - this.cursorVisible = true - this.cursorIndex = 0 - this.scrollChars = 0 - } - - drawTo(writable) { - // There should be room for the cursor so move the "right edge" left a - // single character. - - const startRange = this.scrollChars - const endRange = this.scrollChars + this.w - 3 - - let str = this.value.slice(startRange, endRange) - - writable.write(ansi.moveCursor(this.absTop, this.absLeft + 1)) - writable.write(str) - - // Ellipsis on left side, if there's more characters behind the visible - // area. - if (startRange > 0) { - writable.write(ansi.moveCursor(this.absTop, this.absLeft)) - writable.write(unic.ELLIPSIS) - } - - // Ellipsis on the right side, if there's more characters ahead of the - // visible area. - if (endRange < this.value.length) { - writable.write(ansi.moveCursor(this.absTop, this.absRight - 1)) - writable.write(unic.ELLIPSIS.repeat(2)) - } - - this.cursorX = this.cursorIndex - this.scrollChars + 1 - - super.drawTo(writable) - } - - keyPressed(keyBuf) { - try { - if (keyBuf[0] === 127) { - this.value = ( - this.value.slice(0, this.cursorIndex - 1) + - this.value.slice(this.cursorIndex) - ) - this.cursorIndex-- - this.root.cursorMoved() - return false - } else if (keyBuf[0] === 13) { - // These are aliases for each other. - this.emit('value', this.value) - this.emit('confirm', this.value) - } else if (keyBuf[0] === 0x1b && keyBuf[1] === 0x5b) { - // Keyboard navigation - if (keyBuf[2] === 0x44) { - this.cursorIndex-- - this.root.cursorMoved() - } else if (keyBuf[2] === 0x43) { - this.cursorIndex++ - this.root.cursorMoved() - } - return false - } else if (telc.isEscape(keyBuf)) { - // ESC is bad and we don't want that in the text input! - // Also emit a "cancel" event, which doesn't necessarily do anything, - // but can be listened to. - this.emit('cancel') - } else { - const isTextInput = keyBuf.toString().split('').every(chr => { - const n = chr.charCodeAt(0) - return n > 31 && n < 127 - }) - - if (isTextInput) { - this.value = ( - this.value.slice(0, this.cursorIndex) + keyBuf.toString() + - this.value.slice(this.cursorIndex) - ) - this.cursorIndex += keyBuf.toString().length - this.root.cursorMoved() - this.emit('change', this.value) - - return false - } - } - } finally { - this.keepCursorInRange() - } - } - - setValue(value) { - this.value = value - this.moveToEnd() - } - - moveToEnd() { - this.cursorIndex = this.value.length - this.keepCursorInRange() - } - - keepCursorInRange() { - // Keep the cursor inside or at the end of the input value. - - if (this.cursorIndex < 0) { - this.cursorIndex = 0 - } - - if (this.cursorIndex > this.value.length) { - this.cursorIndex = this.value.length - } - - // Scroll right, if the cursor is past the right edge of where text is - // displayed. - while (this.cursorIndex - this.scrollChars > this.w - 3) { - this.scrollChars++ - } - - // Scroll left, if the cursor is behind the left edge of where text is - // displayed. - while (this.cursorIndex - this.scrollChars < 0) { - this.scrollChars-- - } - - // Scroll left, if we can see past the end of the text. - while (this.scrollChars > 0 && ( - this.scrollChars + this.w - 3 > this.value.length) - ) { - this.scrollChars-- - } - } - - get value() { return this.getDep('value') } - set value(v) { return this.setDep('value', v) } -} diff --git a/ui/index.js b/ui/index.js new file mode 100644 index 0000000..df6cae8 --- /dev/null +++ b/ui/index.js @@ -0,0 +1,4 @@ +export * as controls from './controls/index.js' +export * as dialogs from './dialogs/index.js' +export * as presentation from './presentation/index.js' +export * as primitives from './primitives/index.js' diff --git a/ui/presentation/HorizontalBox.js b/ui/presentation/HorizontalBox.js new file mode 100644 index 0000000..d396ec3 --- /dev/null +++ b/ui/presentation/HorizontalBox.js @@ -0,0 +1,13 @@ +import {DisplayElement} from 'tui-lib/ui/primitives' + +export default class HorizontalBox extends DisplayElement { + // A box that will automatically lay out its children in a horizontal row. + + fixLayout() { + let nextX = 0 + for (const child of this.children) { + child.x = nextX + nextX = child.right + 1 + } + } +} diff --git a/ui/presentation/Label.js b/ui/presentation/Label.js new file mode 100644 index 0000000..81223df --- /dev/null +++ b/ui/presentation/Label.js @@ -0,0 +1,52 @@ +import {DisplayElement} from 'tui-lib/ui/primitives' + +import * as ansi from 'tui-lib/util/ansi' + +export default class Label extends DisplayElement { + // A simple text display. Automatically adjusts size to fit text. + + constructor(text = '') { + super() + + this.text = text + this.textAttributes = [] + } + + fixLayout() { + this.w = ansi.measureColumns(this.text) + } + + drawTo(writable) { + if (this.textAttributes.length) { + writable.write(ansi.setAttributes(this.textAttributes)) + } + + this.writeTextTo(writable) + + if (this.textAttributes.length) { + writable.write(ansi.resetAttributes()) + } + + super.drawTo(writable) + } + + writeTextTo(writable) { + writable.write(ansi.moveCursor(this.absTop, this.absLeft)) + writable.write(this.text) + } + + set text(newText) { + const ret = this.setDep('text', newText) + this.fixLayout() + return ret + } + + get text() { + return this.getDep('text') + } + + // Kinda bad, but works as long as you're overwriting the array instead of + // mutating it. + set textAttributes(val) { return this.setDep('textAttributes', val) } + get textAttributes() { return this.getDep('textAttributes') } +} diff --git a/ui/presentation/Pane.js b/ui/presentation/Pane.js new file mode 100644 index 0000000..4769cf9 --- /dev/null +++ b/ui/presentation/Pane.js @@ -0,0 +1,101 @@ +import {DisplayElement} from 'tui-lib/ui/primitives' + +import * as ansi from 'tui-lib/util/ansi' +import unic from 'tui-lib/util/unichars' + +import Label from './Label.js' + +export default class Pane extends DisplayElement { + // A simple rectangular framed pane. + + constructor() { + super() + + this.frameColor = null + + this.hPadding = 1 + this.vPadding = 1 + } + + drawTo(writable) { + this.drawFrame(writable) + super.drawTo(writable) + } + + drawFrame(writable, debug=false) { + writable.write(ansi.setForeground(this.frameColor)) + + const left = this.absLeft + const right = this.absRight + const top = this.absTop + const bottom = this.absBottom + + // Background + // (TODO) Transparent background (that dimmed everything behind it) would + // be cool at some point! + for (let y = top + 1; y <= bottom - 1; y++) { + writable.write(ansi.moveCursor(y, left)) + writable.write(' '.repeat(this.w)) + } + + // Left/right edges + for (let x = left + 1; x <= right - 1; x++) { + writable.write(ansi.moveCursor(top, x)) + writable.write(unic.BOX_H) + writable.write(ansi.moveCursor(bottom, x)) + writable.write(unic.BOX_H) + } + + // Top/bottom edges + for (let y = top + 1; y <= bottom - 1; y++) { + writable.write(ansi.moveCursor(y, left)) + writable.write(unic.BOX_V) + writable.write(ansi.moveCursor(y, right)) + writable.write(unic.BOX_V) + } + + // Corners + writable.write(ansi.moveCursor(top, left)) + writable.write(unic.BOX_CORNER_TL) + writable.write(ansi.moveCursor(top, right)) + writable.write(unic.BOX_CORNER_TR) + writable.write(ansi.moveCursor(bottom, left)) + writable.write(unic.BOX_CORNER_BL) + writable.write(ansi.moveCursor(bottom, right)) + writable.write(unic.BOX_CORNER_BR) + + // Debug info + if (debug) { + writable.write(ansi.moveCursor(6, 8)) + writable.write( + `x: ${this.x}; y: ${this.y}; w: ${this.w}; h: ${this.h}`) + writable.write(ansi.moveCursor(7, 8)) + writable.write(`AbsX: ${this.absX}; AbsY: ${this.absY}`) + writable.write(ansi.moveCursor(8, 8)) + writable.write(`Left: ${this.left}; Right: ${this.right}`) + writable.write(ansi.moveCursor(9, 8)) + writable.write(`Top: ${this.top}; Bottom: ${this.bottom}`) + } + + writable.write(ansi.setForeground(ansi.C_RESET)) + } + + static alert(parent, text) { + // Show an alert pane in the bottom left of the given parent element for + // a couple seconds. + + const pane = new Pane() + pane.frameColor = ansi.C_WHITE + pane.w = ansi.measureColumns(text) + 2 + pane.h = 3 + parent.addChild(pane) + + const label = new Label(text) + label.textAttributes = [ansi.C_WHITE] + pane.addChild(label) + + setTimeout(() => { + parent.removeChild(pane) + }, 2000) + } +} diff --git a/ui/presentation/Sprite.js b/ui/presentation/Sprite.js new file mode 100644 index 0000000..49ee450 --- /dev/null +++ b/ui/presentation/Sprite.js @@ -0,0 +1,69 @@ +import {DisplayElement} from 'tui-lib/ui/primitives' + +import * as ansi from 'tui-lib/util/ansi' + +export default class Sprite extends DisplayElement { + // "A sprite is a two-dimensional bitmap that is integrated into a larger + // scene." - Wikipedia + // + // Sprites are display objects that have a single texture that will not + // render outside of their parent. + // + // Sprites have a "real" position which overrides their "integer" position. + // This is so that motion can be more fluid (i.e., sprites don't need to + // move a whole number of terminal characters at a time). + + constructor() { + super() + + this.texture = [] + + this.realX = 0 + this.realY = 0 + } + + set x(newX) { this.realX = newX } + set y(newY) { this.realY = newY } + get x() { return Math.round(this.realX) } + get y() { return Math.round(this.realY) } + + drawTo(writable) { + if (this.textureAttributes) { + writable.write(ansi.setAttributes(this.textureAttributes)) + } + + for (let y = 0; y < this.textureHeight; y++) { + // Don't render above or below the parent's content area. + if (this.y + y >= this.parent.contentH || this.y + y < 0) continue + + const right = this.x + this.textureWidth + + const start = (this.x < 0) ? -this.x : 0 + const end = ( + (right > this.parent.contentW) + ? this.parent.contentW - right + : right) + const text = this.texture[y].slice(start, end) + + writable.write(ansi.moveCursor(this.absY + y, this.absX + start)) + writable.write(text) + } + + if (this.textureAttributes) { + writable.write(ansi.resetAttributes()) + } + } + + fixLayout() { + this.w = this.textureWidth + this.h = this.textureHeight + } + + get textureWidth() { + return Math.max(...this.texture.map(row => ansi.measureColumns(row))) + } + + get textureHeight() { + return this.texture.length + } +} diff --git a/ui/presentation/WrapLabel.js b/ui/presentation/WrapLabel.js new file mode 100644 index 0000000..0ecc777 --- /dev/null +++ b/ui/presentation/WrapLabel.js @@ -0,0 +1,45 @@ +import wrap from 'word-wrap' + +import * as ansi from 'tui-lib/util/ansi' + +import Label from './Label.js' + +export default class WrapLabel extends Label { + // A word-wrapping text display. Given a width, wraps text to fit. + + constructor(...args) { + super(...args) + } + + fixLayout() { + // Override Label.fixLayout to do nothing. We don't want to make the + // width of this label be set to the content of the text! (That would + // defeat the entire point of word wrapping.) + } + + writeTextTo(writable) { + const lines = this.getWrappedLines() + for (let i = 0; i < lines.length; i++) { + writable.write(ansi.moveCursor(this.absTop + i, this.absLeft)) + writable.write(lines[i]) + } + } + + getWrappedLines() { + if (this.text.trim().length === 0) { + return [] + } + + const options = {width: this.w, indent: ''} + return wrap(this.text, options).split('\n') + .map(l => l.trim()) + } + + get h() { + return this.getWrappedLines().length + } + + set h(newHeight) { + // Do nothing. Height is computed on the fly. + } +} diff --git a/ui/presentation/index.js b/ui/presentation/index.js new file mode 100644 index 0000000..9605d25 --- /dev/null +++ b/ui/presentation/index.js @@ -0,0 +1,15 @@ +// +// Import mapping: +// +// primitives -> +// HorizontalBox +// Sprite +// +// Label -> Pane, WrapLabel +// + +export {default as HorizontalBox} from './HorizontalBox.js' +export {default as Label} from './Label.js' +export {default as Pane} from './Pane.js' +export {default as Sprite} from './Sprite.js' +export {default as WrapLabel} from './WrapLabel.js' diff --git a/ui/primitives/DisplayElement.js b/ui/primitives/DisplayElement.js new file mode 100644 index 0000000..d2a0956 --- /dev/null +++ b/ui/primitives/DisplayElement.js @@ -0,0 +1,305 @@ +import Element from './Element.js' + +export default class DisplayElement extends Element { + // A general class that handles dealing with screen coordinates, the tree + // of elements, and other common stuff. + // + // This element doesn't handle any real rendering; just layouts. Placing + // characters at specific positions should be implemented in subclasses. + // + // It's a subclass of EventEmitter, so you can make your own events within + // the logic of your subclass. + + static drawValues = Symbol('drawValues') + static lastDrawValues = Symbol('lastDrawValues') + static scheduledDraw = Symbol('scheduledDraw') + + constructor() { + super() + + this[DisplayElement.drawValues] = {} + this[DisplayElement.lastDrawValues] = {} + this[DisplayElement.scheduledDraw] = false + + this.visible = true + + this.x = 0 + this.y = 0 + this.w = 0 + this.h = 0 + + this.hPadding = 0 + this.vPadding = 0 + + // Note! This only applies to the parent, not the children. Useful for + // when you want an element to cover the whole screen but allow mouse + // events to pass through. + this.clickThrough = false + } + + drawTo(writable) { + // Writes text to a "writable" - an object that has a "write" method. + // Custom rendering should be handled as an override of this method in + // subclasses of DisplayElement. + } + + renderTo(writable) { + // Like drawTo, but only calls drawTo if the element is visible. Use this + // with your root element, not drawTo. + + if (!this.visible) { + return + } + + const causeRenderEl = this.shouldRender() + if (causeRenderEl) { + this.drawTo(writable) + this.renderChildrenTo(writable) + this.didRenderTo(writable) + } else { + this.renderChildrenTo(writable) + } + } + + shouldRender() { + // WIP! Until this implementation is finished, always return true (or else + // lots of rendering breaks). + /* + return ( + this[DisplayElement.scheduledDraw] || + [...this.directAncestors].find(el => el.shouldRender()) + ) + */ + return true + } + + renderChildrenTo(writable) { + // Renders all of the children to a writable. + + for (const child of this.children) { + child.renderTo(writable) + } + } + + didRenderTo(writable) { + // Called immediately after rendering this element AND all of its + // children. If you need to do something when that happens, override this + // method in your subclass. + // + // It's fine to draw more things to the writable here - just keep in mind + // that it'll be drawn over this element and its children, but not any + // elements drawn in the future. + } + + fixLayout() { + // Adjusts the layout of children in this element. If your subclass has + // any children in it, you should override this method. + } + + fixAllLayout() { + // Runs fixLayout on this as well as all children. + + this.fixLayout() + for (const child of this.children) { + child.fixAllLayout() + } + } + + confirmDrawValuesExists() { + if (!this[DisplayElement.drawValues]) { + this[DisplayElement.drawValues] = {} + } + } + + getDep(key) { + this.confirmDrawValuesExists() + return this[DisplayElement.drawValues][key] + } + + setDep(key, value) { + this.confirmDrawValuesExists() + const oldValue = this[DisplayElement.drawValues][key] + 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]) + } + + centerInParent() { + // Utility function to center this element in its parent. Must be called + // only when it has a parent. Set the width and height of the element + // before centering it! + + if (this.parent === null) { + throw new Error('Cannot center in parent when parent is null') + } + + this.x = Math.round((this.parent.contentW - this.w) / 2) + this.y = Math.round((this.parent.contentH - this.h) / 2) + } + + fillParent() { + // Utility function to fill this element in its parent. Must be called + // only when it has a parent. + + if (this.parent === null) { + throw new Error('Cannot fill parent when parent is null') + } + + this.x = 0 + this.y = 0 + this.w = this.parent.contentW + this.h = this.parent.contentH + } + + fitToParent() { + // Utility function to position this element so that it stays within its + // parent's bounds. Must be called only when it has a parent. + // + // This function is useful when (and only when) the right or bottom edge + // of this element may be past the right or bottom edge of its parent. + // In such a case, the element will first be moved left or up by the + // distance that its edge exceeds that of its parent, so that its edge is + // no longer past the parent's. Then, if the left or top edge of the + // element is less than zero, i.e. outside the parent, it is set to zero + // and the element's width or height is adjusted so that it does not go + // past the bounds of the parent. + + if (this.x + this.w > this.parent.right) { + const offendExtent = (this.x + this.w) - this.parent.contentW + this.x -= offendExtent + if (this.x < 0) { + const offstartExtent = 0 - this.x + this.w -= offstartExtent + this.x = 0 + } + } + + if (this.y + this.h > this.parent.bottom) { + const offendExtent = (this.y + this.h) - this.parent.contentH + this.y -= offendExtent + if (this.y < 0) { + const offstartExtent = 0 - this.y + this.h -= offstartExtent + this.y = 0 + } + } + } + + getElementAt(x, y) { + // Gets the topmost element at the provided absolute coordinate. + // Note that elements which are not visible or have the clickThrough + // property set to true are not considered. + + const children = this.children.slice() + + // Start searching the last- (top-) rendered children first. + children.reverse() + + for (const el of children) { + if (!el.visible || el.clickThrough) { + 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 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) } + + // Commented out because this doesn't fix any problems (at least ATM). + // get parent() { return this.getDep('parent') } + // set parent(v) { return this.setDep('parent', v) } + + get absX() { + if (this.parent) { + return this.parent.contentX + this.x + } else { + return this.x + } + } + + get absY() { + if (this.parent) { + return this.parent.contentY + this.y + } else { + return this.y + } + } + + // Where contents should be positioned. + get contentX() { return this.absX + this.hPadding } + get contentY() { return this.absY + this.vPadding } + get contentW() { return this.w - this.hPadding * 2 } + get contentH() { return this.h - this.vPadding * 2 } + + get left() { return this.x } + get right() { return this.x + this.w } + get top() { return this.y } + get bottom() { return this.y + this.h } + + get absLeft() { return this.absX } + get absRight() { return this.absX + this.w - 1 } + get absTop() { return this.absY } + get absBottom() { return this.absY + this.h - 1 } +} diff --git a/ui/primitives/Element.js b/ui/primitives/Element.js new file mode 100644 index 0000000..fea8c03 --- /dev/null +++ b/ui/primitives/Element.js @@ -0,0 +1,80 @@ +import EventEmitter from 'node:events' + +export default class Element extends EventEmitter { + // The basic class containing methods for working with an element hierarchy. + // Generally speaking, you usually want to extend DisplayElement instead of + // this class. + + constructor() { + super() + + this.children = [] + this.parent = null + } + + 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! + + if (child === this) { + throw exception( + 'EINVALIDHIERARCHY', 'An element cannot be a child of itself') + } + + child.parent = this + + if (afterIndex === this.children.length) { + this.children.push(child) + } else { + this.children.splice(afterIndex, 0, child) + } + + if (fixLayout) { + child.fixLayout() + } + } + + removeChild(child, {fixLayout = true} = {}) { + // Removes the given child element from the children list of this + // element. It won't be rendered in the future. If the given element + // isn't a direct child of this element, nothing will happen. + + if (child.parent !== this) { + return + } + + child.parent = null + this.children.splice(this.children.indexOf(child), 1) + + if (fixLayout) { + this.fixLayout() + } + } + + get root() { + let el = this + while (el.parent) { + el = el.parent + } + return el + } + + get directAncestors() { + const ancestors = [] + let el = this + while (el.parent) { + el = el.parent + ancestors.push(el) + } + return ancestors + } +} diff --git a/ui/primitives/FocusElement.js b/ui/primitives/FocusElement.js new file mode 100644 index 0000000..2c23b1e --- /dev/null +++ b/ui/primitives/FocusElement.js @@ -0,0 +1,45 @@ +import DisplayElement from './DisplayElement.js' + +export default class FocusElement extends DisplayElement { + // A basic element that can receive cursor focus. + + constructor() { + super() + + this.cursorVisible = false + this.cursorX = 0 + this.cursorY = 0 + } + + selected() { + // Should be overridden in subclasses. + } + + unselected() { + // Should be overridden in subclasses. + } + + get selectable() { + // Should be overridden if you want to make the element unselectable + // (according to particular conditions). + + return true + } + + keyPressed(keyBuf) { + // Do something with a buffer containing the key pressed (that is, + // telnet data sent). Should be overridden in subclasses. + // + // Arrow keys are sent as a buffer in the form of + // ESC[# where # is A, B, C or D. See more here: + // http://stackoverflow.com/a/11432632/4633828 + } + + get isSelected() { + const selected = this.root.selectedElement + return !!(selected && [selected, ...selected.directAncestors].includes(this)) + } + + get absCursorX() { return this.absX + this.cursorX } + get absCursorY() { return this.absY + this.cursorY } +} diff --git a/ui/primitives/Root.js b/ui/primitives/Root.js new file mode 100644 index 0000000..a779637 --- /dev/null +++ b/ui/primitives/Root.js @@ -0,0 +1,284 @@ +import * as ansi from 'tui-lib/util/ansi' +import telc from 'tui-lib/util/telchars' + +import DisplayElement from './DisplayElement.js' + +export default class Root extends DisplayElement { + // An element to be used as the root of a UI. Handles lots of UI and + // socket stuff. + + constructor(interfaceArg, writable = null) { + super() + + this.interface = interfaceArg + this.writable = writable || interfaceArg + + this.selectedElement = null + + this.cursorBlinkOffset = Date.now() + + this.oldSelectionStates = [] + + this.interface.on('inputData', buf => this.handleData(buf)) + + this.renderCount = 0 + } + + handleData(buffer) { + if (telc.isMouse(buffer)) { + const allData = telc.parseMouse(buffer) + const { button, line, col } = allData + 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, allData) === false + } + }) + } + } else { + this.eachAncestor(this.selectedElement, el => { + if (typeof el.keyPressed === 'function') { + const shouldBreak = (el.keyPressed(buffer) === false) + if (shouldBreak) { + 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 + } + } + } + } + + drawTo(writable) { + writable.write(ansi.moveCursor(0, 0)) + 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.anyDescendantShouldRender()) { + this.renderNowTo(writable) + } + } + + renderNowTo(writable) { + if (writable) { + this.renderCount++ + super.renderTo(writable) + // Since shouldRender is false, super.renderTo won't call didRenderTo for + // us. We need to do that ourselves. + this.didRenderTo(writable) + } + } + + anyDescendantShouldRender() { + 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) { + return + } + render = el.hasScheduledDraw() + }) + return render + } + + shouldRender() { + // We need to return false here because otherwise all children will render, + // since they'll see the root as an ancestor who needs to be rendered. Bad! + return false + } + + didRenderTo(writable) { + this.eachDescendant(el => { + el.unscheduleDraw() + el.updateLastDrawValues() + }) + + /* + 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) { + /* + if ((Date.now() - this.cursorBlinkOffset) % 1000 < 500) { + writable.write(ansi.moveCursor( + this.selectedElement.absCursorY, this.selectedElement.absCursorX)) + writable.write(ansi.invert()) + writable.write('I') + writable.write(ansi.resetAttributes()) + } + */ + + writable.write(ansi.showCursor()) + writable.write(ansi.moveCursorRaw( + this.selectedElement.absCursorY, this.selectedElement.absCursorX)) + } else { + writable.write(ansi.hideCursor()) + } + + this.emit('rendered') + } + + cursorMoved() { + // Resets the blinking animation for the cursor. Call this whenever you + // move the cursor. + + this.cursorBlinkOffset = Date.now() + } + + 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? + // + // XXX: We currently use a HUGE HACK instead of `instanceof` to avoid + // breaking the rule of import direction (controls -> primitives, never + // the other way around). This is bad for obvious reasons, but I haven't + // yet looked into what the correct approach would be. + const parent = el.parent + if (!fromForm && parent.constructor.name === 'Form' && parent.inputs.includes(el)) { + parent.selectInput(el) + return + } + + const oldSelected = this.selectedElement + const newSelected = el + + // Relevant elements that COULD have their "isSelected" state change. + const relevantElements = ([ + ...(oldSelected ? [...oldSelected.directAncestors, oldSelected] : []), + ...(newSelected ? newSelected.directAncestors : []) + ] + + // We ignore elements where isSelected is undefined, because they aren't + // built to handle being selected, and they break the compare-old-and-new- + // state code below. + .filter(el => typeof el.isSelected !== 'undefined') + + // Get rid of duplicates - including any that occurred in the already + // existing array of selection states. (We only care about the oldest + // selection state, i.e. the one when we did the first .select().) + .reduce((acc, el) => { + // Duplicates from relevant elements of current .select() + if (acc.includes(el)) return acc + // Duplicates from already existing selection states + if (this.oldSelectionStates.some(x => x[0] === el)) return acc + return acc.concat([el]) + }, [])) + + // Keep track of whether those elements were selected before we call the + // newly selected element's selected() function. We store these on a + // property because we might actually be adding to it from a previous + // root.select() call, if that one itself caused this root.select(). + // One all root.select()s in the "chain" (as it is) have finished, we'll + // go through these states and call the appropriate .select/unselect() + // functions on each element whose .isSelected changed. + const selectionStates = relevantElements.map(el => [el, el.isSelected]) + this.oldSelectionStates = this.oldSelectionStates.concat(selectionStates) + + this.selectedElement = el + + // Same stuff as in the for loop below. We always call selected() on the + // passed element, even if it was already selected before. + if (el.selected) el.selected() + if (typeof el.focused === 'function') el.focused() + + // If the selection changed as a result of the element's selected() + // function, stop here. We will leave calling the appropriate functions on + // the elements in the oldSelectionStates array to the final .select(), + // i.e. the one which caused no change in selected element. + if (this.selectedElement !== newSelected) return + + // Compare the old "isSelected" state of every relevant element with their + // current "isSelected" state, and call the respective selected/unselected + // functions. (Also call focused and unfocused for some sense of trying to + // not break old programs, but, like, old programs are going to be broken + // anyways.) + const states = this.oldSelectionStates.slice() + for (const [ el, wasSelected ] of states) { + // Now that we'll have processed it, we don't want it in the array + // anymore. + this.oldSelectionStates.shift() + + const { isSelected } = el + if (isSelected && !wasSelected) { + // Don't call these functions if this element is the newly selected + // one, because we already called them above! + if (el !== newSelected) { + if (el.selected) el.selected() + if (typeof el.focused === 'function') el.focused() + } + } else if (wasSelected && !isSelected) { + if (el.unselected) el.unselected() + if (typeof el.unfocused === 'function') el.unfocused() + } + + // If the (un)selected() handler actually selected a different element + // itself, then further processing of new selected states is irrelevant, + // so stop here. (We return instead of breaking the for loop because + // anything after this loop would have already been handled by the call + // to Root.select() from the (un)selected() handler.) + if (this.selectedElement !== newSelected) { + return + } + } + + this.cursorMoved() + } + + isChildOrSelfSelected(el) { + if (!this.selectedElement) return false + if (this.selectedElement === el) return true + 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/primitives/index.js b/ui/primitives/index.js new file mode 100644 index 0000000..4e36452 --- /dev/null +++ b/ui/primitives/index.js @@ -0,0 +1,11 @@ +// +// Import mapping: +// +// Element -> +// DisplayElement -> FocusElement, Root +// + +export {default as DisplayElement} from './DisplayElement.js' +export {default as Element} from './Element.js' +export {default as FocusElement} from './FocusElement.js' +export {default as Root} from './Root.js' diff --git a/ui/tools/FilePickerForm.js b/ui/tools/FilePickerForm.js deleted file mode 100644 index 51d59a9..0000000 --- a/ui/tools/FilePickerForm.js +++ /dev/null @@ -1,88 +0,0 @@ -const fs = require('fs') -const util = require('util') -const path = require('path') - -const readdir = util.promisify(fs.readdir) -const stat = util.promisify(fs.stat) -const naturalSort = require('node-natural-sort') - -const Button = require('../form/Button') -const ListScrollForm = require('../form/ListScrollForm') - -module.exports = class FilePickerForm extends ListScrollForm { - fillItems(dirPath) { - this.inputs = [] - this.children = [] - - const button = new Button('..Loading..') - this.addInput(button) - this.firstInput(false) - - readdir(dirPath).then( - async items => { - this.removeInput(button) - - const processedItems = await Promise.all(items.map(item => { - const itemPath = path.resolve(dirPath, item) - return stat(itemPath).then(s => { - return { - path: itemPath, - label: item + (s.isDirectory() ? '/' : ''), - isDirectory: s.isDirectory() - } - }) - })) - - const sort = naturalSort({ - properties: { - caseSensitive: false - } - }) - processedItems.sort((a, b) => { - if (a.isDirectory === b.isDirectory) { - return sort(a.label, b.label) - } else { - if (a.isDirectory) { - return -1 - } else { - return +1 - } - } - }) - - processedItems.unshift({ - path: path.resolve(dirPath, '..'), - label: '../', - isDirectory: true - }) - - let y = 0 - for (const item of processedItems) { - const itemButton = new Button(item.label) - itemButton.y = y - y++ - this.addInput(itemButton) - - itemButton.on('pressed', () => { - if (item.isDirectory) { - this.emit('browsingDirectory', item.path) - this.fillItems(item.path) - } else { - this.emit('selected', item.path) - } - }) - } - - console.log('HALLO.', false) - this.firstInput(false) - this.fixLayout() - }, - () => { - button.text = 'Failed to read path! (Cancel)' - button.on('pressed', () => { - this.emit('canceled') - }) - }) - } -} - diff --git a/ui/tools/OpenFileDialog.js b/ui/tools/OpenFileDialog.js deleted file mode 100644 index 43f2638..0000000 --- a/ui/tools/OpenFileDialog.js +++ /dev/null @@ -1,110 +0,0 @@ -const path = require('path') - -const Button = require('../form/Button') -const Dialog = require('../Dialog') -const FilePickerForm = require('./FilePickerForm') -const Form = require('../form/Form') -const Label = require('../Label') -const TextInput = require('../form/TextInput') - -module.exports = class OpenFileDialog extends Dialog { - constructor() { - super() - - this.visible = false - - this.form = new Form() - this.pane.addChild(this.form) - - this.filePathLabel = new Label('Enter file path:') - this.filePathInput = new TextInput() - this.openButton = new Button('Open') - this.cancelButton = new Button('Cancel') - - this.filePickerForm = new FilePickerForm() - this.filePickerForm.captureTab = false - - this.form.addChild(this.filePathLabel) - this.form.addInput(this.filePathInput) - this.form.addInput(this.filePickerForm) - this.form.addInput(this.openButton) - this.form.addInput(this.cancelButton) - - this._resolve = null - - this.openButton.on('pressed', () => { - this._resolve(this.filePathInput.value) - }) - - this.filePathInput.on('value', () => { - this._resolve(this.filePathInput.value) - }) - - { - const cb = append => p => { - this.filePathInput.setValue((path.relative(__dirname, p) || '.') + append) - } - - this.filePickerForm.on('selected', cb('')) - this.filePickerForm.on('browsingDirectory', cb('/')) - } - - this.cancelButton.on('pressed', () => { - this._resolve(null) - }) - - const dir = (this.lastFilePath - ? path.relative(__dirname, path.dirname(this.lastFilePath)) + '/' - : './') - - this.filePathInput.setValue(dir) - this.filePickerForm.fillItems(dir) - } - - fixLayout() { - super.fixLayout() - - this.pane.w = Math.min(this.contentW, 40) - this.pane.h = Math.min(this.contentH, 20) - this.pane.centerInParent() - - this.form.w = this.pane.contentW - this.form.h = this.pane.contentH - - this.filePathLabel.x = 0 - this.filePathLabel.y = 0 - - this.filePathInput.x = this.filePathLabel.right + 2 - this.filePathInput.y = this.filePathLabel.y - this.filePathInput.w = this.form.contentW - this.filePathInput.x - - this.filePickerForm.x = 0 - this.filePickerForm.y = this.filePathInput.y + 2 - this.filePickerForm.w = this.form.contentW - this.filePickerForm.h = this.form.contentH - this.filePickerForm.y - 2 - - this.openButton.x = 0 - this.openButton.y = this.form.contentH - 1 - - this.cancelButton.x = this.openButton.right + 2 - this.cancelButton.y = this.openButton.y - } - - selected() { - this.form.firstInput() - } - - go() { - this.visible = true - this.root.select(this) - - return new Promise(resolve => { - this._resolve = resolve - }).then(filePath => { - this.visible = false - this.lastFilePath = filePath - return filePath - }) - } -} - -- cgit 1.3.0-6-gf8a5