diff options
Diffstat (limited to 'ui/controls')
-rw-r--r-- | ui/controls/Button.js | 51 | ||||
-rw-r--r-- | ui/controls/FocusBox.js | 32 | ||||
-rw-r--r-- | ui/controls/Form.js | 154 | ||||
-rw-r--r-- | ui/controls/ListScrollForm.js | 304 | ||||
-rw-r--r-- | ui/controls/ScrollBar.js | 121 | ||||
-rw-r--r-- | ui/controls/TextInput.js | 147 | ||||
-rw-r--r-- | ui/controls/index.js | 18 |
7 files changed, 827 insertions, 0 deletions
diff --git a/ui/controls/Button.js b/ui/controls/Button.js new file mode 100644 index 0000000..5be2b2a --- /dev/null +++ b/ui/controls/Button.js @@ -0,0 +1,51 @@ +import {FocusElement} from 'tui-lib/ui/primitives' + +import * as ansi from 'tui-lib/util/ansi' +import telc from 'tui-lib/util/telchars' + +export default class Button extends FocusElement { + // A button. + + constructor(text) { + super() + + this.text = text + + this.cursorX = null + this.cursorY = null + } + + fixLayout() { + this.w = ansi.measureColumns(this.text) + this.h = 1 + } + + drawTo(writable) { + if (this.isSelected) { + writable.write(ansi.invert()) + } + + writable.write(ansi.moveCursor(this.absTop, this.absLeft)) + writable.write(this.text) + + writable.write(ansi.resetAttributes()) + + super.drawTo(writable) + } + + keyPressed(keyBuf) { + if (telc.isSelect(keyBuf)) { + this.emit('pressed') + } + } + + clicked(button) { + if (button === 'left') { + if (this.isSelected) { + this.emit('pressed') + } else { + this.root.select(this) + } + } + } +} diff --git a/ui/controls/FocusBox.js b/ui/controls/FocusBox.js new file mode 100644 index 0000000..64f84c9 --- /dev/null +++ b/ui/controls/FocusBox.js @@ -0,0 +1,32 @@ +import {FocusElement} from 'tui-lib/ui/primitives' + +import * as ansi from 'tui-lib/util/ansi' + +export default class FocusBox extends FocusElement { + // A box (not to be confused with Pane!) that can be selected. When it's + // selected, it applies an invert effect to its children. (This won't work + // well if you have elements inside of it that have their own attributes, + // since they're likely to reset all effects after drawing - including the + // invert from the FocusBox! Bad ANSI limitations; it's relatively likely + // I'll implement maaaaaagic to help deal with this - maybe something + // similar to 'pushMatrix' from Processing - at some point... [TODO]) + + constructor() { + super() + + this.cursorX = null + this.cursorY = null + } + + drawTo(writable) { + if (this.isSelected) { + writable.write(ansi.invert()) + } + } + + didRenderTo(writable) { + if (this.isSelected) { + writable.write(ansi.resetAttributes()) + } + } +} diff --git a/ui/controls/Form.js b/ui/controls/Form.js new file mode 100644 index 0000000..0224247 --- /dev/null +++ b/ui/controls/Form.js @@ -0,0 +1,154 @@ +import telc from 'tui-lib/util/telchars' + +import {FocusElement} from 'tui-lib/ui/primitives' + +export default class Form extends FocusElement { + constructor() { + super() + + this.inputs = [] + this.curIndex = 0 + this.captureTab = true + } + + addInput(input, asChild = true, opts = {}) { + // Adds the given input as a child element and pushes it to the input + // list. If the optional argument asChild is false, it won't add the + // input element as a child of the form. + + this.inputs.push(input) + + if (asChild) { + this.addChild(input, this.children.length, opts) + } + } + + removeInput(input, asChild = true, opts = {}) { + // Removes the given input from the form's input list. If the optional + // argument asChild is false, it won't try to removeChild the input. + + if (this.inputs.includes(input)) { + this.inputs.splice(this.inputs.indexOf(input), 1) + + if (asChild) { + this.removeChild(input, opts) + } + } + } + + selectInput(input) { + if (this.inputs.includes(input)) { + this.curIndex = this.inputs.indexOf(input) + this.updateSelectedElement() + } + } + + keyPressed(keyBuf) { + // Don't do anything if captureTab is set to false. This is handy for + // nested forms. + if (!this.captureTab) { + return + } + + if (telc.isTab(keyBuf) || telc.isBackTab(keyBuf)) { + // No inputs to tab through, so do nothing. + if (this.inputs.length < 2) { + return + } + + if (telc.isTab(keyBuf)) { + this.nextInput() + } else { + this.previousInput() + } + + return false + } + } + + get selectable() { + return this.inputs.some(inp => inp.selectable) + } + + updateSelectedElement() { + if (this.root.select && this.inputs.length) { + if (this.curIndex > this.inputs.length - 1) { + this.curIndex = this.inputs.length - 1 + } + + this.root.select(this.inputs[this.curIndex], {fromForm: true}) + } + } + + previousInput() { + if (this.inputs.length === 0) { + return + } + + do { + this.curIndex = (this.curIndex - 1) + if (this.curIndex < 0) { + this.curIndex = (this.inputs.length - 1) + } + } while (!this.inputs[this.curIndex].selectable) + + this.updateSelectedElement() + } + + nextInput() { + if (this.inputs.length === 0) { + return + } + + do { + this.curIndex = (this.curIndex + 1) % this.inputs.length + } while (!this.inputs[this.curIndex].selectable) + + this.updateSelectedElement() + } + + firstInput(selectForm = true) { + if (this.inputs.length === 0) { + return + } + + this.curIndex = 0 + + if (!this.inputs[this.curIndex].selectable) { + this.nextInput() + } + + if (selectForm || ( + this.root.isChildOrSelfSelected && this.root.isChildOrSelfSelected(this) + )) { + this.updateSelectedElement() + } + } + + lastInput(selectForm = true) { + if (this.inputs.length === 0) { + return + } + + this.curIndex = this.inputs.length - 1 + + if (!this.inputs[this.curIndex].selectable) { + this.previousInput() + } + + if (selectForm || ( + this.root.isChildOrSelfSelected && this.root.isChildOrSelfSelected(this) + )) { + this.updateSelectedElement() + } + } + + selected() { + if (this.root.selectedElement === this) { + this.updateSelectedElement() + } + } + + get curIndex() { return this.getDep('curIndex') } + set curIndex(v) { return this.setDep('curIndex', v) } +} diff --git a/ui/controls/ListScrollForm.js b/ui/controls/ListScrollForm.js new file mode 100644 index 0000000..f74561e --- /dev/null +++ b/ui/controls/ListScrollForm.js @@ -0,0 +1,304 @@ +import * as ansi from 'tui-lib/util/ansi' +import telc from 'tui-lib/util/telchars' +import unic from 'tui-lib/util/unichars' + +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 + // automatically adjusts to always allow the selected item to be visible. + // Unless disabled in the constructor, a scrollbar is automatically displayed + // if there are more items than can be shown in the height of the form at a + // single time. + + constructor(layoutType = 'vertical', enableScrollBar = true) { + super() + + this.layoutType = layoutType + this.wheelMode = 'scroll' // scroll, selection + + this.scrollItems = 0 + + this.scrollBarEnabled = enableScrollBar + + this.scrollBar = new ScrollBar({ + getLayoutType: () => this.layoutType, + getCurrentScroll: () => this.scrollItems, + getMaximumScroll: () => this.getScrollItemsLength(), + getTotalItems: () => this.inputs.length + }) + this.scrollBarShown = false + } + + fixLayout() { + this.keepScrollInBounds() + + const scrollItems = this.scrollItems + + // The scrollItems property represents the item to the very left of where + // we've scrolled, so we know right away that none of those will be + // visible and we won't bother iterating over them. We do need to hide + // them, though. + for (let i = 0; i < Math.min(scrollItems, this.inputs.length); i++) { + this.inputs[i].visible = false + } + + // This variable stores how far along the respective axis (implied by + // layoutType) the next element should be. + let nextPos = 0 + + let formEdge + if (this.layoutType === 'horizontal') { + formEdge = this.contentW + } else { + formEdge = this.contentH + } + + for (let i = scrollItems; i < this.inputs.length; i++) { + const item = this.inputs[i] + item.fixLayout() + + const curPos = nextPos + let curSize + if (this.layoutType === 'horizontal') { + item.x = curPos + curSize = item.w + } else { + item.y = curPos + curSize = item.h + } + nextPos += curSize + + // By default, the item should be visible.. + item.visible = true + + // ..but the item's far edge is past the form's far edge, it isn't + // fully visible and should be hidden. + if (curPos + curSize > formEdge) { + item.visible = false + } + + // Same deal goes for the close edge. We can check it against 0 since + // the close edge of the form's content is going to be 0, of course! + if (curPos < 0) { + item.visible = false + } + } + + delete this._scrollItemsLength + + if (this.scrollBarEnabled) { + this.showScrollbarIfNecessary() + } + } + + keyPressed(keyBuf) { + let ret + + handleKeyPress: { + if (this.layoutType === 'horizontal') { + if (telc.isLeft(keyBuf)) { + this.previousInput() + ret = false; break handleKeyPress + } else if (telc.isRight(keyBuf)) { + this.nextInput() + ret = false; break handleKeyPress + } + } else if (this.layoutType === 'vertical') { + if (telc.isUp(keyBuf)) { + this.previousInput() + ret = false; break handleKeyPress + } else if (telc.isDown(keyBuf)) { + this.nextInput() + ret = false; break handleKeyPress + } + } + + ret = super.keyPressed(keyBuf) + } + + this.scrollSelectedElementIntoView() + + return ret + } + + clicked(button) { + if (this.wheelMode === 'selection') { + // Change the actual selected item. + if (button === 'scroll-up') { + this.previousInput() + this.scrollSelectedElementIntoView() + } else if (button === 'scroll-down') { + 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 + } + } + } + + this.fixLayout() + } + + scrollSelectedElementIntoView() { + const sel = this.inputs[this.curIndex] + + if (!sel) { + return + } + + let formEdge + if (this.layoutType === 'horizontal') { + formEdge = this.contentW + } else { + formEdge = this.contentH + } + + // If the item is ahead of our view (either to the right of or below), + // we should move the view so that the item is the farthest right (of all + // the visible items). + if (this.getItemPos(sel) > formEdge + this.scrollSize) { + this.scrollElementIntoEndOfView(sel) + } + + // Adjusting the number of scroll items is much simpler to deal with if + // the item is behind our view. Since the item's behind, we need to move + // the scroll to be immediately behind it, which is simple since we + // already have its index. + if (this.getItemPos(sel) <= this.scrollSize) { + this.scrollItems = this.curIndex + } + + this.fixLayout() + } + + firstInput(...args) { + this.scrollItems = 0 + + super.firstInput(...args) + + this.fixLayout() + } + + getScrollPositionOfElementAtEndOfView(element) { + // We can decide how many items to scroll past by moving forward until + // the item's far edge is visible. + const pos = this.getItemPos(element) + + let edge + if (this.layoutType === 'horizontal') { + edge = this.contentW + } else { + edge = this.contentH + } + + for (let i = 0; i < this.inputs.length; i++) { + if (pos <= edge) { + return i + } + + if (this.layoutType === 'horizontal') { + edge += this.inputs[i].w + } else { + edge += this.inputs[i].h + } + } + // No result? Well, it's at the end. + return this.inputs.length + } + + scrollElementIntoEndOfView(element) { + this.scrollItems = this.getScrollPositionOfElementAtEndOfView(element) + } + + scrollToBeginning() { + this.scrollItems = 0 + this.fixLayout() + } + + scrollToEnd() { + this.scrollElementIntoEndOfView(this.inputs[this.inputs.length - 1]) + this.fixLayout() + } + + keepScrollInBounds() { + this.scrollItems = Math.max(this.scrollItems, 0) + this.scrollItems = Math.min(this.scrollItems, this.getScrollItemsLength()) + } + + getScrollItemsLength() { + if (typeof this._scrollItemsLength === 'undefined') { + const lastInput = this.inputs[this.inputs.length - 1] + this._scrollItemsLength = this.getScrollPositionOfElementAtEndOfView(lastInput) + } + + return this._scrollItemsLength + } + + getItemPos(item) { + // Gets the position of the item in an unscrolled view. + + const index = this.inputs.indexOf(item) + let pos = 0 + for (let i = 0; i <= index; i++) { + if (this.layoutType === 'horizontal') { + pos += this.inputs[i].w + } else { + pos += this.inputs[i].h + } + } + return pos + } + + showScrollbarIfNecessary() { + this.scrollBarShown = this.scrollBar.canScrollAtAll() + + const isChild = this.children.includes(this.scrollBar) + if (this.scrollBarShown) { + if (!isChild) this.addChild(this.scrollBar) + } else { + if (isChild) this.removeChild(this.scrollBar) + } + } + + get scrollSize() { + // Gets the actual length made up by all of the items currently scrolled + // past. + + let size = 0 + for (let i = 0; i < Math.min(this.scrollItems, this.inputs.length); i++) { + if (this.layoutType === 'horizontal') { + size += this.inputs[i].w + } else { + size += this.inputs[i].h + } + } + return size + } + + get contentW() { + if (this.scrollBarShown && this.layoutType === 'vertical') { + return this.w - 1 + } else { + return this.w + } + } + + get contentH() { + if (this.scrollBarShown && this.layoutType === 'horizontal') { + return this.h - 1 + } else { + return this.h + } + } +} 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/TextInput.js b/ui/controls/TextInput.js new file mode 100644 index 0000000..1a32605 --- /dev/null +++ b/ui/controls/TextInput.js @@ -0,0 +1,147 @@ +import {FocusElement} from 'tui-lib/ui/primitives' + +import * as ansi from 'tui-lib/util/ansi' +import telc from 'tui-lib/util/telchars' +import unic from 'tui-lib/util/unichars' + +export default class TextInput extends FocusElement { + // An element that the user can type in. + + constructor() { + super() + + this.value = '' + this.cursorVisible = true + this.cursorIndex = 0 + this.scrollChars = 0 + } + + drawTo(writable) { + // There should be room for the cursor so move the "right edge" left a + // single character. + + const startRange = this.scrollChars + const endRange = this.scrollChars + this.w - 3 + + let str = this.value.slice(startRange, endRange) + + writable.write(ansi.moveCursor(this.absTop, this.absLeft + 1)) + writable.write(str) + + // Ellipsis on left side, if there's more characters behind the visible + // area. + if (startRange > 0) { + writable.write(ansi.moveCursor(this.absTop, this.absLeft)) + writable.write(unic.ELLIPSIS) + } + + // Ellipsis on the right side, if there's more characters ahead of the + // visible area. + if (endRange < this.value.length) { + writable.write(ansi.moveCursor(this.absTop, this.absRight - 1)) + writable.write(unic.ELLIPSIS.repeat(2)) + } + + this.cursorX = this.cursorIndex - this.scrollChars + 1 + + super.drawTo(writable) + } + + keyPressed(keyBuf) { + try { + if (keyBuf[0] === 127) { + this.value = ( + this.value.slice(0, this.cursorIndex - 1) + + this.value.slice(this.cursorIndex) + ) + this.cursorIndex-- + this.root.cursorMoved() + return false + } else if (keyBuf[0] === 13) { + // These are aliases for each other. + this.emit('value', this.value) + this.emit('confirm', this.value) + } else if (keyBuf[0] === 0x1b && keyBuf[1] === 0x5b) { + // Keyboard navigation + if (keyBuf[2] === 0x44) { + this.cursorIndex-- + this.root.cursorMoved() + } else if (keyBuf[2] === 0x43) { + this.cursorIndex++ + this.root.cursorMoved() + } + return false + } else if (telc.isEscape(keyBuf)) { + // ESC is bad and we don't want that in the text input! + // Also emit a "cancel" event, which doesn't necessarily do anything, + // but can be listened to. + this.emit('cancel') + } else { + const isTextInput = keyBuf.toString().split('').every(chr => { + const n = chr.charCodeAt(0) + return n > 31 && n < 127 + }) + + if (isTextInput) { + this.value = ( + this.value.slice(0, this.cursorIndex) + keyBuf.toString() + + this.value.slice(this.cursorIndex) + ) + this.cursorIndex += keyBuf.toString().length + this.root.cursorMoved() + this.emit('change', this.value) + + return false + } + } + } finally { + this.keepCursorInRange() + } + } + + setValue(value) { + this.value = value + this.moveToEnd() + } + + moveToEnd() { + this.cursorIndex = this.value.length + this.keepCursorInRange() + } + + keepCursorInRange() { + // Keep the cursor inside or at the end of the input value. + + if (this.cursorIndex < 0) { + this.cursorIndex = 0 + } + + if (this.cursorIndex > this.value.length) { + this.cursorIndex = this.value.length + } + + // Scroll right, if the cursor is past the right edge of where text is + // displayed. + while (this.cursorIndex - this.scrollChars > this.w - 3) { + this.scrollChars++ + } + + // Scroll left, if the cursor is behind the left edge of where text is + // displayed. + while (this.cursorIndex - this.scrollChars < 0) { + this.scrollChars-- + } + + // Scroll left, if we can see past the end of the text. + while (this.scrollChars > 0 && ( + this.scrollChars + this.w - 3 > this.value.length) + ) { + this.scrollChars-- + } + } + + get value() { return this.getDep('value') } + set value(v) { return this.setDep('value', v) } + get cursorIndex() { return this.getDep('cursorIndex') } + set cursorIndex(v) { return this.setDep('cursorIndex', v) } +} diff --git a/ui/controls/index.js b/ui/controls/index.js new file mode 100644 index 0000000..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' |