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

+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);
}