From 69615c1f4bc002232a6435a62a2d4f6700d0a3f3 Mon Sep 17 00:00:00 2001 From: root Date: Sat, 12 Sep 2026 23:09:30 +0200 Subject: [PATCH] Tab row: fit nine tabs inside the centre column The ninth tab (Shader) pushed the row past the centre column: List ended at 962 while the column ended at 925, so the last buttons sat under the inspector and clickText('Container') hit the List tab instead (CI runs 5 and 6, module 16). Tab buttons now use padding 4 via a new Button padding option; e2e step 14b guards the row width using the widget box rather than getBounds(), which sees only the label on an untoggled ghost button. Co-Authored-By: Claude Fable 5.1 --- src/editor/Editor.ts | 18 +++++++++++++++++- src/editor/debugHook.ts | 1 + src/ui/widgets/Button.ts | 10 +++++++--- tools/e2e/14-overflow.mjs | 21 +++++++++++++++++---- 4 files changed, 42 insertions(+), 8 deletions(-) diff --git a/src/editor/Editor.ts b/src/editor/Editor.ts index c9c1511b..640a3e7e 100644 --- a/src/editor/Editor.ts +++ b/src/editor/Editor.ts @@ -270,8 +270,24 @@ export class Editor implements PlayHost { 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). + */ + 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 { - return new Button(TAB_LABELS[tab], { variant: 'ghost', minWidth: 90, onClick: () => this.showTab(tab) }); + // 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 { diff --git a/src/editor/debugHook.ts b/src/editor/debugHook.ts index 94140ca3..96313184 100644 --- a/src/editor/debugHook.ts +++ b/src/editor/debugHook.ts @@ -181,6 +181,7 @@ export function installDebugHook(app: Application, state: EditorState, editor: E textOverflows, labelsDropped, shownLabel, + tabBounds: () => editor.tabBounds(), viewportCenter: () => centreOf(viewport), /** Its drawn box – a step that wants an empty spot must stay inside what the console left over. */ viewportRect: () => { diff --git a/src/ui/widgets/Button.ts b/src/ui/widgets/Button.ts index f26f5060..58b089cb 100644 --- a/src/ui/widgets/Button.ts +++ b/src/ui/widgets/Button.ts @@ -12,6 +12,8 @@ export interface ButtonOptions { /** Floor only – for a button whose label changes between forms and must grow with them. */ minWidth?: number; align?: 'center' | 'left'; + /** Horizontal inset of the label; defaults to the theme padding. */ + padding?: number; /** Text style of the label when the button is not on an accent fill. */ style?: TextStyleToken; onClick?: () => void; @@ -28,6 +30,7 @@ export class Button extends Widget { variant: NonNullable; protected readonly align: NonNullable; protected readonly fixedWidth: number; + private readonly padding: number; private readonly style: TextStyleToken; onClick?: () => void; @@ -41,6 +44,7 @@ export class Button extends Widget { this.minHeight = Theme.controlHeight; this.fixedWidth = options.width ?? 0; this.minWidth = this.fixedWidth || (options.minWidth ?? 0); + this.padding = options.padding ?? Theme.padding; this.fullText = text; this.style = options.style ?? 'body'; this.labelText = new Text({ text, style: textStyle(this.variant === 'primary' ? 'accent' : this.style) }); @@ -102,14 +106,14 @@ export class Button extends Widget { // Measured bold where it can be drawn bold (on an accent fill), or a danger or toggled tab truncates. const style = textStyle(this.variant === 'default' ? this.style : 'accent'); const metrics = CanvasTextMetrics.measureText(this.fullText, style); - return { w: Math.max(this.minWidth, Math.ceil(metrics.width) + Theme.padding * 2), h }; + return { w: Math.max(this.minWidth, Math.ceil(metrics.width) + this.padding * 2), h }; } /** Width the label may occupy; subclasses that draw inside the button take their room off here. */ protected labelSlot(): number { // The padding is an inset, not a claim: on a 24 px icon button it would leave 8 px and // truncate the glyph away entirely. From 48 px up this is the full padding again. - return this.w - Math.min(Theme.padding, Math.floor(this.w / 6)) * 2; + return this.w - Math.min(this.padding, Math.floor(this.w / 6)) * 2; } override onKeyDown(event: KeyboardEvent): boolean { @@ -157,7 +161,7 @@ export class Button extends Widget { this.labelText.style = textStyle(this.onAccent ? 'accent' : this.style); const label = this.labelText; label.text = truncate(this.fullText, label.style, this.labelSlot()); - const x = this.align === 'left' ? Theme.padding : (this.w - label.width) / 2; + const x = this.align === 'left' ? this.padding : (this.w - label.width) / 2; label.position.set(Math.round(x), centreY(this.h, label.height)); } } diff --git a/tools/e2e/14-overflow.mjs b/tools/e2e/14-overflow.mjs index a53d8d1a..5b989b2a 100644 --- a/tools/e2e/14-overflow.mjs +++ b/tools/e2e/14-overflow.mjs @@ -38,7 +38,20 @@ export async function overflow(t) { ); } - // 14b. Every tab, because each one owns a different set of controls. + // 14b. The tab row itself: a button past the centre column's edge sits under the inspector, and + // 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); + check( + 'every tab button ends inside the centre column', + spilled.length === 0, + JSON.stringify({ spilled, columnRight: column.x + column.w }), + ); + + // 14c. Every tab, because each one owns a different set of controls. for (const tab of ['scene', 'graph', 'texture', 'game', 'container', 'list']) { await evaluate(`__gm.showTab(${JSON.stringify(tab)})`); await sleep(400); @@ -50,7 +63,7 @@ export async function overflow(t) { ); } - // 14c. Shortened, not simply dropped: the value must still be readable and marked as cut. + // 14d. Shortened, not simply dropped: the value must still be readable and marked as cut. await evaluate("__gm.showTab('graph')"); await sleep(400); const shown = (await json('__gm.textOverflows(true)')).find((e) => e.text.startsWith('Player movement')); @@ -61,7 +74,7 @@ export async function overflow(t) { ); await shot('36-long-labels'); - // 14d. An inspector full of dropdowns – the parent row and the enum fields – with a selection + // 14e. An inspector full of dropdowns – the parent row and the enum fields – with a selection // whose asset name is now overlong too. await evaluate("__gm.showTab('scene')"); await sleep(300); @@ -70,7 +83,7 @@ export async function overflow(t) { const inspector = await json('__gm.textOverflows()'); check('no label spills in the inspector', inspector.length === 0, JSON.stringify(inspector)); - // 14e. The other direction: a control narrow enough to swallow its whole label draws nothing + // 14f. The other direction: a control narrow enough to swallow its whole label draws nothing // at all, which the overflow walk cannot see – an empty text spills over no edge. for (const tab of ['scene', 'graph', 'texture', 'container', 'list']) { await evaluate(`__gm.showTab(${JSON.stringify(tab)})`);