From 6ea74c268a12325296a1d2e7fc31b02030ddb8bc Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Fri, 12 May 2023 17:42:09 -0300 Subject: 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 years since the previous time. First commits are from January 2017, and the code originates a month earlier in KAaRMNoD! --- ui/controls/ListScrollForm.js | 405 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 405 insertions(+) create mode 100644 ui/controls/ListScrollForm.js (limited to 'ui/controls/ListScrollForm.js') diff --git a/ui/controls/ListScrollForm.js b/ui/controls/ListScrollForm.js new file mode 100644 index 0000000..3f75599 --- /dev/null +++ b/ui/controls/ListScrollForm.js @@ -0,0 +1,405 @@ +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' + +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.scrollItems = 0 + + this.scrollBarEnabled = enableScrollBar + + this.scrollBar = new ScrollBar(this) + 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) { + // 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 (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 + } + } +} + +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 + } +} -- cgit 1.3.0-6-gf8a5