@teaui/core
Version:
A high-level terminal UI library for Node
245 lines • 10.3 kB
JavaScript
import * as unicode from '@teaui/term';
import { Rect, Point, Size } from '../geometry.js';
import { Style } from '../Style.js';
import { define } from '../util.js';
import { ZStack } from './ZStack.js';
export class Box extends ZStack {
#border = 'single';
#borderChars = BORDERS.single;
#borderSizes = BORDER_SIZE_ZERO;
#highlight = false;
#title;
constructor(props) {
super(props);
define(this, 'border', { enumerable: true });
this.#update(props);
}
get border() {
return this.#border;
}
set border(value) {
this.#border = value;
[this.#borderChars, this.#borderSizes] = calculateBorder(value);
}
update(props) {
this.#update(props);
super.update(props);
}
get title() {
return this.#title;
}
set title(value) {
this.#title = value;
this.invalidateSize();
}
#update({ highlight, border, title }) {
this.#highlight = highlight ?? false;
this.#title = title;
this.border = border ?? 'single';
}
#resolvedTitle() {
if (this.#title !== undefined)
return this.#title;
return this.children[0]?.heading;
}
#headingHeight() {
if (!this.#resolvedTitle())
return 0;
// When there's a border, the heading overlays the top border — no extra height.
// When border is 'none', we need a dedicated row for the heading.
return this.#borderSizes.maxTop === 0 ? 1 : 0;
}
naturalSize(available) {
const headingHeight = this.#headingHeight();
const naturalSize = super.naturalSize(available.shrink(this.#borderSizes.maxLeft + this.#borderSizes.maxRight, this.#borderSizes.maxTop + this.#borderSizes.maxBottom + headingHeight));
return naturalSize.grow(this.#borderSizes.maxLeft + this.#borderSizes.maxRight, this.#borderSizes.maxTop + this.#borderSizes.maxBottom + headingHeight);
}
render(viewport) {
if (viewport.isEmpty) {
return super.render(viewport);
}
if (this.#highlight) {
viewport.registerMouse('mouse.move');
}
const headingHeight = this.#headingHeight();
const [top, left, tl, tr, bl, br, bottom, right] = this.#borderChars;
const maxX = viewport.contentSize.width;
const maxY = viewport.contentSize.height - headingHeight;
const innerTopWidth = Math.max(0, maxX - this.#borderSizes.topLeft.width - this.#borderSizes.topRight.width);
const innerBottomWidth = Math.max(0, maxX -
this.#borderSizes.bottomLeft.width -
this.#borderSizes.bottomRight.width);
const innerMiddleWidth = Math.max(0, maxX - this.#borderSizes.maxLeft - this.#borderSizes.maxRight);
const innerHeight = Math.max(0, maxY - this.#borderSizes.maxTop - this.#borderSizes.maxBottom);
const leftMaxX = this.#borderSizes.maxLeft;
const topRightX = this.#borderSizes.topLeft.width + innerTopWidth;
const bottomRightX = this.#borderSizes.bottomLeft.width + innerBottomWidth;
const middleRightX = this.#borderSizes.maxLeft + innerMiddleWidth;
const topInnerY = headingHeight + this.#borderSizes.maxTop;
const bottomInnerY = topInnerY + innerHeight;
const borderStyle = this.purpose
.text({ isHover: this.isHover })
.merge(new Style({ background: this.purpose.textBackgroundColor }));
const innerStyle = new Style({ background: this.purpose.textBackgroundColor });
const innerOrigin = new Point(this.#borderSizes.maxLeft, topInnerY);
if (innerHeight && innerMiddleWidth) {
for (let y = 0; y < innerHeight; ++y) {
const spaces = ' '.repeat(innerMiddleWidth);
viewport.write(spaces, innerOrigin.offset(0, y), innerStyle);
}
}
viewport.clipped(new Rect(innerOrigin, [innerMiddleWidth, innerHeight]), inside => super.render(inside));
viewport.usingPen(borderStyle, () => {
const [tlLines, topLines, trLines] = [
tl.split('\n'),
top.split('\n'),
tr.split('\n'),
];
for (let lineY = 0; lineY < this.#borderSizes.maxTop; ++lineY) {
const drawY = headingHeight + lineY;
const [lineTL, lineTop, lineTR] = [
tlLines[lineY] ?? '',
topLines[lineY] ?? '',
trLines[lineY] ?? '',
];
viewport.write(lineTL, new Point(0, drawY));
if (lineTop.length) {
viewport.write(lineTop
.repeat(Math.ceil(innerTopWidth / this.#borderSizes.topMiddle.width))
.slice(0, innerTopWidth), new Point(leftMaxX, drawY));
}
viewport.write(lineTR, new Point(topRightX, drawY));
}
const [leftLines, rightLines] = [left.split('\n'), right.split('\n')];
for (let lineY = topInnerY; lineY < bottomInnerY; ++lineY) {
const [lineL, lineR] = [
leftLines[(lineY - topInnerY) % leftLines.length] ?? '',
rightLines[(lineY - topInnerY) % rightLines.length] ?? '',
];
viewport.write(lineL, new Point(0, lineY));
viewport.write(lineR, new Point(middleRightX, lineY));
}
const [blLines, bottomLines, brLines] = [
bl.split('\n'),
bottom.split('\n'),
br.split('\n'),
];
for (let lineY = bottomInnerY; lineY < maxY + headingHeight; ++lineY) {
const [lineBL, lineBottom, lineBR] = [
blLines[lineY - bottomInnerY] ?? '',
bottomLines[lineY - bottomInnerY] ?? '',
brLines[lineY - bottomInnerY] ?? '',
];
viewport.write(lineBL, new Point(0, lineY));
if (lineBottom.length) {
viewport.write(lineBottom
.repeat(Math.ceil(innerBottomWidth / this.#borderSizes.topMiddle.width))
.slice(0, innerBottomWidth), new Point(leftMaxX, lineY));
}
viewport.write(lineBR, new Point(bottomRightX, lineY));
}
});
// Render heading over the top border (or on a blank line for borderless)
const resolvedTitle = this.#resolvedTitle();
if (resolvedTitle) {
const headingText = resolvedTitle.slice(0, maxX - HEADING_X - HEADING_PAD);
const headingY = headingHeight > 0 ? 0 : 0;
viewport.write(headingText, new Point(HEADING_X, headingY), this.#headingStyle());
}
}
#headingStyle() {
const textStyle = this.purpose.text({ isHover: this.isHover });
return new Style({ bold: true, background: textStyle.background });
}
}
function calculateBorder(border) {
let chars;
if (typeof border === 'string') {
chars = BORDERS[border];
}
else if (border.length === 8) {
chars = border;
}
else if (border.length === 7) {
chars = [...border, border[1]];
}
else {
chars = [...border, border[0], border[1]];
}
// TLTL\n| TOP TOP\n|TRTR\n
// TL 2| TOP 0|TR 3
// ------+----------+----
// LEFT\n| |RIGHT\n
// LEFT 1| |RIGHT 7
// ------+----------+----
// BLBL\n| BOTTOM\n |BRBR\n
// BL 5 | BOTTOM 6|BR 4
const topLeft = borderSize(chars[2]);
const topMiddle = borderSize(chars[0]);
const topRight = borderSize(chars[3]);
const leftMiddle = borderSize(chars[1]);
const bottomLeft = borderSize(chars[4]);
const bottomMiddle = chars[6] !== undefined ? borderSize(chars[6]) : topMiddle;
const bottomRight = borderSize(chars[5]);
const rightMiddle = chars[7] !== undefined ? borderSize(chars[7]) : leftMiddle;
return [
chars,
{
maxLeft: leftMiddle.width,
maxRight: rightMiddle.width,
// if leftMiddle and rightMiddle are the same width as topLeft and topRight
// corners, use topMiddle.height as maxTop.
// Otherwise, use the max of the corners and middle.
maxTop: leftMiddle.width === topLeft.width &&
rightMiddle.width === topRight.width
? topMiddle.height
: Math.max(topLeft.height, topMiddle.height, topRight.height),
// if leftMiddle and rightMiddle are the same width as bottomLeft and bottomRight
// corners, use bottomMiddle.height as maxBottom.
// Otherwise, use the max of the corners and middle.
maxBottom: leftMiddle.width === topLeft.width &&
rightMiddle.width === topRight.width
? bottomMiddle.height
: Math.max(bottomLeft.height, bottomMiddle.height, bottomRight.height),
topLeft,
topMiddle,
topRight,
leftMiddle,
rightMiddle,
bottomLeft,
bottomMiddle,
bottomRight,
},
];
}
function borderSize(str) {
if (str === '') {
return { width: 0, height: 0 };
}
return unicode.stringSize(str);
}
const BORDERS = {
none: ['', '', '', '', '', '', '', ''],
single: ['─', '│', '┌', '┐', '└', '┘', '─', '│'],
bold: ['━', '┃', '┏', '┓', '┗', '┛', '━', '┃'],
double: ['═', '║', '╔', '╗', '╚', '╝', '═', '║'],
rounded: ['─', '│', '╭', '╮', '╰', '╯', '─', '│'],
dotted: ['⠒', '⡇', '⡖', '⢲', '⠧', '⠼', '⠤', '⢸'],
};
const HEADING_X = 2;
const HEADING_PAD = 1;
const BORDER_SIZE_ZERO = {
maxTop: 0,
maxRight: 0,
maxBottom: 0,
maxLeft: 0,
topMiddle: new Size(0, 0),
topLeft: new Size(0, 0),
topRight: new Size(0, 0),
leftMiddle: new Size(0, 0),
rightMiddle: new Size(0, 0),
bottomMiddle: new Size(0, 0),
bottomLeft: new Size(0, 0),
bottomRight: new Size(0, 0),
};
//# sourceMappingURL=Box.js.map