« get me outta code hell

use ESM module syntax & minor cleanups - tui-lib - Pure Node.js library for making visual command-line programs (ala vim, ncdu)
about summary refs log tree commit diff
path: root/util
diff options
context:
space:
mode:
author(quasar) nebula <qznebula@protonmail.com>2023-05-12 17:42:09 -0300
committer(quasar) nebula <qznebula@protonmail.com>2023-05-13 12:48:36 -0300
commit6ea74c268a12325296a1d2e7fc31b02030ddb8bc (patch)
tree5da94d93acb64e7ab650d240d6cb23c659ad02ca /util
parente783bcf8522fa68e6b221afd18469c3c265b1bb7 (diff)
use ESM module syntax & minor cleanups
The biggest change here is moving various element classes under
more scope-specific directories, which helps to avoid circular
dependencies and is just cleaner to navigate and expand in the
future.

Otherwise this is a largely uncritical port to ESM module syntax!
There are probably a number of changes and other cleanups that
remain much needed.

Whenever I make changes to tui-lib it's hard to believe it's
already been <INSERT COUNTING NUMBER HERE> years since the
previous time. First commits are from January 2017, and the
code originates a month earlier in KAaRMNoD!
Diffstat (limited to 'util')
-rw-r--r--util/ansi.js778
-rw-r--r--util/count.js2
-rw-r--r--util/exception.js2
-rw-r--r--util/index.js11
-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.js2
14 files changed, 432 insertions, 418 deletions
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/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 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.