diff --git a/README.md b/README.md index 09541292..57d8e36d 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/src/editor/Editor.ts b/src/editor/Editor.ts index 7c0aa8bd..e1f9194f 100644 --- a/src/editor/Editor.ts +++ b/src/editor/Editor.ts @@ -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; + private readonly tabs: TabStrip; private readonly panes: Record; 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` 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 { - const out = {} as Record; - 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[]; hidden: CentreTab[] } { + return { shown: this.tabs.places(), hidden: this.tabs.hidden() }; } boot(): Promise { diff --git a/src/editor/TabStrip.ts b/src/editor/TabStrip.ts new file mode 100644 index 00000000..6b45d9f4 --- /dev/null +++ b/src/editor/TabStrip.ts @@ -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 { + 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 extends HStack { + private readonly buttons = new Map(); + private readonly overflow: Button; + private hiddenTabs: T[] = []; + private active: T; + + constructor( + private readonly order: readonly T[], + private readonly labels: Record, + 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[] { + const out: TabPlace[] = []; + 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); + } +} diff --git a/src/editor/debugHook.ts b/src/editor/debugHook.ts index 4e8f73e2..e3edf2e9 100644 --- a/src/editor/debugHook.ts +++ b/src/editor/debugHook.ts @@ -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), diff --git a/src/editor/tabRow.ts b/src/editor/tabRow.ts new file mode 100644 index 00000000..37374b91 --- /dev/null +++ b/src/editor/tabRow.ts @@ -0,0 +1,50 @@ +export interface TabWidth { + key: T; + width: number; +} + +export interface TabRowInput { + tabs: TabWidth[]; + /** 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 { + shown: T[]; + hidden: T[]; +} + +function rowWidth(tabs: TabWidth[], 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(input: TabRowInput): TabRowLayout { + 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[] = []; + 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)) }; +} diff --git a/tests/editor/tab-row.test.ts b/tests/editor/tab-row.test.ts new file mode 100644 index 00000000..b612ec59 --- /dev/null +++ b/tests/editor/tab-row.test.ts @@ -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'], + }); + }); +}); diff --git a/tools/e2e/14-overflow.mjs b/tools/e2e/14-overflow.mjs index 5b989b2a..56575c1a 100644 --- a/tools/e2e/14-overflow.mjs +++ b/tools/e2e/14-overflow.mjs @@ -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.