import { Graphics } from 'pixi.js'; import { Button, crisp, hairline, 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 { 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; /** A right-click on the tab (#371). */ onMenu?: (globalX: number, globalY: number) => 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 extends HStack { private readonly order: T[] = []; private readonly buttons = new Map(); /** A document's icon, which the `»` menu shows in front of its name too. */ private readonly icons = new Map(); private readonly overflow: Button; /** The bar the cards sit on, drawn behind every tab. */ private readonly bar = crisp(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, tooltip: 'More tabs', 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, document.onMenu, ) : 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[] { const out: TabPlace[] = []; 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 = { 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 - hairline(), this.w, hairline()).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); } }