Tab row: what does not fit goes behind a menu, the active tab always stays (#84)
game-maker / suite (push) Successful in 9m18s
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:
1 parent
e0c459ce92
commit
f83c568999
7 files changed
+290
-48
No files matched your search
@@ -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:
|
||||
|
||||
```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-tetris.mjs && node tools/smoke-tetris.mjs
|
||||
node tools/seed-actor.mjs && node tools/smoke-actor.mjs
|
||||
|
||||
+14
-40
@@ -4,7 +4,8 @@ import { findAsset, findObjectHolder } from '../core/model/Project';
|
||||
import type { ProjectStore } from '../core/store/ProjectStore';
|
||||
import type { FontStore } from '../core/store/FontStore';
|
||||
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 { GameViewport } from './viewport/GameViewport';
|
||||
import { SceneViewport } from './viewport/SceneViewport';
|
||||
@@ -101,12 +102,12 @@ export class Editor implements PlayHost {
|
||||
private readonly editorRun: EditorRun;
|
||||
private readonly projects: ProjectSession;
|
||||
private readonly leftColumn: VStack;
|
||||
private readonly leftSplitter = new Splitter('x');
|
||||
private readonly rightSplitter = new Splitter('x');
|
||||
readonly leftSplitter = new Splitter('x');
|
||||
readonly rightSplitter = new Splitter('x');
|
||||
readonly consoleSplitter = new Splitter('y');
|
||||
private readonly workspace: HStack;
|
||||
private readonly centre: VStack;
|
||||
private readonly tabButtons: Record<CentreTab, Button>;
|
||||
private readonly tabs: TabStrip<CentreTab>;
|
||||
private readonly panes: Record<CentreTab, Widget>;
|
||||
private readonly toolbar: Toolbar;
|
||||
private fpsFrames = 0;
|
||||
@@ -164,8 +165,8 @@ export class Editor implements PlayHost {
|
||||
this.leftColumn.add(this.hierarchy, 1);
|
||||
|
||||
this.centre = new VStack();
|
||||
const tabs = new HStack({ gap: 2, padding: 4, align: 'center' });
|
||||
tabs.minHeight = Theme.controlHeight + 8;
|
||||
this.tabs = new TabStrip(TAB_ORDER, TAB_LABELS, (tab) => this.showTab(tab));
|
||||
this.tabs.minHeight = Theme.controlHeight + 8;
|
||||
// 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.
|
||||
this.panes = {
|
||||
@@ -179,24 +180,11 @@ export class Editor implements PlayHost {
|
||||
container: this.containerEditor,
|
||||
list: this.listEditor,
|
||||
};
|
||||
this.tabButtons = {
|
||||
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);
|
||||
this.centre.add(this.tabs);
|
||||
for (const tab of TAB_ORDER) {
|
||||
tabs.add(this.tabButtons[tab]);
|
||||
this.centre.add(this.panes[tab], 1);
|
||||
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 workspace: switching tabs must not move it.
|
||||
this.centre.add(this.consoleSplitter);
|
||||
@@ -283,32 +271,18 @@ export class Editor implements PlayHost {
|
||||
? () => findObjectHolder(this.state.project, this.containerEditor.currentAssetId)
|
||||
: () => this.state.activeScene,
|
||||
);
|
||||
for (const key of TAB_ORDER) {
|
||||
this.panes[key].visible = key === tab;
|
||||
this.tabButtons[key].setToggled(key === tab);
|
||||
}
|
||||
for (const key of TAB_ORDER) this.panes[key].visible = key === tab;
|
||||
this.tabs.setActive(tab);
|
||||
this.centre.relayout();
|
||||
ui().focus.validate();
|
||||
}
|
||||
|
||||
/**
|
||||
* Global x-extent of every tab button, from the widget box – not `getBounds()`, which on an
|
||||
* untoggled ghost tab sees nothing but the label (the background is not drawn at all).
|
||||
* Global x-extent of the tab buttons in the row, from the widget box – not `getBounds()`, which
|
||||
* on an untoggled ghost tab sees nothing but the label (the background is not drawn at all).
|
||||
*/
|
||||
tabBounds(): Record<CentreTab, { x: number; right: number }> {
|
||||
const out = {} as Record<CentreTab, { x: number; right: number }>;
|
||||
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) });
|
||||
tabRow(): { shown: TabPlace<CentreTab>[]; hidden: CentreTab[] } {
|
||||
return { shown: this.tabs.places(), hidden: this.tabs.hidden() };
|
||||
}
|
||||
|
||||
boot(): Promise<void> {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -186,7 +186,9 @@ export function installDebugHook(app: Application, state: EditorState, editor: E
|
||||
textOverflows,
|
||||
labelsDropped,
|
||||
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),
|
||||
/** 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),
|
||||
|
||||
@@ -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)) };
|
||||
}
|
||||
@@ -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'],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -6,8 +6,21 @@
|
||||
/** 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';
|
||||
|
||||
/** 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) {
|
||||
const { check, sleep, json, evaluate, shot, mutate } = t;
|
||||
const { check, sleep, json, evaluate, shot, mutate, drag, clickText } = t;
|
||||
|
||||
const pickerWidths = async () => {
|
||||
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).
|
||||
await evaluate("__gm.showTab('scene')");
|
||||
await sleep(300);
|
||||
const column = await json('__gm.viewportRect()');
|
||||
const tabs = await json('__gm.tabBounds()');
|
||||
const spilled = Object.entries(tabs).filter(([, b]) => b.right > column.x + column.w);
|
||||
const columnRight = async () => {
|
||||
const column = await json('__gm.viewportRect()');
|
||||
return column.x + column.w;
|
||||
};
|
||||
const spillOf = (row, right) => row.shown.filter((b) => b.right > right);
|
||||
const wide = await json('__gm.tabRow()');
|
||||
check(
|
||||
'every tab button ends inside the centre column',
|
||||
spilled.length === 0,
|
||||
JSON.stringify({ spilled, columnRight: column.x + column.w }),
|
||||
spillOf(wide, await columnRight()).length === 0 && wide.hidden.length === 0,
|
||||
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.
|
||||
|
||||
Reference in new issue
Block a user