Files
feuerwerk/src/editor/TabStrip.ts
T

194 lines
6.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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);
}
}