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/primitives/Root.js | 284 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 284 insertions(+) create mode 100644 ui/primitives/Root.js (limited to 'ui/primitives/Root.js') 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) } +} -- cgit 1.3.0-6-gf8a5