194 lines
6.5 KiB
TypeScript
194 lines
6.5 KiB
TypeScript
import { Graphics } from 'pixi.js';
|
||
import { Button, HStack, Menu, Theme } from '../ui';
|
||
import { DocumentTab } from './DocumentTab';
|
||
import { TabButton } from './TabButton';
|
||
import { tabRowLayout } from './tabRow';
|
||
|
||
/** Fixed, so that the count of hidden tabs cannot feed back into how much room the row has. */
|
||
const OVERFLOW_WIDTH = 28;
|
||
/** The bar Graphics is the first child, so a tab's index among the children is two past its place. */
|
||
const TABS_AT = 2;
|
||
|
||
export interface TabPlace<T extends string> {
|
||
tab: T;
|
||
x: number;
|
||
right: number;
|
||
/** Top edge and height, so a check can tell the tab from a label of the same text elsewhere. */
|
||
y: number;
|
||
h: number;
|
||
/** Whether the name is drawn bold – the tab showing must not be (#176). */
|
||
bold: boolean;
|
||
/** Whether an icon stands in front of the name; the fixed tabs have one too, bar Plugins (#176). */
|
||
icon: boolean;
|
||
/** A document's kind as shown before its name, and where its × is. */
|
||
kind?: string;
|
||
close?: { x: number; y: number };
|
||
}
|
||
|
||
/** A document's tab: the icon of its kind before the name, and an × that calls `onClose`. */
|
||
export interface DocumentSpec {
|
||
/** The kind as a word, for the check scripts; the tab shows `icon`. */
|
||
kind: string;
|
||
icon: string;
|
||
onClose: () => void;
|
||
}
|
||
|
||
/**
|
||
* The row of tab buttons. What does not fit goes behind a `»` button that opens the rest as a
|
||
* menu, so the row survives any window width and any number of tabs.
|
||
*/
|
||
export class TabStrip<T extends string> extends HStack {
|
||
private readonly order: T[] = [];
|
||
private readonly buttons = new Map<T, Button>();
|
||
/** A document's icon, which the `»` menu shows in front of its name too. */
|
||
private readonly icons = new Map<T, string>();
|
||
private readonly overflow: Button;
|
||
/** The bar the cards sit on, drawn behind every tab. */
|
||
private readonly bar = new Graphics();
|
||
private hiddenTabs: T[] = [];
|
||
private active: T | null;
|
||
|
||
constructor(
|
||
tabs: readonly { tab: T; label: string; icon?: string }[],
|
||
private readonly onPick: (tab: T) => void,
|
||
) {
|
||
super({ gap: 2, padding: 4, align: 'center' });
|
||
this.active = tabs[0]!.tab;
|
||
this.overflow = new Button('»', {
|
||
variant: 'ghost',
|
||
width: OVERFLOW_WIDTH,
|
||
onClick: () => this.openMenu(),
|
||
});
|
||
this.addChild(this.bar);
|
||
this.add(this.overflow);
|
||
for (const { tab, label, icon } of tabs) this.place(tab, label, this.order.length, undefined, icon);
|
||
this.buttons.get(this.active)!.setToggled(true);
|
||
}
|
||
|
||
/** Puts a tab at `at` in the row (default: last, in front of the `»` button). */
|
||
addTab(tab: T, label: string, at = this.order.length, document?: DocumentSpec): void {
|
||
this.place(tab, label, at, document);
|
||
this.relayout();
|
||
}
|
||
|
||
/** Takes a tab out of the row again. Unknown tabs are ignored. */
|
||
removeTab(tab: T): void {
|
||
const button = this.buttons.get(tab);
|
||
if (!button) return;
|
||
this.order.splice(this.order.indexOf(tab), 1);
|
||
this.buttons.delete(tab);
|
||
this.icons.delete(tab);
|
||
this.hiddenTabs = this.hiddenTabs.filter((hidden) => hidden !== tab);
|
||
button.destroy({ children: true });
|
||
this.relayout();
|
||
}
|
||
|
||
/** A renamed document; the row measures again, since the name decides the width. */
|
||
setLabel(tab: T, label: string): void {
|
||
const button = this.buttons.get(tab);
|
||
if (!button || button.text === label) return;
|
||
button.setText(label);
|
||
this.relayout();
|
||
}
|
||
|
||
has(tab: T): boolean {
|
||
return this.buttons.has(tab);
|
||
}
|
||
|
||
/** Every tab in row order, hidden ones included. */
|
||
get tabs(): readonly T[] {
|
||
return this.order;
|
||
}
|
||
|
||
private place(tab: T, label: string, at: number, document?: DocumentSpec, icon?: string): void {
|
||
const button = document
|
||
? new DocumentTab(label, document.kind, document.icon, () => this.onPick(tab), document.onClose)
|
||
: new TabButton(label, { icon, onClick: () => this.onPick(tab) });
|
||
this.order.splice(at, 0, tab);
|
||
this.buttons.set(tab, button);
|
||
const shown = document?.icon ?? icon;
|
||
if (shown) this.icons.set(tab, shown);
|
||
this.addChildAt(button, at + TABS_AT);
|
||
this.setChildIndex(this.overflow, this.children.length - 1);
|
||
button.setToggled(tab === this.active);
|
||
}
|
||
|
||
setActive(tab: T | null): void {
|
||
this.active = tab;
|
||
for (const [key, button] of this.buttons) button.setToggled(key === tab);
|
||
// Not `resize`: the strip keeps its size while a different tab has to come into view.
|
||
this.relayout();
|
||
}
|
||
|
||
/** Global x-extent of the tab buttons currently in the row, in order. */
|
||
places(): TabPlace<T>[] {
|
||
const out: TabPlace<T>[] = [];
|
||
for (const tab of this.order) {
|
||
const button = this.buttons.get(tab)!;
|
||
if (!button.visible) continue;
|
||
const at = button.toGlobal({ x: 0, y: 0 });
|
||
const place: TabPlace<T> = {
|
||
tab,
|
||
x: at.x,
|
||
right: at.x + button.w,
|
||
y: at.y,
|
||
h: button.h,
|
||
bold: button.labelBold,
|
||
icon: button.hasIcon,
|
||
};
|
||
if (button instanceof DocumentTab) {
|
||
place.kind = button.kindLabel;
|
||
place.close = button.closePoint();
|
||
}
|
||
out.push(place);
|
||
}
|
||
return out;
|
||
}
|
||
|
||
hidden(): T[] {
|
||
return [...this.hiddenTabs];
|
||
}
|
||
|
||
override layout(): void {
|
||
this.applyOverflow();
|
||
super.layout();
|
||
// The cards reach down to the bar: the stack's padding below them is theirs, not free space.
|
||
for (const item of this.items) {
|
||
if (!(item instanceof TabButton)) continue;
|
||
item.position.set(item.x, this.padding);
|
||
item.resize(item.w, Math.max(0, this.h - this.padding));
|
||
}
|
||
}
|
||
|
||
override redraw(): void {
|
||
const c = Theme.colors;
|
||
this.bar.clear();
|
||
this.bar.rect(0, 0, this.w, this.h).fill(c.tabBar);
|
||
// The line the cards stand on; the one showing covers its own stretch of it.
|
||
this.bar.rect(0, this.h - 1, this.w, 1).fill(c.border);
|
||
}
|
||
|
||
private applyOverflow(): void {
|
||
const tabs = this.order.map((tab) => ({ key: tab, width: this.buttons.get(tab)!.measure().w }));
|
||
const { hidden } = tabRowLayout({
|
||
tabs,
|
||
available: this.w - this.padding * 2,
|
||
gap: this.gap,
|
||
overflowWidth: OVERFLOW_WIDTH,
|
||
active: this.active,
|
||
});
|
||
this.hiddenTabs = hidden;
|
||
const away = new Set(hidden);
|
||
for (const [tab, button] of this.buttons) button.visible = !away.has(tab);
|
||
this.overflow.visible = hidden.length > 0;
|
||
}
|
||
|
||
private openMenu(): void {
|
||
const items = this.hiddenTabs.map((tab) => {
|
||
const button = this.buttons.get(tab)!;
|
||
return { label: button.text, icon: this.icons.get(tab), onSelect: () => this.onPick(tab) };
|
||
});
|
||
new Menu(items).openBelow(this.overflow);
|
||
}
|
||
}
|