From c37d7f01f97fcc446cd9e54c2b9bafda12a2106d Mon Sep 17 00:00:00 2001 From: sbosse Date: Mon, 21 Jul 2025 23:11:17 +0200 Subject: [PATCH] Mon 21 Jul 22:43:21 CEST 2025 --- js/term/widgets/scrollablebox.js | 446 +++++++++++++++++++++++++++++++ 1 file changed, 446 insertions(+) create mode 100644 js/term/widgets/scrollablebox.js diff --git a/js/term/widgets/scrollablebox.js b/js/term/widgets/scrollablebox.js new file mode 100644 index 0000000..35fdd8d --- /dev/null +++ b/js/term/widgets/scrollablebox.js @@ -0,0 +1,446 @@ +/** + ** ============================== + ** O O O OOOO + ** O O O O O O + ** O O O O O O + ** OOOO OOOO O OOO OOOO + ** O O O O O O O + ** O O O O O O O + ** OOOO OOOO O O OOOO + ** ============================== + ** Dr. Stefan Bosse http://www.bsslab.de + ** + ** COPYRIGHT: THIS SOFTWARE, EXECUTABLE AND SOURCE CODE IS OWNED + ** BY THE AUTHOR(S). + ** THIS SOURCE CODE MAY NOT BE COPIED, EXTRACTED, + ** MODIFIED, OR OTHERWISE USED IN A CONTEXT + ** OUTSIDE OF THE SOFTWARE SYSTEM. + ** + ** $AUTHORS: Christopher Jeffrey and contributors, Stefan Bosse + ** $INITIAL: (C) 2013-2016, Christopher Jeffrey and contributors + ** $MODIFIED: sbosse (2017-2021). + ** $VERSION: 1.2.3 + ** + ** $INFO: + * + * scrollablebox.js - scrollable box element for blessed + * + * events: 'mouse', 'click' (low level), 'clicked' (high level) + * + ** $ENDOFINFO + */ + +/** + * Modules + */ +var Comp = Require('com/compat'); + +var Node = Require('term/widgets/node'); +var Box = Require('term/widgets/box'); + +/** + * ScrollableBox + */ + +function ScrollableBox(options) { + var self = this; + + if (!instanceOf(this,Node)) { + return new ScrollableBox(options); + } + + options = options || {}; + + Box.call(this, options); + + if (options.scrollable === false) { + return this; + } + + this.scrollable = true; + this.childOffset = 0; + this.childBase = 0; + this.baseLimit = options.baseLimit || Infinity; + this.alwaysScroll = options.alwaysScroll; + + this.scrollbar = options.scrollbar; + if (this.scrollbar) { + this.scrollbar.ch = this.scrollbar.ch || ' '; + this.style.scrollbar = this.style.scrollbar || this.scrollbar.style; + if (!this.style.scrollbar) { + this.style.scrollbar = {}; + this.style.scrollbar.fg = this.scrollbar.fg; + this.style.scrollbar.bg = this.scrollbar.bg; + this.style.scrollbar.bold = this.scrollbar.bold; + this.style.scrollbar.underline = this.scrollbar.underline; + this.style.scrollbar.inverse = this.scrollbar.inverse; + this.style.scrollbar.invisible = this.scrollbar.invisible; + } + //this.scrollbar.style = this.style.scrollbar; + if (this.track || this.scrollbar.track) { + this.track = this.scrollbar.track || this.track; + this.style.track = this.style.scrollbar.track || this.style.track; + this.track.ch = this.track.ch || ' '; + this.style.track = this.style.track || this.track.style; + if (!this.style.track) { + this.style.track = {}; + this.style.track.fg = this.track.fg; + this.style.track.bg = this.track.bg; + this.style.track.bold = this.track.bold; + this.style.track.underline = this.track.underline; + this.style.track.inverse = this.track.inverse; + this.style.track.invisible = this.track.invisible; + } + this.track.style = this.style.track; + } + // Allow controlling of the scrollbar via the mouse: + if (options.mouse) { + this.on('mousedown', function(data) { + if (self._scrollingBar) { + // Do not allow dragging on the scrollbar: + delete self.screen._dragging; + delete self._drag; + return; + } + var x = data.x - self.aleft; + var y = data.y - self.atop; + self.emit('clicked',{x:x-1,y:y-1,ev:'click'}); + if (x === self.width - self.iright - 1) { + // Do not allow dragging on the scrollbar: + delete self.screen._dragging; + delete self._drag; + var perc = (y - self.itop) / (self.height - self.iheight); + self.setScrollPerc(perc * 100 | 0); + self.screen.render(); + var smd, smu; + self._scrollingBar = true; + self.onScreenEvent('mousedown', smd = function(data) { + var y = data.y - self.atop; + var perc = y / self.height; + self.setScrollPerc(perc * 100 | 0); + self.screen.render(); + }); + // If mouseup occurs out of the window, no mouseup event fires, and + // scrollbar will drag again on mousedown until another mouseup + // occurs. + self.onScreenEvent('mouseup', smu = function() { + self._scrollingBar = false; + self.removeScreenEvent('mousedown', smd); + self.removeScreenEvent('mouseup', smu); + }); + } + }); + } + } + + if (options.mouse) { + this.on('wheeldown', function() { + self.scroll(self.height / 2 | 0 || 1); + self.screen.render(); + }); + this.on('wheelup', function() { + self.scroll(-(self.height / 2 | 0) || -1); + self.screen.render(); + }); + } + + if (options.keys && !options.ignoreKeys) { + this.on('keypress', function(ch, key) { + if (key.name === 'up' || (options.vi && key.name === 'k')) { + self.scroll(-1); + self.screen.render(); + return; + } + if (key.name === 'down' || (options.vi && key.name === 'j')) { + self.scroll(1); + self.screen.render(); + return; + } + if (key.name === 'pageup') { + self.scroll(-(self.height / 2 | 0) || -1); + self.screen.render(); + return; + } + if (key.name === 'pagedown') { + self.scroll(self.height / 2 | 0 || 1); + self.screen.render(); + return; + } + if (options.vi && key.name === 'u' && key.ctrl) { + self.scroll(-(self.height / 2 | 0) || -1); + self.screen.render(); + return; + } + if (options.vi && key.name === 'd' && key.ctrl) { + self.scroll(self.height / 2 | 0 || 1); + self.screen.render(); + return; + } + if (options.vi && key.name === 'b' && key.ctrl) { + self.scroll(-self.height || -1); + self.screen.render(); + return; + } + if (options.vi && key.name === 'f' && key.ctrl) { + self.scroll(self.height || 1); + self.screen.render(); + return; + } + if (options.vi && key.name === 'g' && !key.shift) { + self.scrollTo(0); + self.screen.render(); + return; + } + if (options.vi && key.name === 'g' && key.shift) { + self.scrollTo(self.getScrollHeight()); + self.screen.render(); + return; + } + }); + } + + this.on('parsed content', function() { + self._recalculateIndex(); + }); + + self._recalculateIndex(); +} + +//ScrollableBox.prototype.__proto__ = Box.prototype; +inheritPrototype(ScrollableBox,Box); + +ScrollableBox.prototype.type = 'scrollable-box'; + +/* depricated +// XXX Potentially use this in place of scrollable checks elsewhere. +ScrollableBox.prototype.__defineGetter__('reallyScrollable', function() { + if (this.shrink) return this.scrollable; + return this.getScrollHeight() > this.height; +}); +*/ +Object.defineProperty(ScrollableBox.prototype,'reallyScrollable',{ + get: function () { + if (this.shrink) return this.scrollable; + return this.getScrollHeight() > this.height; + }, + set: function (val) { + } +}); + + +ScrollableBox.prototype._scrollBottom = function() { + if (!this.scrollable) return 0; + + // We could just calculate the children, but we can + // optimize for lists by just returning the items.length. + if (this._isList) { + return this.items ? this.items.length : 0; + } + + if (this.lpos && this.lpos._scrollBottom) { + return this.lpos._scrollBottom; + } + + var bottom = this.children.reduce(function(current, el) { + // el.height alone does not calculate the shrunken height, we need to use + // getCoords. A shrunken box inside a scrollable element will not grow any + // larger than the scrollable element's context regardless of how much + // content is in the shrunken box, unless we do this (call getCoords + // without the scrollable calculation): + // See: $ node test/widget-shrink-fail-2.js + if (!el.detached) { + var lpos = el._getCoords(false, true); + if (lpos) { + return Math.max(current, el.rtop + (lpos.yl - lpos.yi)); + } + } + return Math.max(current, el.rtop + el.height); + }, 0); + + // XXX Use this? Makes .getScrollHeight() useless! + // if (bottom < this._clines.length) bottom = this._clines.length; + + if (this.lpos) this.lpos._scrollBottom = bottom; + + return bottom; +}; + +ScrollableBox.prototype.setScroll = +ScrollableBox.prototype.scrollTo = function(offset, always) { + // XXX + // At first, this appeared to account for the first new calculation of childBase: + this.scroll(0); + return this.scroll(offset - (this.childBase + this.childOffset), always); + +}; + +ScrollableBox.prototype.getScroll = function() { + return this.childBase + this.childOffset; +}; + +ScrollableBox.prototype.scroll = function(offset, always) { + if (!this.scrollable) return; + + if (this.detached) return; + + // Handle scrolling. + var visible = this.height - this.iheight + , base = this.childBase + , d + , p + , t + , b + , max + , emax; + + if (this.alwaysScroll || always) { + // Semi-workaround + this.childOffset = offset > 0 + ? visible - 1 + offset + : offset; + } else { + this.childOffset += offset; + } + + if (this.childOffset > visible - 1) { + d = this.childOffset - (visible - 1); + this.childOffset -= d; + this.childBase += d; + } else if (this.childOffset < 0) { + d = this.childOffset; + this.childOffset += -d; + this.childBase += d; + } + + if (this.childBase < 0) { + this.childBase = 0; + } else if (this.childBase > this.baseLimit) { + this.childBase = this.baseLimit; + } + + // Find max "bottom" value for + // content and descendant elements. + // Scroll the content if necessary. + if (this.childBase === base) { + return this.emit('scroll'); + } + + // When scrolling text, we want to be able to handle SGR codes as well as line + // feeds. This allows us to take preformatted text output from other programs + // and put it in a scrollable text box. + this.parseContent(); + + // XXX + // max = this.getScrollHeight() - (this.height - this.iheight); + + max = this._clines.length - (this.height - this.iheight); + if (max < 0) max = 0; + emax = this._scrollBottom() - (this.height - this.iheight); + if (emax < 0) emax = 0; + + this.childBase = Math.min(this.childBase, Math.max(emax, max)); + + if (this.childBase < 0) { + this.childBase = 0; + } else if (this.childBase > this.baseLimit) { + this.childBase = this.baseLimit; + } + + // Optimize scrolling with CSR + IL/DL. + p = this.lpos; + // Only really need _getCoords() if we want + // to allow nestable scrolling elements... + // or if we **really** want shrinkable + // scrolling elements. + // p = this._getCoords(); + if (p && this.childBase !== base && this.screen.cleanSides(this)) { + t = p.yi + this.itop; + b = p.yl - this.ibottom - 1; + d = this.childBase - base; + + if (d > 0 && d < visible) { + // scrolled down + this.screen.deleteLine(d, t, t, b); + } else if (d < 0 && -d < visible) { + // scrolled up + d = -d; + this.screen.insertLine(d, t, t, b); + } + } + + return this.emit('scroll'); +}; + +ScrollableBox.prototype.scrollBottom = function () { + // Workaround: inserting lines when scrollbar was manually set + // breaks scroll window (can't usee _scrollBottom...) + this.scrollTo(10000000); +}; + +ScrollableBox.prototype._recalculateIndex = function() { + var max, emax; + + if (this.detached || !this.scrollable) { + return 0; + } + + // XXX + // max = this.getScrollHeight() - (this.height - this.iheight); + + max = this._clines.length - (this.height - this.iheight); + if (max < 0) max = 0; + emax = this._scrollBottom() - (this.height - this.iheight); + if (emax < 0) emax = 0; + + this.childBase = Math.min(this.childBase, Math.max(emax, max)); + + if (this.childBase < 0) { + this.childBase = 0; + } else if (this.childBase > this.baseLimit) { + this.childBase = this.baseLimit; + } +}; + +ScrollableBox.prototype.resetScroll = function() { + if (!this.scrollable) return; + this.childOffset = 0; + this.childBase = 0; + return this.emit('scroll'); +}; + +ScrollableBox.prototype.getScrollHeight = function() { + return Math.max(this._clines.length, this._scrollBottom()); +}; + +ScrollableBox.prototype.getScrollPerc = function(s) { + var pos = this.lpos || this._getCoords(); + if (!pos) return s ? -1 : 0; + + var height = (pos.yl - pos.yi) - this.iheight + , i = this.getScrollHeight() + , p; + + if (height < i) { + if (this.alwaysScroll) { + p = this.childBase / (i - height); + } else { + p = (this.childBase + this.childOffset) / (i - 1); + } + return p * 100; + } + + return s ? -1 : 0; +}; + +ScrollableBox.prototype.setScrollPerc = function(i) { + // XXX + // var m = this.getScrollHeight(); + var m = Math.max(this._clines.length, this._scrollBottom()); + return this.scrollTo((i / 100) * m | 0); +}; + +/** + * Expose + */ + +module.exports = ScrollableBox;