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
-}
|