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! --- README.md | 1 + examples/basic-app.js | 10 +- examples/command-line-interface.js | 25 + examples/interfacer-command-line.js | 24 - examples/interfacer-telnet.js | 56 --- examples/label.js | 5 +- examples/list-scroll-form.js | 16 +- examples/telnet-interface.js | 59 +++ index.js | 43 +- package-lock.json | 46 +- package.json | 15 +- 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 ----- util/CommandLineInterfacer.js | 91 ---- util/Flushable.js | 126 ------ util/TelnetInterfacer.js | 138 ------ util/ansi.js | 778 ++++++++++++++++---------------- util/count.js | 2 +- util/exception.js | 2 +- util/index.js | 11 + util/interfaces/CommandLineInterface.js | 92 ++++ util/interfaces/Flushable.js | 126 ++++++ util/interfaces/TelnetInterface.js | 139 ++++++ util/interfaces/index.js | 4 + util/smoothen.js | 2 +- util/telchars.js | 2 +- util/tui-app.js | 19 +- util/unichars.js | 2 +- util/waitForData.js | 2 +- util/wrap.js | 2 +- 71 files changed, 3145 insertions(+), 3066 deletions(-) create mode 100644 examples/command-line-interface.js delete mode 100644 examples/interfacer-command-line.js delete mode 100644 examples/interfacer-telnet.js create mode 100644 examples/telnet-interface.js 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 delete mode 100644 util/CommandLineInterfacer.js delete mode 100644 util/Flushable.js delete mode 100644 util/TelnetInterfacer.js create mode 100644 util/index.js create mode 100644 util/interfaces/CommandLineInterface.js create mode 100644 util/interfaces/Flushable.js create mode 100644 util/interfaces/TelnetInterface.js create mode 100644 util/interfaces/index.js diff --git a/README.md b/README.md index 4a7da5c..4952a8a 100644 --- a/README.md +++ b/README.md @@ -8,5 +8,6 @@ My examples for you are the dumb pieces of code I've written with this: * [Knights & a Relatively Minimal Number of Dragons](https://github.com/towerofnix/KAaRMNoD/) * [DQ9 mapper](https://github.com/towerofnix/dq9-mapper) +* [mtui](https://nebula.ed1.club/git/mtui/) There are also some very, *very* minimalistic examples in [the examples folder](https://github.com/towerofnix/ui-lib/tree/master/examples). diff --git a/examples/basic-app.js b/examples/basic-app.js index bf8aa41..c4027d1 100644 --- a/examples/basic-app.js +++ b/examples/basic-app.js @@ -6,14 +6,14 @@ // - Sending a quit-app request via Control-C // // This script cannot actually be used on its own; see the examples on -// interfacers (interfacer-command-line.js and inerfacer-telnet.js) for a +// interfaces (command-line-interface.js and telnet-interface.js) for a // working demo. -const Pane = require('../ui/Pane') -const FocusElement = require('../ui/form/FocusElement') -const TextInput = require('../ui/form/TextInput') +import {TextInput} from 'tui-lib/ui/controls' +import {Pane} from 'tui-lib/ui/presentation' +import {FocusElement} from 'tui-lib/ui/primitives' -module.exports = class AppElement extends FocusElement { +export default class AppElement extends FocusElement { constructor() { super() diff --git a/examples/command-line-interface.js b/examples/command-line-interface.js new file mode 100644 index 0000000..ba1d936 --- /dev/null +++ b/examples/command-line-interface.js @@ -0,0 +1,25 @@ +import {Root} from 'tui-lib/ui/primitives' +import {CommandLineInterface} from 'tui-lib/util/interfaces' + +import AppElement from './basic-app.js' + +const clInterface = new CommandLineInterface() + +clInterface.getScreenSize().then(size => { + const root = new Root(clInterface) + root.w = size.width + root.h = size.height + + const appElement = new AppElement() + root.addChild(appElement) + root.select(appElement) + + appElement.on('quitRequested', () => { + process.exit(0) + }) + + setInterval(() => root.render(), 100) +}).catch(error => { + console.error(error) + process.exit(1) +}) diff --git a/examples/interfacer-command-line.js b/examples/interfacer-command-line.js deleted file mode 100644 index 1da6adf..0000000 --- a/examples/interfacer-command-line.js +++ /dev/null @@ -1,24 +0,0 @@ -const Root = require('../ui/Root') -const CommandLineInterfacer = require('../util/CommandLineInterfacer') -const AppElement = require('./basic-app') - -const interfacer = new CommandLineInterfacer() - -interfacer.getScreenSize().then(size => { - const root = new Root(interfacer) - root.w = size.width - root.h = size.height - - const appElement = new AppElement() - root.addChild(appElement) - root.select(appElement) - - appElement.on('quitRequested', () => { - process.exit(0) - }) - - setInterval(() => root.render(), 100) -}).catch(error => { - console.error(error) - process.exit(1) -}) diff --git a/examples/interfacer-telnet.js b/examples/interfacer-telnet.js deleted file mode 100644 index d7aad43..0000000 --- a/examples/interfacer-telnet.js +++ /dev/null @@ -1,56 +0,0 @@ -// Telnet demo: -// - Basic telnet socket handling using the TelnetInterfacer -// - Handling client's screen size -// - Handling socket being closed by client -// - Handling cleanly closing the socket by hand - -const net = require('net') -const Root = require('../ui/Root') -const TelnetInterfacer = require('../TelnetInterfacer') -const AppElement = require('./basic-app') - -const server = new net.Server(socket => { - const interfacer = new TelnetInterfacer(socket) - - interfacer.getScreenSize().then(size => { - const root = new Root(interfacer) - root.w = size.width - root.h = size.height - - interfacer.on('resize', newSize => { - root.w = newSize.width - root.h = newSize.height - root.fixAllLayout() - }) - - const appElement = new AppElement() - root.addChild(appElement) - root.select(appElement) - - let closed = false - - appElement.on('quitRequested', () => { - if (!closed) { - interfacer.cleanTelnetOptions() - socket.write('Goodbye!\n') - socket.end() - clearInterval(interval) - closed = true - } - }) - - socket.on('close', () => { - if (!closed) { - clearInterval(interval) - closed = true - } - }) - - const interval = setInterval(() => root.render(), 100) - }).catch(error => { - console.error(error) - process.exit(1) - }) -}) - -server.listen(8008) diff --git a/examples/label.js b/examples/label.js index b8992d2..f9599c6 100644 --- a/examples/label.js +++ b/examples/label.js @@ -1,7 +1,8 @@ // An example of basic label usage. -const ansi = require('../util/ansi') -const Label = require('../ui/Label') +import {Label} from 'tui-lib/ui/presentation' + +import * as ansi from 'tui-lib/util/ansi' const label1 = new Label('Hello, world!') const label2 = new Label('I love labels.') diff --git a/examples/list-scroll-form.js b/examples/list-scroll-form.js index c015ddb..fc319a6 100644 --- a/examples/list-scroll-form.js +++ b/examples/list-scroll-form.js @@ -1,13 +1,13 @@ -const ansi = require('../util/ansi') -const Root = require('../ui/Root') -const CommandLineInterfacer = require('../util/CommandLineInterfacer') -const ListScrollForm = require('../ui/form/ListScrollForm') -const Button = require('../ui/form/Button') +import {Root} from 'tui-lib/ui/primitives' +import {Button, ListScrollForm} from 'tui-lib/ui/controls' -const interfacer = new CommandLineInterfacer() +import {CommandLineInterface} from 'tui-lib/util/interfaces' +import * as ansi from 'tui-lib/util/ansi' -interfacer.getScreenSize().then(size => { - const root = new Root(interfacer) +const clInterface = new CommandLineInterface() + +clInterface.getScreenSize().then(size => { + const root = new Root(clInterface) root.w = size.width root.h = size.height diff --git a/examples/telnet-interface.js b/examples/telnet-interface.js new file mode 100644 index 0000000..319786f --- /dev/null +++ b/examples/telnet-interface.js @@ -0,0 +1,59 @@ +// Telnet demo: +// - Basic telnet socket handling using the TelnetInterface +// - Handling client's screen size +// - Handling socket being closed by client +// - Handling cleanly closing the socket by hand + +import net from 'node:net' + +import {Root} from 'tui-lib/ui/primitives' + +import {TelnetInterface} from 'tui-lib/util/interfaces' + +import AppElement from './basic-app.js' + +const server = new net.Server(socket => { + const telnetInterface = new TelnetInterface(socket) + + telnetInterface.getScreenSize().then(size => { + const root = new Root(telnetInterface) + root.w = size.width + root.h = size.height + + telnetInterface.on('resize', newSize => { + root.w = newSize.width + root.h = newSize.height + root.fixAllLayout() + }) + + const appElement = new AppElement() + root.addChild(appElement) + root.select(appElement) + + let closed = false + + appElement.on('quitRequested', () => { + if (!closed) { + telnetInterface.cleanTelnetOptions() + socket.write('Goodbye!\n') + socket.end() + clearInterval(interval) + closed = true + } + }) + + socket.on('close', () => { + if (!closed) { + clearInterval(interval) + closed = true + } + }) + + const interval = setInterval(() => root.render(), 100) + }).catch(error => { + console.error(error) + process.exit(1) + }) +}) + +server.listen(8008) diff --git a/index.js b/index.js index de4c680..fc54bfd 100644 --- a/index.js +++ b/index.js @@ -1,36 +1,7 @@ -module.exports = { - ui: { - Dialog: require('./ui/Dialog'), - DisplayElement: require('./ui/DisplayElement'), - HorizontalBox: require('./ui/HorizontalBox'), - Label: require('./ui/Label'), - Pane: require('./ui/Pane'), - Root: require('./ui/Root'), - Sprite: require('./ui/Sprite'), - WrapLabel: require('./ui/WrapLabel'), - form: { - Button: require('./ui/form/Button'), - CancelDialog: require('./ui/form/CancelDialog'), - ConfirmDialog: require('./ui/form/ConfirmDialog'), - FocusBox: require('./ui/form/FocusBox'), - FocusElement: require('./ui/form/FocusElement'), - Form: require('./ui/form/Form'), - ListScrollForm: require('./ui/form/ListScrollForm'), - TextInput: require('./ui/form/TextInput') - } - }, - util: { - tuiApp: require('./util/tui-app'), - ansi: require('./util/ansi'), - CommandLineInterfacer: require('./util/CommandLineInterfacer'), - count: require('./util/count'), - exception: require('./util/exception'), - Flushable: require('./util/Flushable'), - smoothen: require('./util/smoothen'), - telchars: require('./util/telchars'), - TelnetInterfacer: require('./util/TelnetInterfacer'), - unichars: require('./util/unichars'), - waitForData: require('./util/waitForData'), - wrap: require('./util/wrap') - } -} +export * as ui from './ui/index.js' +export * as util from './util/index.js' + +import * as ui from './ui/index.js' +import * as util from './util/index.js' + +export default {ui, util} diff --git a/package-lock.json b/package-lock.json index 33f839f..4c1db18 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,34 +1,58 @@ { "name": "tui-lib", - "version": "0.0.3", - "lockfileVersion": 1, + "version": "0.0.4", + "lockfileVersion": 3, "requires": true, - "dependencies": { - "clone": { + "packages": { + "": { + "name": "tui-lib", + "version": "0.0.4", + "license": "GPL-3.0", + "dependencies": { + "natural-orderby": "^3.0.2", + "wcwidth": "^1.0.1", + "word-wrap": "^1.2.3" + } + }, + "node_modules/clone": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/clone/-/clone-1.0.4.tgz", - "integrity": "sha1-2jCcwmPfFZlMaIypAheco8fNfH4=" + "integrity": "sha1-2jCcwmPfFZlMaIypAheco8fNfH4=", + "engines": { + "node": ">=0.8" + } }, - "defaults": { + "node_modules/defaults": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/defaults/-/defaults-1.0.3.tgz", "integrity": "sha1-xlYFHpgX2f8I7YgUd/P+QBnz730=", - "requires": { + "dependencies": { "clone": "^1.0.2" } }, - "wcwidth": { + "node_modules/natural-orderby": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/natural-orderby/-/natural-orderby-3.0.2.tgz", + "integrity": "sha512-x7ZdOwBxZCEm9MM7+eQCjkrNLrW3rkBKNHVr78zbtqnMGVNlnDi6C/eUEYgxHNrcbu0ymvjzcwIL/6H1iHri9g==", + "engines": { + "node": ">=18" + } + }, + "node_modules/wcwidth": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/wcwidth/-/wcwidth-1.0.1.tgz", "integrity": "sha1-8LDc+RW8X/FSivrbLA4XtTLaL+g=", - "requires": { + "dependencies": { "defaults": "^1.0.3" } }, - "word-wrap": { + "node_modules/word-wrap": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.3.tgz", - "integrity": "sha512-Hz/mrNwitNRh/HUAtM/VT/5VH+ygD6DV7mYKZAtHOrbs8U7lvPS6xf7EJKMF0uW1KJCl0H701g3ZGus+muE5vQ==" + "integrity": "sha512-Hz/mrNwitNRh/HUAtM/VT/5VH+ygD6DV7mYKZAtHOrbs8U7lvPS6xf7EJKMF0uW1KJCl0H701g3ZGus+muE5vQ==", + "engines": { + "node": ">=0.10.0" + } } } } diff --git a/package.json b/package.json index 1842a11..f73bc27 100644 --- a/package.json +++ b/package.json @@ -2,12 +2,25 @@ "name": "tui-lib", "version": "0.0.4", "description": "terminal ui library", - "main": "index.js", + "type": "module", "repository": "https://notabug.org/towerofnix/tui-lib.git", "author": "Florrie ", "license": "GPL-3.0", "dependencies": { + "natural-orderby": "^3.0.2", "wcwidth": "^1.0.1", "word-wrap": "^1.2.3" + }, + "exports": { + ".": "./index.js", + "./ui": "./ui/index.js", + "./ui/controls": "./ui/controls/index.js", + "./ui/dialogs": "./ui/dialogs/index.js", + "./ui/presentation": "./ui/presentation/index.js", + "./ui/primitives": "./ui/primitives/index.js", + "./util/ansi": "./util/ansi.js", + "./util/interfaces": "./util/interfaces/index.js", + "./util/telchars": "./util/telchars.js", + "./util/unichars": "./util/unichars.js" } } 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 - }) - } -} - diff --git a/util/CommandLineInterfacer.js b/util/CommandLineInterfacer.js deleted file mode 100644 index d2007fb..0000000 --- a/util/CommandLineInterfacer.js +++ /dev/null @@ -1,91 +0,0 @@ -const EventEmitter = require('events') -const waitForData = require('./waitForData') -const ansi = require('./ansi') - -module.exports = class CommandLineInterfacer extends EventEmitter { - constructor(inStream = process.stdin, outStream = process.stdout, proc = process) { - super() - - this.inStream = inStream - this.outStream = outStream - this.process = proc - - inStream.on('data', buffer => { - this.emit('inputData', buffer) - }) - - inStream.setRawMode(true) - - proc.on('SIGWINCH', async buffer => { - this.emit('resize', await this.getScreenSize()) - }) - } - - async getScreenSize() { - const waitUntil = cond => waitForData(this.inStream, cond) - - // Get old cursor position.. - this.outStream.write(ansi.requestCursorPosition()) - const { options: oldCoords } = this.parseANSICommand( - await waitUntil(buf => ansi.isANSICommand(buf, 82)) - ) - - // Move far to the bottom right of the screen, then get cursor position.. - // (We could use moveCursor here, but the 0-index offset isn't really - // relevant.) - this.outStream.write(ansi.moveCursorRaw(9999, 9999)) - this.outStream.write(ansi.requestCursorPosition()) - const { options: sizeCoords } = this.parseANSICommand( - await waitUntil(buf => ansi.isANSICommand(buf, 82)) - ) - - // Restore to old cursor position.. (Using moveCursorRaw is actaully - // necessary here, since we'll be passing the coordinates returned from - // another ANSI command.) - this.outStream.write(ansi.moveCursorRaw(oldCoords[0], oldCoords[1])) - - // And return dimensions. - const [ sizeLine, sizeCol ] = sizeCoords - return { - lines: sizeLine, cols: sizeCol, - width: sizeCol, height: sizeLine - } - } - - parseANSICommand(buffer) { - // Typically ANSI commands are written ESC[1;2;3;4C - // ..where ESC is the ANSI escape code, equal to hexadecimal 1B and - // decimal 33 - // ..where [ and ; are the literal strings "[" and ";" - // ..where 1, 2, 3, and 4 are decimal integer arguments written in ASCII - // that may last more than one byte (e.g. "15") - // ..where C is some number representing the code of the command - - if (buffer[0] !== 0x1b || buffer[1] !== 0x5b) { - throw new Error('Not an ANSI command') - } - - const options = [] - let curOption = '' - let commandCode = null - for (const val of buffer.slice(2)) { - if (48 <= val && val <= 57) { // 0124356789 - curOption = curOption.concat(val - 48) - } else { - options.push(parseInt(curOption)) - curOption = '' - - if (val !== 59) { // ; - commandCode = val - break - } - } - } - - return {code: commandCode, options: options} - } - - write(data) { - this.outStream.write(data) - } -} diff --git a/util/Flushable.js b/util/Flushable.js deleted file mode 100644 index 058d186..0000000 --- a/util/Flushable.js +++ /dev/null @@ -1,126 +0,0 @@ -const ansi = require('./ansi') -const unic = require('./unichars') - -module.exports = class Flushable { - // A writable that can be used to collect chunks of data before writing - // them. - - constructor(writable, shouldCompress = false) { - this.target = writable - - // Use the magical ANSI self-made compression method that probably - // doesn't *quite* work but should drastically decrease write size? - this.shouldCompress = shouldCompress - - // Whether or not to show compression statistics (original written size - // and ANSI-interpreted compressed size) in the output of flush. - this.shouldShowCompressionStatistics = false - - // Use resizeScreen if you plan on using the ANSI compressor! - this.screenLines = 24 - this.screenCols = 80 - this.lastFrame = undefined - - this.ended = false - this.paused = false - this.requestedFlush = false - - this.chunks = [] - } - - resizeScreen({lines, cols}) { - this.screenLines = lines - this.screenCols = cols - this.clearLastFrame() - } - - clearLastFrame() { - this.lastFrame = undefined - } - - write(what) { - this.chunks.push(what) - } - - flush() { - // If we're paused, we don't want to write, but we will keep a note that a - // flush was requested for when we unpause. - if (this.paused) { - this.requestedFlush = true - return - } - - // Don't write if we've ended. - if (this.ended) { - return - } - - // End if the target is destroyed. - // Yes, this relies on the target having a destroyed property - // Don't worry, it'll still work if there is no destroyed property though - // (I think) - if (this.target.destroyed) { - this.end() - return - } - - let toWrite = this.chunks.join('') - - if (this.shouldCompress) { - toWrite = this.compress(toWrite) - } - - try { - this.target.write(toWrite) - } catch(err) { - console.error('Flushable write error (ending):', err.message) - this.end() - } - - this.chunks = [] - } - - pause() { - this.paused = true - } - - resume() { - this.paused = false - - if (this.requestedFlush) { - this.flush() - } - } - - end() { - this.ended = true - } - - compress(toWrite) { - // TODO: customize screen size - const output = ansi.interpret( - toWrite, this.screenLines, this.screenCols, this.lastFrame - ) - - let { screen } = output - - this.lastFrame = output - - if (this.shouldShowCompressionStatistics) { - let msg = this.lastInterpretMessage - if (screen.length > 0 || !this.lastInterpretMessage) { - const pcSaved = Math.round(1000 - (1000 / toWrite.length * screen.length)) / 10 - const kbSaved = Math.round((toWrite.length - screen.length) / 100) / 10 - msg = this.lastInterpretMessage = ( - '(ANSI-interpret: ' + - `${toWrite.length} -> ${screen.length} ${pcSaved}% / ${kbSaved} KB saved)` - ) - } - screen += '\x1b[H\x1b[0m' - screen += msg + unic.BOX_H_DOUBLE.repeat(this.screenCols - msg.length) - this.lastFrame.oldLastChar.attributes = [] - } - - return screen - } -} diff --git a/util/TelnetInterfacer.js b/util/TelnetInterfacer.js deleted file mode 100644 index dc71157..0000000 --- a/util/TelnetInterfacer.js +++ /dev/null @@ -1,138 +0,0 @@ -const ansi = require('./ansi') -const waitForData = require('./waitForData') -const EventEmitter = require('events') - -module.exports = class TelnetInterfacer extends EventEmitter { - constructor(socket) { - super() - - this.socket = socket - - socket.on('data', buffer => { - if (buffer[0] === 255) { - this.handleTelnetData(buffer) - } else { - this.emit('inputData', buffer) - } - }) - - this.initTelnetOptions() - } - - initTelnetOptions() { - // Initializes various socket options, using telnet magic. - - // Disables linemode. - this.socket.write(Buffer.from([ - 255, 253, 34, // IAC DO LINEMODE - 255, 250, 34, 1, 0, 255, 240, // IAC SB LINEMODE MODE 0 IAC SE - 255, 251, 1 // IAC WILL ECHO - ])) - - // Will SGA. Helps with putty apparently. - this.socket.write(Buffer.from([ - 255, 251, 3 // IAC WILL SGA - ])) - - this.socket.write(ansi.hideCursor()) - } - - cleanTelnetOptions() { - // Resets the telnet options and magic set in initTelnetOptions. - - this.socket.write(ansi.resetAttributes()) - this.socket.write(ansi.showCursor()) - } - - async getScreenSize() { - this.socket.write(Buffer.from([255, 253, 31])) // IAC DO NAWS - - let didWillNAWS = false - let didSBNAWS = false - let sb - - inputLoop: while (true) { - const data = await waitForData(this.socket) - - for (const command of this.parseTelnetCommands(data)) { - // WILL NAWS - if (command[1] === 251 && command[2] === 31) { - didWillNAWS = true - continue - } - - // SB NAWS - if (didWillNAWS && command[1] === 250 && command[2] === 31) { - didSBNAWS = true - sb = command.slice(3) - continue - } - - // SE - if (didSBNAWS && command[1] === 240) { // SE - break inputLoop - } - } - } - - return this.parseSBNAWS(sb) - } - - parseTelnetCommands(buffer) { - if (buffer[0] === 255) { - // Telnet IAC (Is A Command) - ignore - - // Split the data into multiple IAC commands if more than one IAC was - // sent. - const values = Array.from(buffer.values()) - const commands = [] - const curCmd = [255] - for (const value of values) { - if (value === 255) { // IAC - commands.push(Array.from(curCmd)) - curCmd.splice(1, curCmd.length) - continue - } - curCmd.push(value) - } - commands.push(curCmd) - - return commands - } else { - return [] - } - } - - write(data) { - this.socket.write(data) - } - - handleTelnetData(buffer) { - let didSBNAWS = false - let sbNAWS - - for (let command of this.parseTelnetCommands(buffer)) { - // SB NAWS - if (command[1] === 250 && command[2] === 31) { - didSBNAWS = true - sbNAWS = command.slice(3) - continue - } - - // SE - if (didSBNAWS && command[1] === 240) { // SE - didSBNAWS = false - this.emit('screenSizeUpdated', this.parseSBNAWS(sbNAWS)) - this.emit('resize', this.parseSBNAWS(sbNAWS)) - continue - } - } - } - - parseSBNAWS(sb) { - const cols = (sb[0] << 8) + sb[1] - const lines = (sb[2] << 8) + sb[3] - - return { cols, lines, width: cols, height: lines } - } -} diff --git a/util/ansi.js b/util/ansi.js index ac511ed..2ae5166 100644 --- a/util/ansi.js +++ b/util/ansi.js @@ -1,498 +1,496 @@ -const wcwidth = require('wcwidth') +import wcwidth from 'wcwidth' -const ESC = '\x1b' +function isDigit(char) { + return '0123456789'.indexOf(char) >= 0 +} -const isDigit = char => '0123456789'.indexOf(char) >= 0 +export const ESC = '\x1b' + +// Attributes +export const A_RESET = 0 +export const A_BRIGHT = 1 +export const A_DIM = 2 +export const A_INVERT = 7 + +// Colors +export const C_BLACK = 30 +export const C_RED = 31 +export const C_GREEN = 32 +export const C_YELLOW = 33 +export const C_BLUE = 34 +export const C_MAGENTA = 35 +export const C_CYAN = 36 +export const C_WHITE = 37 +export const C_RESET = 39 + +export function clearScreen() { + // Clears the screen, removing any characters displayed, and resets the + // cursor position. + + return `${ESC}[2J` +} -const ansi = { - ESC, +export function moveCursorRaw(line, col) { + // Moves the cursor to the given line and column on the screen. + // Returns the pure ANSI code, with no modification to line or col. - // Attributes - A_RESET: 0, - A_BRIGHT: 1, - A_DIM: 2, - A_INVERT: 7, - C_BLACK: 30, - C_RED: 31, - C_GREEN: 32, - C_YELLOW: 33, - C_BLUE: 34, - C_MAGENTA: 35, - C_CYAN: 36, - C_WHITE: 37, - C_RESET: 39, + return `${ESC}[${line};${col}H` +} - clearScreen() { - // Clears the screen, removing any characters displayed, and resets the - // cursor position. +export function moveCursor(line, col) { + // Moves the cursor to the given line and column on the screen. + // Note that since in JavaScript indexes start at 0, but in ANSI codes + // the top left of the screen is (1, 1), this function adjusts the + // arguments to act as if the top left of the screen is (0, 0). - return `${ESC}[2J` - }, + return `${ESC}[${line + 1};${col + 1}H` +} - moveCursorRaw(line, col) { - // Moves the cursor to the given line and column on the screen. - // Returns the pure ANSI code, with no modification to line or col. +export function cleanCursor() { + // A combination of codes that generally cleans up the cursor. - return `${ESC}[${line};${col}H` - }, + return resetAttributes() + + stopTrackingMouse() + + showCursor() +} - moveCursor(line, col) { - // Moves the cursor to the given line and column on the screen. - // Note that since in JavaScript indexes start at 0, but in ANSI codes - // the top left of the screen is (1, 1), this function adjusts the - // arguments to act as if the top left of the screen is (0, 0). +export function hideCursor() { + // Makes the cursor invisible. - return `${ESC}[${line + 1};${col + 1}H` - }, + return `${ESC}[?25l` +} - cleanCursor() { - // A combination of codes that generally cleans up the cursor. +export function showCursor() { + // Makes the cursor visible. - return ansi.resetAttributes() + - ansi.stopTrackingMouse() + - ansi.showCursor() - }, + return `${ESC}[?25h` +} - hideCursor() { - // Makes the cursor invisible. +export function resetAttributes() { + // Resets all attributes, including text decorations, foreground and + // background color. - return `${ESC}[?25l` - }, + return `${ESC}[0m` +} - showCursor() { - // Makes the cursor visible. +export function setAttributes(attrs) { + // Set some raw attributes. See the attributes section of the ansi.js + // source code for attributes that can be used with this; A_RESET resets + // all attributes. - return `${ESC}[?25h` - }, + return `${ESC}[${attrs.join(';')}m` +} - resetAttributes() { - // Resets all attributes, including text decorations, foreground and - // background color. +export function setForeground(color) { + // Sets the foreground color to print text with. See C_(COLOR) for colors + // that can be used with this; C_RESET resets the foreground. + // + // If null or undefined is passed, this function will return a blank + // string (no ANSI escape codes). - return `${ESC}[0m` - }, + if (typeof color === 'undefined' || color === null) { + return '' + } - setAttributes(attrs) { - // Set some raw attributes. See the attributes section of the ansi.js - // source code for attributes that can be used with this; A_RESET resets - // all attributes. + return setAttributes([color]) +} - return `${ESC}[${attrs.join(';')}m` - }, +export function setBackground(color) { + // Sets the background color to print text with. Accepts the same arguments + // as setForeground (C_(COLOR), C_RESET, etc). + // + // Note that attributes such as A_BRIGHT and A_DIM apply apply to only the + // foreground, not the background. To set a bright or dim background, you + // can set the appropriate color as the foreground and then invert. - setForeground(color) { - // Sets the foreground color to print text with. See C_(COLOR) for colors - // that can be used with this; C_RESET resets the foreground. - // - // If null or undefined is passed, this function will return a blank - // string (no ANSI escape codes). + if (typeof color === 'undefined' || color === null) { + return '' + } - if (typeof color === 'undefined' || color === null) { - return '' - } + return setAttributes([color + 10]) +} - return ansi.setAttributes([color]) - }, +export function invert() { + // Inverts the foreground and background colors. - setBackground(color) { - // Sets the background color to print text with. Accepts the same arguments - // as setForeground (C_(COLOR), C_RESET, etc). - // - // Note that attributes such as A_BRIGHT and A_DIM apply apply to only the - // foreground, not the background. To set a bright or dim background, you - // can set the appropriate color as the foreground and then invert. + return `${ESC}[7m` +} - if (typeof color === 'undefined' || color === null) { - return '' - } +export function invertOff() { + // Un-inverts the foreground and backgrund colors. - return ansi.setAttributes([color + 10]) - }, + return `${ESC}[27m` +} - invert() { - // Inverts the foreground and background colors. +export function startTrackingMouse() { + return `${ESC}[?1002h` +} - return `${ESC}[7m` - }, +export function stopTrackingMouse() { + return `${ESC}[?1002l` +} - invertOff() { - // Un-inverts the foreground and backgrund colors. +export function requestCursorPosition() { + // Requests the position of the cursor. + // Expect a stdin-result '\ESC[l;cR', where l is the line number (1-based), + // c is the column number (also 1-based), and R is the literal character + // 'R' (decimal code 82). - return `${ESC}[27m` - }, + return `${ESC}[6n` +} - startTrackingMouse() { - return `${ESC}[?1002h` - }, +export function enableAlternateScreen() { + // Enables alternate screen: + // "Xterm maintains two screen buffers. The normal screen buffer allows + // you to scroll back to view saved lines of output up to the maximum set + // by the saveLines resource. The alternate screen buffer is exactly as + // large as the display, contains no additional saved lines." - stopTrackingMouse() { - return `${ESC}[?1002l` - }, + return `${ESC}[?1049h` +} - requestCursorPosition() { - // Requests the position of the cursor. - // Expect a stdin-result '\ESC[l;cR', where l is the line number (1-based), - // c is the column number (also 1-based), and R is the literal character - // 'R' (decimal code 82). +export function disableAlternateScreen() { + return `${ESC}[?1049l` +} - return `${ESC}[6n` - }, +export function measureColumns(text) { + // Returns the number of columns the given text takes. - enableAlternateScreen() { - // Enables alternate screen: - // "Xterm maintains two screen buffers. The normal screen buffer allows - // you to scroll back to view saved lines of output up to the maximum set - // by the saveLines resource. The alternate screen buffer is exactly as - // large as the display, contains no additional saved lines." + return wcwidth(text) +} - return `${ESC}[?1049h` - }, +export function trimToColumns(text, cols) { + // Trims off the end of the passed text so that its width doesn't exceed + // the size passed in columns. - disableAlternateScreen() { - return `${ESC}[?1049l` - }, + let out = '' + for (const char of text) { + if (measureColumns(out + char) <= cols) { + out += char + } else { + break + } + } + return out +} + +export function isANSICommand(buffer, code = null) { + return ( + buffer[0] === 0x1b && buffer[1] === 0x5b && + (code ? buffer[buffer.length - 1] === code : true) + ) +} - measureColumns(text) { - // Returns the number of columns the given text takes. +export function interpret(text, scrRows, scrCols, { + oldChars = null, oldLastChar = null, + oldScrRows = null, oldScrCols = null, + oldCursorRow = 1, oldCursorCol = 1, oldShowCursor = true +} = {}) { + // Interprets the given ansi code, more or less. - return wcwidth(text) - }, + const blank = { + attributes: [], + char: ' ' + } - trimToColumns(text, cols) { - // Trims off the end of the passed text so that its width doesn't exceed - // the size passed in columns. + const chars = new Array(scrRows * scrCols).fill(blank) - let out = '' - for (const char of text) { - if (ansi.measureColumns(out + char) <= cols) { - out += char - } else { - break + if (oldChars) { + for (let row = 0; row < scrRows && row < oldScrRows; row++) { + for (let col = 0; col < scrCols && col < oldScrCols; col++) { + chars[row * scrCols + col] = oldChars[row * oldScrCols + col] } } - return out - }, - - isANSICommand(buffer, code = null) { - return ( - buffer[0] === 0x1b && buffer[1] === 0x5b && - (code ? buffer[buffer.length - 1] === code : true) - ) - }, - - interpret(text, scrRows, scrCols, { - oldChars = null, oldLastChar = null, - oldScrRows = null, oldScrCols = null, - oldCursorRow = 1, oldCursorCol = 1, oldShowCursor = true - } = {}) { - // Interprets the given ansi code, more or less. - - const blank = { - attributes: [], - char: ' ' - } + } - const chars = new Array(scrRows * scrCols).fill(blank) + let showCursor = oldShowCursor + let cursorRow = oldCursorRow + let cursorCol = oldCursorCol + let attributes = [] - if (oldChars) { - for (let row = 0; row < scrRows && row < oldScrRows; row++) { - for (let col = 0; col < scrCols && col < oldScrCols; col++) { - chars[row * scrCols + col] = oldChars[row * oldScrCols + col] - } - } - } + for (let charI = 0; charI < text.length; charI++) { + const cursorIndex = (cursorRow - 1) * scrCols + (cursorCol - 1) + + if (text[charI] === ESC) { + charI++ - let showCursor = oldShowCursor - let cursorRow = oldCursorRow - let cursorCol = oldCursorCol - let attributes = [] + if (text[charI] !== '[') { + throw new Error('ESC not followed by [') + } - for (let charI = 0; charI < text.length; charI++) { - const cursorIndex = (cursorRow - 1) * scrCols + (cursorCol - 1) + charI++ - if (text[charI] === ESC) { + // Selective control sequences (look them up) - we can just skip the + // question mark. + if (text[charI] === '?') { charI++ + } - if (text[charI] !== '[') { - throw new Error('ESC not followed by [') - } - + const args = [] + let val = '' + while (isDigit(text[charI])) { + val += text[charI] charI++ - // Selective control sequences (look them up) - we can just skip the - // question mark. - if (text[charI] === '?') { + if (text[charI] === ';') { charI++ + args.push(val) + val = '' + continue } + } + args.push(val) - const args = [] - let val = '' - while (isDigit(text[charI])) { - val += text[charI] - charI++ + // CUP - Cursor Position (moveCursor) + if (text[charI] === 'H') { + cursorRow = parseInt(args[0]) + cursorCol = parseInt(args[1]) + } - if (text[charI] === ';') { - charI++ - args.push(val) - val = '' - continue - } + // SM - Set Mode + if (text[charI] === 'h') { + if (args[0] === '25') { + showCursor = true } - args.push(val) + } - // CUP - Cursor Position (moveCursor) - if (text[charI] === 'H') { - cursorRow = parseInt(args[0]) - cursorCol = parseInt(args[1]) + // ED - Erase Display (clearScreen) + if (text[charI] === 'J') { + // ESC[2J - erase whole display + if (args[0] === '2') { + chars.fill(blank) + charI += 3 + cursorCol = 1 + cursorRow = 1 } - // SM - Set Mode - if (text[charI] === 'h') { - if (args[0] === '25') { - showCursor = true + // ESC[1J - erase to beginning + else if (args[0] === '1') { + for (let i = 0; i < cursorIndex; i++) { + chars[i * 2] = ' ' + chars[i * 2 + 1] = [] } } - // ED - Erase Display (clearScreen) - if (text[charI] === 'J') { - // ESC[2J - erase whole display - if (args[0] === '2') { - chars.fill(blank) - charI += 3 - cursorCol = 1 - cursorRow = 1 - } - - // ESC[1J - erase to beginning - else if (args[0] === '1') { - for (let i = 0; i < cursorIndex; i++) { - chars[i * 2] = ' ' - chars[i * 2 + 1] = [] - } + // ESC[0J - erase to end + else if (args.length === 0 || args[0] === '0') { + for (let i = cursorIndex; i < chars.length; i++) { + chars[i * 2] = ' ' + chars[i * 2 + 1] = [] } + } + } - // ESC[0J - erase to end - else if (args.length === 0 || args[0] === '0') { - for (let i = cursorIndex; i < chars.length; i++) { - chars[i * 2] = ' ' - chars[i * 2 + 1] = [] - } - } + // RM - Reset Mode + if (text[charI] === 'l') { + if (args[0] === '25') { + showCursor = false } + } - // RM - Reset Mode - if (text[charI] === 'l') { - if (args[0] === '25') { - showCursor = false + // SGR - Select Graphic Rendition + if (text[charI] === 'm') { + const removeAttribute = attr => { + if (attributes.includes(attr)) { + attributes = attributes.slice() + attributes.splice(attributes.indexOf(attr), 1) } } - // SGR - Select Graphic Rendition - if (text[charI] === 'm') { - const removeAttribute = attr => { - if (attributes.includes(attr)) { - attributes = attributes.slice() - attributes.splice(attributes.indexOf(attr), 1) + for (const arg of args) { + if (arg === '0') { + attributes = [] + } else if (arg === '22') { // Neither bold nor faint + removeAttribute('1') + removeAttribute('2') + } else if (arg === '23') { // Neither italic nor Fraktur + removeAttribute('3') + removeAttribute('20') + } else if (arg === '24') { // Not underlined + removeAttribute('4') + } else if (arg === '25') { // Blink off + removeAttribute('5') + } else if (arg === '27') { // Inverse off + removeAttribute('7') + } else if (arg === '28') { // Conceal off + removeAttribute('8') + } else if (arg === '29') { // Not crossed out + removeAttribute('9') + } else if (arg === '39') { // Default foreground + for (let i = 0; i < 10; i++) { + removeAttribute('3' + i) } - } - - for (const arg of args) { - if (arg === '0') { - attributes = [] - } else if (arg === '22') { // Neither bold nor faint - removeAttribute('1') - removeAttribute('2') - } else if (arg === '23') { // Neither italic nor Fraktur - removeAttribute('3') - removeAttribute('20') - } else if (arg === '24') { // Not underlined - removeAttribute('4') - } else if (arg === '25') { // Blink off - removeAttribute('5') - } else if (arg === '27') { // Inverse off - removeAttribute('7') - } else if (arg === '28') { // Conceal off - removeAttribute('8') - } else if (arg === '29') { // Not crossed out - removeAttribute('9') - } else if (arg === '39') { // Default foreground - for (let i = 0; i < 10; i++) { - removeAttribute('3' + i) - } - } else if (arg === '49') { // Default background - for (let i = 0; i < 10; i++) { - removeAttribute('4' + i) - } - } else { - attributes = attributes.concat([arg]) + } else if (arg === '49') { // Default background + for (let i = 0; i < 10; i++) { + removeAttribute('4' + i) } + } else { + attributes = attributes.concat([arg]) } } - - continue } - chars[cursorIndex] = { - char: text[charI], attributes - } + continue + } - // Some characters take up multiple columns, e.g. Japanese text. Take - // this into consideration when drawing. - const charColumns = wcwidth(text[charI]) - cursorCol += charColumns + chars[cursorIndex] = { + char: text[charI], attributes + } - // If the character takes up 2+ columns, treat columns past the first - // one (where the character is) as empty. (Note this is different from - // "blank", which represents an empty space character ' '.) - for (let i = 1; i < charColumns; i++) { - chars[cursorIndex + i] = {char: '', attributes: []} - } + // Some characters take up multiple columns, e.g. Japanese text. Take + // this into consideration when drawing. + const charColumns = wcwidth(text[charI]) + cursorCol += charColumns - if (cursorCol > scrCols) { - cursorCol = 1 - cursorRow++ - } + // If the character takes up 2+ columns, treat columns past the first + // one (where the character is) as empty. (Note this is different from + // "blank", which represents an empty space character ' '.) + for (let i = 1; i < charColumns; i++) { + chars[cursorIndex + i] = {char: '', attributes: []} } - // SPOooooOOoky diffing! ------------- - // - // - Search for series of differences. This means a collection of characters - // which have different text or attribute properties. - // - // - Figure out how to print these differences. Move the cursor to the beginning - // character's row/column, then print the differences. + if (cursorCol > scrCols) { + cursorCol = 1 + cursorRow++ + } + } - const newChars = chars + // SPOooooOOoky diffing! ------------- + // + // - Search for series of differences. This means a collection of characters + // which have different text or attribute properties. + // + // - Figure out how to print these differences. Move the cursor to the beginning + // character's row/column, then print the differences. + + const newChars = chars + + const differences = [] + + if (oldChars === null) { + differences.push(0) + differences.push(newChars.slice()) + } else { + const charsEqual = (oldChar, newChar) => { + if (oldChar.char !== newChar.char) { + return false + } - const differences = [] + let oldAttrs = oldChar.attributes.slice() + let newAttrs = newChar.attributes.slice() - if (oldChars === null) { - differences.push(0) - differences.push(newChars.slice()) - } else { - const charsEqual = (oldChar, newChar) => { - if (oldChar.char !== newChar.char) { + while (newAttrs.length) { + const attr = newAttrs.shift() + if (oldAttrs.includes(attr)) { + oldAttrs.splice(oldAttrs.indexOf(attr), 1) + } else { return false } + } - let oldAttrs = oldChar.attributes.slice() - let newAttrs = newChar.attributes.slice() - - while (newAttrs.length) { - const attr = newAttrs.shift() - if (oldAttrs.includes(attr)) { - oldAttrs.splice(oldAttrs.indexOf(attr), 1) - } else { - return false - } - } - - oldAttrs = oldChar.attributes.slice() - newAttrs = newChar.attributes.slice() + oldAttrs = oldChar.attributes.slice() + newAttrs = newChar.attributes.slice() - while (oldAttrs.length) { - const attr = oldAttrs.shift() - if (newAttrs.includes(attr)) { - newAttrs.splice(newAttrs.indexOf(attr), 1) - } else { - return false - } + while (oldAttrs.length) { + const attr = oldAttrs.shift() + if (newAttrs.includes(attr)) { + newAttrs.splice(newAttrs.indexOf(attr), 1) + } else { + return false } - - return true } - let curChars = null + return true + } - for (let i = 0; i < chars.length; i++) { - const oldChar = oldChars[i] - const newChar = newChars[i] + let curChars = null - // TODO: Some sort of "distance" before we should clear curDiff? - // It may take *less* characters if this diff and the next are merged - // (entering a single character is smaller than the length of the code - // used to move past that character). Probably not very significant of - // an impact, though. - if (charsEqual(oldChar, newChar)) { - curChars = null - } else { - if (curChars === null) { - curChars = [] - differences.push(i, curChars) - } + for (let i = 0; i < chars.length; i++) { + const oldChar = oldChars[i] + const newChar = newChars[i] - curChars.push(newChar) + // TODO: Some sort of "distance" before we should clear curDiff? + // It may take *less* characters if this diff and the next are merged + // (entering a single character is smaller than the length of the code + // used to move past that character). Probably not very significant of + // an impact, though. + if (charsEqual(oldChar, newChar)) { + curChars = null + } else { + if (curChars === null) { + curChars = [] + differences.push(i, curChars) } + + curChars.push(newChar) } } + } - // Character concatenation ----------- + // Character concatenation ----------- - let lastChar = oldLastChar || { - char: '', - attributes: [] - } + let lastChar = oldLastChar || { + char: '', + attributes: [] + } - const result = [] - - for (let parse = 0; parse < differences.length; parse += 2) { - const i = differences[parse] - const chars = differences[parse + 1] - - const col = i % scrCols - const row = (i - col) / scrCols - result.push(ansi.moveCursor(row, col)) - - for (const char of chars) { - const newAttributes = ( - char.attributes.filter(attr => !(lastChar.attributes.includes(attr))) - ) - - const removedAttributes = ( - lastChar.attributes.filter(attr => !(char.attributes.includes(attr))) - ) - - // The only way to practically remove any character attribute is to - // reset all of its attributes and then re-add its existing attributes. - // If we do that, there's no need to add new attributes. - if (removedAttributes.length) { - result.push(ansi.resetAttributes()) - result.push(`${ESC}[${char.attributes.join(';')}m`) - } else if (newAttributes.length) { - result.push(`${ESC}[${newAttributes.join(';')}m`) - } + const result = [] + + for (let parse = 0; parse < differences.length; parse += 2) { + const i = differences[parse] + const chars = differences[parse + 1] + + const col = i % scrCols + const row = (i - col) / scrCols + result.push(moveCursor(row, col)) + + for (const char of chars) { + const newAttributes = ( + char.attributes.filter(attr => !(lastChar.attributes.includes(attr))) + ) + + const removedAttributes = ( + lastChar.attributes.filter(attr => !(char.attributes.includes(attr))) + ) + + // The only way to practically remove any character attribute is to + // reset all of its attributes and then re-add its existing attributes. + // If we do that, there's no need to add new attributes. + if (removedAttributes.length) { + result.push(resetAttributes()) + result.push(`${ESC}[${char.attributes.join(';')}m`) + } else if (newAttributes.length) { + result.push(`${ESC}[${newAttributes.join(';')}m`) + } - result.push(char.char) + result.push(char.char) - lastChar = char - } + lastChar = char } + } - // If anything changed *or* the cursor moved, we need to put it back where - // it was before: - if (result.length || cursorCol !== oldCursorCol || cursorRow !== oldCursorRow) { - result.push(ansi.moveCursor(cursorRow, cursorCol)) - } + // If anything changed *or* the cursor moved, we need to put it back where + // it was before: + if (result.length || cursorCol !== oldCursorCol || cursorRow !== oldCursorRow) { + result.push(moveCursor(cursorRow, cursorCol)) + } - // If the cursor is visible and wasn't before, or vice versa, we need to - // show that: - if (showCursor && !oldShowCursor) { - result.push(ansi.showCursor()) - } else if (!showCursor && oldShowCursor) { - result.push(ansi.hideCursor()) - } + // If the cursor is visible and wasn't before, or vice versa, we need to + // show that: + if (showCursor && !oldShowCursor) { + result.push(showCursor()) + } else if (!showCursor && oldShowCursor) { + result.push(hideCursor()) + } - return { - oldChars: newChars.slice(), - oldLastChar: Object.assign({}, lastChar), - oldScrRows: scrRows, - oldScrCols: scrCols, - oldCursorRow: cursorRow, - oldCursorCol: cursorCol, - oldShowCursor: showCursor, - screen: result.join('') - } + return { + oldChars: newChars.slice(), + oldLastChar: Object.assign({}, lastChar), + oldScrRows: scrRows, + oldScrCols: scrCols, + oldCursorRow: cursorRow, + oldCursorCol: cursorCol, + oldShowCursor: showCursor, + screen: result.join('') } } - -module.exports = ansi diff --git a/util/count.js b/util/count.js index 24c11b0..d4c0919 100644 --- a/util/count.js +++ b/util/count.js @@ -1,4 +1,4 @@ -module.exports = function count(arr) { +export default function count(arr) { // Counts the number of times the items of an array appear (only on the top // level; it doesn't search through nested arrays!). Returns a map of // item -> count. diff --git a/util/exception.js b/util/exception.js index e88ff99..1271b6a 100644 --- a/util/exception.js +++ b/util/exception.js @@ -1,4 +1,4 @@ -module.exports = function exception(code, message) { +export default function exception(code, message) { // Makes a custom error with the given code and message. const err = new Error(`${code}: ${message}`) diff --git a/util/index.js b/util/index.js new file mode 100644 index 0000000..db3d8a7 --- /dev/null +++ b/util/index.js @@ -0,0 +1,11 @@ +export * as ansi from './ansi.js' +export * as interfaces from './interfaces/index.js' + +export {default as count} from './count.js' +export {default as exception} from './exception.js' +export {default as smoothen} from './smoothen.js' +export {default as telchars} from './telchars.js' +export {default as tuiApp} from './tui-app.js' +export {default as unichars} from './unichars.js' +export {default as waitForData} from './waitForData.js' +export {default as wrap} from './wrap.js' diff --git a/util/interfaces/CommandLineInterface.js b/util/interfaces/CommandLineInterface.js new file mode 100644 index 0000000..66c8c43 --- /dev/null +++ b/util/interfaces/CommandLineInterface.js @@ -0,0 +1,92 @@ +import EventEmitter from 'node:events' + +import * as ansi from '../ansi.js' +import waitForData from '../waitForData.js' + +export default class CommandLineInterface extends EventEmitter { + constructor(inStream = process.stdin, outStream = process.stdout, proc = process) { + super() + + this.inStream = inStream + this.outStream = outStream + this.process = proc + + inStream.on('data', buffer => { + this.emit('inputData', buffer) + }) + + inStream.setRawMode(true) + + proc.on('SIGWINCH', async buffer => { + this.emit('resize', await this.getScreenSize()) + }) + } + + async getScreenSize() { + const waitUntil = cond => waitForData(this.inStream, cond) + + // Get old cursor position.. + this.outStream.write(ansi.requestCursorPosition()) + const { options: oldCoords } = this.parseANSICommand( + await waitUntil(buf => ansi.isANSICommand(buf, 82)) + ) + + // Move far to the bottom right of the screen, then get cursor position.. + // (We could use moveCursor here, but the 0-index offset isn't really + // relevant.) + this.outStream.write(ansi.moveCursorRaw(9999, 9999)) + this.outStream.write(ansi.requestCursorPosition()) + const { options: sizeCoords } = this.parseANSICommand( + await waitUntil(buf => ansi.isANSICommand(buf, 82)) + ) + + // Restore to old cursor position.. (Using moveCursorRaw is actaully + // necessary here, since we'll be passing the coordinates returned from + // another ANSI command.) + this.outStream.write(ansi.moveCursorRaw(oldCoords[0], oldCoords[1])) + + // And return dimensions. + const [ sizeLine, sizeCol ] = sizeCoords + return { + lines: sizeLine, cols: sizeCol, + width: sizeCol, height: sizeLine + } + } + + parseANSICommand(buffer) { + // Typically ANSI commands are written ESC[1;2;3;4C + // ..where ESC is the ANSI escape code, equal to hexadecimal 1B and + // decimal 33 + // ..where [ and ; are the literal strings "[" and ";" + // ..where 1, 2, 3, and 4 are decimal integer arguments written in ASCII + // that may last more than one byte (e.g. "15") + // ..where C is some number representing the code of the command + + if (buffer[0] !== 0x1b || buffer[1] !== 0x5b) { + throw new Error('Not an ANSI command') + } + + const options = [] + let curOption = '' + let commandCode = null + for (const val of buffer.slice(2)) { + if (48 <= val && val <= 57) { // 0124356789 + curOption = curOption.concat(val - 48) + } else { + options.push(parseInt(curOption)) + curOption = '' + + if (val !== 59) { // ; + commandCode = val + break + } + } + } + + return {code: commandCode, options: options} + } + + write(data) { + this.outStream.write(data) + } +} diff --git a/util/interfaces/Flushable.js b/util/interfaces/Flushable.js new file mode 100644 index 0000000..d8b72d3 --- /dev/null +++ b/util/interfaces/Flushable.js @@ -0,0 +1,126 @@ +import * as ansi from '../ansi.js' +import unic from '../unichars.js' + +export default class Flushable { + // A writable that can be used to collect chunks of data before writing + // them. + + constructor(writable, shouldCompress = false) { + this.target = writable + + // Use the magical ANSI self-made compression method that probably + // doesn't *quite* work but should drastically decrease write size? + this.shouldCompress = shouldCompress + + // Whether or not to show compression statistics (original written size + // and ANSI-interpreted compressed size) in the output of flush. + this.shouldShowCompressionStatistics = false + + // Use resizeScreen if you plan on using the ANSI compressor! + this.screenLines = 24 + this.screenCols = 80 + this.lastFrame = undefined + + this.ended = false + this.paused = false + this.requestedFlush = false + + this.chunks = [] + } + + resizeScreen({lines, cols}) { + this.screenLines = lines + this.screenCols = cols + this.clearLastFrame() + } + + clearLastFrame() { + this.lastFrame = undefined + } + + write(what) { + this.chunks.push(what) + } + + flush() { + // If we're paused, we don't want to write, but we will keep a note that a + // flush was requested for when we unpause. + if (this.paused) { + this.requestedFlush = true + return + } + + // Don't write if we've ended. + if (this.ended) { + return + } + + // End if the target is destroyed. + // Yes, this relies on the target having a destroyed property + // Don't worry, it'll still work if there is no destroyed property though + // (I think) + if (this.target.destroyed) { + this.end() + return + } + + let toWrite = this.chunks.join('') + + if (this.shouldCompress) { + toWrite = this.compress(toWrite) + } + + try { + this.target.write(toWrite) + } catch(err) { + console.error('Flushable write error (ending):', err.message) + this.end() + } + + this.chunks = [] + } + + pause() { + this.paused = true + } + + resume() { + this.paused = false + + if (this.requestedFlush) { + this.flush() + } + } + + end() { + this.ended = true + } + + compress(toWrite) { + // TODO: customize screen size + const output = ansi.interpret( + toWrite, this.screenLines, this.screenCols, this.lastFrame + ) + + let { screen } = output + + this.lastFrame = output + + if (this.shouldShowCompressionStatistics) { + let msg = this.lastInterpretMessage + if (screen.length > 0 || !this.lastInterpretMessage) { + const pcSaved = Math.round(1000 - (1000 / toWrite.length * screen.length)) / 10 + const kbSaved = Math.round((toWrite.length - screen.length) / 100) / 10 + msg = this.lastInterpretMessage = ( + '(ANSI-interpret: ' + + `${toWrite.length} -> ${screen.length} ${pcSaved}% / ${kbSaved} KB saved)` + ) + } + screen += '\x1b[H\x1b[0m' + screen += msg + unic.BOX_H_DOUBLE.repeat(this.screenCols - msg.length) + this.lastFrame.oldLastChar.attributes = [] + } + + return screen + } +} diff --git a/util/interfaces/TelnetInterface.js b/util/interfaces/TelnetInterface.js new file mode 100644 index 0000000..8777680 --- /dev/null +++ b/util/interfaces/TelnetInterface.js @@ -0,0 +1,139 @@ +import EventEmitter from 'node:events' + +import * as ansi from '../ansi.js' +import waitForData from '../waitForData.js' + +export default class TelnetInterface extends EventEmitter { + constructor(socket) { + super() + + this.socket = socket + + socket.on('data', buffer => { + if (buffer[0] === 255) { + this.handleTelnetData(buffer) + } else { + this.emit('inputData', buffer) + } + }) + + this.initTelnetOptions() + } + + initTelnetOptions() { + // Initializes various socket options, using telnet magic. + + // Disables linemode. + this.socket.write(Buffer.from([ + 255, 253, 34, // IAC DO LINEMODE + 255, 250, 34, 1, 0, 255, 240, // IAC SB LINEMODE MODE 0 IAC SE + 255, 251, 1 // IAC WILL ECHO + ])) + + // Will SGA. Helps with putty apparently. + this.socket.write(Buffer.from([ + 255, 251, 3 // IAC WILL SGA + ])) + + this.socket.write(ansi.hideCursor()) + } + + cleanTelnetOptions() { + // Resets the telnet options and magic set in initTelnetOptions. + + this.socket.write(ansi.resetAttributes()) + this.socket.write(ansi.showCursor()) + } + + async getScreenSize() { + this.socket.write(Buffer.from([255, 253, 31])) // IAC DO NAWS + + let didWillNAWS = false + let didSBNAWS = false + let sb + + inputLoop: while (true) { + const data = await waitForData(this.socket) + + for (const command of this.parseTelnetCommands(data)) { + // WILL NAWS + if (command[1] === 251 && command[2] === 31) { + didWillNAWS = true + continue + } + + // SB NAWS + if (didWillNAWS && command[1] === 250 && command[2] === 31) { + didSBNAWS = true + sb = command.slice(3) + continue + } + + // SE + if (didSBNAWS && command[1] === 240) { // SE + break inputLoop + } + } + } + + return this.parseSBNAWS(sb) + } + + parseTelnetCommands(buffer) { + if (buffer[0] === 255) { + // Telnet IAC (Is A Command) - ignore + + // Split the data into multiple IAC commands if more than one IAC was + // sent. + const values = Array.from(buffer.values()) + const commands = [] + const curCmd = [255] + for (const value of values) { + if (value === 255) { // IAC + commands.push(Array.from(curCmd)) + curCmd.splice(1, curCmd.length) + continue + } + curCmd.push(value) + } + commands.push(curCmd) + + return commands + } else { + return [] + } + } + + write(data) { + this.socket.write(data) + } + + handleTelnetData(buffer) { + let didSBNAWS = false + let sbNAWS + + for (let command of this.parseTelnetCommands(buffer)) { + // SB NAWS + if (command[1] === 250 && command[2] === 31) { + didSBNAWS = true + sbNAWS = command.slice(3) + continue + } + + // SE + if (didSBNAWS && command[1] === 240) { // SE + didSBNAWS = false + this.emit('screenSizeUpdated', this.parseSBNAWS(sbNAWS)) + this.emit('resize', this.parseSBNAWS(sbNAWS)) + continue + } + } + } + + parseSBNAWS(sb) { + const cols = (sb[0] << 8) + sb[1] + const lines = (sb[2] << 8) + sb[3] + + return { cols, lines, width: cols, height: lines } + } +} diff --git a/util/interfaces/index.js b/util/interfaces/index.js new file mode 100644 index 0000000..83aeb2c --- /dev/null +++ b/util/interfaces/index.js @@ -0,0 +1,4 @@ +export {default as Flushable} from './Flushable.js' + +export {default as CommandLineInterface} from './CommandLineInterface.js' +export {default as TelnetInterface} from './TelnetInterface.js' diff --git a/util/smoothen.js b/util/smoothen.js index 55ba23c..5809271 100644 --- a/util/smoothen.js +++ b/util/smoothen.js @@ -1,4 +1,4 @@ -module.exports = function(tx, x, divisor) { +export default function smoothen(tx, x, divisor) { // Smoothly transitions givens X to TX using a given divisor. Rounds the // amount moved. diff --git a/util/telchars.js b/util/telchars.js index 12d4095..5a5ad42 100644 --- a/util/telchars.js +++ b/util/telchars.js @@ -97,4 +97,4 @@ const telchars = { isCharacter: (buf, char) => compareBufStr(buf, char), } -module.exports = telchars +export default telchars diff --git a/util/tui-app.js b/util/tui-app.js index a695e57..2f09818 100644 --- a/util/tui-app.js +++ b/util/tui-app.js @@ -2,27 +2,26 @@ // program. Contained to reduce boilerplate and improve consistency between // programs. -const ansi = require('./ansi'); +import {Root} from 'tui-lib/ui/primitives' -const CommandLineInterfacer = require('./CommandLineInterfacer'); -const Flushable = require('./Flushable'); -const Root = require('../ui/Root'); +import {CommandLineInterface, Flushable} from './interfaces/index.js' +import * as ansi from './ansi.js' -module.exports = async function tuiApp(callback) { - // TODO: Support other interfacers. - const interfacer = new CommandLineInterfacer(); +export default async function tuiApp(callback) { + // TODO: Support other screen interfaces. + const screenInterface = new CommandLineInterface(); const flushable = new Flushable(process.stdout, true); - const root = new Root(interfacer); + const root = new Root(screenInterface); - const size = await interfacer.getScreenSize(); + const size = await screenInterface.getScreenSize(); root.w = size.width; root.h = size.height; flushable.resizeScreen(size); root.on('rendered', () => flushable.flush()); - interfacer.on('resize', newSize => { + screenInterface.on('resize', newSize => { root.w = newSize.width; root.h = newSize.height; flushable.resizeScreen(newSize); diff --git a/util/unichars.js b/util/unichars.js index ee137e8..2099b62 100644 --- a/util/unichars.js +++ b/util/unichars.js @@ -1,6 +1,6 @@ // Useful Unicode characters. -module.exports = { +export default { /* … */ ELLIPSIS: '\u2026', /* ─ */ BOX_H: '\u2500', diff --git a/util/waitForData.js b/util/waitForData.js index bf40c52..f8d4a92 100644 --- a/util/waitForData.js +++ b/util/waitForData.js @@ -1,4 +1,4 @@ -module.exports = function waitForData(stream, cond = null) { +export default function waitForData(stream, cond = null) { return new Promise(resolve => { stream.on('data', data => { if (cond ? cond(data) : true) { diff --git a/util/wrap.js b/util/wrap.js index 3c381d4..2c720c8 100644 --- a/util/wrap.js +++ b/util/wrap.js @@ -1,4 +1,4 @@ -module.exports = function wrap(str, width) { +export default function wrap(str, width) { // Wraps a string into separate lines. Returns an array of strings, for // each line of the text. -- cgit 1.3.0-6-gf8a5