@teaui/core
Version:
A high-level terminal UI library for Node
383 lines • 17.2 kB
JavaScript
import { Container } from '../Container.js';
import { Point, Rect, Size, interpolate } from '../geometry.js';
import { isMouseWheel } from '../events/index.js';
import { Style } from '../Style.js';
import { Stack } from './Stack.js';
function fromShorthand(props, direction, extraProps = {}) {
if (Array.isArray(props)) {
return { children: props, direction, ...extraProps };
}
else {
return { ...props, direction, ...extraProps };
}
}
/**
* Scrollable manages an internal Stack for layout and adds scroll offset,
* scrollbar rendering, and mouse wheel handling on top.
*
* Children added to the Scrollable are delegated to the internal Stack.
* Use `direction` to control layout (default: 'down'), or the static
* constructors `Scrollable.down()`, `Scrollable.right()`, etc.
*/
export class Scrollable extends Container {
#stack;
#scrollable = 'both';
#showScrollbars = true;
#scrollHeight = 1;
#scrollWidth = 2;
#keepAtBottom = false;
#isAtBottom = true;
#contentOffset;
#contentSize = Size.zero;
#contentSizeOverride;
#visibleSize = Size.zero;
#prevMouseDown = undefined;
#onOffsetChange;
static down(props = {}, extraProps = {}) {
return new Scrollable(fromShorthand(props, 'down', extraProps));
}
static up(props = {}, extraProps = {}) {
return new Scrollable(fromShorthand(props, 'up', extraProps));
}
static right(props = {}, extraProps = {}) {
return new Scrollable(fromShorthand(props, 'right', extraProps));
}
static left(props = {}, extraProps = {}) {
return new Scrollable(fromShorthand(props, 'left', extraProps));
}
constructor({ children, child, direction, gap, ...props }) {
super(props);
this.#stack = new Stack({ direction: direction ?? 'down', gap });
// Add the Stack as the actual child of the Container
super.add(this.#stack);
this.#contentOffset = { x: 0, y: 0 };
this.#update(props);
// Add user children to the internal Stack
if (child) {
this.#stack.add(child);
}
if (children) {
for (const c of children) {
this.#stack.add(c);
}
}
}
update({ children, child, direction, gap, ...props }) {
this.#update(props);
if (direction !== undefined) {
this.#stack.direction = direction;
}
if (gap !== undefined) {
this.#stack.gap = gap;
}
// Delegate child management to the internal Stack
if (child !== undefined || children !== undefined) {
const allChildren = [];
if (children) {
allChildren.push(...children);
}
if (child) {
allChildren.push(child);
}
this.#stack.update({
direction: direction ?? this.#stack.direction,
gap: gap ?? this.#stack.gap,
children: allChildren,
});
}
super.update(props);
}
#update({ scrollable, showScrollbars, keepAtBottom, contentSize: contentSizeOverride, offset, onOffsetChange, }) {
this.#scrollable = scrollable ?? 'both';
this.#showScrollbars = showScrollbars ?? true;
this.#keepAtBottom = keepAtBottom ?? false;
this.#contentSizeOverride = contentSizeOverride;
this.#onOffsetChange = onOffsetChange;
if (offset) {
this.#contentOffset = { x: -offset.x, y: -offset.y };
}
}
/**
* Children are delegated to the internal Stack.
*/
add(child, at) {
this.#stack.add(child, at);
}
removeChild(child) {
this.#stack.removeChild(child);
}
removeAllChildren() {
this.#stack.removeAllChildren();
}
/**
* Returns the children of the internal Stack (the user's children),
* not the Scrollable's direct children (which is just the Stack).
*/
get children() {
return this.#stack.children;
}
naturalSize(available) {
const size = this.#stack.naturalSize(available).mutableCopy();
size.width = Math.min(size.width, available.width);
size.height = Math.min(size.height, available.height);
return size;
}
#maxOffsetX() {
const tooTall = this.#contentSize.height > this.contentSize.height;
return this.#visibleSize.width - this.#contentSize.width + (tooTall ? 0 : 1);
}
#maxOffsetY() {
const tooWide = this.#contentSize.width > this.contentSize.width;
return (this.#visibleSize.height - this.#contentSize.height + (tooWide ? 0 : 1));
}
receiveMouse(event) {
if (isMouseWheel(event)) {
this.receiveWheel(event);
return;
}
if (event.name === 'mouse.button.up') {
this.#prevMouseDown = undefined;
return;
}
const tooWide = this.#contentSize.width > this.contentSize.width;
const tooTall = this.#contentSize.height > this.contentSize.height;
if (tooWide &&
tooTall &&
event.position.y === this.contentSize.height - 1 &&
event.position.x === this.contentSize.width - 1) {
// bottom-right corner click
return;
}
if (this.#prevMouseDown === undefined) {
if (tooWide && event.position.y === this.contentSize.height) {
this.#prevMouseDown = 'horizontal';
}
else if (tooTall && event.position.x === this.contentSize.width) {
this.#prevMouseDown = 'vertical';
}
else {
return;
}
}
this.receiveMouseDown(event);
}
receiveMouseDown(event) {
const tooWide = this.#contentSize.width > this.contentSize.width;
const tooTall = this.#contentSize.height > this.contentSize.height;
const showVBar = this.#showVerticalScrollbar() && tooTall;
const showHBar = this.#showHorizontalScrollbar() && tooWide;
const visibleWidth = this.contentSize.width - (showVBar ? 1 : 0);
const visibleHeight = this.contentSize.height - (showHBar ? 1 : 0);
if (tooWide && this.#prevMouseDown === 'horizontal') {
const trackWidth = visibleWidth;
const maxOffsetX = Math.max(0, this.#contentSize.width - visibleWidth);
const thumbWidth = this.#scrollbarThumbLength(trackWidth, visibleWidth, this.#contentSize.width);
const maxScrollbarX = Math.max(0, trackWidth - thumbWidth);
const thumbX = Math.max(0, Math.min(maxScrollbarX, event.position.x - Math.floor(thumbWidth / 2)));
const offsetX = this.#scrollbarThumbPosition(thumbX, maxScrollbarX, maxOffsetX);
this.#contentOffset = {
x: -offsetX,
y: this.#contentOffset.y,
};
}
else if (tooTall && this.#prevMouseDown === 'vertical') {
const trackHeight = visibleHeight;
const maxOffsetY = Math.max(0, this.#contentSize.height - visibleHeight);
const thumbHeight = this.#scrollbarThumbLength(trackHeight, visibleHeight, this.#contentSize.height);
const maxScrollbarY = Math.max(0, trackHeight - thumbHeight);
const thumbY = Math.max(0, Math.min(maxScrollbarY, event.position.y - Math.floor(thumbHeight / 2)));
const offsetY = this.#scrollbarThumbPosition(thumbY, maxScrollbarY, maxOffsetY);
const y = -offsetY;
this.#contentOffset = {
x: this.#contentOffset.x,
y,
};
this.#isAtBottom = y <= this.#maxOffsetY();
}
}
receiveWheel(event) {
let deltaY = 0, deltaX = 0;
if (event.name === 'mouse.wheel.up') {
deltaY = this.#scrollHeight * -1;
}
else if (event.name === 'mouse.wheel.down') {
deltaY = this.#scrollHeight;
}
else if (event.name === 'mouse.wheel.left') {
deltaX = this.#scrollWidth;
}
else if (event.name === 'mouse.wheel.right') {
deltaX = this.#scrollWidth * -1;
}
if (event.ctrl) {
deltaY *= 5;
deltaX *= 5;
}
const tooTall = (this.#contentSize?.height ?? 0) > this.contentSize.height;
if (!tooTall && deltaX === 0) {
deltaX = deltaY;
}
this.scrollBy(deltaX, deltaY);
}
/**
* Moves the visible region. The visible region is stored as a pointer to the
* top-most row and an offset from the top of that row (see `interface ContentOffset`)
*
* Positive offset scrolls *down* (currentOffset goes more negative)
*
* When current cell is entirely above the top, we set the `contentOffset` to the
* row that is at the top of the screen and still visible, similarly if the current
* cell is below the top, we fetch enough rows about and update the `contentOffset`
* to point to the top-most row.
*/
scrollBy(offsetX, offsetY) {
if (offsetX === 0 && offsetY === 0) {
return;
}
// Restrict scrolling to allowed direction(s)
if (this.#scrollable === 'horizontal') {
offsetY = 0;
}
else if (this.#scrollable === 'vertical') {
offsetX = 0;
}
if (offsetX === 0 && offsetY === 0) {
return;
}
let { x, y } = this.#contentOffset;
const maxX = this.#maxOffsetX();
const maxY = this.#maxOffsetY();
x = Math.min(0, Math.max(maxX, x - offsetX));
y = Math.min(0, Math.max(maxY, y - offsetY));
this.#contentOffset = { x, y };
// Track whether we're at the bottom (for keepAtBottom)
this.#isAtBottom = y <= maxY;
this.#onOffsetChange?.(new Point(-x || 0, -y || 0));
}
#showHorizontalScrollbar() {
return (this.#showScrollbars === true || this.#showScrollbars === 'horizontal');
}
#showVerticalScrollbar() {
return this.#showScrollbars === true || this.#showScrollbars === 'vertical';
}
#scrollbarStyle() {
return new Style({
foreground: this.purpose.darkenColor,
background: this.purpose.darkenColor,
});
}
#scrollbarThumbStyle() {
return new Style({
foreground: this.purpose.highlightColor,
background: this.purpose.highlightColor,
});
}
#scrollbarThumbLength(trackLength, visibleLength, contentLength) {
const proportionalLength = Math.round((visibleLength / contentLength) * trackLength);
const maxLength = contentLength > visibleLength ? trackLength - 1 : trackLength;
return Math.max(1, Math.min(maxLength, proportionalLength));
}
#scrollbarThumbPosition(contentOffset, maxContentOffset, maxScrollbarOffset) {
return Math.round(interpolate(contentOffset, [0, maxContentOffset], [0, maxScrollbarOffset], true));
}
get contentSize() {
const deltaW = this.#showVerticalScrollbar() ? 1 : 0;
const deltaH = this.#showHorizontalScrollbar() ? 1 : 0;
return super.contentSize.shrink(deltaW, deltaH);
}
render(viewport) {
if (viewport.isEmpty) {
return super.render(viewport);
}
viewport.registerMouse('mouse.wheel');
let contentSize = Size.zero.mutableCopy();
if (this.#contentSizeOverride) {
contentSize.width =
this.#contentSizeOverride.width ?? viewport.contentSize.width;
contentSize.height =
this.#contentSizeOverride.height ?? viewport.contentSize.height;
}
else {
const stackSize = this.#stack.naturalSize(viewport.contentSize);
contentSize.width = stackSize.width;
contentSize.height = stackSize.height;
}
this.#contentSize = contentSize;
const canScrollHoriz = this.#scrollable !== 'vertical';
const canScrollVert = this.#scrollable !== 'horizontal';
const tooWide = canScrollHoriz && contentSize.width > viewport.contentSize.width;
const tooTall = canScrollVert && contentSize.height > viewport.contentSize.height;
// keepAtBottom: snap to end when content grows and we were at the bottom
if (this.#keepAtBottom && this.#isAtBottom && tooTall) {
const maxY = this.#maxOffsetY();
this.#contentOffset = { x: this.#contentOffset.x, y: maxY };
}
const showVBar = this.#showVerticalScrollbar() && tooTall;
const showHBar = this.#showHorizontalScrollbar() && tooWide;
// #contentOffset is _negative_ (indicates the amount to move the view away
// from the origin, which will always be up/left of 0,0)
// Children are laid out in a region that is at least as wide/tall as the
// content, and at least as wide/tall as the viewport (minus scrollbars).
// This ensures flex children expand to fill the visible area, while
// children that overflow extend beyond it.
const visibleWidth = viewport.contentSize.width - (showVBar ? 1 : 0);
const visibleHeight = viewport.contentSize.height - (showHBar ? 1 : 0);
// First clip to exclude scrollbar area — this ensures that the inner
// viewport's visibleRect does not include the scrollbar column/row.
// Without this, pinned children (which size to visibleRect) would
// overlap the scrollbar.
const scrollableArea = new Rect(Point.zero, new Size(visibleWidth, visibleHeight));
const outside = new Rect([this.#contentOffset.x, this.#contentOffset.y], [
Math.max(contentSize.width, visibleWidth),
Math.max(contentSize.height, visibleHeight),
]);
viewport.clipped(scrollableArea, contentViewport => {
contentViewport.clipped(outside, inside => {
this.#stack.render(inside);
});
});
// Note: #visibleSize is used in #maxOffsetX/#maxOffsetY calculations.
// The formula requires shrinking by overflow status (not scrollbar visibility)
// because the +1 correction in the offset formulas compensates for this.
this.#visibleSize = viewport.visibleRect.size.shrink(tooWide ? 1 : 0, tooTall ? 1 : 0);
if (showVBar || showHBar) {
const scrollBar = this.#scrollbarStyle();
const scrollControl = this.#scrollbarThumbStyle();
// scrollMaxX: x of the last column of the view
// scrollMaxY: y of the last row of the view
// scrollMaxHorizX: horizontal scroll bar is drawn from 0 to scrollMaxHorizX
// scrollMaxHorizY: vertical scroll bar is drawn from 0 to scrollMaxHorizY
const scrollMaxX = viewport.contentSize.width - 1, scrollMaxY = viewport.contentSize.height - 1, scrollMaxHorizX = scrollMaxX - (showVBar ? 1 : 0), scrollMaxVertY = scrollMaxY - (showHBar ? 1 : 0);
if (showHBar && showVBar) {
viewport.write('█', new Point(scrollMaxX, scrollMaxY), scrollBar);
}
if (showHBar) {
viewport.registerMouse('mouse.button.left', new Rect(new Point(0, scrollMaxY), new Size(scrollMaxHorizX + 1, 1)));
const trackWidth = scrollMaxHorizX + 1;
const maxOffsetX = Math.max(0, contentSize.width - visibleWidth);
const thumbWidth = this.#scrollbarThumbLength(trackWidth, visibleWidth, contentSize.width);
const maxScrollbarX = Math.max(0, trackWidth - thumbWidth);
const contentOffsetX = -this.#contentOffset.x;
const viewX = this.#scrollbarThumbPosition(contentOffsetX, maxOffsetX, maxScrollbarX);
for (let x = 0; x <= scrollMaxHorizX; x++) {
const inRange = x >= viewX && x < viewX + thumbWidth;
viewport.write(inRange ? '█' : ' ', new Point(x, scrollMaxY), inRange ? scrollControl : scrollBar);
}
}
if (showVBar) {
viewport.registerMouse('mouse.button.left', new Rect(new Point(scrollMaxX, 0), new Size(1, scrollMaxVertY + 1)));
const trackHeight = scrollMaxVertY + 1;
const maxOffsetY = Math.max(0, contentSize.height - visibleHeight);
const thumbHeight = this.#scrollbarThumbLength(trackHeight, visibleHeight, contentSize.height);
const maxScrollbarY = Math.max(0, trackHeight - thumbHeight);
const contentOffsetY = -this.#contentOffset.y;
const viewY = this.#scrollbarThumbPosition(contentOffsetY, maxOffsetY, maxScrollbarY);
for (let y = 0; y <= scrollMaxVertY; y++) {
const inRange = y >= viewY && y < viewY + thumbHeight;
viewport.write(inRange ? '█' : ' ', new Point(scrollMaxX, y), inRange ? scrollControl : scrollBar);
}
}
}
}
}
//# sourceMappingURL=Scrollable.js.map