diff options
author | (quasar) nebula <qznebula@protonmail.com> | 2023-05-12 17:42:09 -0300 |
---|---|---|
committer | (quasar) nebula <qznebula@protonmail.com> | 2023-05-13 12:48:36 -0300 |
commit | 6ea74c268a12325296a1d2e7fc31b02030ddb8bc (patch) | |
tree | 5da94d93acb64e7ab650d240d6cb23c659ad02ca /ui/primitives | |
parent | e783bcf8522fa68e6b221afd18469c3c265b1bb7 (diff) |
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 <INSERT COUNTING NUMBER HERE> years since the previous time. First commits are from January 2017, and the code originates a month earlier in KAaRMNoD!
Diffstat (limited to 'ui/primitives')
-rw-r--r-- | ui/primitives/DisplayElement.js | 305 | ||||
-rw-r--r-- | ui/primitives/Element.js | 80 | ||||
-rw-r--r-- | ui/primitives/FocusElement.js | 45 | ||||
-rw-r--r-- | ui/primitives/Root.js | 284 | ||||
-rw-r--r-- | ui/primitives/index.js | 11 |
5 files changed, 725 insertions, 0 deletions
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' |