diff --git a/README.md b/README.md index c2432bb1..2528be91 100644 --- a/README.md +++ b/README.md @@ -38,7 +38,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, 1103 checks (GM_STEPS=20 for one step) +node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1109 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/DocumentTab.ts b/src/editor/DocumentTab.ts index 29abe765..37ac7b52 100644 --- a/src/editor/DocumentTab.ts +++ b/src/editor/DocumentTab.ts @@ -1,5 +1,6 @@ import { CanvasTextMetrics, type FederatedPointerEvent, Rectangle, Text } from 'pixi.js'; -import { Button, centreY, Theme, textStyle } from '../ui'; +import { centreY, Theme, textStyle } from '../ui'; +import { TabButton } from './TabButton'; const GAP = 4; const CLOSE = '×'; @@ -12,7 +13,7 @@ const MAX_NAME_WIDTH = 160; * A tab that stands for an open document (#171): the icon of its kind before the name, so three * assets called "Player" can be told apart, and an × that closes it – as does a middle click. */ -export class DocumentTab extends Button { +export class DocumentTab extends TabButton { private readonly closeText: Text; constructor( @@ -22,8 +23,7 @@ export class DocumentTab extends Button { onPick: () => void, private readonly onClose: () => void, ) { - // `toggle`: measured bold, so the active tab does not cut its own name short. - super(label, { variant: 'ghost', padding: 4, align: 'left', toggle: true, icon, onClick: onPick }); + super(label, { align: 'left', icon, onClick: onPick }); this.closeText = new Text({ text: CLOSE, style: textStyle('muted') }); this.closeText.eventMode = 'static'; this.closeText.cursor = Theme.hoverCursor; @@ -63,7 +63,7 @@ export class DocumentTab extends Button { override redraw(): void { super.redraw(); - this.closeText.style = textStyle(this.toggled ? 'accent' : 'muted'); + this.closeText.style = textStyle(this.toggled ? 'body' : 'muted'); const closeX = this.w - this.padding - this.closeText.width; this.closeText.position.set(closeX, centreY(this.h, this.closeText.height)); this.closeText.hitArea = new Rectangle( diff --git a/src/editor/Editor.ts b/src/editor/Editor.ts index 5fa53fc3..c88a2f92 100644 --- a/src/editor/Editor.ts +++ b/src/editor/Editor.ts @@ -1,6 +1,5 @@ import type { Application } from 'pixi.js'; import type { Asset } from '../core/model/Asset'; -import type { AssetType } from '../core/model/AssetTypes'; import { findAsset, findObjectHolder } from '../core/model/Project'; import type { ProjectStore } from '../core/store/ProjectStore'; import type { FontStore } from '../core/store/FontStore'; @@ -26,6 +25,7 @@ import { closeDocument, type DocumentRow, openDocument, replaceDocument } from ' import { ConsolePanel } from './console/ConsolePanel'; import { GameViewport } from './viewport/GameViewport'; import { SceneViewport } from './viewport/SceneViewport'; +import { TAB_OF_ASSET } from './assetTabs'; import { attachAutosave } from './autosave'; import { ContainerEditor } from './container/ContainerEditor'; import { EditorRun } from './EditorRun'; @@ -36,7 +36,7 @@ import { resolveList } from './list/slot'; import { AnimationEditor } from './animation/AnimationEditor'; import { TileEditor } from './tile/TileEditor'; import { AssetPanel } from './panels/AssetPanel'; -import { ASSET_ICON_SVG, LIST_ICON_SVG } from './panels/assetIcons'; +import { ASSET_ICON_SVG, GAME_ICON_SVG, LIST_ICON_SVG } from './panels/assetIcons'; import { HierarchyPanel } from './panels/HierarchyPanel'; import { TilemapPanel } from './panels/TilemapPanel'; import { Toolbar } from './panels/Toolbar'; @@ -113,17 +113,10 @@ const TAB_LABELS: Record = { plugins: 'Plugins', }; -/** Where an opened asset goes; a placeable one (sprite, camera, text, collider) has no tab of its own. */ -const TAB_OF_ASSET: Partial> = { - scene: 'scene', - behavior: 'graph', - statemachine: 'statemachine', - shader: 'shader', - particles: 'particles', - texture: 'texture', - animation: 'animation', - tile: 'tile', - container: 'container', +/** Icons of the fixed tabs (#176); the Plugins tab has none, as plugins have no picture of their own. */ +const FIXED_TAB_ICONS: Partial> = { + scene: ASSET_ICON_SVG.scene, + game: GAME_ICON_SVG, }; function isDocument(tab: StripTab): tab is DocumentId { @@ -279,7 +272,7 @@ export class Editor implements PlayHost { this.centre = new VStack(); this.tabs = new TabStrip( - FIXED_TABS.map((tab) => ({ tab, label: TAB_LABELS[tab] })), + FIXED_TABS.map((tab) => ({ tab, label: TAB_LABELS[tab], icon: FIXED_TAB_ICONS[tab] })), (tab) => this.pickTab(tab), ); this.tabs.minHeight = Theme.controlHeight + 8; diff --git a/src/editor/TabButton.ts b/src/editor/TabButton.ts new file mode 100644 index 00000000..5142d48b --- /dev/null +++ b/src/editor/TabButton.ts @@ -0,0 +1,58 @@ +import { Button, type ButtonOptions, Theme, type TextStyleToken } from '../ui'; + +/** The upper corners are rounded, the lower ones square: the card sits on the bar of the strip. */ +const RADIUS = 6; + +/** + * Inset of icon and name inside a card. Half the theme padding was too tight once the cards got a + * border of their own, so the row keeps its own value instead of `Theme.padding`. + */ +export const TAB_PADDING = 6; + +/** + * One card of the tab row. It is drawn as a real index card rather than as a toggled button: a + * light grey card for the tab showing, nothing for the others, and never the pressed fill – a + * click on the tab that is already showing must not flash a colour of its own (#176). + */ +export class TabButton extends Button { + constructor(text: string, options: ButtonOptions = {}) { + super(text, { padding: TAB_PADDING, ...options, variant: 'ghost' }); + } + + /** Plain weight in both states: the fill says which tab is showing, the font does not. */ + protected override labelToken(): TextStyleToken { + return this.toggled ? 'body' : 'muted'; + } + + /** `body` and `muted` differ only in colour, so one width holds for both states. */ + protected override measureToken(): TextStyleToken { + return 'body'; + } + + protected override iconTint(): number { + return this.toggled ? Theme.colors.text : Theme.colors.textMuted; + } + + protected override drawBackground(): void { + const c = Theme.colors; + const { w, h } = this; + const fill = this.toggled ? c.tabActive : this.hovered ? c.tabHover : null; + this.bg.clear(); + if (fill !== null) { + // Two shapes instead of one rounded rect: the card must meet the bar with square corners. + this.bg.roundRect(0, 0, w, h, RADIUS).fill(fill); + this.bg.rect(0, h - RADIUS, w, RADIUS).fill(fill); + } + const border = this.focused ? c.accent : this.toggled ? c.border : null; + if (border === null) return; + // An open path along the three edges the card has: a closed one would draw a line over the bar. + this.bg + .moveTo(0.5, h) + .lineTo(0.5, RADIUS) + .quadraticCurveTo(0.5, 0.5, RADIUS, 0.5) + .lineTo(w - RADIUS, 0.5) + .quadraticCurveTo(w - 0.5, 0.5, w - 0.5, RADIUS) + .lineTo(w - 0.5, h) + .stroke({ width: 1, color: border }); + } +} diff --git a/src/editor/TabStrip.ts b/src/editor/TabStrip.ts index 4d384a5f..9f54babd 100644 --- a/src/editor/TabStrip.ts +++ b/src/editor/TabStrip.ts @@ -1,9 +1,13 @@ -import { Button, HStack, Menu } from '../ui'; +import { Graphics } from 'pixi.js'; +import { Button, HStack, Menu, Theme } from '../ui'; import { DocumentTab } from './DocumentTab'; +import { TabButton } from './TabButton'; 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; +/** The bar Graphics is the first child, so a tab's index among the children is two past its place. */ +const TABS_AT = 2; export interface TabPlace { tab: T; @@ -12,6 +16,10 @@ export interface TabPlace { /** Top edge and height, so a check can tell the tab from a label of the same text elsewhere. */ y: number; h: number; + /** Whether the name is drawn bold – the tab showing must not be (#176). */ + bold: boolean; + /** Whether an icon stands in front of the name; the fixed tabs have one too, bar Plugins (#176). */ + icon: boolean; /** A document's kind as shown before its name, and where its × is. */ kind?: string; close?: { x: number; y: number }; @@ -35,11 +43,13 @@ export class TabStrip extends HStack { /** A document's icon, which the `»` menu shows in front of its name too. */ private readonly icons = new Map(); private readonly overflow: Button; + /** The bar the cards sit on, drawn behind every tab. */ + private readonly bar = new Graphics(); private hiddenTabs: T[] = []; private active: T | null; constructor( - tabs: readonly { tab: T; label: string }[], + tabs: readonly { tab: T; label: string; icon?: string }[], private readonly onPick: (tab: T) => void, ) { super({ gap: 2, padding: 4, align: 'center' }); @@ -49,8 +59,9 @@ export class TabStrip extends HStack { width: OVERFLOW_WIDTH, onClick: () => this.openMenu(), }); + this.addChild(this.bar); this.add(this.overflow); - for (const { tab, label } of tabs) this.place(tab, label, this.order.length); + for (const { tab, label, icon } of tabs) this.place(tab, label, this.order.length, undefined, icon); this.buttons.get(this.active)!.setToggled(true); } @@ -89,17 +100,15 @@ export class TabStrip extends HStack { return this.order; } - private place(tab: T, label: string, at: number, document?: DocumentSpec): void { - // 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). + private place(tab: T, label: string, at: number, document?: DocumentSpec, icon?: string): void { const button = document ? new DocumentTab(label, document.kind, document.icon, () => this.onPick(tab), document.onClose) - : new Button(label, { variant: 'ghost', padding: 4, onClick: () => this.onPick(tab) }); + : new TabButton(label, { icon, onClick: () => this.onPick(tab) }); this.order.splice(at, 0, tab); this.buttons.set(tab, button); - if (document) this.icons.set(tab, document.icon); - // The `»` button is the first child, so a tab's index among the children is one past its place. - this.addChildAt(button, at + 1); + const shown = document?.icon ?? icon; + if (shown) this.icons.set(tab, shown); + this.addChildAt(button, at + TABS_AT); this.setChildIndex(this.overflow, this.children.length - 1); button.setToggled(tab === this.active); } @@ -118,7 +127,15 @@ export class TabStrip extends HStack { const button = this.buttons.get(tab)!; if (!button.visible) continue; const at = button.toGlobal({ x: 0, y: 0 }); - const place: TabPlace = { tab, x: at.x, right: at.x + button.w, y: at.y, h: button.h }; + const place: TabPlace = { + tab, + x: at.x, + right: at.x + button.w, + y: at.y, + h: button.h, + bold: button.labelBold, + icon: button.hasIcon, + }; if (button instanceof DocumentTab) { place.kind = button.kindLabel; place.close = button.closePoint(); @@ -135,6 +152,20 @@ export class TabStrip extends HStack { override layout(): void { this.applyOverflow(); super.layout(); + // The cards reach down to the bar: the stack's padding below them is theirs, not free space. + for (const item of this.items) { + if (!(item instanceof TabButton)) continue; + item.position.set(item.x, this.padding); + item.resize(item.w, Math.max(0, this.h - this.padding)); + } + } + + override redraw(): void { + const c = Theme.colors; + this.bar.clear(); + this.bar.rect(0, 0, this.w, this.h).fill(c.tabBar); + // The line the cards stand on; the one showing covers its own stretch of it. + this.bar.rect(0, this.h - 1, this.w, 1).fill(c.border); } private applyOverflow(): void { diff --git a/src/editor/assetTabs.ts b/src/editor/assetTabs.ts new file mode 100644 index 00000000..f3f755b1 --- /dev/null +++ b/src/editor/assetTabs.ts @@ -0,0 +1,23 @@ +import type { AssetType } from '../core/model/AssetTypes'; +import type { CentreTab } from './Editor'; + +/** Where an opened asset goes; a placeable one (sprite, camera, text, collider) has no tab of its own. */ +export const TAB_OF_ASSET: Partial> = { + scene: 'scene', + behavior: 'graph', + statemachine: 'statemachine', + shader: 'shader', + particles: 'particles', + texture: 'texture', + animation: 'animation', + tile: 'tile', + container: 'container', +}; + +/** + * Whether opening an asset of this type brings an editor up. Everything else is only selected, so a + * menu entry that promises to open it is offered greyed out instead. + */ +export function hasAssetEditor(type: AssetType): boolean { + return TAB_OF_ASSET[type] !== undefined; +} diff --git a/src/editor/debugHook.ts b/src/editor/debugHook.ts index 24d6cebe..69e25bd4 100644 --- a/src/editor/debugHook.ts +++ b/src/editor/debugHook.ts @@ -262,6 +262,8 @@ export function installDebugHook(app: Application, state: EditorState, editor: E menuRows: () => currentMenu()?.rowsDrawn() ?? null, /** Per entry of `menuRows`, whether an icon stands in front of it. */ menuIcons: () => currentMenu()?.iconsDrawn() ?? null, + /** Per entry of `menuRows`, whether it can be picked (a greyed-out one cannot). */ + menuEnabled: () => currentMenu()?.enabledDrawn() ?? null, textOverflows, labelsDropped, shownLabel, diff --git a/src/editor/panels/HierarchyPanel.ts b/src/editor/panels/HierarchyPanel.ts index 574f6265..c63d5e65 100644 --- a/src/editor/panels/HierarchyPanel.ts +++ b/src/editor/panels/HierarchyPanel.ts @@ -15,6 +15,7 @@ import { createContainer, removeObjects, } from '../actions'; +import { hasAssetEditor } from '../assetTabs'; import { ASSET_ICON_SVG } from './assetIcons'; import { type EditorState, objectSelection } from '../state'; @@ -161,6 +162,7 @@ export class HierarchyPanel extends Panel { if (!holder) return undefined; const inScene = holder.type === 'scene'; const object = findObject(holder, id); + const asset = object && findAsset(state.project, object.assetId); // Content of a placed container belongs to its template: values can be changed here, the // structure cannot (see `model/instance.ts`). const source = this.source(); @@ -176,7 +178,9 @@ export class HierarchyPanel extends Panel { }, { // The same as double-clicking the asset in the asset panel: selects it and opens its tab. + // A kind without an editor of its own would only be selected, so it is offered greyed out. label: 'Open asset', + disabled: !asset || !hasAssetEditor(asset.type), onSelect: () => object && state.openAsset(object.assetId), }, { diff --git a/src/editor/panels/assetIcons.ts b/src/editor/panels/assetIcons.ts index 527142e5..cfcbbb4d 100644 --- a/src/editor/panels/assetIcons.ts +++ b/src/editor/panels/assetIcons.ts @@ -9,6 +9,7 @@ import camera from './icons/camera.svg?raw'; import collider from './icons/collider.svg?raw'; import container from './icons/container.svg?raw'; import font from './icons/font.svg?raw'; +import game from './icons/game.svg?raw'; import list from './icons/list.svg?raw'; import particles from './icons/particles.svg?raw'; import rigidbody from './icons/rigidbody.svg?raw'; @@ -46,3 +47,6 @@ export const ASSET_ICON_SVG: Record = { /** The List tab's: it edits a list value, which is no asset. */ export const LIST_ICON_SVG = list; + +/** The Game tab's: it runs the project, so neither is it an asset. The Scene tab takes `scene`. */ +export const GAME_ICON_SVG = game; diff --git a/src/editor/panels/icons/game.svg b/src/editor/panels/icons/game.svg new file mode 100644 index 00000000..ae68268b --- /dev/null +++ b/src/editor/panels/icons/game.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/ui/Theme.ts b/src/ui/Theme.ts index 652f2cf9..a50fbd7f 100644 --- a/src/ui/Theme.ts +++ b/src/ui/Theme.ts @@ -28,6 +28,10 @@ export const Theme = { controlActive: 0x16181d, accent: 0x4c8dff, accentText: 0xffffff, + /** Tab row: the bar the index cards sit on, the card showing, and one under the pointer. */ + tabBar: 0x1f2229, + tabActive: 0x3f444e, + tabHover: 0x2c3038, text: 0xe6e8ee, textMuted: 0x8a90a0, selection: 0x2b4a80, diff --git a/src/ui/widgets/Button.ts b/src/ui/widgets/Button.ts index 2186c90e..a0eb4dec 100644 --- a/src/ui/widgets/Button.ts +++ b/src/ui/widgets/Button.ts @@ -29,11 +29,11 @@ const ICON_SIZE = 16; const ICON_GAP = 6; export class Button extends Widget { - private readonly bg = new Graphics(); + protected readonly bg = new Graphics(); protected readonly labelText: Text; /** The label as set; `labelText` holds the possibly truncated version on screen. */ private fullText: string; - private pressed = false; + protected pressed = false; /** Caller-owned: renders like a primary button but is never flipped by a click. */ toggled = false; variant: NonNullable; @@ -114,15 +114,31 @@ export class Button extends Widget { return this.toggled || this.variant === 'primary' || this.variant === 'danger'; } + /** Style of the label as drawn now. */ + protected labelToken(): TextStyleToken { + return this.onAccent ? 'accent' : this.style; + } + + /** + * Style the width is measured from: the widest the label can get in any state, or a button that + * turns bold when toggled would cut its own text short. + */ + protected measureToken(): TextStyleToken { + return this.variant === 'default' && !this.toggle ? this.style : 'accent'; + } + + /** Colour of the icon in front of the label; it follows the label. */ + protected iconTint(): number { + return this.onAccent ? Theme.colors.accentText : Theme.colors.textMuted; + } + override measure(): Size { const h = Math.max(this.minHeight, Theme.controlHeight); // A given width is a width, not a floor: a picker must not resize around the name it shows. if (this.fixedWidth) return { w: this.fixedWidth, h }; // From the full label, never the on-screen one: measuring truncated text would keep a button // that was once too narrow too narrow for good. - // 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.toggle ? this.style : 'accent'); - const metrics = CanvasTextMetrics.measureText(this.fullText, style); + const metrics = CanvasTextMetrics.measureText(this.fullText, textStyle(this.measureToken())); return { w: Math.max(this.minWidth, Math.ceil(metrics.width) + this.padding * 2 + this.iconSpace()), h }; } @@ -131,6 +147,12 @@ export class Button extends Widget { return this.icon ? ICON_SIZE + ICON_GAP : 0; } + /** Whether the label is drawn in a bold weight (test hook). */ + get labelBold(): boolean { + const weight = this.labelText.style.fontWeight; + return weight === 'bold' || weight === 'bolder' || Number(weight) >= 600; + } + /** Whether an icon is drawn in front of the label (test hook). */ get hasIcon(): boolean { return this.icon !== null; @@ -151,7 +173,8 @@ export class Button extends Widget { return false; } - override redraw(): void { + /** The box behind the label. Overridden where a button is not a rounded rectangle (tabs). */ + protected drawBackground(): void { const c = Theme.colors; const flat = this.variant === 'default' || this.variant === 'ghost'; let fill: number | null = c.control; @@ -184,15 +207,19 @@ export class Button extends Widget { } else if (stroke !== null) { this.bg.roundRect(0.5, 0.5, this.w - 1, this.h - 1, Theme.radius).stroke({ width: 1, color: stroke }); } + } + + override redraw(): void { + this.drawBackground(); // Style first: it decides the glyph widths that the truncation then measures against. - this.labelText.style = textStyle(this.onAccent ? 'accent' : this.style); + this.labelText.style = textStyle(this.labelToken()); const label = this.labelText; label.text = truncate(this.fullText, label.style, this.labelSlot()); const content = this.iconSpace() + label.width; const x = this.align === 'left' ? this.padding : (this.w - content) / 2; label.position.set(Math.round(x + this.iconSpace()), centreY(this.h, label.height)); if (this.icon) { - const color = this.onAccent ? c.accentText : c.textMuted; + const color = this.iconTint(); if (color !== this.iconColor) { this.iconColor = color; this.iconGfx.clear(); diff --git a/src/ui/widgets/Menu.ts b/src/ui/widgets/Menu.ts index f9c3a5ac..0ee7a0aa 100644 --- a/src/ui/widgets/Menu.ts +++ b/src/ui/widgets/Menu.ts @@ -98,6 +98,13 @@ export class Menu extends Popup { .map((child) => (child instanceof Button ? child.text : null)); } + /** Per entry of `rowsDrawn`, whether it can be picked; `null` for a separator (test hook). */ + enabledDrawn(): (boolean | null)[] { + return this.list.children + .filter((child) => child !== this.empty && child.visible) + .map((child) => (child instanceof Button ? child.enabled : null)); + } + /** Per entry of `rowsDrawn`, whether it shows an icon; `null` for a separator (test hook). */ iconsDrawn(): (boolean | null)[] { return this.list.children diff --git a/tests/ui/svg-icons.test.ts b/tests/ui/svg-icons.test.ts index 675f3679..a994420b 100644 --- a/tests/ui/svg-icons.test.ts +++ b/tests/ui/svg-icons.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; import { ASSET_TYPES } from '../../src/core/model/AssetTypes'; -import { ASSET_ICON_SVG, LIST_ICON_SVG } from '../../src/editor/panels/assetIcons'; +import { ASSET_ICON_SVG, GAME_ICON_SVG, LIST_ICON_SVG } from '../../src/editor/panels/assetIcons'; import { tintSvg } from '../../src/ui/svg'; import { SYMBOL_SVG } from '../../src/viewport/symbolIcons'; @@ -32,6 +32,7 @@ describe('icon files', () => { ...Object.entries(SYMBOL_SVG).map(([name, svg]) => [`symbol ${name}`, svg]), ...Object.entries(ASSET_ICON_SVG).map(([name, svg]) => [`asset ${name}`, svg]), ['list tab', LIST_ICON_SVG], + ['game tab', GAME_ICON_SVG], ]; // Pixi draws the file's coordinates as they are and ignores the viewBox, so the viewBox only @@ -45,12 +46,14 @@ describe('icon files', () => { }); it('draws the asset icons in the 16 units a list row and a button reserve', () => { - for (const svg of [...Object.values(ASSET_ICON_SVG), LIST_ICON_SVG]) + for (const svg of [...Object.values(ASSET_ICON_SVG), LIST_ICON_SVG, GAME_ICON_SVG]) expect(tags(svg)[0]!.attrs.viewBox).toBe('-8 -8 16 16'); }); - it('gives the List tab a picture no asset type has', () => { + it('gives the List and Game tabs a picture no asset type has (#176)', () => { expect(Object.values(ASSET_ICON_SVG)).not.toContain(LIST_ICON_SVG); + expect(Object.values(ASSET_ICON_SVG)).not.toContain(GAME_ICON_SVG); + expect(GAME_ICON_SVG).not.toBe(LIST_ICON_SVG); }); it.each(icons)('%s uses only what the Pixi parser draws like a browser', (_, svg) => { diff --git a/tools/e2e/01-hierarchy.mjs b/tools/e2e/01-hierarchy.mjs index 14c60eb8..083143d5 100644 --- a/tools/e2e/01-hierarchy.mjs +++ b/tools/e2e/01-hierarchy.mjs @@ -6,7 +6,7 @@ import { same } from '../lib/harness.mjs'; * without touching the selection, and selecting a nested object unfolds the way down to it. */ export async function hierarchy(t) { - const { check, evaluate, json, click, clickText, doubleClick, select, state, shot } = t; + const { check, evaluate, json, click, clickText, doubleClick, press, select, state, shot } = t; const rowIds = () => json('__gm.hierarchyOrder()'); const rows = async () => JSON.stringify(await rowIds()); const selectionBefore = (await state()).selection; @@ -71,4 +71,18 @@ export async function hierarchy(t) { JSON.stringify({ opened, openedSelection }), ); await evaluate(`__gm.closeDocument('asset:spawner'); __gm.state.select(null)`); + + // A sprite has no editor of its own, so the entry that promises to open one is greyed out. + const crateRow = await json(`__gm.hierarchyRow('o1')`); + await click(crateRow.x, crateRow.y, 'right'); + const menuRows = await json('__gm.menuRows()'); + const menuEnabled = await json('__gm.menuEnabled()'); + check( + 'Open asset is greyed out for a kind without an editor, Select asset stays on', + menuEnabled[menuRows.indexOf('Open asset')] === false && + menuEnabled[menuRows.indexOf('Select asset')] === true, + JSON.stringify({ menuRows, menuEnabled }), + ); + await press('Escape'); + await evaluate('__gm.state.select(null)'); } diff --git a/tools/e2e/14-overflow.mjs b/tools/e2e/14-overflow.mjs index a1975c9d..e4c4d86f 100644 --- a/tools/e2e/14-overflow.mjs +++ b/tools/e2e/14-overflow.mjs @@ -31,9 +31,72 @@ export async function overflow(t) { JSON.stringify(widths), ); + // 14a-ii. What a tab looks like (#176): the one showing is a light grey card with rounded upper + // corners standing on the row's line, its name in plain weight, and a press on it keeps + // that colour instead of flashing the pressed fill of a button. + await showTab('scene'); + const ACTIVE = '#3f444e'; + const BAR = '#1f2229'; + const LINE = '#383c46'; + const pixel = (x, y) => json(`__gm.pixelAt(${Math.round(x)}, ${Math.round(y)})`); + const styled = await json('__gm.tabRow()'); + const card = styled.shown.find((b) => b.tab === 'scene'); + const plain = styled.shown.find((b) => b.tab !== 'scene'); + const cardFill = await pixel(card.x + 3, card.y + card.h - 3); + const plainFill = await pixel(plain.x + 3, plain.y + plain.h - 3); + check( + 'the tab showing is a light grey card, the others stay on the bar', + cardFill === ACTIVE && plainFill === BAR, + JSON.stringify({ cardFill, plainFill, card: card.tab, plain: plain.tab }), + ); + const corner = await pixel(card.x, card.y); + const top = await pixel(card.x + card.h / 2, card.y + 3); + const line = await pixel(plain.x + 4, plain.y + plain.h - 1); + check( + 'its upper corners are rounded and it covers the line the other tabs stand on', + corner === BAR && top === ACTIVE && line === LINE, + JSON.stringify({ corner, top, line }), + ); + check( + 'neither the tab showing nor the others write their name bold', + styled.shown.every((b) => b.bold === false), + JSON.stringify(styled.shown.map((b) => ({ tab: b.tab, bold: b.bold }))), + ); + const pressX = Math.round(card.x + 3); + const pressY = Math.round(card.y + card.h - 3); + await t.mouse('mouseMoved', pressX, pressY); + await t.mouse('mousePressed', pressX, pressY); + const held = await pixel(pressX, pressY); + await t.mouse('mouseReleased', pressX, pressY); + check( + 'holding the mouse down on the tab showing does not change its colour', + held === ACTIVE, + JSON.stringify({ held, expected: ACTIVE }), + ); + // The fixed tabs carry an icon as well (#176); the Scene card shows it in front of its name. + const sceneInk = await json(`(() => { + const x0 = ${card.x} + 6, y = ${card.y + card.h / 2}; + let n = 0; + for (let dy = -7; dy <= 7; dy++) + for (let dx = 0; dx < 16; dx++) if (__gm.pixelAt(x0 + dx, y + dy) !== '${ACTIVE}') n++; + return n; + })()`); + const fixedIcons = styled.shown + .filter((b) => ['scene', 'game', 'plugins'].includes(b.tab)) + .map((b) => [b.tab, b.icon]); + check( + 'Scene and Game show an icon before their name, Plugins none', + JSON.stringify(fixedIcons) === + JSON.stringify([ + ['scene', true], + ['game', true], + ['plugins', false], + ]) && sceneInk >= 20, + JSON.stringify({ fixedIcons, sceneInk }), + ); + // 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 showTab('scene'); const columnRight = async () => { const column = await json('__gm.viewportRect()'); return column.x + column.w; @@ -78,10 +141,12 @@ export async function overflow(t) { await clickText('»'); const offered = (await json('__gm.menuRows()')).filter(Boolean); const offeredIcons = (await json('__gm.menuIcons()')).filter((icon) => icon !== null); + // Every hidden tab brings its icon along; only Plugins and a plugin's own tab have none (#176). + const withIcon = (tab) => tab !== 'plugins' && !tab.startsWith('plugin:'); check( - 'the overflow button offers every tab that had to go, a document with the icon of its kind', + 'the overflow button offers every tab that had to go, each with the icon its tab shows', offered.length === narrow.hidden.length && - offeredIcons.filter(Boolean).length === narrow.hidden.filter((tab) => tab.startsWith('asset:')).length, + offeredIcons.filter(Boolean).length === narrow.hidden.filter(withIcon).length, JSON.stringify({ offered, offeredIcons, hidden: narrow.hidden }), ); const wanted = narrow.hidden[narrow.hidden.length - 1]; diff --git a/tools/e2e/44-documents.mjs b/tools/e2e/44-documents.mjs index 86cdbefa..d0dddfb2 100644 --- a/tools/e2e/44-documents.mjs +++ b/tools/e2e/44-documents.mjs @@ -46,7 +46,7 @@ export async function documents(t) { const kindWords = (await json(`__gm.locateAll('Texture')`)).filter(inTab); // The icon's 16 px in front of the name, read against the tab's own background at its left edge. const iconInk = await json(`(() => { - const x0 = ${placeA.x} + 4, y = ${placeA.y + placeA.h / 2}; + const x0 = ${placeA.x} + 6, y = ${placeA.y + placeA.h / 2}; const background = __gm.pixelAt(${placeA.x} + 1, y); let n = 0; for (let dy = -7; dy <= 7; dy++)