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)
game-maker / gate (push) Successful in 1m58s
game-maker / suite (push) Successful in 6m54s

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5 committed 2026-09-19 18:36:20 +02:00
1 parent 1c212b1ccc
commit e3ebf44593
46 files changed
+724 -129

No files matched your search

+1 -1
View File
@@ -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
-4
View File
@@ -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';
}
+12 -19
View File
@@ -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;
}
}
+9 -1
View File
@@ -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<CentreTab, string> = {
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));
+9 -4
View File
@@ -17,9 +17,11 @@ export interface TabPlace<T extends string> {
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<T extends string> extends HStack {
private readonly order: T[] = [];
private readonly buttons = new Map<T, Button>();
/** A document's icon, which the `»` menu shows in front of its name too. */
private readonly icons = new Map<T, string>();
private readonly overflow: Button;
private hiddenTabs: T[] = [];
private active: T | null;
@@ -62,6 +66,7 @@ export class TabStrip<T extends string> 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<T extends string> 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<T extends string> 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);
}
+11 -1
View File
@@ -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:<type>')`. */
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,
+101 -28
View File
@@ -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<AssetType>();
/** 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);
}
+8 -5
View File
@@ -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),
};
+51
View File
@@ -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<AssetType, string> = {
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<AssetType, Asset[]>();
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]));
}
+48
View File
@@ -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<AssetType, string> = {
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;
+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-8 -8 16 16" width="16" height="16">
<rect x="-7" y="-6" width="14" height="12" rx="1.5" fill="none" stroke="currentColor" stroke-width="1.5" />
<path d="M-2 -3 L3.5 0 L-2 3 Z" fill="currentColor" />
</svg>

After

Width:  |  Height:  |  Size: 260 B

+5
View File
@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-8 -8 16 16" width="16" height="16">
<rect x="-7.5" y="-6.5" width="6" height="5" rx="1" fill="currentColor" />
<rect x="1.5" y="1.5" width="6" height="5" rx="1" fill="currentColor" />
<path d="M-1.5 -4 C2.5 -4 -2.5 4 1.5 4" fill="none" stroke="currentColor" stroke-width="1.5" />
</svg>

After

Width:  |  Height:  |  Size: 343 B

+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-8 -8 16 16" width="16" height="16">
<rect x="-7" y="-4" width="10" height="8" rx="1.5" fill="none" stroke="currentColor" stroke-width="1.5" />
<path d="M3.5 0 L7.5 -3.5 L7.5 3.5 Z" fill="currentColor" />
</svg>

After

Width:  |  Height:  |  Size: 265 B

+7
View File
@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-8 -8 16 16" width="16" height="16">
<rect x="-5.5" y="-5.5" width="11" height="11" fill="none" stroke="currentColor" stroke-width="1.5" />
<rect x="-7.5" y="-7.5" width="4" height="4" fill="currentColor" />
<rect x="3.5" y="-7.5" width="4" height="4" fill="currentColor" />
<rect x="-7.5" y="3.5" width="4" height="4" fill="currentColor" />
<rect x="3.5" y="3.5" width="4" height="4" fill="currentColor" />
</svg>

After

Width:  |  Height:  |  Size: 474 B

+7
View File
@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-8 -8 16 16" width="16" height="16">
<rect x="-7" y="-7" width="14" height="14" rx="2" fill="none" stroke="currentColor" stroke-width="1.5" />
<rect x="-4" y="-4" width="3.5" height="3.5" fill="currentColor" />
<rect x="0.5" y="-4" width="3.5" height="3.5" fill="currentColor" />
<rect x="-4" y="0.5" width="3.5" height="3.5" fill="currentColor" />
<rect x="0.5" y="0.5" width="3.5" height="3.5" fill="currentColor" />
</svg>

After

Width:  |  Height:  |  Size: 485 B

+3
View File
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-8 -8 16 16" width="16" height="16">
<path d="M-6 7 L0 -7 L6 7 M-3.5 2 H3.5" fill="none" stroke="currentColor" stroke-width="1.5" />
</svg>

After

Width:  |  Height:  |  Size: 191 B

+9
View File
@@ -0,0 +1,9 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-8 -8 16 16" width="16" height="16">
<!-- Three bullet points: the List tab edits a list value, not an asset. -->
<circle cx="-5" cy="-4.5" r="1.25" fill="currentColor" />
<circle cx="-5" cy="0" r="1.25" fill="currentColor" />
<circle cx="-5" cy="4.5" r="1.25" fill="currentColor" />
<line x1="-2" y1="-4.5" x2="6.5" y2="-4.5" fill="none" stroke="currentColor" stroke-width="1.5" />
<line x1="-2" y1="0" x2="6.5" y2="0" fill="none" stroke="currentColor" stroke-width="1.5" />
<line x1="-2" y1="4.5" x2="6.5" y2="4.5" fill="none" stroke="currentColor" stroke-width="1.5" />
</svg>

After

Width:  |  Height:  |  Size: 643 B

+8
View File
@@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-8 -8 16 16" width="16" height="16">
<!-- As in the scene view: a hollow ring with sparks flying off it. -->
<circle cx="0" cy="0" r="3.5" fill="none" stroke="currentColor" stroke-width="1.5" />
<circle cx="5.5" cy="5.5" r="1.5" fill="currentColor" />
<circle cx="-5.5" cy="5.5" r="1.5" fill="currentColor" />
<circle cx="5.5" cy="-5.5" r="1.5" fill="currentColor" />
<circle cx="-5.5" cy="-5.5" r="1.5" fill="currentColor" />
</svg>

After

Width:  |  Height:  |  Size: 495 B

+6
View File
@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-8 -8 16 16" width="16" height="16">
<!-- A weight, as in the scene view: a ring with an arrow pointing the way gravity pulls. -->
<circle cx="0" cy="0" r="6.5" fill="none" stroke="currentColor" stroke-width="1.5" />
<path d="M0 -3.5 V1.5" fill="none" stroke="currentColor" stroke-width="1.5" />
<path d="M-3 0.5 L0 4 L3 0.5 Z" fill="currentColor" />
</svg>

After

Width:  |  Height:  |  Size: 415 B

+5
View File
@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-8 -8 16 16" width="16" height="16">
<rect x="-7" y="-6" width="14" height="12" rx="1.5" fill="none" stroke="currentColor" stroke-width="1.5" />
<path d="M-5 4 L-1.5 -0.5 L1.5 2.5 L3 1 L5 4" fill="none" stroke="currentColor" stroke-width="1.5" />
<circle cx="3" cy="-2.5" r="1.25" fill="currentColor" />
</svg>

After

Width:  |  Height:  |  Size: 366 B

+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-8 -8 16 16" width="16" height="16">
<circle cx="0" cy="0" r="6.5" fill="none" stroke="currentColor" stroke-width="1.5" />
<path d="M0 -6.5 A6.5 6.5 0 0 1 0 6.5 Z" fill="currentColor" />
</svg>

After

Width:  |  Height:  |  Size: 247 B

+5
View File
@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-8 -8 16 16" width="16" height="16">
<path d="M-7.5 -2.5 H-4.5 L-0.5 -6 V6 L-4.5 2.5 H-7.5 Z" fill="currentColor" />
<path d="M2 -2.5 A3.5 3.5 0 0 1 2 2.5" fill="none" stroke="currentColor" stroke-width="1.5" />
<path d="M4 -5.5 A7 7 0 0 1 4 5.5" fill="none" stroke="currentColor" stroke-width="1.5" />
</svg>

After

Width:  |  Height:  |  Size: 365 B

+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-8 -8 16 16" width="16" height="16">
<circle cx="0" cy="-3.5" r="2.75" fill="currentColor" />
<path d="M-5.5 7 C-5.5 1.5 5.5 1.5 5.5 7 Z" fill="currentColor" />
</svg>

After

Width:  |  Height:  |  Size: 221 B

+6
View File
@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-8 -8 16 16" width="16" height="16">
<circle cx="-4" cy="-3.5" r="2.75" fill="none" stroke="currentColor" stroke-width="1.5" />
<circle cx="4" cy="3.5" r="2.75" fill="none" stroke="currentColor" stroke-width="1.5" />
<path d="M-1.5 -3.5 C2 -3.5 4 -2.5 4 0.25" fill="none" stroke="currentColor" stroke-width="1.5" />
<path d="M2 -0.5 L4 2 L6 -0.5 Z" fill="currentColor" />
</svg>

After

Width:  |  Height:  |  Size: 436 B

+3
View File
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-8 -8 16 16" width="16" height="16">
<path d="M-6 -5.5 H6 M0 -5.5 V7" fill="none" stroke="currentColor" stroke-width="2" />
</svg>

After

Width:  |  Height:  |  Size: 182 B

+6
View File
@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-8 -8 16 16" width="16" height="16">
<!-- A checkerboard, what an image editor shows behind a picture. -->
<rect x="-6.25" y="-6.25" width="12.5" height="12.5" fill="none" stroke="currentColor" stroke-width="1.5" />
<rect x="-6.25" y="-6.25" width="6.25" height="6.25" fill="currentColor" />
<rect x="0" y="0" width="6.25" height="6.25" fill="currentColor" />
</svg>

After

Width:  |  Height:  |  Size: 424 B

+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-8 -8 16 16" width="16" height="16">
<rect x="-5.5" y="-5.5" width="11" height="11" rx="1" fill="currentColor" fill-opacity="0.35" stroke="currentColor" stroke-width="1.5" />
<path d="M-5.5 0 H5.5 M0 -5.5 V0 M-2.5 0 V5.5 M2.5 0 V5.5" fill="none" stroke="currentColor" stroke-width="1" />
</svg>

After

Width:  |  Height:  |  Size: 348 B

+5
View File
@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-8 -8 16 16" width="16" height="16">
<rect x="-7" y="-7" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.5" />
<path d="M-7 -2.5 H7 M-7 2.5 H7 M-2.5 -7 V7 M2.5 -7 V7" fill="none" stroke="currentColor" stroke-width="1" />
<rect x="-2.5" y="-2.5" width="5" height="5" fill="currentColor" />
</svg>

After

Width:  |  Height:  |  Size: 376 B

+5
View File
@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-8 -8 16 16" width="16" height="16">
<path d="M-3 -7 C-5 -7 -5 -5.5 -5 -3.5 C-5 -1.5 -5.5 0 -7 0 C-5.5 0 -5 1.5 -5 3.5 C-5 5.5 -5 7 -3 7" fill="none" stroke="currentColor" stroke-width="1.5" />
<path d="M3 -7 C5 -7 5 -5.5 5 -3.5 C5 -1.5 5.5 0 7 0 C5.5 0 5 1.5 5 3.5 C5 5.5 5 7 3 7" fill="none" stroke="currentColor" stroke-width="1.5" />
<circle cx="0" cy="0" r="1.5" fill="currentColor" />
</svg>

After

Width:  |  Height:  |  Size: 453 B

-1
View File
@@ -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,
})),
);
-1
View File
@@ -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,
+1
View File
@@ -50,3 +50,4 @@ export {
toHex,
} from './color';
export { pressCount } from './clicks';
export { drawSvg, tintSvg } from './svg';
+26
View File
@@ -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<string, Map<number, SVGElement>>();
/** 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<number, SVGElement>()));
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);
}
+37 -4
View File
@@ -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));
}
}
}
+25 -12
View File
@@ -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(
+21 -4
View File
@@ -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);
+10
View File
@@ -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);
+4 -21
View File
@@ -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<SymbolIcon, number> = {
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<SymbolIcon, SVGElement>();
/** 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]);
}
+55
View File
@@ -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']);
});
});
@@ -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<string, string> }[] {
@@ -9,10 +12,8 @@ function tags(svg: string): { name: string; attrs: Record<string, string> }[] {
}));
}
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('<path fill="currentColor" stroke="currentColor"/>', 0x0000ff)).toBe(
'<path fill="#0000ff" stroke="#0000ff"/>',
);
for (const [, svg] of icons) expect(tintSvg(svg, 0xb066ff)).not.toContain('currentColor');
for (const [, svg] of icons) expect(tintSvg(svg!, 0xb066ff)).not.toContain('currentColor');
});
});
+141 -1
View File
@@ -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 }),
);
}
+4 -4
View File
@@ -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);
+5 -3
View File
@@ -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}`);
+2 -1
View File
@@ -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)'),
),
+14 -6
View File
@@ -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.
+1 -1
View File
@@ -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);
}