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>
This commit is contained in:
1 parent
1c212b1ccc
commit
e3ebf44593
46 files changed
+724
-129
No files matched your search
@@ -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');
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user