Files
feuerwerk/src/editor/TabStrip.ts
T
DeveloperandClaude Opus 5 f83c568999
game-maker / suite (push) Successful in 9m18s
Tab row: what does not fit goes behind a menu, the active tab always stays (#84)
Nine tabs need 631 px; at 1280x800 the centre column offers 666 px, so the tenth
tab would have spilled under the inspector - the same overflow CI caught in run 5.

TabStrip replaces the bare HStack: it fits the longest prefix that stays inside
the budget, always appends the active tab, and offers the rest behind a fixed
28 px button. The button's width is fixed on purpose - deriving it from the
number of hidden tabs would make width depend on the count and the count on the
width.

The rule itself is a pure function (tabRow.ts), so it is checked without a
browser. setActive() calls relayout() itself because Widget.resize only lays out
on a changed size, and switching tabs does not change any size.

Adding a tab now needs no space arithmetic - only a line in TAB_ORDER,
TAB_LABELS and panes, all three Record<CentreTab, ...>, so forgetting one is a
type error.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 22:06:46 +02:00

101 lines
3.0 KiB
TypeScript

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<T extends string> {
tab: T;
x: number;
right: 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<T extends string> extends HStack {
private readonly buttons = new Map<T, Button>();
private readonly overflow: Button;
private hiddenTabs: T[] = [];
private active: T;
constructor(
private readonly order: readonly T[],
private readonly labels: Record<T, string>,
private readonly onPick: (tab: T) => void,
) {
super({ gap: 2, padding: 4, align: 'center' });
this.active = order[0]!;
for (const tab of order) {
// 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(labels[tab], {
variant: 'ghost',
padding: 4,
onClick: () => this.onPick(tab),
});
this.buttons.set(tab, button);
this.add(button);
}
this.overflow = new Button('»', {
variant: 'ghost',
width: OVERFLOW_WIDTH,
onClick: () => this.openMenu(),
});
this.add(this.overflow);
this.buttons.get(this.active)!.setToggled(true);
}
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<T>[] {
const out: TabPlace<T>[] = [];
for (const tab of this.order) {
const button = this.buttons.get(tab)!;
if (!button.visible) continue;
const x = button.toGlobal({ x: 0, y: 0 }).x;
out.push({ tab, x, right: x + button.w });
}
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[tab],
onSelect: () => this.onPick(tab),
}));
new Menu(items).openBelow(this.overflow);
}
}