import { Button, HStack, Menu } from '../ui'; 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; 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; } /** * 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 labels = new Map(); private readonly buttons = new Map(); private readonly overflow: Button; private hiddenTabs: T[] = []; private active: T; constructor( tabs: readonly { tab: T; label: 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.add(this.overflow); for (const { tab, label } of tabs) this.place(tab, label); this.buttons.get(this.active)!.setToggled(true); } /** Appends a tab at the end of the row, in front of the `»` button. */ addTab(tab: T, label: string): void { this.place(tab, label); this.relayout(); } /** Takes a tab out of the row again; a plugin switched off (#114). 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.labels.delete(tab); this.buttons.delete(tab); this.hiddenTabs = this.hiddenTabs.filter((hidden) => hidden !== tab); button.destroy({ children: true }); this.relayout(); } private place(tab: T, label: string): void { // Half the theme padding: nine tabs at the full 8 px overflow the centre column of a 1280 px // window by 27 px, and a button past its edge sits under the inspector (measured, CI run 5). const button = new Button(label, { variant: 'ghost', padding: 4, onClick: () => this.onPick(tab), }); this.order.push(tab); this.labels.set(tab, label); this.buttons.set(tab, button); this.add(button); this.setChildIndex(this.overflow, this.children.length - 1); } setActive(tab: T): 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 }); out.push({ tab, x: at.x, right: at.x + button.w, y: at.y, h: button.h }); } return out; } hidden(): T[] { return [...this.hiddenTabs]; } override layout(): void { this.applyOverflow(); super.layout(); } 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) => ({ label: this.labels.get(tab)!, onSelect: () => this.onPick(tab), })); new Menu(items).openBelow(this.overflow); } }