Tab row: what does not fit goes behind a menu, the active tab always stays (#84)
game-maker / suite (push) Successful in 9m18s

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>
This commit is contained in:
DeveloperandClaude Opus 5 committed 2026-09-13 22:06:46 +02:00
1 parent e0c459ce92
commit f83c568999
7 files changed
+290 -48

No files matched your search

+1 -1
View File
@@ -37,7 +37,7 @@ npm run build # writes dist/ and regenerates index.html; the folder is wh
The browser suite and the three example games need a machine with a GPU: The browser suite and the three example games need a machine with a GPU:
```bash ```bash
node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 785 checks (GM_STEPS=20 for one step) node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 790 checks (GM_STEPS=20 for one step)
node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs
node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs
node tools/seed-actor.mjs && node tools/smoke-actor.mjs node tools/seed-actor.mjs && node tools/smoke-actor.mjs
+14 -40
View File
@@ -4,7 +4,8 @@ import { findAsset, findObjectHolder } from '../core/model/Project';
import type { ProjectStore } from '../core/store/ProjectStore'; import type { ProjectStore } from '../core/store/ProjectStore';
import type { FontStore } from '../core/store/FontStore'; import type { FontStore } from '../core/store/FontStore';
import type { TextureStore } from '../core/store/TextureStore'; import type { TextureStore } from '../core/store/TextureStore';
import { Button, clamp, HStack, Panel, Splitter, Theme, ui, VStack, type Widget } from '../ui'; import { clamp, HStack, Panel, Splitter, Theme, ui, VStack, type Widget } from '../ui';
import { TabStrip, type TabPlace } from './TabStrip';
import { ConsolePanel } from './console/ConsolePanel'; import { ConsolePanel } from './console/ConsolePanel';
import { GameViewport } from './viewport/GameViewport'; import { GameViewport } from './viewport/GameViewport';
import { SceneViewport } from './viewport/SceneViewport'; import { SceneViewport } from './viewport/SceneViewport';
@@ -101,12 +102,12 @@ export class Editor implements PlayHost {
private readonly editorRun: EditorRun; private readonly editorRun: EditorRun;
private readonly projects: ProjectSession; private readonly projects: ProjectSession;
private readonly leftColumn: VStack; private readonly leftColumn: VStack;
private readonly leftSplitter = new Splitter('x'); readonly leftSplitter = new Splitter('x');
private readonly rightSplitter = new Splitter('x'); readonly rightSplitter = new Splitter('x');
readonly consoleSplitter = new Splitter('y'); readonly consoleSplitter = new Splitter('y');
private readonly workspace: HStack; private readonly workspace: HStack;
private readonly centre: VStack; private readonly centre: VStack;
private readonly tabButtons: Record<CentreTab, Button>; private readonly tabs: TabStrip<CentreTab>;
private readonly panes: Record<CentreTab, Widget>; private readonly panes: Record<CentreTab, Widget>;
private readonly toolbar: Toolbar; private readonly toolbar: Toolbar;
private fpsFrames = 0; private fpsFrames = 0;
@@ -164,8 +165,8 @@ export class Editor implements PlayHost {
this.leftColumn.add(this.hierarchy, 1); this.leftColumn.add(this.hierarchy, 1);
this.centre = new VStack(); this.centre = new VStack();
const tabs = new HStack({ gap: 2, padding: 4, align: 'center' }); this.tabs = new TabStrip(TAB_ORDER, TAB_LABELS, (tab) => this.showTab(tab));
tabs.minHeight = Theme.controlHeight + 8; this.tabs.minHeight = Theme.controlHeight + 8;
// Written out per tab rather than derived, because a `Record<CentreTab, …>` is what makes // Written out per tab rather than derived, because a `Record<CentreTab, …>` is what makes
// adding a tab a type error everywhere it has to be handled instead of a silent omission. // adding a tab a type error everywhere it has to be handled instead of a silent omission.
this.panes = { this.panes = {
@@ -179,24 +180,11 @@ export class Editor implements PlayHost {
container: this.containerEditor, container: this.containerEditor,
list: this.listEditor, list: this.listEditor,
}; };
this.tabButtons = { this.centre.add(this.tabs);
scene: this.tabButton('scene'),
game: this.tabButton('game'),
graph: this.tabButton('graph'),
statemachine: this.tabButton('statemachine'),
shader: this.tabButton('shader'),
texture: this.tabButton('texture'),
animation: this.tabButton('animation'),
container: this.tabButton('container'),
list: this.tabButton('list'),
};
this.centre.add(tabs);
for (const tab of TAB_ORDER) { for (const tab of TAB_ORDER) {
tabs.add(this.tabButtons[tab]);
this.centre.add(this.panes[tab], 1); this.centre.add(this.panes[tab], 1);
this.panes[tab].visible = tab === 'scene'; this.panes[tab].visible = tab === 'scene';
} }
this.tabButtons.scene.setToggled(true);
// The console sits below whichever tab is showing, so it is a sibling of the panes, not of // The console sits below whichever tab is showing, so it is a sibling of the panes, not of
// the workspace: switching tabs must not move it. // the workspace: switching tabs must not move it.
this.centre.add(this.consoleSplitter); this.centre.add(this.consoleSplitter);
@@ -283,32 +271,18 @@ export class Editor implements PlayHost {
? () => findObjectHolder(this.state.project, this.containerEditor.currentAssetId) ? () => findObjectHolder(this.state.project, this.containerEditor.currentAssetId)
: () => this.state.activeScene, : () => this.state.activeScene,
); );
for (const key of TAB_ORDER) { for (const key of TAB_ORDER) this.panes[key].visible = key === tab;
this.panes[key].visible = key === tab; this.tabs.setActive(tab);
this.tabButtons[key].setToggled(key === tab);
}
this.centre.relayout(); this.centre.relayout();
ui().focus.validate(); ui().focus.validate();
} }
/** /**
* Global x-extent of every tab button, from the widget box – not `getBounds()`, which on an * Global x-extent of the tab buttons in the row, from the widget box – not `getBounds()`, which
* untoggled ghost tab sees nothing but the label (the background is not drawn at all). * on an untoggled ghost tab sees nothing but the label (the background is not drawn at all).
*/ */
tabBounds(): Record<CentreTab, { x: number; right: number }> { tabRow(): { shown: TabPlace<CentreTab>[]; hidden: CentreTab[] } {
const out = {} as Record<CentreTab, { x: number; right: number }>; return { shown: this.tabs.places(), hidden: this.tabs.hidden() };
for (const tab of TAB_ORDER) {
const button = this.tabButtons[tab];
const x = button.toGlobal({ x: 0, y: 0 }).x;
out[tab] = { x, right: x + button.w };
}
return out;
}
private tabButton(tab: CentreTab): Button {
// 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).
return new Button(TAB_LABELS[tab], { variant: 'ghost', padding: 4, onClick: () => this.showTab(tab) });
} }
boot(): Promise<void> { boot(): Promise<void> {
+100
View File
@@ -0,0 +1,100 @@
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);
}
}
+3 -1
View File
@@ -186,7 +186,9 @@ export function installDebugHook(app: Application, state: EditorState, editor: E
textOverflows, textOverflows,
labelsDropped, labelsDropped,
shownLabel, shownLabel,
tabBounds: () => editor.tabBounds(), tabRow: () => editor.tabRow(),
/** The dividers left and right of the centre column – dragging them narrows the tab row. */
panelSplitters: () => ({ left: centreOf(editor.leftSplitter), right: centreOf(editor.rightSplitter) }),
viewportCenter: () => centreOf(viewport), viewportCenter: () => centreOf(viewport),
/** Pans the Scene tab to a world point – a step's own lane, away from what earlier steps left. */ /** Pans the Scene tab to a world point – a step's own lane, away from what earlier steps left. */
lookAt: (x: number, y: number) => viewport.lookAt(x, y), lookAt: (x: number, y: number) => viewport.lookAt(x, y),
+50
View File
@@ -0,0 +1,50 @@
export interface TabWidth<T extends string> {
key: T;
width: number;
}
export interface TabRowInput<T extends string> {
tabs: TabWidth<T>[];
/** Width the row may occupy, padding already taken off. */
available: number;
gap: number;
/** Width of the button that opens the hidden ones; only claimed when something is hidden. */
overflowWidth: number;
active: T;
}
export interface TabRowLayout<T extends string> {
shown: T[];
hidden: T[];
}
function rowWidth<T extends string>(tabs: TabWidth<T>[], gap: number): number {
if (tabs.length === 0) return 0;
return tabs.reduce((sum, tab) => sum + tab.width, 0) + gap * (tabs.length - 1);
}
/**
* Which tabs fit in the row and which go behind the overflow button. Tabs keep their order; the
* active one is always shown, even if the row can hold nothing else.
*/
export function tabRowLayout<T extends string>(input: TabRowInput<T>): TabRowLayout<T> {
const { tabs, available, gap, overflowWidth, active } = input;
if (rowWidth(tabs, gap) <= available) return { shown: tabs.map((tab) => tab.key), hidden: [] };
const budget = available - overflowWidth - gap;
const shown: TabWidth<T>[] = [];
for (const tab of tabs) {
if (rowWidth([...shown, tab], gap) > budget) break;
shown.push(tab);
}
if (!shown.some((tab) => tab.key === active)) {
const activeTab = tabs.find((tab) => tab.key === active);
if (activeTab) {
// The prefix stops before the active tab, so appending it keeps the row in order.
while (shown.length > 0 && rowWidth([...shown, activeTab], gap) > budget) shown.pop();
shown.push(activeTab);
}
}
const kept = new Set(shown.map((tab) => tab.key));
return { shown: [...kept], hidden: tabs.map((tab) => tab.key).filter((key) => !kept.has(key)) };
}
+49
View File
@@ -0,0 +1,49 @@
import { describe, expect, it } from 'vitest';
import { tabRowLayout } from '../../src/editor/tabRow';
/** The nine tabs as measured in the browser at padding 4, in their editor order. */
const TABS = [
{ key: 'scene', width: 53 },
{ key: 'game', width: 50 },
{ key: 'graph', width: 74 },
{ key: 'statemachine', width: 114 },
{ key: 'shader', width: 61 },
{ key: 'texture', width: 64 },
{ key: 'animation', width: 83 },
{ key: 'container', width: 81 },
{ key: 'list', width: 35 },
];
const layout = (available: number, active: string) =>
tabRowLayout({ tabs: TABS, available, gap: 2, overflowWidth: 28, active });
describe('tab row', () => {
it('hides nothing while the row fits', () => {
const { shown, hidden } = layout(631, 'scene');
expect(hidden).toEqual([]);
expect(shown).toHaveLength(9);
});
it('hides the tail one pixel short of fitting', () => {
expect(layout(630, 'scene').hidden).toEqual(['list']);
});
it('keeps the tabs in order', () => {
expect(layout(400, 'scene').shown).toEqual(['scene', 'game', 'graph', 'statemachine', 'shader']);
});
it('shows the active tab even when it sits past the room there is', () => {
const { shown, hidden } = layout(400, 'container');
expect(shown).toContain('container');
expect(hidden).toContain('shader');
// It went in at the back, so the row still reads left to right.
expect(shown[shown.length - 1]).toBe('container');
});
it('gives up every other tab before it drops the active one', () => {
expect(layout(60, 'animation')).toEqual({
shown: ['animation'],
hidden: ['scene', 'game', 'graph', 'statemachine', 'shader', 'texture', 'container', 'list'],
});
});
});
+73 -6
View File
@@ -6,8 +6,21 @@
/** A name far wider than any 200 px picker, so a control that does not truncate must spill. */ /** A name far wider than any 200 px picker, so a control that does not truncate must spill. */
const LONG_NAME = 'Player movement with jumping and coyote time'; const LONG_NAME = 'Player movement with jumping and coyote time';
/** What the tab buttons read, to find one in the overflow menu. */
const LABELS = {
scene: 'Scene',
game: 'Game',
graph: 'Behavior',
statemachine: 'State machine',
shader: 'Shader',
texture: 'Texture',
animation: 'Animation',
container: 'Container',
list: 'List',
};
export async function overflow(t) { export async function overflow(t) {
const { check, sleep, json, evaluate, shot, mutate } = t; const { check, sleep, json, evaluate, shot, mutate, drag, clickText } = t;
const pickerWidths = async () => { const pickerWidths = async () => {
const out = {}; const out = {};
@@ -42,13 +55,67 @@ export async function overflow(t) {
// a click on it lands there (nine tabs on a 90 px floor did exactly that at 1280 px wide). // a click on it lands there (nine tabs on a 90 px floor did exactly that at 1280 px wide).
await evaluate("__gm.showTab('scene')"); await evaluate("__gm.showTab('scene')");
await sleep(300); await sleep(300);
const column = await json('__gm.viewportRect()'); const columnRight = async () => {
const tabs = await json('__gm.tabBounds()'); const column = await json('__gm.viewportRect()');
const spilled = Object.entries(tabs).filter(([, b]) => b.right > column.x + column.w); return column.x + column.w;
};
const spillOf = (row, right) => row.shown.filter((b) => b.right > right);
const wide = await json('__gm.tabRow()');
check( check(
'every tab button ends inside the centre column', 'every tab button ends inside the centre column',
spilled.length === 0, spillOf(wide, await columnRight()).length === 0 && wide.hidden.length === 0,
JSON.stringify({ spilled, columnRight: column.x + column.w }), JSON.stringify({ spilled: spillOf(wide, await columnRight()), hidden: wide.hidden }),
);
// 14b-ii. Narrowed until the row cannot hold nine tabs, the leftovers go behind the overflow
// button instead of under the inspector – the guard against the tenth tab (#84).
const widenPanels = async (by) => {
const bars = await json('__gm.panelSplitters()');
await drag(bars.left.x, bars.left.y, bars.left.x + by, bars.left.y, { steps: 8 });
const after = await json('__gm.panelSplitters()');
await drag(after.right.x, after.right.y, after.right.x - by, after.right.y, { steps: 8 });
await sleep(300);
};
await widenPanels(250);
const narrow = await json('__gm.tabRow()');
const narrowRight = await columnRight();
check(
'a row too narrow for its tabs puts the leftovers away instead of past the edge',
narrow.hidden.length > 0 && spillOf(narrow, narrowRight).length === 0,
JSON.stringify({ hidden: narrow.hidden, spilled: spillOf(narrow, narrowRight) }),
);
check(
'the tab being edited stays in the row',
narrow.shown.some((b) => b.tab === 'scene'),
JSON.stringify(narrow.shown.map((b) => b.tab)),
);
await shot('35-tab-overflow');
// The overflow button names them, and picking one brings it into the row.
const wanted = narrow.hidden[narrow.hidden.length - 1];
await clickText('»');
await sleep(400);
const offered = await json('__gm.overlayTexts()');
check(
'the overflow button offers every tab that had to go',
narrow.hidden.every((tab) => offered.includes(LABELS[tab])),
JSON.stringify({ offered, hidden: narrow.hidden }),
);
await clickText(LABELS[wanted]);
await sleep(400);
const picked = await json('__gm.tabRow()');
check(
`picking ${LABELS[wanted]} from the overflow menu opens it and puts it in the row`,
(await json('__gm.tab()')) === wanted && picked.shown.some((b) => b.tab === wanted),
JSON.stringify({ tab: await json('__gm.tab()'), shown: picked.shown.map((b) => b.tab) }),
);
await widenPanels(-250);
await evaluate("__gm.showTab('scene')");
await sleep(300);
const back = await json('__gm.tabRow()');
check(
'giving the room back brings every tab into the row',
back.hidden.length === 0 && spillOf(back, await columnRight()).length === 0,
JSON.stringify({ hidden: back.hidden }),
); );
// 14c. Every tab, because each one owns a different set of controls. // 14c. Every tab, because each one owns a different set of controls.