« get me outta code hell

Merge remote-tracking branch 'notabug/master' - 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:
author(quasar) nebula <qznebula@protonmail.com>2023-05-13 13:20:59 -0300
committer(quasar) nebula <qznebula@protonmail.com>2023-05-13 13:20:59 -0300
commit34b7ff22766bae0e4b1b3121bd63d037c27285c7 (patch)
treef2f66a17c67bdf9be0b1576b28ffd0cf107b483c
parent684e369c7b01b4c69995fc604ef33919077ffdf5 (diff)
parenta37d6be77261b2aae25c4235dcb38dd7b1bb60b1 (diff)
Merge remote-tracking branch 'notabug/master' HEAD main
-rw-r--r--.editorconfig9
-rw-r--r--package-lock.json28
-rw-r--r--package.json6
-rw-r--r--todo.txt4
-rw-r--r--ui/controls/Form.js21
-rw-r--r--ui/controls/ListScrollForm.js153
-rw-r--r--ui/controls/ScrollBar.js121
-rw-r--r--ui/controls/index.js2
-rw-r--r--ui/presentation/Label.js17
-rw-r--r--ui/presentation/WrapLabel.js8
-rw-r--r--ui/primitives/DisplayElement.js6
-rw-r--r--ui/primitives/Element.js2
-rw-r--r--util/ansi.js34
-rw-r--r--util/index.js1
-rw-r--r--util/tui-app.js2
-rw-r--r--util/waitForData.js6
-rw-r--r--util/wrap.js22
17 files changed, 255 insertions, 187 deletions
diff --git a/.editorconfig b/.editorconfig
new file mode 100644
index 0000000..5f27700
--- /dev/null
+++ b/.editorconfig
@@ -0,0 +1,9 @@
+root = true
+
+[*]
+end_of_line = lf
+insert_final_newline = true
+charset = utf-8
+indent_size = 2
+indent_style = space
+trim_trailing_whitespace = true
diff --git a/package-lock.json b/package-lock.json
index 5f6156e..fd6d714 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,33 +1,35 @@
 {
   "name": "tui-lib",
-  "version": "0.1.0",
+  "version": "0.4.0",
   "lockfileVersion": 3,
   "requires": true,
   "packages": {
     "": {
       "name": "tui-lib",
-      "version": "0.1.0",
+      "version": "0.4.0",
       "license": "GPL-3.0",
       "dependencies": {
         "natural-orderby": "^3.0.2",
-        "wcwidth": "^1.0.1",
-        "word-wrap": "^1.2.3"
+        "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/natural-orderby": {
@@ -41,18 +43,10 @@
     "node_modules/wcwidth": {
       "version": "1.0.1",
       "resolved": "https://registry.npmjs.org/wcwidth/-/wcwidth-1.0.1.tgz",
-      "integrity": "sha1-8LDc+RW8X/FSivrbLA4XtTLaL+g=",
+      "integrity": "sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==",
       "dependencies": {
         "defaults": "^1.0.3"
       }
-    },
-    "node_modules/word-wrap": {
-      "version": "1.2.3",
-      "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.3.tgz",
-      "integrity": "sha512-Hz/mrNwitNRh/HUAtM/VT/5VH+ygD6DV7mYKZAtHOrbs8U7lvPS6xf7EJKMF0uW1KJCl0H701g3ZGus+muE5vQ==",
-      "engines": {
-        "node": ">=0.10.0"
-      }
     }
   }
 }
diff --git a/package.json b/package.json
index bc26059..4816f36 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
 {
   "name": "tui-lib",
-  "version": "0.1.0",
+  "version": "0.4.0",
   "description": "terminal ui library",
   "type": "module",
   "repository": "https://nebula.ed1.club/git/tui-lib/",
@@ -8,8 +8,7 @@
   "license": "GPL-3.0",
   "dependencies": {
     "natural-orderby": "^3.0.2",
-    "wcwidth": "^1.0.1",
-    "word-wrap": "^1.2.3"
+    "wcwidth": "^1.0.1"
   },
   "exports": {
     ".": "./index.js",
@@ -19,6 +18,7 @@
     "./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/todo.txt b/todo.txt
new file mode 100644
index 0000000..6a0fffc
--- /dev/null
+++ b/todo.txt
@@ -0,0 +1,4 @@
+TODO: Figure out why the text cursor is positioned differently when using
+      ANSI compression.
+
+TODO: Horizontal scroll-bars.
diff --git a/ui/controls/Form.js b/ui/controls/Form.js
index 921096a..0224247 100644
--- a/ui/controls/Form.js
+++ b/ui/controls/Form.js
@@ -81,8 +81,10 @@ export default class Form extends FocusElement {
   }
 
   previousInput() {
-    // TODO: Forms currently assume there is at least one selectable input,
-    // but this isn't necessarily always the case.
+    if (this.inputs.length === 0) {
+      return
+    }
+
     do {
       this.curIndex = (this.curIndex - 1)
       if (this.curIndex < 0) {
@@ -94,7 +96,10 @@ export default class Form extends FocusElement {
   }
 
   nextInput() {
-    // TODO: See previousInput
+    if (this.inputs.length === 0) {
+      return
+    }
+
     do {
       this.curIndex = (this.curIndex + 1) % this.inputs.length
     } while (!this.inputs[this.curIndex].selectable)
@@ -103,9 +108,12 @@ export default class Form extends FocusElement {
   }
 
   firstInput(selectForm = true) {
+    if (this.inputs.length === 0) {
+      return
+    }
+
     this.curIndex = 0
 
-    // TODO: See previousInput
     if (!this.inputs[this.curIndex].selectable) {
       this.nextInput()
     }
@@ -118,9 +126,12 @@ export default class Form extends FocusElement {
   }
 
   lastInput(selectForm = true) {
+    if (this.inputs.length === 0) {
+      return
+    }
+
     this.curIndex = this.inputs.length - 1
 
-    // TODO: See previousInput
     if (!this.inputs[this.curIndex].selectable) {
       this.previousInput()
     }
diff --git a/ui/controls/ListScrollForm.js b/ui/controls/ListScrollForm.js
index 3f75599..f74561e 100644
--- a/ui/controls/ListScrollForm.js
+++ b/ui/controls/ListScrollForm.js
@@ -2,9 +2,8 @@ import * as ansi from 'tui-lib/util/ansi'
 import telc from 'tui-lib/util/telchars'
 import unic from 'tui-lib/util/unichars'
 
-import {DisplayElement} from 'tui-lib/ui/primitives'
-
 import Form from './Form.js'
+import ScrollBar from './ScrollBar.js'
 
 export default class ListScrollForm extends Form {
   // A form that lets the user scroll through a list of items. It
@@ -17,12 +16,18 @@ export default class ListScrollForm extends Form {
     super()
 
     this.layoutType = layoutType
+    this.wheelMode = 'scroll' // scroll, selection
 
     this.scrollItems = 0
 
     this.scrollBarEnabled = enableScrollBar
 
-    this.scrollBar = new ScrollBar(this)
+    this.scrollBar = new ScrollBar({
+      getLayoutType: () => this.layoutType,
+      getCurrentScroll: () => this.scrollItems,
+      getMaximumScroll: () => this.getScrollItemsLength(),
+      getTotalItems: () => this.inputs.length
+    })
     this.scrollBarShown = false
   }
 
@@ -119,27 +124,26 @@ export default class ListScrollForm extends Form {
   }
 
   clicked(button) {
-    // Old code for changing the actual selected item...maybe an interesting
-    // functionality to explore later?
-    /*
-    if (button === 'scroll-up') {
-      this.previousInput()
-      this.scrollSelectedElementIntoView()
-    } else if (button === 'scroll-down') {
-      this.nextInput()
-      this.scrollSelectedElementIntoView()
-    }
-    */
-
-    // Scrolling is typically pretty slow with a mouse wheel when it's by
-    // a single line, so scroll at 3x that speed.
-    for (let i = 0; i < 3; i++) {
+    if (this.wheelMode === 'selection') {
+      // Change the actual selected item.
       if (button === 'scroll-up') {
-        this.scrollItems--
+        this.previousInput()
+        this.scrollSelectedElementIntoView()
       } else if (button === 'scroll-down') {
-        this.scrollItems++
-      } else {
-        return
+        this.nextInput()
+        this.scrollSelectedElementIntoView()
+      }
+    } else if (this.wheelMode === 'scroll') {
+      // Scrolling is typically pretty slow with a mouse wheel when it's by
+      // a single line, so scroll at 3x that speed.
+      for (let i = 0; i < 3; i++) {
+        if (button === 'scroll-up') {
+          this.scrollItems--
+        } else if (button === 'scroll-down') {
+          this.scrollItems++
+        } else {
+          return
+        }
       }
     }
 
@@ -298,108 +302,3 @@ export default class ListScrollForm extends Form {
     }
   }
 }
-
-class ScrollBar extends DisplayElement {
-  constructor(listScrollForm) {
-    super()
-
-    this.listScrollForm = listScrollForm
-  }
-
-  fixLayout() {
-    // Normally we'd subtract one from contentW/contentH when setting the x/y
-    // position, but the scrollbar is actually displayed OUTSIDE of (adjacent
-    // to) the parent's content area.
-    if (this.listScrollForm.layoutType === 'vertical') {
-      this.h = this.listScrollForm.contentH
-      this.w = 1
-      this.x = this.listScrollForm.contentW
-      this.y = 0
-    } else {
-      this.h = 1
-      this.w = this.listScrollForm.contentW
-      this.x = 0
-      this.y = this.listScrollForm.contentH
-    }
-  }
-
-  drawTo(writable) {
-    // Uuuurgh
-    this.fixLayout()
-
-    // TODO: Horizontal layout! Not functionally a lot different, but I'm too
-    // lazy to write a test UI for it right now.
-
-    const {
-      backwards: canScrollBackwards,
-      forwards: canScrollForwards
-    } = this.getScrollableDirections()
-
-    // - 2 for extra UI elements (arrows)
-    const totalLength = this.h - 2
-
-    // ..[-----]..
-    //   ^start|
-    //         ^end
-    //
-    // Start and end should correspond to how much of the scroll area
-    // is currently visible. So, if you can see 60% of the full scroll length
-    // at a time, and you are scrolled 10% down, the start position of the
-    // handle should be 10% down, and it should extend 60% of the scrollbar
-    // length, to the 70% mark.
-
-    const currentScroll = this.listScrollForm.scrollItems
-    const edgeLength = this.listScrollForm.contentH
-    const totalItems = this.listScrollForm.inputs.length
-    const itemsVisibleAtOnce = Math.min(totalItems, edgeLength)
-    const handleLength = itemsVisibleAtOnce / totalItems * totalLength
-    let handlePosition = Math.floor(totalLength / totalItems * currentScroll)
-
-    // Silly peeve of mine: The handle should only be visibly touching the top
-    // or bottom of the scrollbar area if you're actually scrolled all the way
-    // to the start or end. Otherwise, it shouldn't be touching! There should
-    // visible space indicating that you can scroll in that direction
-    // (in addition to the arrows we show at the ends).
-
-    if (canScrollBackwards && handlePosition === 0) {
-      handlePosition = 1
-    }
-
-    if (canScrollForwards && (handlePosition + handleLength) === edgeLength) {
-      handlePosition--
-    }
-
-    if (this.listScrollForm.layoutType === 'vertical') {
-      const start = this.absTop + handlePosition + 1
-      for (let i = 0; i < handleLength; i++) {
-        writable.write(ansi.moveCursor(start + i, this.absLeft))
-        writable.write(unic.BOX_V_DOUBLE)
-      }
-
-      if (canScrollBackwards) {
-        writable.write(ansi.moveCursor(this.absTop, this.absLeft))
-        writable.write(unic.ARROW_UP_DOUBLE)
-      }
-
-      if (canScrollForwards) {
-        writable.write(ansi.moveCursor(this.absBottom, this.absLeft))
-        writable.write(unic.ARROW_DOWN_DOUBLE)
-      }
-    }
-  }
-
-  getScrollableDirections() {
-    const currentScroll = this.listScrollForm.scrollItems
-    const totalScroll = this.listScrollForm.getScrollItemsLength()
-
-    return {
-      backwards: (currentScroll > 0),
-      forwards: (currentScroll < totalScroll)
-    }
-  }
-
-  canScrollAtAll() {
-    const {backwards, forwards} = this.getScrollableDirections()
-    return backwards || forwards
-  }
-}
diff --git a/ui/controls/ScrollBar.js b/ui/controls/ScrollBar.js
new file mode 100644
index 0000000..4b79d57
--- /dev/null
+++ b/ui/controls/ScrollBar.js
@@ -0,0 +1,121 @@
+import * as ansi from 'tui-lib/util/ansi'
+import unic from 'tui-lib/util/unichars'
+
+import {DisplayElement} from 'tui-lib/ui/primitives'
+
+export default class ScrollBar extends DisplayElement {
+  constructor({
+    getLayoutType,
+    getCurrentScroll,
+    getMaximumScroll,
+    getTotalItems
+  }) {
+    super()
+
+    this.getLayoutType = getLayoutType
+    this.getCurrentScroll = getCurrentScroll
+    this.getMaximumScroll = getMaximumScroll
+    this.getTotalItems = getTotalItems
+  }
+
+  fixLayout() {
+    // Normally we'd subtract one from contentW/contentH when setting the x/y
+    // position, but the scroll-bar is actually displayed OUTSIDE of (adjacent
+    // to) the parent's content area.
+    if (this.getLayoutType() === 'vertical') {
+      this.h = this.parent.contentH
+      this.w = 1
+      this.x = this.parent.contentW
+      this.y = 0
+    } else {
+      this.h = 1
+      this.w = this.parent.contentW
+      this.x = 0
+      this.y = this.parent.contentH
+    }
+  }
+
+  drawTo(writable) {
+    // Uuuurgh
+    this.fixLayout()
+
+    // TODO: Horizontal layout! Not functionally a lot different, but I'm too
+    // lazy to write a test UI for it right now.
+
+    const {
+      backwards: canScrollBackwards,
+      forwards: canScrollForwards
+    } = this.getScrollableDirections()
+
+    // - 2 for extra UI elements (arrows)
+    const totalLength = this.h - 2
+
+    // ..[-----]..
+    //   ^start|
+    //         ^end
+    //
+    // Start and end should correspond to how much of the scroll area
+    // is currently visible. So, if you can see 60% of the full scroll length
+    // at a time, and you are scrolled 10% down, the start position of the
+    // handle should be 10% down, and it should extend 60% of the scrollbar
+    // length, to the 70% mark.
+
+    // NB: I think this math mixes the units for "items" and "lines".
+    // edgeLength is measured in lines, while totalItems is a number of items.
+    // This isn't a problem when the length of an item is equal to one line,
+    // but it's still worth investigating at some point.
+    const currentScroll = this.getCurrentScroll()
+    const totalItems = this.getTotalItems()
+    const edgeLength = this.parent.contentH
+    const visibleAtOnce = Math.min(totalItems, edgeLength)
+    const handleLength = visibleAtOnce / totalItems * totalLength
+    let handlePosition = Math.floor(totalLength / totalItems * currentScroll)
+
+    // Silly peeve of mine: The handle should only be visibly touching the top
+    // or bottom of the scrollbar area if you're actually scrolled all the way
+    // to the start or end. Otherwise, it shouldn't be touching! There should
+    // visible space indicating that you can scroll in that direction
+    // (in addition to the arrows we show at the ends).
+
+    if (canScrollBackwards && handlePosition === 0) {
+      handlePosition = 1
+    }
+
+    if (canScrollForwards && (handlePosition + handleLength) === edgeLength) {
+      handlePosition--
+    }
+
+    if (this.getLayoutType() === 'vertical') {
+      const start = this.absTop + handlePosition + 1
+      for (let i = 0; i < handleLength; i++) {
+        writable.write(ansi.moveCursor(start + i, this.absLeft))
+        writable.write(unic.BOX_V_DOUBLE)
+      }
+
+      if (canScrollBackwards) {
+        writable.write(ansi.moveCursor(this.absTop, this.absLeft))
+        writable.write(unic.ARROW_UP_DOUBLE)
+      }
+
+      if (canScrollForwards) {
+        writable.write(ansi.moveCursor(this.absBottom, this.absLeft))
+        writable.write(unic.ARROW_DOWN_DOUBLE)
+      }
+    }
+  }
+
+  getScrollableDirections() {
+    const currentScroll = this.getCurrentScroll()
+    const maximumScroll = this.getMaximumScroll()
+
+    return {
+      backwards: (currentScroll > 0),
+      forwards: (currentScroll < maximumScroll)
+    }
+  }
+
+  canScrollAtAll() {
+    const {backwards, forwards} = this.getScrollableDirections()
+    return backwards || forwards
+  }
+}
diff --git a/ui/controls/index.js b/ui/controls/index.js
index e99add1..7f290c2 100644
--- a/ui/controls/index.js
+++ b/ui/controls/index.js
@@ -4,12 +4,14 @@
 //   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'
diff --git a/ui/presentation/Label.js b/ui/presentation/Label.js
index 81223df..ed45601 100644
--- a/ui/presentation/Label.js
+++ b/ui/presentation/Label.js
@@ -4,6 +4,16 @@ import * as ansi from 'tui-lib/util/ansi'
 
 export default class Label extends DisplayElement {
   // A simple text display. Automatically adjusts size to fit text.
+  //
+  // Supports formatted text in two ways:
+  // 1) Modify the textAttributes to be an array containing the ANSI numerical
+  //    codes for any wanted attributes, and/or
+  // 2) Supply full ANSI escape codes within the text itself. (The reset
+  //    attributes code, ESC[0m, will be processed to reset to the provided
+  //    values in textAttributes.
+  //
+  // Subclasses overriding the writeTextTo function should be sure to call
+  // processFormatting before actually writing text.
 
   constructor(text = '') {
     super()
@@ -32,7 +42,12 @@ export default class Label extends DisplayElement {
 
   writeTextTo(writable) {
     writable.write(ansi.moveCursor(this.absTop, this.absLeft))
-    writable.write(this.text)
+    writable.write(this.processFormatting(this.text))
+  }
+
+  processFormatting(text) {
+    return text.replace(new RegExp(ansi.ESC + '\\[0m', 'g'),
+      ansi.setAttributes([ansi.A_RESET, ...this.textAttributes]))
   }
 
   set text(newText) {
diff --git a/ui/presentation/WrapLabel.js b/ui/presentation/WrapLabel.js
index 0ecc777..eae8960 100644
--- a/ui/presentation/WrapLabel.js
+++ b/ui/presentation/WrapLabel.js
@@ -1,5 +1,3 @@
-import wrap from 'word-wrap'
-
 import * as ansi from 'tui-lib/util/ansi'
 
 import Label from './Label.js'
@@ -21,7 +19,7 @@ export default class WrapLabel extends Label {
     const lines = this.getWrappedLines()
     for (let i = 0; i < lines.length; i++) {
       writable.write(ansi.moveCursor(this.absTop + i, this.absLeft))
-      writable.write(lines[i])
+      writable.write(this.processFormatting(lines[i]))
     }
   }
 
@@ -30,9 +28,7 @@ export default class WrapLabel extends Label {
       return []
     }
 
-    const options = {width: this.w, indent: ''}
-    return wrap(this.text, options).split('\n')
-      .map(l => l.trim())
+    return ansi.wrapToColumns(this.text, this.w - 1).map(l => l.trim())
   }
 
   get h() {
diff --git a/ui/primitives/DisplayElement.js b/ui/primitives/DisplayElement.js
index d2a0956..6452887 100644
--- a/ui/primitives/DisplayElement.js
+++ b/ui/primitives/DisplayElement.js
@@ -237,7 +237,7 @@ export default class DisplayElement extends Element {
     children.reverse()
 
     for (const el of children) {
-      if (!el.visible || el.clickThrough) {
+      if (!el.visible) {
         continue
       }
 
@@ -246,6 +246,10 @@ export default class DisplayElement extends Element {
         return el2
       }
 
+      if (el.clickThrough) {
+        continue
+      }
+
       const { absX, absY, w, h } = el
       if (absX <= x && absX + w > x) {
         if (absY <= y && absY + h > y) {
diff --git a/ui/primitives/Element.js b/ui/primitives/Element.js
index fea8c03..a5dbea6 100644
--- a/ui/primitives/Element.js
+++ b/ui/primitives/Element.js
@@ -1,5 +1,7 @@
 import EventEmitter from 'node:events'
 
+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
diff --git a/util/ansi.js b/util/ansi.js
index 2ae5166..4e8abb0 100644
--- a/util/ansi.js
+++ b/util/ansi.js
@@ -154,7 +154,12 @@ export function disableAlternateScreen() {
 }
 
 export function measureColumns(text) {
-  // Returns the number of columns the given text takes.
+  // Returns the number of columns the given text takes. Accounts for escape
+  // codes (by not including them in the returned width).
+
+  if (text.includes(ESC)) {
+    text = text.replace(new RegExp(String.raw`${ESC}\[\??[0-9;]*.`, 'g'), '')
+  }
 
   return wcwidth(text)
 }
@@ -174,6 +179,33 @@ export function trimToColumns(text, cols) {
   return out
 }
 
+export function wrapToColumns(text, cols) {
+  // Wraps a string into separate lines. Returns an array of strings, for
+  // each line of the text.
+
+  const lines = []
+  const words = text.split(' ')
+
+  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
+    }
+  }
+
+  lines.push(curLine)
+
+  return lines
+}
+
 export function isANSICommand(buffer, code = null) {
   return (
     buffer[0] === 0x1b && buffer[1] === 0x5b &&
diff --git a/util/index.js b/util/index.js
index db3d8a7..43a08bd 100644
--- a/util/index.js
+++ b/util/index.js
@@ -8,4 +8,3 @@ 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/tui-app.js b/util/tui-app.js
index 2f09818..0dfd821 100644
--- a/util/tui-app.js
+++ b/util/tui-app.js
@@ -13,7 +13,7 @@ export default async function tuiApp(callback) {
 
     const flushable = new Flushable(process.stdout, true);
 
-    const root = new Root(screenInterface);
+    const root = new Root(screenInterface, flushable);
 
     const size = await screenInterface.getScreenSize();
     root.w = size.width;
diff --git a/util/waitForData.js b/util/waitForData.js
index f8d4a92..75f740e 100644
--- a/util/waitForData.js
+++ b/util/waitForData.js
@@ -1,9 +1,11 @@
 export default function waitForData(stream, cond = null) {
   return new Promise(resolve => {
-    stream.on('data', data => {
+    const listener = data => {
       if (cond ? cond(data) : true) {
         resolve(data)
+        stream.removeListener('data', listener)
       }
-    })
+    }
+    stream.on('data', listener)
   })
 }
diff --git a/util/wrap.js b/util/wrap.js
deleted file mode 100644
index 2c720c8..0000000
--- a/util/wrap.js
+++ /dev/null
@@ -1,22 +0,0 @@
-export default 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]
-
-  for (const word of words.slice(1)) {
-    if (curLine.length + word.length > width) {
-      lines.push(curLine)
-      curLine = word
-    } else {
-      curLine += ' ' + word
-    }
-  }
-
-  lines.push(curLine)
-
-  return lines
-}