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 <noreply@anthropic.com>
No files matched your search
@@ -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
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
|
||||
@@ -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]));
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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,
|
||||
})),
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -50,3 +50,4 @@ export {
|
||||
toHex,
|
||||
} from './color';
|
||||
export { pressCount } from './clicks';
|
||||
export { drawSvg, tintSvg } from './svg';
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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 }),
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
|
||||
@@ -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)'),
|
||||
),
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||