@teaui/core
Version:
A high-level terminal UI library for Node
377 lines • 13.9 kB
JavaScript
import { Container } from '../Container.js';
import { Point, Rect, Size } from '../geometry.js';
import { Style } from '../Style.js';
import { Text } from './Text.js';
import { isMouseClicked } from '../events/index.js';
import { define } from '../util.js';
// tabs = new Tabs()
// tabs.addTab('title', tab)
// tabs.addTab(new Text({text: 'title', style: …}), tab)
//
// tabs.add()
export class Tabs extends Container {
static Section;
#selectedTab = 0;
#separatorLocation;
#separatorMovement;
#separatorWidths = [];
#border = false;
static create(tabs, extraProps = {}) {
const tabsView = new Tabs(extraProps);
for (const tab of tabs) {
if (tab instanceof Section) {
tabsView.addTab(tab);
}
else {
const [title, view] = tab;
tabsView.addTab(title, view);
}
}
return tabsView;
}
constructor(props = {}) {
super(props);
this.#update(props);
}
get tabs() {
return this.children.filter(view => view instanceof Section);
}
get tabTitles() {
return this.children.filter(view => view instanceof TabTitle);
}
get border() {
return this.#border;
}
set border(value) {
if (value === this.#border)
return;
this.#border = value;
this.invalidateSize();
}
get selected() {
return this.#selectedTab;
}
set selected(value) {
this.select(value);
}
update(props) {
super.update(props);
this.#update(props);
}
#update({ border, selected }) {
this.#border = border ?? true;
if (selected !== undefined) {
this.select(selected);
}
}
select(tab) {
const selectedTab = Math.max(0, Math.floor(tab));
if (selectedTab === this.#selectedTab) {
return;
}
this.#selectedTab = selectedTab;
this.invalidateRender();
}
addTab(titleOrTab, child) {
let tabView;
if (titleOrTab instanceof Section) {
tabView = titleOrTab;
}
else {
tabView = Section.create(titleOrTab, child);
}
this.add(tabView);
return tabView;
}
removeTab(index) {
if (index instanceof Section) {
this.removeChild(index);
}
else {
const tab = this.tabs.at(index);
if (tab) {
this.removeChild(tab);
}
}
}
add(child, at) {
if (child instanceof Section) {
child.titleView.onClick = tab => this.#selectTitle(tab);
super.add(child.titleView);
}
super.add(child, at);
}
removeChild(child) {
if (child instanceof Section && child.titleView) {
super.removeChild(child.titleView);
}
super.removeChild(child);
}
#selectTitle(tab) {
const tabTitles = this.tabTitles;
const index = tabTitles.indexOf(tab);
if (index === -1) {
return;
}
this.select(index);
}
naturalSize(available) {
const remainingSize = available.mutableCopy();
const tabTitleSize = this.tabTitles.reduce((size, tab) => {
const tabSize = tab.naturalSize(remainingSize).mutableCopy();
size.width += tabSize.width;
size.height = Math.max(size.height, tabSize.height);
remainingSize.width = Math.max(0, remainingSize.width - tabSize.width);
return size;
}, Size.zero.mutableCopy());
const childSize = Size.zero.mutableCopy();
const availableChildSize = available.shrink(0, tabTitleSize.height);
for (const tab of this.tabs) {
const tabSize = tab.naturalSize(availableChildSize);
childSize.width = Math.max(childSize.width, tabSize.width);
childSize.height = Math.max(childSize.height, tabSize.height);
}
return new Size(Math.max(tabTitleSize.width, childSize.width) + (this.#border ? 3 : 0), tabTitleSize.height + childSize.height + (this.#border ? 1 : 0));
}
receiveTick(dt) {
if (this.#separatorLocation === undefined ||
this.#selectedTab >= this.#separatorWidths.length) {
return false;
}
const [start, stop] = this.#separatorWidths.reduce(([start, stop, _prev], width, index) => index === this.#selectedTab
? [start, stop + width, 0]
: index > this.#selectedTab
? [start, stop, 0]
: [start + width, stop + width, width], [0, 0, 0]);
const movement = this.#separatorMovementFor(start, stop);
const dx = movement.pixelsPerMs * dt;
let didMove = false;
if (start < this.#separatorLocation[0]) {
this.#separatorLocation[0] = Math.max(start, this.#separatorLocation[0] - dx);
didMove = true;
}
else if (start > this.#separatorLocation[0]) {
this.#separatorLocation[0] = Math.min(start, this.#separatorLocation[0] + dx);
didMove = true;
}
if (stop > this.#separatorLocation[1]) {
this.#separatorLocation[1] = Math.min(stop, this.#separatorLocation[1] + dx);
didMove = true;
}
else if (stop < this.#separatorLocation[1]) {
this.#separatorLocation[1] = Math.max(stop, this.#separatorLocation[1] - dx);
didMove = true;
}
if (!didMove) {
this.#separatorMovement = undefined;
return false;
}
if (this.#separatorLocation[1] <= this.#separatorLocation[0] + 1) {
this.#separatorLocation[1] = Math.min(stop, this.#separatorLocation[0] + 1);
}
if (this.#separatorLocation[0] === start &&
this.#separatorLocation[1] === stop) {
this.#separatorMovement = undefined;
}
return true;
}
#separatorMovementFor(start, stop) {
if (this.#separatorMovement?.start === start &&
this.#separatorMovement.stop === stop) {
return this.#separatorMovement;
}
const maxDistance = Math.max(Math.abs(start - this.#separatorLocation[0]), Math.abs(stop - this.#separatorLocation[1]));
this.#separatorMovement = {
start,
stop,
pixelsPerMs: Math.max(TAB_SEPARATOR_PIXELS_PER_MS, maxDistance / TAB_SEPARATOR_MAX_DURATION_MS),
};
return this.#separatorMovement;
}
render(viewport) {
viewport.registerTick();
const remainingSize = viewport.contentSize.mutableCopy();
const tabInfo = [];
const separatorWidths = [];
let x = this.#border ? 2 : 0, tabHeight = 0;
this.tabTitles.forEach((tab, index) => {
const tabRect = new Rect(new Point(x, 0), tab.naturalSize(remainingSize));
tabInfo.push([tabRect, tab]);
remainingSize.width -= tabRect.size.width;
if (this.#separatorLocation === undefined &&
this.#selectedTab === index) {
const borderOffset = this.#border ? 2 : 0;
this.#separatorLocation = [
x - borderOffset,
x - borderOffset + tabRect.size.width,
];
}
x += tabRect.size.width;
tabHeight = Math.max(tabHeight, tabRect.size.height - TAB_SEPARATOR_HEIGHT);
separatorWidths.push(tabRect.size.width);
});
this.#selectedTab = Math.max(0, Math.min(separatorWidths.length - 1, this.#selectedTab));
this.#separatorWidths = separatorWidths;
if (this.#separatorLocation) {
this.#renderSeparator(viewport, tabHeight, separatorWidths, this.#separatorLocation);
}
if (this.#border) {
const borderRect = viewport.contentRect.inset(tabHeight + TAB_SEPARATOR_HEIGHT - 1, 0, 0);
viewport.clipped(borderRect, inner => this.#renderBorder(inner, this.#separatorWidths));
}
tabInfo.forEach(([tabRect, tab]) => {
viewport.clipped(tabRect, inner => tab.render(inner));
});
const selectedTab = this.tabs.at(this.#selectedTab);
if (selectedTab) {
const childRect = viewport.contentRect.inset(tabHeight + TAB_SEPARATOR_HEIGHT, this.#border ? 1 : 0, this.#border ? 1 : 0);
viewport.clipped(childRect, inner => {
selectedTab.render(inner);
});
}
}
#renderBorder(viewport, separatorWidths) {
const totalWidth = separatorWidths.reduce((a, b) => a + b, 2);
for (let x = viewport.contentSize.width - 2; x > 0; x--) {
if (x === totalWidth) {
viewport.write('╶', new Point(x, 0));
}
else if (x > totalWidth) {
viewport.write('─', new Point(x, 0));
}
viewport.write('─', new Point(x, viewport.contentSize.height - 1));
}
for (let y = viewport.contentSize.height - 2; y > 0; y--) {
viewport.write('│', new Point(0, y));
viewport.write('│', new Point(viewport.contentSize.width - 1, y));
}
viewport.write('┌╴', new Point(0, 0));
viewport.write('┐', new Point(viewport.contentSize.width - 1, 0));
viewport.write('└', new Point(0, viewport.contentSize.height - 1));
viewport.write('┘', new Point(viewport.contentSize.width - 1, viewport.contentSize.height - 1));
}
#renderSeparator(viewport, tabHeight, separatorWidths, separatorLocation) {
// separatorLocation is rounded down in this function
let xLeft = this.#border ? 2 : 0, xRight = 0, didDrawSeparator = false;
const [separatorStart, separatorStop] = [
~~separatorLocation[0] + xLeft,
~~separatorLocation[1] + xLeft,
];
viewport.paint(new Style({
background: this.purpose.ui().background,
}), new Rect([separatorStart, 0], [separatorStop - separatorStart, tabHeight + 1]));
separatorWidths.forEach((separatorWidth, index) => {
const tab = this.tabs.at(index);
const isHover = tab?.isHover ?? false;
xRight = xLeft + separatorWidth;
let underline;
if (xLeft >= separatorStart && xLeft <= separatorStop) {
const xMid = Math.min(separatorStop, xRight);
const u1 = '━'.repeat(xMid - xLeft);
const u2 = dashesLeft(xRight - separatorStop, isHover);
underline = u1 + u2;
didDrawSeparator = false;
}
else if (xRight >= separatorStart && xLeft < separatorStop) {
const xMid = Math.min(separatorStop, xRight);
const u0 = dashesRight(separatorStart - xLeft, isHover);
const u1 = '━'.repeat(xMid - separatorStart);
const u2 = dashesLeft(xRight - separatorStop, isHover);
underline = u0 + u1 + u2;
didDrawSeparator = xRight > separatorStop;
}
else if (didDrawSeparator) {
underline = dashesLeft(separatorWidth, isHover);
didDrawSeparator = false;
}
else if (xRight === separatorStart) {
underline = dashesRight(separatorWidth, isHover);
}
else {
underline = dashes(separatorWidth, isHover);
}
viewport.write(underline, new Point(xLeft, tabHeight));
xLeft += separatorWidth;
});
}
}
class TabTitle extends Container {
#textView;
onClick;
constructor(title) {
super({});
this.#textView = new Text({
text: title ?? '',
style: this.titleStyle,
});
this.add(this.#textView);
}
get title() {
return this.#textView.text;
}
set title(value) {
this.#textView.text = value;
}
get titleStyle() {
return new Style({ bold: this.isHover });
}
naturalSize(available) {
return this.#textView
.naturalSize(available)
.grow(TAB_TITLE_PAD, TAB_SEPARATOR_HEIGHT);
}
receiveMouse(event, system) {
super.receiveMouse(event, system);
if (isMouseClicked(event)) {
this.onClick?.(this);
}
this.#textView.style = this.titleStyle;
}
render(viewport) {
viewport.registerMouse(['mouse.button.left', 'mouse.move']);
viewport.clipped(new Rect([1, 0], viewport.contentSize.shrink(TAB_TITLE_PAD, 0)), inner => {
this.#textView.render(inner);
});
}
}
class Section extends Container {
titleView = new TabTitle('');
static create(title, child, extraProps = {}) {
return new Section({ title, child, ...extraProps });
}
constructor({ title, ...props }) {
super(props);
this.titleView.title = title ?? '';
define(this, 'title', { enumerable: true });
}
get title() {
return this.titleView.title;
}
set title(value) {
this.titleView.title = value;
}
}
function dashesLeft(w, isHover) {
if (w <= 0) {
return '';
}
return (isHover ? '╺' : '╶') + dashes(w - 1, isHover);
}
function dashesRight(w, isHover) {
if (w <= 0) {
return '';
}
return dashes(w - 1, isHover) + (isHover ? '╸' : '╴');
}
function dashes(w, isHover) {
if (w <= 0) {
return '';
}
return (isHover ? '━' : '─').repeat(w);
}
Tabs.Section = Section;
const TAB_TITLE_PAD = 2;
const TAB_SEPARATOR_HEIGHT = 1;
const TAB_SEPARATOR_PIXELS_PER_MS = 1 / 20;
const TAB_SEPARATOR_MAX_DURATION_MS = 700;
//# sourceMappingURL=Tabs.js.map