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
+141
-1
@@ -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);
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user