« get me outta code hell

tui-lib - Pure Node.js library for making visual command-line programs (ala vim, ncdu)
about summary refs log tree commit diff
diff options
context:
space:
mode:
-rw-r--r--README.md1
-rw-r--r--examples/basic-app.js10
-rw-r--r--examples/command-line-interface.js (renamed from examples/interfacer-command-line.js)13
-rw-r--r--examples/label.js5
-rw-r--r--examples/list-scroll-form.js16
-rw-r--r--examples/telnet-interface.js (renamed from examples/interfacer-telnet.js)23
-rw-r--r--index.js44
-rw-r--r--package-lock.json51
-rw-r--r--package.json22
-rw-r--r--ui/controls/Button.js (renamed from ui/form/Button.js)8
-rw-r--r--ui/controls/FocusBox.js (renamed from ui/form/FocusBox.js)6
-rw-r--r--ui/controls/Form.js (renamed from ui/form/Form.js)6
-rw-r--r--ui/controls/ListScrollForm.js (renamed from ui/form/ListScrollForm.js)11
-rw-r--r--ui/controls/ScrollBar.js (renamed from ui/form/ScrollBar.js)8
-rw-r--r--ui/controls/TextInput.js (renamed from ui/form/TextInput.js)12
-rw-r--r--ui/controls/index.js18
-rw-r--r--ui/dialogs/CancelDialog.js (renamed from ui/form/CancelDialog.js)13
-rw-r--r--ui/dialogs/ConfirmDialog.js (renamed from ui/form/ConfirmDialog.js)13
-rw-r--r--ui/dialogs/Dialog.js (renamed from ui/Dialog.js)9
-rw-r--r--ui/dialogs/FilePickerForm.js (renamed from ui/tools/FilePickerForm.js)23
-rw-r--r--ui/dialogs/OpenFileDialog.js (renamed from ui/tools/OpenFileDialog.js)20
-rw-r--r--ui/dialogs/index.js16
-rw-r--r--ui/index.js4
-rw-r--r--ui/presentation/HorizontalBox.js (renamed from ui/HorizontalBox.js)4
-rw-r--r--ui/presentation/Label.js (renamed from ui/Label.js)6
-rw-r--r--ui/presentation/Pane.js (renamed from ui/Pane.js)10
-rw-r--r--ui/presentation/Sprite.js (renamed from ui/Sprite.js)6
-rw-r--r--ui/presentation/WrapLabel.js (renamed from ui/WrapLabel.js)9
-rw-r--r--ui/presentation/index.js15
-rw-r--r--ui/primitives/DisplayElement.js (renamed from ui/DisplayElement.js)12
-rw-r--r--ui/primitives/Element.js (renamed from ui/Element.js)7
-rw-r--r--ui/primitives/FocusElement.js (renamed from ui/form/FocusElement.js)4
-rw-r--r--ui/primitives/Root.js (renamed from ui/Root.js)26
-rw-r--r--ui/primitives/index.js11
-rw-r--r--util/ansi.js811
-rw-r--r--util/count.js2
-rw-r--r--util/exception.js2
-rw-r--r--util/index.js10
-rw-r--r--util/interfaces/CommandLineInterface.js (renamed from util/CommandLineInterfacer.js)9
-rw-r--r--util/interfaces/Flushable.js (renamed from util/Flushable.js)6
-rw-r--r--util/interfaces/TelnetInterface.js (renamed from util/TelnetInterfacer.js)9
-rw-r--r--util/interfaces/index.js4
-rw-r--r--util/smoothen.js2
-rw-r--r--util/telchars.js2
-rw-r--r--util/tui-app.js19
-rw-r--r--util/unichars.js2
-rw-r--r--util/waitForData.js2
-rw-r--r--util/wrap.js28
48 files changed, 707 insertions, 663 deletions
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/interfacer-command-line.js b/examples/command-line-interface.js
index 1da6adf..ba1d936 100644
--- a/examples/interfacer-command-line.js
+++ b/examples/command-line-interface.js
@@ -1,11 +1,12 @@
-const Root = require('../ui/Root')
-const CommandLineInterfacer = require('../util/CommandLineInterfacer')
-const AppElement = require('./basic-app')
+import {Root} from 'tui-lib/ui/primitives'
+import {CommandLineInterface} from 'tui-lib/util/interfaces'
 
-const interfacer = new CommandLineInterfacer()
+import AppElement from './basic-app.js'
 
-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/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/interfacer-telnet.js b/examples/telnet-interface.js
index d7aad43..319786f 100644
--- a/examples/interfacer-telnet.js
+++ b/examples/telnet-interface.js
@@ -1,23 +1,26 @@
 // Telnet demo:
-// - Basic telnet socket handling using the TelnetInterfacer
+// - 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
 
-const net = require('net')
-const Root = require('../ui/Root')
-const TelnetInterfacer = require('../TelnetInterfacer')
-const AppElement = require('./basic-app')
+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 interfacer = new TelnetInterfacer(socket)
+  const telnetInterface = new TelnetInterface(socket)
 
-  interfacer.getScreenSize().then(size => {
-    const root = new Root(interfacer)
+  telnetInterface.getScreenSize().then(size => {
+    const root = new Root(telnetInterface)
     root.w = size.width
     root.h = size.height
 
-    interfacer.on('resize', newSize => {
+    telnetInterface.on('resize', newSize => {
       root.w = newSize.width
       root.h = newSize.height
       root.fixAllLayout()
@@ -31,7 +34,7 @@ const server = new net.Server(socket => {
 
     appElement.on('quitRequested', () => {
       if (!closed) {
-        interfacer.cleanTelnetOptions()
+        telnetInterface.cleanTelnetOptions()
         socket.write('Goodbye!\n')
         socket.end()
         clearInterval(interval)
diff --git a/index.js b/index.js
index b848814..fc54bfd 100644
--- a/index.js
+++ b/index.js
@@ -1,37 +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'),
-      ScrollBar: require('./ui/form/ScrollBar'),
-      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 e62e5f9..fd6d714 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,61 +1,50 @@
 {
   "name": "tui-lib",
-  "version": "0.3.3",
-  "lockfileVersion": 2,
+  "version": "0.4.0",
+  "lockfileVersion": 3,
   "requires": true,
   "packages": {
     "": {
       "name": "tui-lib",
-      "version": "0.3.3",
+      "version": "0.4.0",
       "license": "GPL-3.0",
       "dependencies": {
+        "natural-orderby": "^3.0.2",
         "wcwidth": "^1.0.1"
       }
     },
     "node_modules/clone": {
       "version": "1.0.4",
       "resolved": "https://registry.npmjs.org/clone/-/clone-1.0.4.tgz",
-      "integrity": "sha1-2jCcwmPfFZlMaIypAheco8fNfH4=",
+      "integrity": "sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==",
       "engines": {
         "node": ">=0.8"
       }
     },
     "node_modules/defaults": {
-      "version": "1.0.3",
-      "resolved": "https://registry.npmjs.org/defaults/-/defaults-1.0.3.tgz",
-      "integrity": "sha1-xlYFHpgX2f8I7YgUd/P+QBnz730=",
+      "version": "1.0.4",
+      "resolved": "https://registry.npmjs.org/defaults/-/defaults-1.0.4.tgz",
+      "integrity": "sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==",
       "dependencies": {
         "clone": "^1.0.2"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
       }
     },
-    "node_modules/wcwidth": {
-      "version": "1.0.1",
-      "resolved": "https://registry.npmjs.org/wcwidth/-/wcwidth-1.0.1.tgz",
-      "integrity": "sha1-8LDc+RW8X/FSivrbLA4XtTLaL+g=",
-      "dependencies": {
-        "defaults": "^1.0.3"
-      }
-    }
-  },
-  "dependencies": {
-    "clone": {
-      "version": "1.0.4",
-      "resolved": "https://registry.npmjs.org/clone/-/clone-1.0.4.tgz",
-      "integrity": "sha1-2jCcwmPfFZlMaIypAheco8fNfH4="
-    },
-    "defaults": {
-      "version": "1.0.3",
-      "resolved": "https://registry.npmjs.org/defaults/-/defaults-1.0.3.tgz",
-      "integrity": "sha1-xlYFHpgX2f8I7YgUd/P+QBnz730=",
-      "requires": {
-        "clone": "^1.0.2"
+    "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"
       }
     },
-    "wcwidth": {
+    "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": {
+      "integrity": "sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==",
+      "dependencies": {
         "defaults": "^1.0.3"
       }
     }
diff --git a/package.json b/package.json
index f07f4d1..4816f36 100644
--- a/package.json
+++ b/package.json
@@ -1,12 +1,26 @@
 {
   "name": "tui-lib",
-  "version": "0.3.3",
+  "version": "0.4.0",
   "description": "terminal ui library",
-  "main": "index.js",
-  "repository": "https://notabug.org/towerofnix/tui-lib.git",
-  "author": "Florrie <towerofnix@gmail.com>",
+  "type": "module",
+  "repository": "https://nebula.ed1.club/git/tui-lib/",
+  "author": "Nebula <qznebula@protonmail.com>",
   "license": "GPL-3.0",
   "dependencies": {
+    "natural-orderby": "^3.0.2",
     "wcwidth": "^1.0.1"
+  },
+  "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/exception": "./util/exception.js",
+    "./util/interfaces": "./util/interfaces/index.js",
+    "./util/telchars": "./util/telchars.js",
+    "./util/unichars": "./util/unichars.js"
   }
 }
diff --git a/ui/form/Button.js b/ui/controls/Button.js
index 46329a6..5be2b2a 100644
--- a/ui/form/Button.js
+++ b/ui/controls/Button.js
@@ -1,9 +1,9 @@
-const ansi = require('../../util/ansi')
-const telc = require('../../util/telchars')
+import {FocusElement} from 'tui-lib/ui/primitives'
 
-const FocusElement = require('./FocusElement')
+import * as ansi from 'tui-lib/util/ansi'
+import telc from 'tui-lib/util/telchars'
 
-module.exports = class Button extends FocusElement {
+export default class Button extends FocusElement {
   // A button.
 
   constructor(text) {
diff --git a/ui/form/FocusBox.js b/ui/controls/FocusBox.js
index 69b5bf5..64f84c9 100644
--- a/ui/form/FocusBox.js
+++ b/ui/controls/FocusBox.js
@@ -1,8 +1,8 @@
-const ansi = require('../../util/ansi')
+import {FocusElement} from 'tui-lib/ui/primitives'
 
-const FocusElement = require('./FocusElement')
+import * as ansi from 'tui-lib/util/ansi'
 
-module.exports = class FocusBox extends FocusElement {
+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,
diff --git a/ui/form/Form.js b/ui/controls/Form.js
index f61c7b6..0224247 100644
--- a/ui/form/Form.js
+++ b/ui/controls/Form.js
@@ -1,8 +1,8 @@
-const telc = require('../../util/telchars')
+import telc from 'tui-lib/util/telchars'
 
-const FocusElement = require('./FocusElement')
+import {FocusElement} from 'tui-lib/ui/primitives'
 
-module.exports = class Form extends FocusElement {
+export default class Form extends FocusElement {
   constructor() {
     super()
 
diff --git a/ui/form/ListScrollForm.js b/ui/controls/ListScrollForm.js
index e4f4249..f74561e 100644
--- a/ui/form/ListScrollForm.js
+++ b/ui/controls/ListScrollForm.js
@@ -1,10 +1,11 @@
-const ansi = require('../../util/ansi')
-const telc = require('../../util/telchars')
+import * as ansi from 'tui-lib/util/ansi'
+import telc from 'tui-lib/util/telchars'
+import unic from 'tui-lib/util/unichars'
 
-const Form = require('./Form')
-const ScrollBar = require('./ScrollBar')
+import Form from './Form.js'
+import ScrollBar from './ScrollBar.js'
 
-module.exports = class ListScrollForm extends Form {
+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
diff --git a/ui/form/ScrollBar.js b/ui/controls/ScrollBar.js
index 13ba7fe..4b79d57 100644
--- a/ui/form/ScrollBar.js
+++ b/ui/controls/ScrollBar.js
@@ -1,9 +1,9 @@
-const DisplayElement = require('../DisplayElement')
+import * as ansi from 'tui-lib/util/ansi'
+import unic from 'tui-lib/util/unichars'
 
-const ansi = require('../../util/ansi')
-const unic = require('../../util/unichars')
+import {DisplayElement} from 'tui-lib/ui/primitives'
 
-module.exports = class ScrollBar extends DisplayElement {
+export default class ScrollBar extends DisplayElement {
   constructor({
     getLayoutType,
     getCurrentScroll,
diff --git a/ui/form/TextInput.js b/ui/controls/TextInput.js
index 78d3b6d..1a32605 100644
--- a/ui/form/TextInput.js
+++ b/ui/controls/TextInput.js
@@ -1,10 +1,10 @@
-const ansi = require('../../util/ansi')
-const unic = require('../../util/unichars')
-const telc = require('../../util/telchars')
+import {FocusElement} from 'tui-lib/ui/primitives'
 
-const FocusElement = require('./FocusElement')
+import * as ansi from 'tui-lib/util/ansi'
+import telc from 'tui-lib/util/telchars'
+import unic from 'tui-lib/util/unichars'
 
-module.exports = class TextInput extends FocusElement {
+export default class TextInput extends FocusElement {
   // An element that the user can type in.
 
   constructor() {
@@ -142,4 +142,6 @@ module.exports = class TextInput extends FocusElement {
 
   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..7f290c2
--- /dev/null
+++ b/ui/controls/index.js
@@ -0,0 +1,18 @@
+//
+// Import mapping:
+//
+//   primitives ->
+//     Button
+//     FocusBox
+//     ScrollBar
+//     TextInput
+//
+//     Form -> ListScrollForm
+//
+
+export {default as Button} from './Button.js'
+export {default as ScrollBar} from './ScrollBar.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/form/CancelDialog.js b/ui/dialogs/CancelDialog.js
index 21ff6df..9069d43 100644
--- a/ui/form/CancelDialog.js
+++ b/ui/dialogs/CancelDialog.js
@@ -1,13 +1,10 @@
-const telc = require('../../util/telchars')
+import {Button, Form} from 'tui-lib/ui/controls'
+import {Label, Pane} from 'tui-lib/ui/presentation'
+import {FocusElement} from 'tui-lib/ui/primitives'
 
-const FocusElement = require('./FocusElement')
+import telc from 'tui-lib/util/telchars'
 
-const Button = require('./Button')
-const Form = require('./Form')
-const Label = require('../Label')
-const Pane = require('../Pane')
-
-module.exports = class ConfirmDialog extends FocusElement {
+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).
diff --git a/ui/form/ConfirmDialog.js b/ui/dialogs/ConfirmDialog.js
index 230230d..c0bcfae 100644
--- a/ui/form/ConfirmDialog.js
+++ b/ui/dialogs/ConfirmDialog.js
@@ -1,13 +1,10 @@
-const telc = require('../../util/telchars')
+import {Button, Form} from 'tui-lib/ui/controls'
+import {Label, Pane} from 'tui-lib/ui/presentation'
+import {FocusElement} from 'tui-lib/ui/primitives'
 
-const FocusElement = require('./FocusElement')
+import telc from 'tui-lib/util/telchars'
 
-const Button = require('./Button')
-const Form = require('./Form')
-const Label = require('../Label')
-const Pane = require('../Pane')
-
-module.exports = class ConfirmDialog extends FocusElement {
+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).
diff --git a/ui/Dialog.js b/ui/dialogs/Dialog.js
index 0b77b12..19565f5 100644
--- a/ui/Dialog.js
+++ b/ui/dialogs/Dialog.js
@@ -1,10 +1,9 @@
-const FocusElement = require('./form/FocusElement')
+import {Pane} from 'tui-lib/ui/presentation'
+import {FocusElement} from 'tui-lib/ui/primitives'
 
-const Pane = require('./Pane')
+import telc from 'tui-lib/util/telchars'
 
-const telc = require('../util/telchars')
-
-module.exports = class Dialog extends FocusElement {
+export default class Dialog extends FocusElement {
   // A simple base dialog.
   //
   // Emits the 'cancelled' event when the cancel key (escape) is pressed,
diff --git a/ui/tools/FilePickerForm.js b/ui/dialogs/FilePickerForm.js
index 51d59a9..6414818 100644
--- a/ui/tools/FilePickerForm.js
+++ b/ui/dialogs/FilePickerForm.js
@@ -1,15 +1,11 @@
-const fs = require('fs')
-const util = require('util')
-const path = require('path')
+import {readdir, stat} from 'node:fs/promises'
+import path from 'node:path'
 
-const readdir = util.promisify(fs.readdir)
-const stat = util.promisify(fs.stat)
-const naturalSort = require('node-natural-sort')
+import {compare as naturalCompare} from 'natural-orderby'
 
-const Button = require('../form/Button')
-const ListScrollForm = require('../form/ListScrollForm')
+import {Button, ListScrollForm} from 'tui-lib/ui/controls'
 
-module.exports = class FilePickerForm extends ListScrollForm {
+export default class FilePickerForm extends ListScrollForm {
   fillItems(dirPath) {
     this.inputs = []
     this.children = []
@@ -33,14 +29,10 @@ module.exports = class FilePickerForm extends ListScrollForm {
           })
         }))
 
-        const sort = naturalSort({
-          properties: {
-            caseSensitive: false
-          }
-        })
+        const compare = naturalCompare()
         processedItems.sort((a, b) => {
           if (a.isDirectory === b.isDirectory) {
-            return sort(a.label, b.label)
+            return compare(a.label, b.label)
           } else {
             if (a.isDirectory) {
               return -1
@@ -85,4 +77,3 @@ module.exports = class FilePickerForm extends ListScrollForm {
       })
   }
 }
-
diff --git a/ui/tools/OpenFileDialog.js b/ui/dialogs/OpenFileDialog.js
index 43f2638..970e291 100644
--- a/ui/tools/OpenFileDialog.js
+++ b/ui/dialogs/OpenFileDialog.js
@@ -1,13 +1,12 @@
-const path = require('path')
+import path from 'node: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')
+import {Button, Form, TextInput} from 'tui-lib/ui/controls'
+import {Label} from 'tui-lib/ui/presentation'
 
-module.exports = class OpenFileDialog extends Dialog {
+import Dialog from './Dialog.js'
+import FilePickerForm from './FilePickerForm.js'
+
+export default class OpenFileDialog extends Dialog {
   constructor() {
     super()
 
@@ -42,7 +41,7 @@ module.exports = class OpenFileDialog extends Dialog {
 
     {
       const cb = append => p => {
-        this.filePathInput.setValue((path.relative(__dirname, p) || '.') + append)
+        this.filePathInput.setValue((path.relative(process.cwd(), p) || '.') + append)
       }
 
       this.filePickerForm.on('selected', cb(''))
@@ -54,7 +53,7 @@ module.exports = class OpenFileDialog extends Dialog {
     })
 
     const dir = (this.lastFilePath
-      ? path.relative(__dirname, path.dirname(this.lastFilePath)) + '/'
+      ? path.relative(process.cwd(), path.dirname(this.lastFilePath)) + '/'
       : './')
 
     this.filePathInput.setValue(dir)
@@ -107,4 +106,3 @@ module.exports = class OpenFileDialog extends Dialog {
     })
   }
 }
-
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/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/HorizontalBox.js b/ui/presentation/HorizontalBox.js
index f92bf10..d396ec3 100644
--- a/ui/HorizontalBox.js
+++ b/ui/presentation/HorizontalBox.js
@@ -1,6 +1,6 @@
-const DisplayElement = require('./DisplayElement')
+import {DisplayElement} from 'tui-lib/ui/primitives'
 
-module.exports = class HorizontalBox extends DisplayElement {
+export default class HorizontalBox extends DisplayElement {
   // A box that will automatically lay out its children in a horizontal row.
 
   fixLayout() {
diff --git a/ui/Label.js b/ui/presentation/Label.js
index b5828cb..ed45601 100644
--- a/ui/Label.js
+++ b/ui/presentation/Label.js
@@ -1,8 +1,8 @@
-const ansi = require('../util/ansi')
+import {DisplayElement} from 'tui-lib/ui/primitives'
 
-const DisplayElement = require('./DisplayElement')
+import * as ansi from 'tui-lib/util/ansi'
 
-module.exports = class Label extends DisplayElement {
+export default class Label extends DisplayElement {
   // A simple text display. Automatically adjusts size to fit text.
   //
   // Supports formatted text in two ways:
diff --git a/ui/Pane.js b/ui/presentation/Pane.js
index b33a1b7..4769cf9 100644
--- a/ui/Pane.js
+++ b/ui/presentation/Pane.js
@@ -1,11 +1,11 @@
-const ansi = require('../util/ansi')
-const unic = require('../util/unichars')
+import {DisplayElement} from 'tui-lib/ui/primitives'
 
-const DisplayElement = require('./DisplayElement')
+import * as ansi from 'tui-lib/util/ansi'
+import unic from 'tui-lib/util/unichars'
 
-const Label = require('./Label')
+import Label from './Label.js'
 
-module.exports = class Pane extends DisplayElement {
+export default class Pane extends DisplayElement {
   // A simple rectangular framed pane.
 
   constructor() {
diff --git a/ui/Sprite.js b/ui/presentation/Sprite.js
index 701f1b8..49ee450 100644
--- a/ui/Sprite.js
+++ b/ui/presentation/Sprite.js
@@ -1,8 +1,8 @@
-const ansi = require('../util/ansi')
+import {DisplayElement} from 'tui-lib/ui/primitives'
 
-const DisplayElement = require('./DisplayElement')
+import * as ansi from 'tui-lib/util/ansi'
 
-module.exports = class Sprite extends DisplayElement {
+export default class Sprite extends DisplayElement {
   // "A sprite is a two-dimensional bitmap that is integrated into a larger
   // scene." - Wikipedia
   //
diff --git a/ui/WrapLabel.js b/ui/presentation/WrapLabel.js
index d621a49..eae8960 100644
--- a/ui/WrapLabel.js
+++ b/ui/presentation/WrapLabel.js
@@ -1,9 +1,8 @@
-const ansi = require('../util/ansi')
-const wrap = require('../util/wrap')
+import * as ansi from 'tui-lib/util/ansi'
 
-const Label = require('./Label')
+import Label from './Label.js'
 
-module.exports = class WrapLabel extends Label {
+export default class WrapLabel extends Label {
   // A word-wrapping text display. Given a width, wraps text to fit.
 
   constructor(...args) {
@@ -29,7 +28,7 @@ module.exports = class WrapLabel extends Label {
       return []
     }
 
-    return wrap(this.text, this.w - 1).map(l => l.trim())
+    return ansi.wrapToColumns(this.text, this.w - 1).map(l => l.trim())
   }
 
   get h() {
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/DisplayElement.js b/ui/primitives/DisplayElement.js
index 32a62b8..6452887 100644
--- a/ui/DisplayElement.js
+++ b/ui/primitives/DisplayElement.js
@@ -1,6 +1,6 @@
-const Element = require('./Element')
+import Element from './Element.js'
 
-module.exports = class DisplayElement extends Element {
+export default class DisplayElement extends Element {
   // A general class that handles dealing with screen coordinates, the tree
   // of elements, and other common stuff.
   //
@@ -10,6 +10,10 @@ module.exports = class DisplayElement extends Element {
   // 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()
 
@@ -303,7 +307,3 @@ module.exports = class DisplayElement extends Element {
   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/primitives/Element.js
index b9b8c61..a5dbea6 100644
--- a/ui/Element.js
+++ b/ui/primitives/Element.js
@@ -1,7 +1,8 @@
-const EventEmitter = require('events')
-const exception = require('../util/exception')
+import EventEmitter from 'node:events'
 
-module.exports = class Element extends EventEmitter {
+import exception from 'tui-lib/util/exception'
+
+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.
diff --git a/ui/form/FocusElement.js b/ui/primitives/FocusElement.js
index 23c2e02..2c23b1e 100644
--- a/ui/form/FocusElement.js
+++ b/ui/primitives/FocusElement.js
@@ -1,6 +1,6 @@
-const DisplayElement = require('../DisplayElement')
+import DisplayElement from './DisplayElement.js'
 
-module.exports = class FocusElement extends DisplayElement {
+export default class FocusElement extends DisplayElement {
   // A basic element that can receive cursor focus.
 
   constructor() {
diff --git a/ui/Root.js b/ui/primitives/Root.js
index 2b13203..a779637 100644
--- a/ui/Root.js
+++ b/ui/primitives/Root.js
@@ -1,19 +1,17 @@
-const ansi = require('../util/ansi')
-const telc = require('../util/telchars')
+import * as ansi from 'tui-lib/util/ansi'
+import telc from 'tui-lib/util/telchars'
 
-const DisplayElement = require('./DisplayElement')
+import DisplayElement from './DisplayElement.js'
 
-const Form = require('./form/Form')
-
-module.exports = class Root extends DisplayElement {
+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(interfacer, writable = null) {
+  constructor(interfaceArg, writable = null) {
     super()
 
-    this.interfacer = interfacer
-    this.writable = writable || interfacer
+    this.interface = interfaceArg
+    this.writable = writable || interfaceArg
 
     this.selectedElement = null
 
@@ -21,7 +19,7 @@ module.exports = class Root extends DisplayElement {
 
     this.oldSelectionStates = []
 
-    interfacer.on('inputData', buf => this.handleData(buf))
+    this.interface.on('inputData', buf => this.handleData(buf))
 
     this.renderCount = 0
   }
@@ -174,11 +172,17 @@ module.exports = class Root extends DisplayElement {
 
     // 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 instanceof Form && parent.inputs.includes(el)) {
+    if (!fromForm && parent.constructor.name === 'Form' && parent.inputs.includes(el)) {
       parent.selectInput(el)
       return
     }
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/util/ansi.js b/util/ansi.js
index d4ed71d..4e8abb0 100644
--- a/util/ansi.js
+++ b/util/ansi.js
@@ -1,503 +1,528 @@
-const wcwidth = require('wcwidth')
+import wcwidth from 'wcwidth'
 
-const ESC = '\x1b'
-
-const isDigit = char => '0123456789'.indexOf(char) >= 0
+function isDigit(char) {
+  return '0123456789'.indexOf(char) >= 0
+}
 
-const ansi = {
-  ESC,
+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`
+}
 
-  // 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,
+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.
 
-  clearScreen() {
-    // Clears the screen, removing any characters displayed, and resets the
-    // cursor position.
+  return `${ESC}[${line};${col}H`
+}
 
-    return `${ESC}[2J`
-  },
+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).
 
-  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.
+  return `${ESC}[${line + 1};${col + 1}H`
+}
 
-    return `${ESC}[${line};${col}H`
-  },
+export function cleanCursor() {
+  // A combination of codes that generally cleans up the cursor.
 
-  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 resetAttributes() +
+    stopTrackingMouse() +
+    showCursor()
+}
 
-    return `${ESC}[${line + 1};${col + 1}H`
-  },
+export function hideCursor() {
+  // Makes the cursor invisible.
 
-  cleanCursor() {
-    // A combination of codes that generally cleans up the cursor.
+  return `${ESC}[?25l`
+}
 
-    return ansi.resetAttributes() +
-      ansi.stopTrackingMouse() +
-      ansi.showCursor()
-  },
+export function showCursor() {
+  // Makes the cursor visible.
 
-  hideCursor() {
-    // Makes the cursor invisible.
+  return `${ESC}[?25h`
+}
 
-    return `${ESC}[?25l`
-  },
+export function resetAttributes() {
+  // Resets all attributes, including text decorations, foreground and
+  // background color.
 
-  showCursor() {
-    // Makes the cursor visible.
+  return `${ESC}[0m`
+}
 
-    return `${ESC}[?25h`
-  },
+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.
 
-  resetAttributes() {
-    // Resets all attributes, including text decorations, foreground and
-    // background color.
+  return `${ESC}[${attrs.join(';')}m`
+}
 
-    return `${ESC}[0m`
-  },
+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).
 
-  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.
+  if (typeof color === 'undefined' || color === null) {
+    return ''
+  }
 
-    return `${ESC}[${attrs.join(';')}m`
-  },
+  return setAttributes([color])
+}
 
-  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).
+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.
 
-    if (typeof color === 'undefined' || color === null) {
-      return ''
-    }
+  if (typeof color === 'undefined' || color === null) {
+    return ''
+  }
 
-    return ansi.setAttributes([color])
-  },
+  return setAttributes([color + 10])
+}
 
-  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.
+export function invert() {
+  // Inverts the foreground and background colors.
 
-    if (typeof color === 'undefined' || color === null) {
-      return ''
-    }
+  return `${ESC}[7m`
+}
 
-    return ansi.setAttributes([color + 10])
-  },
+export function invertOff() {
+  // Un-inverts the foreground and backgrund colors.
 
-  invert() {
-    // Inverts the foreground and background colors.
+  return `${ESC}[27m`
+}
 
-    return `${ESC}[7m`
-  },
+export function startTrackingMouse() {
+  return `${ESC}[?1002h`
+}
 
-  invertOff() {
-    // Un-inverts the foreground and backgrund colors.
+export function stopTrackingMouse() {
+  return `${ESC}[?1002l`
+}
 
-    return `${ESC}[27m`
-  },
+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).
 
-  startTrackingMouse() {
-    return `${ESC}[?1002h`
-  },
+  return `${ESC}[6n`
+}
 
-  stopTrackingMouse() {
-    return `${ESC}[?1002l`
-  },
+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."
 
-  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}[?1049h`
+}
 
-    return `${ESC}[6n`
-  },
+export function disableAlternateScreen() {
+  return `${ESC}[?1049l`
+}
 
-  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."
+export function measureColumns(text) {
+  // Returns the number of columns the given text takes. Accounts for escape
+  // codes (by not including them in the returned width).
 
-    return `${ESC}[?1049h`
-  },
+  if (text.includes(ESC)) {
+    text = text.replace(new RegExp(String.raw`${ESC}\[\??[0-9;]*.`, 'g'), '')
+  }
 
-  disableAlternateScreen() {
-    return `${ESC}[?1049l`
-  },
+  return wcwidth(text)
+}
 
-  measureColumns(text) {
-    // Returns the number of columns the given text takes. Accounts for escape
-    // codes (by not including them in the returned width).
+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.
 
-    if (text.includes(ESC)) {
-      text = text.replace(new RegExp(ESC + '\\[\\??[0-9;]*.', 'g'), '')
+  let out = ''
+  for (const char of text) {
+    if (measureColumns(out + char) <= cols) {
+      out += char
+    } else {
+      break
     }
+  }
+  return out
+}
 
-    return wcwidth(text)
-  },
+export function wrapToColumns(text, cols) {
+  // Wraps a string into separate lines. Returns an array of strings, for
+  // each line of the text.
 
-  trimToColumns(text, cols) {
-    // Trims off the end of the passed text so that its width doesn't exceed
-    // the size passed in columns.
+  const lines = []
+  const words = text.split(' ')
 
-    let out = ''
-    for (const char of text) {
-      if (ansi.measureColumns(out + char) <= cols) {
-        out += char
-      } else {
-        break
-      }
-    }
-    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: ' '
+  let curLine = words[0]
+  let curColumns = measureColumns(curLine)
+
+  for (const word of words.slice(1)) {
+    const wordColumns = measureColumns(word)
+    if (curColumns + wordColumns > cols) {
+      lines.push(curLine)
+      curLine = word
+      curColumns = wordColumns
+    } else {
+      curLine += ' ' + word
+      curColumns += 1 + wordColumns
     }
+  }
 
-    const chars = new Array(scrRows * scrCols).fill(blank)
+  lines.push(curLine)
 
-    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 lines
+}
+
+export function isANSICommand(buffer, code = null) {
+  return (
+    buffer[0] === 0x1b && buffer[1] === 0x5b &&
+    (code ? buffer[buffer.length - 1] === code : true)
+  )
+}
+
+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.
+
+  const blank = {
+    attributes: [],
+    char: ' '
+  }
+
+  const chars = new Array(scrRows * scrCols).fill(blank)
+
+  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]
       }
     }
+  }
 
-    let showCursor = oldShowCursor
-    let cursorRow = oldCursorRow
-    let cursorCol = oldCursorCol
-    let attributes = []
+  let showCursor = oldShowCursor
+  let cursorRow = oldCursorRow
+  let cursorCol = oldCursorCol
+  let attributes = []
 
-    for (let charI = 0; charI < text.length; charI++) {
-      const cursorIndex = (cursorRow - 1) * scrCols + (cursorCol - 1)
+  for (let charI = 0; charI < text.length; charI++) {
+    const cursorIndex = (cursorRow - 1) * scrCols + (cursorCol - 1)
 
-      if (text[charI] === ESC) {
-        charI++
+    if (text[charI] === ESC) {
+      charI++
 
-        if (text[charI] !== '[') {
-          throw new Error('ESC not followed by [')
-        }
+      if (text[charI] !== '[') {
+        throw new Error('ESC not followed by [')
+      }
+
+      charI++
 
+      // Selective control sequences (look them up) - we can just skip the
+      // question mark.
+      if (text[charI] === '?') {
+        charI++
+      }
+
+      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..43a08bd
--- /dev/null
+++ b/util/index.js
@@ -0,0 +1,10 @@
+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'
diff --git a/util/CommandLineInterfacer.js b/util/interfaces/CommandLineInterface.js
index d2007fb..66c8c43 100644
--- a/util/CommandLineInterfacer.js
+++ b/util/interfaces/CommandLineInterface.js
@@ -1,8 +1,9 @@
-const EventEmitter = require('events')
-const waitForData = require('./waitForData')
-const ansi = require('./ansi')
+import EventEmitter from 'node:events'
 
-module.exports = class CommandLineInterfacer extends EventEmitter {
+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()
 
diff --git a/util/Flushable.js b/util/interfaces/Flushable.js
index 058d186..d8b72d3 100644
--- a/util/Flushable.js
+++ b/util/interfaces/Flushable.js
@@ -1,7 +1,7 @@
-const ansi = require('./ansi')
-const unic = require('./unichars')
+import * as ansi from '../ansi.js'
+import unic from '../unichars.js'
 
-module.exports = class Flushable {
+export default class Flushable {
   // A writable that can be used to collect chunks of data before writing
   // them.
 
diff --git a/util/TelnetInterfacer.js b/util/interfaces/TelnetInterface.js
index dc71157..8777680 100644
--- a/util/TelnetInterfacer.js
+++ b/util/interfaces/TelnetInterface.js
@@ -1,8 +1,9 @@
-const ansi = require('./ansi')
-const waitForData = require('./waitForData')
-const EventEmitter = require('events')
+import EventEmitter from 'node:events'
 
-module.exports = class TelnetInterfacer extends EventEmitter {
+import * as ansi from '../ansi.js'
+import waitForData  from '../waitForData.js'
+
+export default class TelnetInterface extends EventEmitter {
   constructor(socket) {
     super()
 
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 fe1cd03..0dfd821 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, flushable);
+    const root = new Root(screenInterface, flushable);
 
-    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 ed88402..75f740e 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 => {
     const listener = data => {
       if (cond ? cond(data) : true) {
diff --git a/util/wrap.js b/util/wrap.js
deleted file mode 100644
index 71a1f1c..0000000
--- a/util/wrap.js
+++ /dev/null
@@ -1,28 +0,0 @@
-const ansi = require('./ansi')
-
-module.exports = function wrap(str, width) {
-  // Wraps a string into separate lines. Returns an array of strings, for
-  // each line of the text.
-
-  const lines = []
-  const words = str.split(' ')
-
-  let curLine = words[0]
-  let curColumns = ansi.measureColumns(curLine)
-
-  for (const word of words.slice(1)) {
-    const wordColumns = ansi.measureColumns(word)
-    if (curColumns + wordColumns > width) {
-      lines.push(curLine)
-      curLine = word
-      curColumns = wordColumns
-    } else {
-      curLine += ' ' + word
-      curColumns += 1 + wordColumns
-    }
-  }
-
-  lines.push(curLine)
-
-  return lines
-}