From e3ebf44593b3f10788c10c3ab7da80243362ce11 Mon Sep 17 00:00:00 2001 From: MelekTaus Date: Sat, 19 Sep 2026 18:36:20 +0200 Subject: [PATCH] Asset panel grouped by kind with icons: assets are listed under a header per kind (Sprites, Behaviors, ...) with the count on the right, empty groups are not shown, every group starts folded when a project is opened, a click on the header folds and unfolds it, selecting an asset elsewhere unfolds its group and folding the group of the selected asset clears the selection; every asset kind has an SVG icon in src/editor/panels/icons/ drawn before the name, the pure rule for grouping and sorting lives in assetGroups.ts; the same icons replace the kind word in document tabs and the overflow menu (a list icon for the List tab, which also fixes the close x lying over the end of the active tab's text), stand before every entry of the + menu (Button got an icon option) and on the right of scene object rows instead of the type name; the colour swatch next to sprite names is gone from both panels; the SVG loader moved to src/ui/svg.ts for both icon sets; test hook assetRow unfolds the group and scrolls to the row, assetRowShown only reads, new hook menuIcons; e2e 4g and 4h, 14 and 44a check the icons (#176) Co-Authored-By: Claude Opus 5 --- README.md | 2 +- src/core/model/AssetTypes.ts | 4 - src/editor/DocumentTab.ts | 31 ++-- src/editor/Editor.ts | 10 +- src/editor/TabStrip.ts | 13 +- src/editor/debugHook.ts | 12 +- src/editor/panels/AssetPanel.ts | 129 ++++++++++++---- src/editor/panels/HierarchyPanel.ts | 13 +- src/editor/panels/assetGroups.ts | 51 +++++++ src/editor/panels/assetIcons.ts | 48 ++++++ src/editor/panels/icons/animation.svg | 4 + src/editor/panels/icons/behavior.svg | 5 + src/editor/panels/icons/camera.svg | 4 + src/editor/panels/icons/collider.svg | 7 + src/editor/panels/icons/container.svg | 7 + src/editor/panels/icons/font.svg | 3 + src/editor/panels/icons/list.svg | 9 ++ src/editor/panels/icons/particles.svg | 8 + src/editor/panels/icons/rigidbody.svg | 6 + src/editor/panels/icons/scene.svg | 5 + src/editor/panels/icons/shader.svg | 4 + src/editor/panels/icons/sound.svg | 5 + src/editor/panels/icons/sprite.svg | 4 + src/editor/panels/icons/statemachine.svg | 6 + src/editor/panels/icons/text.svg | 3 + src/editor/panels/icons/texture.svg | 6 + src/editor/panels/icons/tile.svg | 4 + src/editor/panels/icons/tilemap.svg | 5 + src/editor/panels/icons/variables.svg | 5 + src/playground.ts | 1 - src/ui/Theme.ts | 1 - src/ui/index.ts | 1 + src/ui/svg.ts | 26 ++++ src/ui/widgets/Button.ts | 41 ++++- src/ui/widgets/ListRow.ts | 37 +++-- src/ui/widgets/ListView.ts | 25 ++- src/ui/widgets/Menu.ts | 10 ++ src/viewport/symbolIcons.ts | 25 +-- tests/editor/asset-groups.test.ts | 55 +++++++ .../svg-icons.test.ts} | 35 ++++- tools/e2e/04-assets.mjs | 142 +++++++++++++++++- tools/e2e/14-overflow.mjs | 8 +- tools/e2e/33-render-on-change.mjs | 8 +- tools/e2e/35-load-scene.mjs | 3 +- tools/e2e/44-documents.mjs | 20 ++- tools/e2e/lib/scaffold.mjs | 2 +- 46 files changed, 724 insertions(+), 129 deletions(-) create mode 100644 src/editor/panels/assetGroups.ts create mode 100644 src/editor/panels/assetIcons.ts create mode 100644 src/editor/panels/icons/animation.svg create mode 100644 src/editor/panels/icons/behavior.svg create mode 100644 src/editor/panels/icons/camera.svg create mode 100644 src/editor/panels/icons/collider.svg create mode 100644 src/editor/panels/icons/container.svg create mode 100644 src/editor/panels/icons/font.svg create mode 100644 src/editor/panels/icons/list.svg create mode 100644 src/editor/panels/icons/particles.svg create mode 100644 src/editor/panels/icons/rigidbody.svg create mode 100644 src/editor/panels/icons/scene.svg create mode 100644 src/editor/panels/icons/shader.svg create mode 100644 src/editor/panels/icons/sound.svg create mode 100644 src/editor/panels/icons/sprite.svg create mode 100644 src/editor/panels/icons/statemachine.svg create mode 100644 src/editor/panels/icons/text.svg create mode 100644 src/editor/panels/icons/texture.svg create mode 100644 src/editor/panels/icons/tile.svg create mode 100644 src/editor/panels/icons/tilemap.svg create mode 100644 src/editor/panels/icons/variables.svg create mode 100644 src/ui/svg.ts create mode 100644 tests/editor/asset-groups.test.ts rename tests/{viewport/symbol-icons.test.ts => ui/svg-icons.test.ts} (60%) diff --git a/README.md b/README.md index 17738346..eeb3b6af 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, 1093 checks (GM_STEPS=20 for one step) +node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1102 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/core/model/AssetTypes.ts b/src/core/model/AssetTypes.ts index 008fe258..08d0ebd0 100644 --- a/src/core/model/AssetTypes.ts +++ b/src/core/model/AssetTypes.ts @@ -94,7 +94,3 @@ export function isSymbolType(type: AssetType): boolean { export function hasSceneIcon(type: AssetType): boolean { return isSymbolType(type) || type === 'particles'; } - -export function hasColorSwatch(type: AssetType): boolean { - return type === 'sprite' || type === 'text'; -} diff --git a/src/editor/DocumentTab.ts b/src/editor/DocumentTab.ts index 49236c00..29abe765 100644 --- a/src/editor/DocumentTab.ts +++ b/src/editor/DocumentTab.ts @@ -9,21 +9,21 @@ const CLOSE_SLOP = 4; const MAX_NAME_WIDTH = 160; /** - * A tab that stands for an open document (#171): its kind in muted type before the name, so three + * 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 { - private readonly kindText: Text; private readonly closeText: Text; constructor( label: string, private readonly kind: string, + icon: string, onPick: () => void, private readonly onClose: () => void, ) { - super(label, { variant: 'ghost', padding: 4, align: 'left', onClick: onPick }); - this.kindText = new Text({ text: kind, style: textStyle('muted') }); + // `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 }); this.closeText = new Text({ text: CLOSE, style: textStyle('muted') }); this.closeText.eventMode = 'static'; this.closeText.cursor = Theme.hoverCursor; @@ -37,7 +37,7 @@ export class DocumentTab extends Button { this.on('pointerup', (e: FederatedPointerEvent) => { if (e.button === 1) this.onClose(); }); - this.addChild(this.kindText, this.closeText); + this.addChild(this.closeText); } /** The × in global coordinates, for the check scripts. */ @@ -46,28 +46,24 @@ export class DocumentTab extends Button { return { x: at.x + this.closeText.width / 2, y: at.y + this.closeText.height / 2 }; } + /** The kind the icon stands for, as a word – the icon itself says nothing to a check script. */ get kindLabel(): string { return this.kind; } override measure(): { w: number; h: number } { const own = super.measure(); - return { w: Math.min(own.w, MAX_NAME_WIDTH + this.padding * 2) + this.extraWidth(), h: own.h }; + const cap = MAX_NAME_WIDTH + this.padding * 2 + this.iconSpace(); + return { w: Math.min(own.w, cap) + this.closeWidth(), h: own.h }; } protected override labelSlot(): number { - return super.labelSlot() - this.extraWidth(); + return super.labelSlot() - this.closeWidth(); } override redraw(): void { super.redraw(); - const style = textStyle(this.toggled ? 'accent' : 'muted'); - this.kindText.style = style; - this.closeText.style = style; - // On the accent fill the muted grey would vanish; the accent text, dimmed, stays the quieter one. - this.kindText.alpha = this.toggled ? 0.7 : 1; - this.kindText.position.set(this.padding, centreY(this.h, this.kindText.height)); - this.labelText.x = this.padding + this.kindText.width + GAP; + this.closeText.style = textStyle(this.toggled ? 'accent' : '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( @@ -78,10 +74,7 @@ export class DocumentTab extends Button { ); } - private extraWidth(): number { - const style = textStyle('muted'); - const kind = CanvasTextMetrics.measureText(this.kind, style).width; - const close = CanvasTextMetrics.measureText(CLOSE, style).width; - return Math.ceil(kind + close) + GAP * 2; + private closeWidth(): number { + return Math.ceil(CanvasTextMetrics.measureText(CLOSE, textStyle('muted')).width) + GAP; } } diff --git a/src/editor/Editor.ts b/src/editor/Editor.ts index ba9f35cc..5fa53fc3 100644 --- a/src/editor/Editor.ts +++ b/src/editor/Editor.ts @@ -36,6 +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 { HierarchyPanel } from './panels/HierarchyPanel'; import { TilemapPanel } from './panels/TilemapPanel'; import { Toolbar } from './panels/Toolbar'; @@ -96,7 +97,7 @@ const PANE_ORDER = [ 'plugins', ] as const; -/** The fixed tabs' names, and the kind shown in front of a document's. */ +/** The fixed tabs' names, and a document's kind as a word (its tab shows the icon). */ const TAB_LABELS: Record = { scene: 'Scene', game: 'Game', @@ -526,6 +527,12 @@ export class Editor implements PlayHost { return findAsset(this.state.project, doc.slice('asset:'.length))?.name ?? ''; } + /** The icon in front of a document's name: its asset type's, as in the asset panel. */ + private iconOf(doc: DocumentId): string { + const type = doc === 'list' ? undefined : findAsset(this.state.project, doc.slice('asset:'.length))?.type; + return type ? ASSET_ICON_SVG[type] : LIST_ICON_SVG; + } + /** Brings the row in line with `documents`: new tabs in their place, gone ones out, names current. */ private syncStrip(): void { for (const tab of [...this.tabs.tabs]) @@ -536,6 +543,7 @@ export class Editor implements PlayHost { if (!this.tabs.has(doc) && kind) this.tabs.addTab(doc, this.labelOf(doc), base + index, { kind: TAB_LABELS[kind], + icon: this.iconOf(doc), onClose: () => this.closeDocument(doc), }); else this.tabs.setLabel(doc, this.labelOf(doc)); diff --git a/src/editor/TabStrip.ts b/src/editor/TabStrip.ts index c4d02d65..4d384a5f 100644 --- a/src/editor/TabStrip.ts +++ b/src/editor/TabStrip.ts @@ -17,9 +17,11 @@ export interface TabPlace { close?: { x: number; y: number }; } -/** A document's tab: its kind before the name, and an × that calls `onClose`. */ +/** A document's tab: the icon of its kind before the name, and an × that calls `onClose`. */ export interface DocumentSpec { + /** The kind as a word, for the check scripts; the tab shows `icon`. */ kind: string; + icon: string; onClose: () => void; } @@ -30,6 +32,8 @@ export interface DocumentSpec { export class TabStrip extends HStack { private readonly order: T[] = []; private readonly buttons = new Map(); + /** A document's icon, which the `»` menu shows in front of its name too. */ + private readonly icons = new Map(); private readonly overflow: Button; private hiddenTabs: T[] = []; private active: T | null; @@ -62,6 +66,7 @@ export class TabStrip extends HStack { if (!button) return; this.order.splice(this.order.indexOf(tab), 1); this.buttons.delete(tab); + this.icons.delete(tab); this.hiddenTabs = this.hiddenTabs.filter((hidden) => hidden !== tab); button.destroy({ children: true }); this.relayout(); @@ -88,10 +93,11 @@ export class TabStrip extends HStack { // 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 = document - ? new DocumentTab(label, document.kind, () => this.onPick(tab), document.onClose) + ? new DocumentTab(label, document.kind, document.icon, () => this.onPick(tab), document.onClose) : new Button(label, { variant: 'ghost', padding: 4, 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); this.setChildIndex(this.overflow, this.children.length - 1); @@ -149,8 +155,7 @@ export class TabStrip extends HStack { private openMenu(): void { const items = this.hiddenTabs.map((tab) => { const button = this.buttons.get(tab)!; - const kind = button instanceof DocumentTab ? `${button.kindLabel} · ` : ''; - return { label: `${kind}${button.text}`, onSelect: () => this.onPick(tab) }; + return { label: button.text, icon: this.icons.get(tab), onSelect: () => this.onPick(tab) }; }); new Menu(items).openBelow(this.overflow); } diff --git a/src/editor/debugHook.ts b/src/editor/debugHook.ts index f793cd4d..24d6cebe 100644 --- a/src/editor/debugHook.ts +++ b/src/editor/debugHook.ts @@ -1,6 +1,7 @@ import { type Application, Color, type ColorSource, type Container, Mesh, Rectangle, Text } from 'pixi.js'; import { NameTransitionCommand } from '../core/commands/graphCommands'; import { previewObjectId } from '../core/model/animationPreview'; +import type { AssetType } from '../core/model/AssetTypes'; import { resolveObjects } from '../core/model/instance'; import { findAsset, findAssetOf, findObjectHolder } from '../core/model/Project'; import type { FrameRect } from '../core/model/texture'; @@ -259,6 +260,8 @@ export function installDebugHook(app: Application, state: EditorState, editor: E overlayRect, /** Entries of the open menu in drawn order, `null` for a separator line (#43). */ menuRows: () => currentMenu()?.rowsDrawn() ?? null, + /** Per entry of `menuRows`, whether an icon stands in front of it. */ + menuIcons: () => currentMenu()?.iconsDrawn() ?? null, textOverflows, labelsDropped, shownLabel, @@ -304,8 +307,13 @@ export function installDebugHook(app: Application, state: EditorState, editor: E /** The panel-to-view drag in flight, if any – a drop that never started looks like a refused one. */ dragPayload: () => activeDrag(), /** An asset row by id – two assets can share a name with the container picker's label. */ - assetRow: (id: string) => assets.rowGlobal(id), + /** An asset's row, its group unfolded first as a user would; `assetRowShown` leaves it folded. */ + assetRow: (id: string) => assets.rowGlobal(id, true), + assetRowShown: (id: string) => assets.rowGlobal(id), assetRowColor: (id: string) => assets.rowColor(id), + /** The asset panel's groups in order; a heading's row is `assetRow('group:')`. */ + assetGroups: () => assets.groups(), + assetGroupIcon: (type: AssetType) => assets.groupIcon(type), /** The panel's own rectangle – what tells a row in view from one scrolled past its edge. */ assetsRect: () => rectOf(assets), gameInfo: () => gameView.debugInfo(), @@ -655,6 +663,8 @@ export function installDebugHook(app: Application, state: EditorState, editor: E /** Centre of a hierarchy row, clear of the chevron – for clicks that must hit the row itself. */ hierarchyRow: (id: string) => hierarchy.rowPosition(id), hierarchyRowColor: (id: string) => hierarchy.rowColor(id), + /** Centre of the type icon at the right edge of a hierarchy row. */ + hierarchyIcon: (id: string) => hierarchy.iconPosition(id), hierarchyExpandAll: () => hierarchy.expandAll(), hierarchyCollapseAll: () => hierarchy.collapseAll(), pixelAt, diff --git a/src/editor/panels/AssetPanel.ts b/src/editor/panels/AssetPanel.ts index 414db430..63309d29 100644 --- a/src/editor/panels/AssetPanel.ts +++ b/src/editor/panels/AssetPanel.ts @@ -10,12 +10,7 @@ import { } from '../../core/commands/assetCommands'; import { oneStep } from '../../core/commands/CompoundCommand'; import { nextAssetName } from '../../core/commands/naming'; -import { - ASSET_TYPE_LABELS, - type AssetType, - hasColorSwatch, - isPlaceableType, -} from '../../core/model/AssetTypes'; +import { ASSET_TYPE_LABELS, type AssetType, isPlaceableType } from '../../core/model/AssetTypes'; import type { TextureAsset } from '../../core/model/Asset'; import type { PropertyBag } from '../../core/model/Properties'; import { findAsset } from '../../core/model/Project'; @@ -26,6 +21,7 @@ import type { SoundStore } from '../../core/store/SoundStore'; import type { TextureStore } from '../../core/store/TextureStore'; import { Button, + type ListItem, ListView, Menu, type MenuEntry, @@ -34,6 +30,8 @@ import { ScrollContainer, startDrag, } from '../../ui'; +import { ASSET_GROUP_LABELS, groupAssets } from './assetGroups'; +import { ASSET_ICON_SVG } from './assetIcons'; import { assetMenuGroups } from './assetMenu'; import { confirmDeleteAsset } from '../actions'; import type { EditorState } from '../state'; @@ -41,8 +39,13 @@ import { openFontPicker } from '../font/FontPicker'; import { openSoundPicker } from '../sound/SoundPicker'; import { openImagePicker } from '../texture/ImagePicker'; +/** Row ids of the group headings are this plus the type; an asset id never starts with it. */ +const HEADING = 'group:'; +const headingId = (type: AssetType): string => HEADING + type; + /** - * Lists all assets of the project; creates and deletes them. Placing is a drag into a view. + * Lists all assets of the project, grouped by type under a folding heading (#176); creates and + * deletes them. Placing is a drag into a view. * * A row's name turns gold when the asset carries a property the inspector marks – the same rule, * so the list cannot promise a deviation the inspector then fails to show. @@ -50,6 +53,10 @@ import { openImagePicker } from '../texture/ImagePicker'; export class AssetPanel extends Panel { private readonly list = new ListView(); private readonly scroll = new ScrollContainer(); + /** Types whose group is unfolded, for the session; every group starts folded. */ + private readonly unfolded = new Set(); + /** Ids of the rows as shown, headings included. */ + private rows: string[] = []; constructor( private readonly state: EditorState, @@ -72,11 +79,16 @@ export class AssetPanel extends Panel { this.list.onActivate = (id) => this.activate(id); this.list.onContextMenu = (id, x, y) => this.openContextMenu(id, x, y); this.list.onDragOut = (id) => this.startPlacementDrag(id); + this.list.onToggle = (id, expand) => this.setFolded(id.slice(HEADING.length) as AssetType, !expand); this.scroll.setContent(this.list); this.content.add(this.scroll, 1); state.on('project', () => this.refresh()); - state.on('load', () => this.refresh()); + // A different project brings different assets – start folded again. + state.on('load', () => { + this.unfolded.clear(); + this.refresh(); + }); state.on('selection', () => this.syncSelection()); this.refresh(); } @@ -85,6 +97,7 @@ export class AssetPanel extends Panel { private addMenuEntries(): MenuEntry[] { const entry = (type: AssetType): MenuEntry => ({ label: ASSET_TYPE_LABELS[type], + icon: ASSET_ICON_SVG[type], onSelect: () => this.addAsset(type), }); return assetMenuGroups().flatMap((group, index) => @@ -97,36 +110,79 @@ export class AssetPanel extends Panel { * with. Two marks rather than one, because they are two different questions and often two * different scenes – the level you are editing versus the title screen. */ - private sceneHint(id: string): string { + private sceneMarks(id: string): string { const { activeSceneId, startSceneId } = this.state.project; - const marks = `${id === startSceneId ? '▶' : ''}${id === activeSceneId ? '●' : ''}`; - return marks ? `scene ${marks}` : 'scene'; + return `${id === startSceneId ? '▶' : ''}${id === activeSceneId ? '●' : ''}`; } private refresh(): void { - this.list.setItems( - this.state.project.assets.map((asset) => ({ - id: asset.id, - label: asset.name, - hint: asset.type === 'scene' ? this.sceneHint(asset.id) : asset.type, - swatch: hasColorSwatch(asset.type) ? Number(asset.props.color ?? 0) : undefined, - overridden: assetDeviates(this.state.project, asset), - })), - ); - this.syncSelection(); + const project = this.state.project; + const items: ListItem[] = []; + for (const { type, assets } of groupAssets(project.assets)) { + const expanded = this.unfolded.has(type); + items.push({ + id: headingId(type), + label: ASSET_GROUP_LABELS[type], + hint: String(assets.length), + icon: ASSET_ICON_SVG[type], + heading: true, + expanded, + }); + if (!expanded) continue; + for (const asset of assets) + items.push({ + id: asset.id, + label: asset.name, + hint: asset.type === 'scene' ? this.sceneMarks(asset.id) : undefined, + depth: 1, + overridden: assetDeviates(project, asset), + }); + } + this.rows = items.map((item) => item.id); + this.list.setItems(items); + // Measured first: scrolling to the selection needs the new height of the list. this.scroll.relayoutContent(); + this.syncSelection(); + } + + private setFolded(type: AssetType, fold: boolean): void { + if (fold) { + // A selected asset unfolds its group again – folding lets go of it first. + const s = this.state.selection; + if (s?.kind === 'asset' && findAsset(this.state.project, s.assetId)?.type === type) + this.state.select(null); + this.unfolded.delete(type); + } else this.unfolded.add(type); + this.refresh(); } private syncSelection(): void { const s = this.state.selection; const id = s?.kind === 'asset' ? s.assetId : null; - this.list.select(id, true); // A selection made anywhere else – a graph node, a picker, a fresh asset – has to be reachable - // in the list, and with fifteen kinds of asset in a project the row is often below the fold. - if (id) { - const index = this.state.project.assets.findIndex((a) => a.id === id); - if (index >= 0) this.scroll.ensureVisible(index * this.list.rowHeight, this.list.rowHeight); + // in the list: its group unfolds, and with many assets the row is often below the fold. + const type = id ? findAsset(this.state.project, id)?.type : undefined; + if (type && !this.unfolded.has(type)) { + this.setFolded(type, false); + return; } + this.list.select(id, true); + if (id) this.scrollTo(id); + } + + private scrollTo(id: string): void { + const index = this.rows.indexOf(id); + if (index >= 0) this.scroll.ensureVisible(index * this.list.rowHeight, this.list.rowHeight); + } + + /** The groups as shown, in order (test hook). */ + groups(): { type: AssetType; label: string; expanded: boolean; ids: string[] }[] { + return groupAssets(this.state.project.assets).map(({ type, assets }) => ({ + type, + label: ASSET_GROUP_LABELS[type], + expanded: this.unfolded.has(type), + ids: assets.map((a) => a.id), + })); } private createAsset(type: AssetType, name = ASSET_TYPE_LABELS[type], props: PropertyBag = {}): void { @@ -199,13 +255,30 @@ export class AssetPanel extends Panel { this.state.openAsset(id); } - /** Where an asset's row sits on screen, so a check can pick it up by id (test hook). */ - rowGlobal(assetId: string): { x: number; y: number } | null { + /** + * Where an asset's row sits on screen, so a check can pick it up by id (test hook); with `reveal` + * its group is unfolded first, otherwise a folded group's row is `null`. + */ + rowGlobal(assetId: string, reveal = false): { x: number; y: number } | null { + if (reveal) this.reveal(assetId); return this.list.rowGlobal(assetId); } + /** Unfolds the asset's group and scrolls to its row, as a user would before clicking it (test hooks). */ + private reveal(assetId: string): void { + const type = findAsset(this.state.project, assetId)?.type; + if (type && !this.unfolded.has(type)) this.setFolded(type, false); + this.scrollTo(assetId); + } + + /** Centre of a group heading's icon (test hook). */ + groupIcon(type: AssetType): { x: number; y: number } | null { + return this.list.iconGlobal(headingId(type)); + } + /** Label colour of a row as `#rrggbb` – gold means the asset deviates from the defaults (test hook). */ rowColor(assetId: string): string | null { + this.reveal(assetId); return this.list.rowColor(assetId); } diff --git a/src/editor/panels/HierarchyPanel.ts b/src/editor/panels/HierarchyPanel.ts index 2551d9cd..ef698c77 100644 --- a/src/editor/panels/HierarchyPanel.ts +++ b/src/editor/panels/HierarchyPanel.ts @@ -2,12 +2,10 @@ import type { Command } from '../../core/commands/Command'; import { oneStep } from '../../core/commands/CompoundCommand'; import { UnpackContainerCommand } from '../../core/commands/containerCommands'; import { ReorderObjectCommand, SetParentCommand } from '../../core/commands/sceneObjectCommands'; -import { hasColorSwatch } from '../../core/model/AssetTypes'; import { ancestorIdsOf, childIdsOf, findObject, visibleTreeOrder } from '../../core/model/hierarchy'; import { isLiveInstance, isTemplateContent, resolvedHolder } from '../../core/model/instance'; import { findAsset, type ObjectHolder } from '../../core/model/Project'; import { deviationMarks } from '../../core/properties/deviation'; -import { resolveSceneObjectProps } from '../../core/properties/resolve'; import { type DropSpot, ListView, Menu, Panel, ScrollContainer } from '../../ui'; import { addCollider, @@ -17,6 +15,7 @@ import { createContainer, removeObjects, } from '../actions'; +import { ASSET_ICON_SVG } from './assetIcons'; import { type EditorState, objectSelection } from '../state'; /** @@ -139,6 +138,11 @@ export class HierarchyPanel extends Panel { return this.list.rowGlobal(id); } + /** Global centre of the type icon at a row's right edge (test hook). */ + iconPosition(id: string): { x: number; y: number } | null { + return this.list.iconGlobal(id); + } + expandAll(): void { for (const object of this.holder()?.objects ?? []) this.expanded.add(object.id); this.refresh(); @@ -243,13 +247,12 @@ export class HierarchyPanel extends Panel { this.list.setItems( (holder ? visibleTreeOrder(holder, this.expanded) : []).map(({ object, depth, hasChildren }) => { const asset = findAsset(this.state.project, object.assetId); - const props = asset ? resolveSceneObjectProps(this.state.project, asset, object) : {}; return { id: object.id, label: object.name || asset?.name || '?', - hint: asset?.type ?? '', + icon: asset && ASSET_ICON_SVG[asset.type], + iconAtEnd: true, depth, - swatch: asset && hasColorSwatch(asset.type) ? Number(props.color ?? 0) : undefined, expanded: hasChildren ? this.expanded.has(object.id) : undefined, overridden: marks.has(object.id), }; diff --git a/src/editor/panels/assetGroups.ts b/src/editor/panels/assetGroups.ts new file mode 100644 index 00000000..a27e1466 --- /dev/null +++ b/src/editor/panels/assetGroups.ts @@ -0,0 +1,51 @@ +import type { Asset } from '../../core/model/Asset'; +import type { AssetType } from '../../core/model/AssetTypes'; + +/** + * Group headings of the asset panel. Plural, because a heading names what is under it – and so no + * heading reads like the `+` menu entry that creates one of its kind. + */ +export const ASSET_GROUP_LABELS: Record = { + scene: 'Scenes', + camera: 'Cameras', + sprite: 'Sprites', + text: 'Texts', + behavior: 'Behaviors', + variables: 'Variables', + collider: 'Colliders', + rigidbody: 'Rigidbodies', + container: 'Containers', + texture: 'Textures', + animation: 'Animations', + tile: 'Tiles', + tilemap: 'Tilemaps', + statemachine: 'State machines', + shader: 'Shaders', + font: 'Fonts', + sound: 'Sounds', + particles: 'Particles', +}; + +export interface AssetGroup { + type: AssetType; + assets: Asset[]; +} + +/** Case aside and digits as numbers, so `Level 2` comes before `Level 10`. */ +const collator = new Intl.Collator('en', { numeric: true, sensitivity: 'base' }); + +/** + * The asset panel's rows (#176): one group per type that has assets, groups and the assets in + * each alphabetical. Equal names keep the project's order, so a row does not jump on a rebuild. + */ +export function groupAssets(assets: readonly Asset[]): AssetGroup[] { + const byType = new Map(); + for (const asset of assets) { + const list = byType.get(asset.type); + if (list) list.push(asset); + else byType.set(asset.type, [asset]); + } + return [...byType] + .map(([type, list]) => ({ type, assets: list.sort((a, b) => collator.compare(a.name, b.name)) })) + .sort((a, b) => collator.compare(ASSET_GROUP_LABELS[a.type], ASSET_GROUP_LABELS[b.type])); +} diff --git a/src/editor/panels/assetIcons.ts b/src/editor/panels/assetIcons.ts new file mode 100644 index 00000000..527142e5 --- /dev/null +++ b/src/editor/panels/assetIcons.ts @@ -0,0 +1,48 @@ +/** + * One icon per asset type (#176), one SVG file each in `icons/`, drawn by `drawSvg`: the asset + * panel's groups, the `+` menu, the document tabs and the scene objects. + */ +import type { AssetType } from '../../core/model/AssetTypes'; +import animation from './icons/animation.svg?raw'; +import behavior from './icons/behavior.svg?raw'; +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 list from './icons/list.svg?raw'; +import particles from './icons/particles.svg?raw'; +import rigidbody from './icons/rigidbody.svg?raw'; +import scene from './icons/scene.svg?raw'; +import shader from './icons/shader.svg?raw'; +import sound from './icons/sound.svg?raw'; +import sprite from './icons/sprite.svg?raw'; +import statemachine from './icons/statemachine.svg?raw'; +import text from './icons/text.svg?raw'; +import texture from './icons/texture.svg?raw'; +import tile from './icons/tile.svg?raw'; +import tilemap from './icons/tilemap.svg?raw'; +import variables from './icons/variables.svg?raw'; + +export const ASSET_ICON_SVG: Record = { + animation, + behavior, + camera, + collider, + container, + font, + particles, + rigidbody, + scene, + shader, + sound, + sprite, + statemachine, + text, + texture, + tile, + tilemap, + variables, +}; + +/** The List tab's: it edits a list value, which is no asset. */ +export const LIST_ICON_SVG = list; diff --git a/src/editor/panels/icons/animation.svg b/src/editor/panels/icons/animation.svg new file mode 100644 index 00000000..4261900d --- /dev/null +++ b/src/editor/panels/icons/animation.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/editor/panels/icons/behavior.svg b/src/editor/panels/icons/behavior.svg new file mode 100644 index 00000000..7dd1b565 --- /dev/null +++ b/src/editor/panels/icons/behavior.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/editor/panels/icons/camera.svg b/src/editor/panels/icons/camera.svg new file mode 100644 index 00000000..3a377ced --- /dev/null +++ b/src/editor/panels/icons/camera.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/editor/panels/icons/collider.svg b/src/editor/panels/icons/collider.svg new file mode 100644 index 00000000..b7ffa5ad --- /dev/null +++ b/src/editor/panels/icons/collider.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/src/editor/panels/icons/container.svg b/src/editor/panels/icons/container.svg new file mode 100644 index 00000000..7de57cd7 --- /dev/null +++ b/src/editor/panels/icons/container.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/src/editor/panels/icons/font.svg b/src/editor/panels/icons/font.svg new file mode 100644 index 00000000..8fd1cf51 --- /dev/null +++ b/src/editor/panels/icons/font.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/editor/panels/icons/list.svg b/src/editor/panels/icons/list.svg new file mode 100644 index 00000000..d8c1914f --- /dev/null +++ b/src/editor/panels/icons/list.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/src/editor/panels/icons/particles.svg b/src/editor/panels/icons/particles.svg new file mode 100644 index 00000000..efac2f90 --- /dev/null +++ b/src/editor/panels/icons/particles.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/src/editor/panels/icons/rigidbody.svg b/src/editor/panels/icons/rigidbody.svg new file mode 100644 index 00000000..0e7b8a69 --- /dev/null +++ b/src/editor/panels/icons/rigidbody.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/editor/panels/icons/scene.svg b/src/editor/panels/icons/scene.svg new file mode 100644 index 00000000..383c26f5 --- /dev/null +++ b/src/editor/panels/icons/scene.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/editor/panels/icons/shader.svg b/src/editor/panels/icons/shader.svg new file mode 100644 index 00000000..58d795c2 --- /dev/null +++ b/src/editor/panels/icons/shader.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/editor/panels/icons/sound.svg b/src/editor/panels/icons/sound.svg new file mode 100644 index 00000000..9d4d00df --- /dev/null +++ b/src/editor/panels/icons/sound.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/editor/panels/icons/sprite.svg b/src/editor/panels/icons/sprite.svg new file mode 100644 index 00000000..1e389c8c --- /dev/null +++ b/src/editor/panels/icons/sprite.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/editor/panels/icons/statemachine.svg b/src/editor/panels/icons/statemachine.svg new file mode 100644 index 00000000..85366679 --- /dev/null +++ b/src/editor/panels/icons/statemachine.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/editor/panels/icons/text.svg b/src/editor/panels/icons/text.svg new file mode 100644 index 00000000..950fe9b7 --- /dev/null +++ b/src/editor/panels/icons/text.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/editor/panels/icons/texture.svg b/src/editor/panels/icons/texture.svg new file mode 100644 index 00000000..e1e08bd3 --- /dev/null +++ b/src/editor/panels/icons/texture.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/editor/panels/icons/tile.svg b/src/editor/panels/icons/tile.svg new file mode 100644 index 00000000..ac504a54 --- /dev/null +++ b/src/editor/panels/icons/tile.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/editor/panels/icons/tilemap.svg b/src/editor/panels/icons/tilemap.svg new file mode 100644 index 00000000..20c967b8 --- /dev/null +++ b/src/editor/panels/icons/tilemap.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/editor/panels/icons/variables.svg b/src/editor/panels/icons/variables.svg new file mode 100644 index 00000000..b77dfde9 --- /dev/null +++ b/src/editor/panels/icons/variables.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/playground.ts b/src/playground.ts index 9938f2a7..06626cfd 100644 --- a/src/playground.ts +++ b/src/playground.ts @@ -108,7 +108,6 @@ export function mountPlayground(app: Application, root: VStack): void { id: `item-${i}`, label: `Item ${i + 1}${i % 7 === 0 ? ' with a rather long name that must truncate' : ''}`, hint: i % 3 === 0 ? 'sprite' : i % 3 === 1 ? 'camera' : 'scene', - swatch: i % 3 === 0 ? (0x3fa0e0 + i * 0x040201) & 0xffffff : undefined, depth: i % 5 === 4 ? 1 : 0, })), ); diff --git a/src/ui/Theme.ts b/src/ui/Theme.ts index 8e142e66..652f2cf9 100644 --- a/src/ui/Theme.ts +++ b/src/ui/Theme.ts @@ -14,7 +14,6 @@ export const Theme = { radius: 4, fieldPadding: 6, checkboxSize: 16, - swatchSize: 12, scrollbarWidth: 6, /** Horizontal indent per nesting level in tree lists. */ treeIndent: 14, diff --git a/src/ui/index.ts b/src/ui/index.ts index caa55a7d..caf74550 100644 --- a/src/ui/index.ts +++ b/src/ui/index.ts @@ -50,3 +50,4 @@ export { toHex, } from './color'; export { pressCount } from './clicks'; +export { drawSvg, tintSvg } from './svg'; diff --git a/src/ui/svg.ts b/src/ui/svg.ts new file mode 100644 index 00000000..a0ca7216 --- /dev/null +++ b/src/ui/svg.ts @@ -0,0 +1,26 @@ +/** + * Drawing a fixed SVG file into a `Graphics`. A file draws in its own units around its origin, and + * `currentColor` stands for the colour it is drawn in – so it still shows in any SVG viewer, and a + * theme change reaches it. What Pixi's parser draws like a browser is held by the icon tests. + */ +import { type Graphics, SVGParser } from 'pixi.js'; + +export function tintSvg(svg: string, color: number): string { + return svg.replaceAll('currentColor', `#${color.toString(16).padStart(6, '0')}`); +} + +/** Parsed once per file and colour: a scene symbol is redrawn with every frame. */ +const parsed = new Map>(); + +/** Adds the file to what `node` already draws. */ +export function drawSvg(node: Graphics, svg: string, color: number): void { + let byColor = parsed.get(svg); + if (!byColor) parsed.set(svg, (byColor = new Map())); + let element = byColor.get(color); + if (!element) { + const text = tintSvg(svg, color); + element = new DOMParser().parseFromString(text, 'image/svg+xml').documentElement as unknown as SVGElement; + byColor.set(color, element); + } + SVGParser(element, node.context); +} diff --git a/src/ui/widgets/Button.ts b/src/ui/widgets/Button.ts index 8f4db737..2186c90e 100644 --- a/src/ui/widgets/Button.ts +++ b/src/ui/widgets/Button.ts @@ -1,6 +1,7 @@ import { type FederatedPointerEvent, Graphics, Text, CanvasTextMetrics } from 'pixi.js'; import { drawControlBox } from '../draw'; import { centreY } from '../math'; +import { drawSvg } from '../svg'; import { Theme, textStyle, type TextStyleToken } from '../Theme'; import { type Size, Widget } from '../Widget'; import { truncate } from './Label'; @@ -18,9 +19,15 @@ export interface ButtonOptions { style?: TextStyleToken; /** Switched on and off by its owner (`setToggled`): measured for the bold label it shows when on. */ toggle?: boolean; + /** SVG file drawn in front of the label, 16 units around its origin (see `drawSvg`). */ + icon?: string; onClick?: () => void; } +/** Units an icon file spans around its origin, and the room between it and the label. */ +const ICON_SIZE = 16; +const ICON_GAP = 6; + export class Button extends Widget { private readonly bg = new Graphics(); protected readonly labelText: Text; @@ -35,6 +42,10 @@ export class Button extends Widget { protected readonly padding: number; private readonly style: TextStyleToken; private readonly toggle: boolean; + private readonly icon: string | null; + private readonly iconGfx = new Graphics(); + /** Colour the icon was last drawn in; it follows the label from muted to the accent text. */ + private iconColor = -1; onClick?: () => void; constructor(text: string, options: ButtonOptions = {}) { @@ -52,7 +63,9 @@ export class Button extends Widget { this.style = options.style ?? 'body'; this.toggle = options.toggle ?? false; this.labelText = new Text({ text, style: textStyle(this.variant === 'primary' ? 'accent' : this.style) }); + this.icon = options.icon ?? null; this.addChild(this.bg, this.labelText); + if (this.icon) this.addChild(this.iconGfx); // Leaving while pressed cancels the click: a release elsewhere must not activate. this.trackHover(() => { @@ -110,14 +123,24 @@ 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.toggle ? this.style : 'accent'); const metrics = CanvasTextMetrics.measureText(this.fullText, style); - return { w: Math.max(this.minWidth, Math.ceil(metrics.width) + this.padding * 2), h }; + return { w: Math.max(this.minWidth, Math.ceil(metrics.width) + this.padding * 2 + this.iconSpace()), h }; + } + + /** Room the icon takes in front of the label, gap included; 0 without one. */ + protected iconSpace(): number { + return this.icon ? ICON_SIZE + ICON_GAP : 0; + } + + /** Whether an icon is drawn in front of the label (test hook). */ + get hasIcon(): boolean { + return this.icon !== null; } /** 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(this.padding, Math.floor(this.w / 6)) * 2; + return this.w - Math.min(this.padding, Math.floor(this.w / 6)) * 2 - this.iconSpace(); } override onKeyDown(event: KeyboardEvent): boolean { @@ -165,7 +188,17 @@ 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' ? this.padding : (this.w - label.width) / 2; - label.position.set(Math.round(x), centreY(this.h, label.height)); + 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; + if (color !== this.iconColor) { + this.iconColor = color; + this.iconGfx.clear(); + drawSvg(this.iconGfx, this.icon, color); + } + this.iconGfx.position.set(Math.round(x) + ICON_SIZE / 2, Math.round(this.h / 2)); + } } } diff --git a/src/ui/widgets/ListRow.ts b/src/ui/widgets/ListRow.ts index 10ff2c87..f7017206 100644 --- a/src/ui/widgets/ListRow.ts +++ b/src/ui/widgets/ListRow.ts @@ -1,6 +1,6 @@ import { Color, type ColorSource, Graphics, Text } from 'pixi.js'; -import { drawControlBox } from '../draw'; import { centreY } from '../math'; +import { drawSvg } from '../svg'; import { Theme, textStyle } from '../Theme'; import { Widget } from '../Widget'; import { truncate } from './Label'; @@ -8,12 +8,15 @@ import type { ListItem } from './ListView'; /** Width reserved in front of every row of a tree list for the chevron. */ const CHEVRON_SLOT = 14; +/** Units an icon file spans around its origin. */ +const ICON_SIZE = 16; /** One row of a `ListView`. Purely visual: pointer handling and selection state live in the list. */ export class ListRow extends Widget { private readonly bg = new Graphics(); private readonly labelText = new Text({ text: '', style: textStyle('body') }); private readonly hint = new Text({ text: '', style: textStyle('small') }); + private readonly icon = new Graphics(); private selected = false; private listFocused = false; @@ -26,7 +29,12 @@ export class ListRow extends Widget { this.cursor = Theme.hoverCursor; this.minHeight = height; if (item.overridden) this.labelText.style = textStyle('override'); + else if (item.heading) this.labelText.style = textStyle('bodyBold'); this.addChild(this.bg, this.labelText, this.hint); + if (item.icon) { + drawSvg(this.icon, item.icon, Theme.colors.textMuted); + this.addChild(this.icon); + } this.trackHover(); } @@ -55,6 +63,13 @@ export class ListRow extends Widget { return { x: p.x + this.indent + CHEVRON_SLOT / 2, y: p.y + this.h / 2 }; } + /** Global centre of the icon, its origin; null for rows without one (test hook). */ + iconGlobal(): { x: number; y: number } | null { + if (!this.item.icon) return null; + const p = this.icon.getGlobalPosition(); + return { x: p.x, y: p.y }; + } + /** Whether a click at this local x lands on the chevron rather than on the row. */ hitsChevron(localX: number): boolean { if (!this.chevronSlot || this.item.expanded === undefined) return false; @@ -83,19 +98,17 @@ export class ListRow extends Widget { if (this.item.expanded !== undefined) this.drawChevron(x + 4, this.h / 2, this.item.expanded); x += CHEVRON_SLOT; } - if (this.item.swatch !== undefined) { - const size = Theme.swatchSize; - drawControlBox(this.bg, size, size, { - fill: this.item.swatch, - border: c.border, - radius: 2, - x, - y: centreY(this.h, size), - }); - x += size + Theme.gap; + const iconAtEnd = Boolean(this.item.icon && this.item.iconAtEnd); + if (this.item.icon && !iconAtEnd) { + this.icon.position.set(x + ICON_SIZE / 2, this.h / 2); + x += ICON_SIZE + Theme.gap; } this.hint.text = this.item.hint ?? ''; - const hintW = this.hint.text ? this.hint.width + Theme.padding : 0; + let hintW = this.hint.text ? this.hint.width + Theme.padding : 0; + if (iconAtEnd) { + this.icon.position.set(this.w - Theme.padding - hintW - ICON_SIZE / 2, this.h / 2); + hintW += ICON_SIZE + Theme.padding; + } this.labelText.text = truncate(this.item.label, this.labelText.style, this.w - x - hintW - Theme.padding); this.labelText.position.set(x, centreY(this.h, this.labelText.height)); this.hint.position.set( diff --git a/src/ui/widgets/ListView.ts b/src/ui/widgets/ListView.ts index 3d9cddde..fead52c6 100644 --- a/src/ui/widgets/ListView.ts +++ b/src/ui/widgets/ListView.ts @@ -12,12 +12,19 @@ export interface ListItem { hint?: string; /** Left indent in levels. */ depth?: number; - /** Marker color drawn as a small square before the label. */ - swatch?: number; /** Whether the subtree is shown; such rows get a chevron. Left undefined for leaves. */ expanded?: boolean; /** Draws the label in the override colour – the row, or something inside it, deviates. */ overridden?: boolean; + /** SVG file drawn in front of the label, 16 units around its origin, `currentColor` muted. */ + icon?: string; + /** Draws `icon` at the right edge, where a hint would stand, instead of in front of the label. */ + iconAtEnd?: boolean; + /** + * A heading over the rows that follow: a press anywhere on it folds it, it is never selected, + * and ↑/↓ pass over it. Give it `expanded` for the chevron. + */ + heading?: boolean; } /** Pixels of travel before a press turns into a drag. */ @@ -145,6 +152,11 @@ export class ListView extends Widget { return { x: p.x + this.w / 2, y: p.y + this.rowHeight / 2 }; } + /** Global centre of a row's icon; null when the row has none (test hook). */ + iconGlobal(id: string): { x: number; y: number } | null { + return this.rows.find((r) => r.item.id === id)?.iconGlobal() ?? null; + } + /** Global centre of a row's chevron; null when the row has none. */ chevronGlobal(id: string): { x: number; y: number } | null { return this.rows.find((r) => r.item.id === id)?.chevronGlobal() ?? null; @@ -157,12 +169,13 @@ export class ListView extends Widget { override onKeyDown(event: KeyboardEvent): boolean { const index = this.items.findIndex((i) => i.id === this.selectedId); if (event.key === 'ArrowDown') { - const next = this.items[Math.min(this.items.length - 1, index + 1)]; + const next = this.items.slice(index + 1).find((i) => !i.heading); if (next) this.select(next.id); return true; } if (event.key === 'ArrowUp') { - const prev = this.items[Math.max(0, index - 1)]; + const above = index < 0 ? this.items : this.items.slice(0, index).reverse(); + const prev = above.find((i) => !i.heading); if (prev) this.select(prev.id); return true; } @@ -200,6 +213,10 @@ export class ListView extends Widget { private onRowPress(row: ListRow, e: FederatedPointerEvent): void { const { item } = row; const modifiers: PickModifiers = { ctrl: e.ctrlKey || e.metaKey, shift: e.shiftKey }; + if (item.heading) { + if (e.button === 0) this.onToggle?.(item.id, !item.expanded); + return; + } // The chevron only folds the row – it never selects, so folding keeps the selection put. if (e.button === 0 && row.hitsChevron(row.toLocal(e.global).x)) { this.onToggle?.(item.id, !item.expanded); diff --git a/src/ui/widgets/Menu.ts b/src/ui/widgets/Menu.ts index 1a059aed..f9c3a5ac 100644 --- a/src/ui/widgets/Menu.ts +++ b/src/ui/widgets/Menu.ts @@ -15,6 +15,8 @@ export interface MenuItem { onSelect: () => void; danger?: boolean; disabled?: boolean; + /** SVG file in front of the label (see `ButtonOptions.icon`). */ + icon?: string; } /** A hairline between two groups of entries; it is not selectable and matches no search. */ @@ -66,6 +68,7 @@ export class Menu extends Popup { const button = new Button(item.label, { variant: item.danger ? 'danger' : 'ghost', align: 'left', + icon: item.icon, }); button.setEnabled(!item.disabled); button.onClick = this.closeThen(item.onSelect); @@ -95,6 +98,13 @@ export class Menu extends Popup { .map((child) => (child instanceof Button ? child.text : null)); } + /** Per entry of `rowsDrawn`, whether it shows an icon; `null` for a separator (test hook). */ + iconsDrawn(): (boolean | null)[] { + return this.list.children + .filter((child) => child !== this.empty && child.visible) + .map((child) => (child instanceof Button ? child.hasIcon : null)); + } + private filter(): void { const query = this.search?.value.trim().toLowerCase() ?? ''; const shown = query ? this.ranked(query) : groupsOf(this.rows); diff --git a/src/viewport/symbolIcons.ts b/src/viewport/symbolIcons.ts index 4f56358b..a8c3e128 100644 --- a/src/viewport/symbolIcons.ts +++ b/src/viewport/symbolIcons.ts @@ -1,10 +1,6 @@ -/** - * The fixed pictures of the scene view's symbols, kept as SVG files in `icons/` (#175). A file - * draws in the symbol's own units around its origin, and `currentColor` stands for the colour the - * theme gives the symbol – so the file still shows in any SVG viewer, and a theme change reaches it. - */ -import { type Graphics, SVGParser } from 'pixi.js'; -import { Theme } from '../ui'; +/** The fixed pictures of the scene view's symbols, kept as SVG files in `icons/` (#175). */ +import type { Graphics } from 'pixi.js'; +import { drawSvg, Theme } from '../ui'; import behavior from './icons/behavior.svg?raw'; import camera from './icons/camera.svg?raw'; import container from './icons/container.svg?raw'; @@ -23,20 +19,7 @@ const SYMBOL_COLOR: Record = { rigidbody: Theme.colors.collider, }; -export function tintSvg(svg: string, color: number): string { - return svg.replaceAll('currentColor', `#${color.toString(16).padStart(6, '0')}`); -} - -/** Parsed once: a symbol is redrawn with every frame, and the text parse is what costs. */ -const parsed = new Map(); - /** Adds the icon to what `node` already draws. */ export function drawSymbolIcon(node: Graphics, icon: SymbolIcon): void { - let svg = parsed.get(icon); - if (!svg) { - const text = tintSvg(SYMBOL_SVG[icon], SYMBOL_COLOR[icon]); - svg = new DOMParser().parseFromString(text, 'image/svg+xml').documentElement as unknown as SVGElement; - parsed.set(icon, svg); - } - SVGParser(svg, node.context); + drawSvg(node, SYMBOL_SVG[icon], SYMBOL_COLOR[icon]); } diff --git a/tests/editor/asset-groups.test.ts b/tests/editor/asset-groups.test.ts new file mode 100644 index 00000000..6a1365c3 --- /dev/null +++ b/tests/editor/asset-groups.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from 'vitest'; +import type { Asset } from '../../src/core/model/Asset'; +import { ASSET_TYPE_LABELS, ASSET_TYPES, type AssetType } from '../../src/core/model/AssetTypes'; +import { ASSET_GROUP_LABELS, groupAssets } from '../../src/editor/panels/assetGroups'; + +let serial = 0; +const asset = (type: AssetType, name: string): Asset => + ({ id: `a${++serial}`, type, name, props: {} }) as unknown as Asset; + +const shape = (assets: Asset[]) => groupAssets(assets).map((g) => [g.type, g.assets.map((a) => a.name)]); + +describe('groupAssets (#176)', () => { + it('puts every asset into the group of its type, and no empty group', () => { + const assets = [asset('texture', 'Sheet'), asset('sprite', 'Hero'), asset('texture', 'Tiles')]; + expect(shape(assets)).toEqual([ + ['sprite', ['Hero']], + ['texture', ['Sheet', 'Tiles']], + ]); + }); + + it('sorts the groups by their heading, not by type or declaration order', () => { + const types = [...ASSET_TYPES].reverse(); + const headings = groupAssets(types.map((t) => asset(t, 'x'))).map((g) => ASSET_GROUP_LABELS[g.type]); + expect(headings).toEqual([...headings].sort((a, b) => a.localeCompare(b))); + expect(headings).toHaveLength(ASSET_TYPES.length); + // By heading: "State machines" sorts under S, the type key `statemachine` would not tell. + expect(headings.indexOf('Sprites')).toBeLessThan(headings.indexOf('State machines')); + }); + + it('sorts the assets in a group by name, ignoring case and reading numbers as numbers', () => { + const assets = ['Level 10', 'level 2', 'Boss', 'Level 1', 'arena'].map((n) => asset('scene', n)); + expect(shape(assets)).toEqual([['scene', ['arena', 'Boss', 'Level 1', 'level 2', 'Level 10']]]); + }); + + it('keeps the project order among equal names', () => { + const first = asset('sprite', 'Coin'); + const second = asset('sprite', 'coin'); + expect(groupAssets([first, second])[0]!.assets).toEqual([first, second]); + expect(groupAssets([second, first])[0]!.assets).toEqual([second, first]); + }); + + it('leaves the project list alone', () => { + const assets = [asset('sprite', 'B'), asset('sprite', 'A')]; + groupAssets(assets); + expect(assets.map((a) => a.name)).toEqual(['B', 'A']); + }); + + it('names a heading like a + menu entry only where the type is plural anyway', () => { + // A click by text takes the first hit, and the panel is drawn before any menu. + const entries = new Set(Object.values(ASSET_TYPE_LABELS)); + // Variables and Particles are already plural; nothing else may collide. + const clashes = Object.values(ASSET_GROUP_LABELS).filter((label) => entries.has(label)); + expect(clashes.sort()).toEqual(['Particles', 'Variables']); + }); +}); diff --git a/tests/viewport/symbol-icons.test.ts b/tests/ui/svg-icons.test.ts similarity index 60% rename from tests/viewport/symbol-icons.test.ts rename to tests/ui/svg-icons.test.ts index 3ed7a198..675f3679 100644 --- a/tests/viewport/symbol-icons.test.ts +++ b/tests/ui/svg-icons.test.ts @@ -1,5 +1,8 @@ import { describe, expect, it } from 'vitest'; -import { SYMBOL_SVG, tintSvg } from '../../src/viewport/symbolIcons'; +import { ASSET_TYPES } from '../../src/core/model/AssetTypes'; +import { ASSET_ICON_SVG, LIST_ICON_SVG } from '../../src/editor/panels/assetIcons'; +import { tintSvg } from '../../src/ui/svg'; +import { SYMBOL_SVG } from '../../src/viewport/symbolIcons'; /** Opening tags as name + attributes; the files are flat, so no real XML parser is needed. */ function tags(svg: string): { name: string; attrs: Record }[] { @@ -9,10 +12,8 @@ function tags(svg: string): { name: string; attrs: Record }[] { })); } -describe('scene view symbol icons (#175)', () => { - const icons = Object.entries(SYMBOL_SVG); - - it('has a file for every symbol', () => { +describe('icon files', () => { + it('has a scene view symbol for every symbol (#175)', () => { expect(Object.keys(SYMBOL_SVG).sort()).toEqual([ 'behavior', 'camera', @@ -22,8 +23,19 @@ describe('scene view symbol icons (#175)', () => { ]); }); + it('has an asset panel icon for every asset type, each its own picture (#176)', () => { + expect(Object.keys(ASSET_ICON_SVG).sort()).toEqual([...ASSET_TYPES].sort()); + expect(new Set(Object.values(ASSET_ICON_SVG)).size).toBe(ASSET_TYPES.length); + }); + + const icons = [ + ...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], + ]; + // Pixi draws the file's coordinates as they are and ignores the viewBox, so the viewBox only - // shows the file right elsewhere – centred on the origin, it matches what the scene view draws. + // shows the file right elsewhere – centred on the origin, it matches what the editor draws. it.each(icons)('%s is centred on its origin', (_, svg) => { const root = tags(svg)[0]!; expect(root.name).toBe('svg'); @@ -32,6 +44,15 @@ describe('scene view symbol icons (#175)', () => { expect([Number(root.attrs.width), Number(root.attrs.height)]).toEqual([w, h]); }); + 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]) + expect(tags(svg)[0]!.attrs.viewBox).toBe('-8 -8 16 16'); + }); + + it('gives the List tab a picture no asset type has', () => { + expect(Object.values(ASSET_ICON_SVG)).not.toContain(LIST_ICON_SVG); + }); + it.each(icons)('%s uses only what the Pixi parser draws like a browser', (_, svg) => { for (const { name, attrs } of tags(svg).slice(1)) { // polygon and polyline read their points as whole numbers; groups would carry transforms, @@ -49,6 +70,6 @@ describe('scene view symbol icons (#175)', () => { expect(tintSvg('', 0x0000ff)).toBe( '', ); - for (const [, svg] of icons) expect(tintSvg(svg, 0xb066ff)).not.toContain('currentColor'); + for (const [, svg] of icons) expect(tintSvg(svg!, 0xb066ff)).not.toContain('currentColor'); }); }); diff --git a/tools/e2e/04-assets.mjs b/tools/e2e/04-assets.mjs index 4e3fd07b..296c33fe 100644 --- a/tools/e2e/04-assets.mjs +++ b/tools/e2e/04-assets.mjs @@ -18,9 +18,12 @@ export async function assets(t) { objects, asset, showTab, + has, } = t; - // 4a. Context menu on the Crate asset. Placing left the menu when it became a drag. + // 4a. Context menu on the Crate asset. Placing left the menu when it became a drag. The groups + // start folded (#176) – the hook unfolds Crate's. + await json("__gm.assetRow('crate')"); await clickText('Crate', 'right'); check('asset context menu opens', Boolean(await evaluate(`__gm.locate('Delete…')`))); check('placing is no longer a menu item', !(await evaluate(`__gm.locate('Place in scene')`))); @@ -291,4 +294,141 @@ export async function assets(t) { (await assetColor('crate')) === GOLD, `${await assetColor('crate')} props=${JSON.stringify((await asset('crate')).props)}`, ); + + // 4g. The list is grouped by type (#176): a heading per type, groups and the assets in them + // alphabetical, each heading with an icon of its own. A freshly opened project shows every + // group folded – reloaded here, since the modules before have unfolded groups by selecting. + await evaluate('__gm.state.loadProject(__gm.state.project, __gm.state.revision)'); + await sleep(100); + const groups = await json('__gm.assetGroups()'); + const nameOf = Object.fromEntries( + (await json('__gm.state.project.assets.map((a) => [a.id, a.name])')).map(([id, name]) => [id, name]), + ); + const collator = new Intl.Collator('en', { numeric: true, sensitivity: 'base' }); + const sorted = (list) => list.every((v, i) => i === 0 || collator.compare(list[i - 1], v) <= 0); + check( + 'the assets panel groups by type, groups and assets sorted by name', + groups.length >= 4 && + sorted(groups.map((g) => g.label)) && + groups.every((g) => sorted(g.ids.map((id) => nameOf[id]))) && + groups.flatMap((g) => g.ids).length === Object.keys(nameOf).length, + JSON.stringify(groups.map((g) => [g.label, g.ids.map((id) => nameOf[id])])), + ); + check( + 'a loaded project shows every group folded', + groups.every((g) => !g.expanded) && (await json("__gm.assetRowShown('crate')")) === null, + JSON.stringify(groups.map((g) => [g.label, g.expanded])), + ); + // Read in the drawn picture: every icon inks its 16 units in the muted colour, and no two alike. + // Every pixel against the row's own background (`+`), since a 1.5 unit outline on half pixels is + // smoothed and never quite the colour; the exact colour (`#`) still shows where the ink is solid. + const MUTED = '#8a90a0'; + const icons = await json(`(() => Object.fromEntries(__gm.assetGroups().map((g) => { + const c = __gm.assetGroupIcon(g.type); + const background = __gm.pixelAt(c.x, c.y - 9); + let mask = ''; + for (let dy = -7; dy <= 7; dy++) + for (let dx = -7; dx <= 7; dx++) { + const p = __gm.pixelAt(c.x + dx, c.y + dy); + mask += p === '${MUTED}' ? '#' : p === background ? '.' : '+'; + } + return [g.type, mask]; + })))()`); + const masks = Object.values(icons); + check( + 'every heading draws its icon in the muted colour, and no two icons look alike', + masks.every((m) => m.replaceAll('.', '').length >= 20 && m.includes('#')) && + new Set(masks.map((m) => m.replaceAll('#', '+'))).size === masks.length, + JSON.stringify(icons), + ); + + // A press on the heading unfolds the group, a second folds it again: rows come and go, the + // heading and its count stay, and the selection is left where it was. + await select({ kind: 'asset', assetId: 'cam' }); + const sprites = await json("__gm.assetRow('group:sprite')"); + const spriteGroup = async () => ({ + expanded: (await json('__gm.assetGroups()')).find((g) => g.type === 'sprite').expanded, + crate: await json("__gm.assetRowShown('crate')"), + heading: await json("__gm.assetRow('group:sprite')"), + selected: (await json('__gm.state.selection'))?.assetId, + }); + await click(sprites.x, sprites.y); + const unfolded = await spriteGroup(); + check( + 'a click on a folded heading unfolds its group below it and selects nothing', + unfolded.expanded === true && + Boolean(unfolded.crate) && + Boolean(unfolded.heading) && + unfolded.heading.y < unfolded.crate.y && + unfolded.selected === 'cam' && + (await has('Sprites')), + JSON.stringify(unfolded), + ); + await shot('15c-asset-groups'); + await click(sprites.x, sprites.y); + const folded = await spriteGroup(); + check( + 'a second click folds it again', + folded.expanded === false && + folded.crate === null && + Boolean(folded.heading) && + folded.selected === 'cam', + JSON.stringify(folded), + ); + // Selecting a folded asset from elsewhere – a picker, a graph – unfolds its group. + await select({ kind: 'asset', assetId: 'crate' }); + check( + 'selecting an asset in a folded group unfolds it', + (await json('__gm.assetGroups()')).find((g) => g.type === 'sprite').expanded === true && + Boolean(await json("__gm.assetRowShown('crate')")), + JSON.stringify(await json('__gm.assetGroups()')), + ); + // Folding the group of the selected asset lets go of the selection – it would unfold it again. + const heading = await json("__gm.assetRow('group:sprite')"); + await click(heading.x, heading.y); + const released = await spriteGroup(); + check( + 'folding the group of the selected asset folds it and clears the selection', + released.expanded === false && released.crate === null && released.selected === undefined, + JSON.stringify(released), + ); + + // 4h. The same icons elsewhere: in front of every type in the `+` menu, and at the right edge of + // a scene object's row, where its type used to be spelled out. + await clickText('+'); + const menuRows = await json('__gm.menuRows()'); + const menuIcons = await json('__gm.menuIcons()'); + await shot('15d-add-menu'); + await press('Escape'); + check( + 'every type in the + menu has its icon in front', + menuRows.filter(Boolean).length >= 15 && + menuRows.every((row, i) => (row === null) === (menuIcons[i] === null) && menuIcons[i] !== false), + JSON.stringify({ menuRows, menuIcons }), + ); + await showTab('scene'); + const first = (await json('__gm.hierarchyOrder()'))[0]; + const typeOfFirst = await json(`(() => { + const scene = __gm.state.activeScene; + const o = scene.objects.find((x) => x.id === ${JSON.stringify(first)}); + return __gm.state.project.assets.find((a) => a.id === o.assetId).type; + })()`); + const rowAt = await json(`__gm.hierarchyRow(${JSON.stringify(first)})`); + const iconAt = await json(`__gm.hierarchyIcon(${JSON.stringify(first)})`); + const ink = await json(`(() => { + const c = __gm.hierarchyIcon(${JSON.stringify(first)}); + const background = __gm.pixelAt(c.x - 12, c.y); + let n = 0; + for (let dy = -7; dy <= 7; dy++) + for (let dx = -7; dx <= 7; dx++) if (__gm.pixelAt(c.x + dx, c.y + dy) !== background) n++; + return n; + })()`); + const typeWords = (await json(`__gm.locateAll(${JSON.stringify(typeOfFirst)})`)).filter( + (p) => Math.abs(p.y - rowAt.y) < 8, + ); + check( + 'a scene object shows its type as an icon at the right edge, no longer as a word', + Boolean(iconAt) && iconAt.x > rowAt.x && ink >= 20 && typeWords.length === 0, + JSON.stringify({ first, typeOfFirst, rowAt, iconAt, ink, typeWords }), + ); } diff --git a/tools/e2e/14-overflow.mjs b/tools/e2e/14-overflow.mjs index 24801e01..a1975c9d 100644 --- a/tools/e2e/14-overflow.mjs +++ b/tools/e2e/14-overflow.mjs @@ -77,12 +77,12 @@ export async function overflow(t) { // The overflow button names them, and picking one brings it into the row. await clickText('»'); const offered = (await json('__gm.menuRows()')).filter(Boolean); + const offeredIcons = (await json('__gm.menuIcons()')).filter((icon) => icon !== null); check( - 'the overflow button offers every tab that had to go', + 'the overflow button offers every tab that had to go, a document with the icon of its kind', offered.length === narrow.hidden.length && - offered.filter((label) => label.includes(' · ')).length === - narrow.hidden.filter((tab) => tab.startsWith('asset:')).length, - JSON.stringify({ offered, hidden: narrow.hidden }), + offeredIcons.filter(Boolean).length === narrow.hidden.filter((tab) => tab.startsWith('asset:')).length, + JSON.stringify({ offered, offeredIcons, hidden: narrow.hidden }), ); const wanted = narrow.hidden[narrow.hidden.length - 1]; const item = await t.locate(offered[offered.length - 1], true); diff --git a/tools/e2e/33-render-on-change.mjs b/tools/e2e/33-render-on-change.mjs index 36f8256f..8d44e037 100644 --- a/tools/e2e/33-render-on-change.mjs +++ b/tools/e2e/33-render-on-change.mjs @@ -10,7 +10,7 @@ import { openTab } from './lib/scaffold.mjs'; * The step runs last and leaves the editor as it found it. */ export async function renderOnChange(t) { - const { check, sleep, evaluate, select, click, locate, play, stop, shot, waitUntil } = t; + const { check, sleep, evaluate, json, select, click, play, stop, shot, waitUntil } = t; const stats = () => evaluate('__gm.renderStats()'); /** Frames drawn over `ms` of doing nothing at all. */ @@ -70,8 +70,10 @@ export async function renderOnChange(t) { // 33c. Input wakes it, and it goes back to sleep on its own afterwards. await settle(); const beforeClick = (await stats()).drawn; - const spot = await locate('Behavior'); - await click(spot.x, spot.y); + // A behavior's tab – its kind is an icon since #176, so found through the row, not by a word. + const shown = (await json('__gm.tabRow()')).shown; + const tab = shown.find((p) => p.kind === 'Behavior') ?? shown.find((p) => p.tab === 'scene'); + await click(tab.x + 12, tab.y + tab.h / 2); const afterClick = (await stats()).drawn; check('a click draws again', afterClick > beforeClick, `${beforeClick} → ${afterClick}`); diff --git a/tools/e2e/35-load-scene.mjs b/tools/e2e/35-load-scene.mjs index 1bd5ae08..9ab24a33 100644 --- a/tools/e2e/35-load-scene.mjs +++ b/tools/e2e/35-load-scene.mjs @@ -144,7 +144,8 @@ export async function loadScene(t) { ); check( 'the assets panel marks the start scene apart from the active one', - await has('scene ▶'), + // Under the Scenes heading the row no longer repeats its type – the marks are all it says. + await has('▶'), JSON.stringify( await json('__gm.state.project.assets.filter((a) => a.type === "scene").map((a) => a.name)'), ), diff --git a/tools/e2e/44-documents.mjs b/tools/e2e/44-documents.mjs index 3e743b9c..86cdbefa 100644 --- a/tools/e2e/44-documents.mjs +++ b/tools/e2e/44-documents.mjs @@ -41,14 +41,22 @@ export async function documents(t) { JSON.stringify({ opened, current: await current() }), ); const placeA = await place(`asset:${a.id}`); + const inTab = (p) => p.x > placeA.x && p.x < placeA.right && p.y > placeA.y && p.y < placeA.y + placeA.h; const nameHits = await json(`__gm.locateAll(${JSON.stringify(a.name)})`); + 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 background = __gm.pixelAt(${placeA.x} + 1, y); + let n = 0; + for (let dy = -7; dy <= 7; dy++) + for (let dx = 0; dx < 16; dx++) if (__gm.pixelAt(x0 + dx, y + dy) !== background) n++; + return n; + })()`); check( - 'a tab reads its kind, then the asset’s name', - placeA?.kind === 'Texture' && - nameHits.some( - (p) => p.x > placeA.x && p.x < placeA.right && p.y > placeA.y && p.y < placeA.y + placeA.h, - ), - JSON.stringify({ placeA, nameHits }), + 'a tab shows the icon of its kind, then the asset’s name – the kind no longer as a word', + placeA?.kind === 'Texture' && nameHits.some(inTab) && kindWords.length === 0 && iconInk >= 20, + JSON.stringify({ placeA, nameHits, kindWords, iconInk }), ); // 44b. Selecting the other texture is a look at the inspector, not a change of document. diff --git a/tools/e2e/lib/scaffold.mjs b/tools/e2e/lib/scaffold.mjs index e1a60150..7169b1f8 100644 --- a/tools/e2e/lib/scaffold.mjs +++ b/tools/e2e/lib/scaffold.mjs @@ -185,7 +185,7 @@ export async function clickDocumentTab(t, doc) { return asset ? asset.name : null; })()`); await t.clickText('»'); - const item = (await t.json('__gm.menuRows()')).find((r) => r && r.endsWith(` · ${label}`)); + const item = (await t.json('__gm.menuRows()')).find((r) => r === label); const at = await t.locate(item, true); await t.click(at.x, at.y); }