Pick a new asset from category submenus in the + menu
The + menu was one long list in two groups, placeable types and library ones. That split is no longer wanted. The menu now lists the categories of the asset panel, and each opens its types beside it, so a new asset is filed where the panel shows it. - Menu learns submenus: an entry with submenu instead of onSelect opens a second Menu right of the first, or left of it where the canvas ends. Its first entry lines up with the row that opened it. - A submenu opens on hover or click. Resting on another entry swaps or closes it after 150 ms, so a pointer cutting across the rows on its way into a submenu does not lose it. - The row of an open submenu stays lit, and a triangle pointing right marks every such row. - A submenu has no backdrop and no Escape of its own. Popup takes a backdrop option for that. The first menu's backdrop and Escape close the whole stack, and picking an entry does the same. - assetMenuCategories replaces assetMenuGroups and takes categories and their order from ASSET_GROUPS. The types are sorted by label. - New test hooks __gm.menuSubmenus() and __gm.addMenuCategory(). pickAssetType opens the category first, so the modules that pick a type through it need no change of their own. Modules 04, 10, 47, 48, 53 and 54 open the category themselves where they read the menu. Tests: - Unit asset-menu: 4 green - e2e 04: ALL PASSED, 63 checks. New: categories in panel order, each type once and sorted, submenu placement, hover swaps the submenu, picking closes menu and submenu - Gate: CHECK EXIT 0, 261 files, 4040 tests - The other modules that use the + menu did not run here Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
5e09ba9a50
commit
a848cf8469
16 files changed
+320
-131
No files matched your search
+72
-64
@@ -1,8 +1,10 @@
|
||||
import { colourDistance } from '../lib/harness.mjs';
|
||||
import { inView } from './lib/scaffold.mjs';
|
||||
import { pickAssetType } from './lib/scaffold.mjs';
|
||||
|
||||
/** One per entry of `ASSET_TYPES` – a new asset type counts here too. */
|
||||
const ASSET_TYPE_COUNT = 30;
|
||||
/** The categories of the asset panel and the `+` menu, in their order (`ASSET_CATEGORIES`). */
|
||||
const CATEGORIES = ['Basics', 'Visuals', 'Logics', 'Physics', 'Audio'];
|
||||
|
||||
/** The asset panel: placing by dragging into the view, creating through "+", and resetting an asset value. */
|
||||
export async function assets(t) {
|
||||
@@ -100,68 +102,72 @@ export async function assets(t) {
|
||||
await redo();
|
||||
check('redo puts it back', (await objects()).length === 8);
|
||||
|
||||
// 4c. Create a new asset through the + menu. The menu is two alphabetical groups (#43) – what
|
||||
// can be placed in a scene, a line, then what only lives in the library. Its types do not
|
||||
// all fit under the button on a canvas 800 high: there it scrolls.
|
||||
// 4c. Create a new asset through the + menu. The menu lists the categories of the asset panel, in
|
||||
// the panel's order; each opens the types filed under it, sorted by label, beside the menu.
|
||||
await clickText('+');
|
||||
const rows = await json('__gm.menuRows()');
|
||||
const cut = rows.indexOf(null);
|
||||
const placeable = rows.slice(0, cut);
|
||||
const library = rows.slice(cut + 1);
|
||||
check(
|
||||
'the + menu is split by exactly one line',
|
||||
rows.filter((r) => r === null).length === 1 && cut > 0,
|
||||
JSON.stringify(rows),
|
||||
);
|
||||
check(
|
||||
'every asset type is offered once, each group sorted by label',
|
||||
placeable.length + library.length === ASSET_TYPE_COUNT &&
|
||||
String(placeable) === String([...placeable].sort()) &&
|
||||
String(library) === String([...library].sort()),
|
||||
JSON.stringify({ placeable, library }),
|
||||
);
|
||||
check(
|
||||
'placeable types come first, the library ones after the line',
|
||||
placeable.includes('Sprite') &&
|
||||
!placeable.includes('Scene') &&
|
||||
library.includes('Scene') &&
|
||||
library.includes('Texture'),
|
||||
JSON.stringify({ placeable, library }),
|
||||
);
|
||||
// The height cap is the canvas, not a constant: fifteen entries used to run under the mask edge,
|
||||
// where `locate` still finds them but a click lands on nothing (that is what took step 30 down).
|
||||
// What does not fit is scrolled to, never drawn past the box.
|
||||
await shot('tmp-plus-menu');
|
||||
const opens = await json('__gm.menuSubmenus()');
|
||||
const menuBox = await json('__gm.overlayRect()');
|
||||
const lastRow = await inView(
|
||||
t,
|
||||
'__gm.overlayRect()',
|
||||
`__gm.locate(${JSON.stringify(library.at(-1))}, true)`,
|
||||
check(
|
||||
'the + menu lists the categories of the asset panel, each opening a submenu',
|
||||
String(rows) === String(CATEGORIES) && opens.every(Boolean),
|
||||
JSON.stringify({ rows, opens }),
|
||||
);
|
||||
const offered = {};
|
||||
for (const category of CATEGORIES) {
|
||||
await clickText(category);
|
||||
offered[category] = await json('__gm.menuRows()');
|
||||
}
|
||||
const flat = Object.values(offered).flat();
|
||||
check(
|
||||
'every asset type is offered once, each category sorted by label',
|
||||
flat.length === ASSET_TYPE_COUNT &&
|
||||
new Set(flat).size === flat.length &&
|
||||
Object.values(offered).every((list) => String(list) === String([...list].sort())),
|
||||
JSON.stringify(offered),
|
||||
);
|
||||
check(
|
||||
'a type sits under the category the asset panel files it under',
|
||||
offered.Basics.includes('Scene') &&
|
||||
offered.Visuals.includes('Sprite') &&
|
||||
offered.Logics.includes('Behavior') &&
|
||||
offered.Physics.includes('Collider') &&
|
||||
offered.Audio.includes('Song'),
|
||||
JSON.stringify(offered),
|
||||
);
|
||||
// The submenu opens right of the menu, its first entry level with the row that opened it. The
|
||||
// pointer resting on another category swaps it after a short wait.
|
||||
await clickText('Visuals');
|
||||
const visualsRow = await json("__gm.locate('Visuals')");
|
||||
const subBox = await json('__gm.overlayRect()');
|
||||
const firstEntry = await json(`__gm.locate(${JSON.stringify(offered.Visuals[0])}, true)`);
|
||||
await shot('tmp-plus-menu');
|
||||
const screenH = await json('__gm.app.screen.height');
|
||||
check(
|
||||
'the whole menu stands on screen, last entry reached by scrolling at most',
|
||||
Boolean(lastRow) &&
|
||||
lastRow.y < menuBox.y + menuBox.h &&
|
||||
menuBox.y + menuBox.h <= screenH &&
|
||||
menuBox.y >= 0,
|
||||
JSON.stringify({ menuBox, lastRow, screenH }),
|
||||
'the submenu stands right of the menu, its first entry level with its category, all on screen',
|
||||
subBox.x >= menuBox.x + menuBox.w - 4 &&
|
||||
Math.abs(firstEntry.y - visualsRow.y) <= 2 &&
|
||||
subBox.y + subBox.h <= screenH - Math.round(screenH * 0.1) + 1,
|
||||
JSON.stringify({ menuBox, subBox, visualsRow, firstEntry, screenH }),
|
||||
);
|
||||
// A tenth of the canvas stays free below every popup. The margin used to live only in the height
|
||||
// cap, never in the placement, so a popup that had to be shifted up ended flush on the bottom
|
||||
// edge with the whole gap collected above it. Above it is a budget, not a distance: this menu
|
||||
// hangs off a button near the top and stays there.
|
||||
const above = menuBox.y;
|
||||
const below = screenH - (menuBox.y + menuBox.h);
|
||||
const margin = Math.round(screenH * 0.1);
|
||||
const audioRow = await json("__gm.locate('Audio')");
|
||||
await mouse('mouseMoved', audioRow.x, audioRow.y);
|
||||
await sleep(400);
|
||||
const hovered = await json('__gm.menuRows()');
|
||||
check(
|
||||
'the menu keeps a tenth of the canvas free below, and stays under its button',
|
||||
below >= margin - 1 && above >= 0 && above < margin,
|
||||
JSON.stringify({ above, below, margin, screenH }),
|
||||
'resting the pointer on another category swaps the submenu',
|
||||
String(hovered) === String(offered.Audio),
|
||||
JSON.stringify(hovered),
|
||||
);
|
||||
await clickText('Visuals');
|
||||
await clickText('Sprite');
|
||||
const names = await json('__gm.state.project.assets.map(a => a.name)');
|
||||
check('+ menu creates a sprite asset', names.includes('Sprite'), JSON.stringify(names));
|
||||
const leftOpen = await json('__gm.menuRows()');
|
||||
check(
|
||||
'+ menu creates a sprite asset, and picking it closes menu and submenu',
|
||||
names.includes('Sprite') && leftOpen === null,
|
||||
JSON.stringify({ names, leftOpen }),
|
||||
);
|
||||
await shot('12-new-asset');
|
||||
|
||||
// 4d. Double-click on a scene row: the one thing activating an asset still does since placing
|
||||
@@ -247,8 +253,7 @@ export async function assets(t) {
|
||||
// click does not give it – so it is not driven here.
|
||||
await showTab('scene');
|
||||
await clickText('+');
|
||||
const entry = await evaluate(`__gm.locate('Behavior', true)`);
|
||||
await click(entry.x, entry.y);
|
||||
await pickAssetType(t, 'Behavior');
|
||||
const created = await json(
|
||||
"(__gm.state.project.assets.find((a) => a.type === 'behavior' && !['mover', 'spawner'].includes(a.id)) ?? {}).id ?? ''",
|
||||
);
|
||||
@@ -361,12 +366,11 @@ export async function assets(t) {
|
||||
);
|
||||
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);
|
||||
const ORDER = ['Basics', 'Visuals', 'Logics', 'Physics', 'Audio'];
|
||||
check(
|
||||
'the assets panel groups by category and type, groups and assets sorted by name',
|
||||
groups.length >= 4 &&
|
||||
categories.length >= 2 &&
|
||||
sorted(categories.map((c) => ORDER.indexOf(c.category)).map(String)) &&
|
||||
sorted(categories.map((c) => CATEGORIES.indexOf(c.category)).map(String)) &&
|
||||
categories.every((c) => sorted(groups.filter((g) => g.category === c.category).map((g) => g.label))) &&
|
||||
groups.every((g) => sorted(g.ids.map((id) => nameOf[id]))) &&
|
||||
groups.flatMap((g) => g.ids).length === Object.keys(nameOf).length,
|
||||
@@ -479,15 +483,18 @@ export async function assets(t) {
|
||||
// 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()');
|
||||
const menuIcons = {};
|
||||
for (const category of CATEGORIES) {
|
||||
await clickText(category);
|
||||
menuIcons[category] = await json('__gm.menuIcons()');
|
||||
}
|
||||
await shot('15d-add-menu');
|
||||
await press('Escape');
|
||||
const iconRows = Object.values(menuIcons).flat();
|
||||
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 }),
|
||||
iconRows.length === ASSET_TYPE_COUNT && iconRows.every((icon) => icon === true),
|
||||
JSON.stringify(menuIcons),
|
||||
);
|
||||
await showTab('scene');
|
||||
const first = (await json('__gm.hierarchyOrder()'))[0];
|
||||
@@ -521,8 +528,7 @@ export async function assets(t) {
|
||||
*/
|
||||
async function addMenuOnRightClick(t) {
|
||||
const { check, json, shot, click, clickText, press, undo, showTab } = t;
|
||||
const addRows = (rows) =>
|
||||
Array.isArray(rows) && rows.filter(Boolean).length === ASSET_TYPE_COUNT && rows.includes('Sprite');
|
||||
const addRows = (rows) => String(rows) === String(CATEGORIES);
|
||||
const heading = await json("__gm.assetRowShown('group:scene')");
|
||||
const sceneFolded = async () =>
|
||||
!(await json('__gm.assetGroups()')).find((g) => g.type === 'scene').expanded;
|
||||
@@ -551,7 +557,9 @@ async function addMenuOnRightClick(t) {
|
||||
);
|
||||
await shot('11c-add-menu-on-right-click');
|
||||
const before = (await json('__gm.state.project.assets')).length;
|
||||
// The heading of the behaviors reads like the entry and is drawn first: the entry still wins.
|
||||
// The headings of the panel read like the menu's categories and entries and are drawn first: the
|
||||
// menu still wins, for the category as for the type.
|
||||
await clickText('Logics');
|
||||
const behaviorWords = (await json("__gm.locateAll('Behavior')")).length;
|
||||
await clickText('Behavior');
|
||||
const fromMenu = await json('__gm.state.project.assets.at(-1)');
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { pickAssetType } from './lib/scaffold.mjs';
|
||||
|
||||
/** Text assets rendered in the scene, and the enabled switch in the editor and in the running game. */
|
||||
export async function textAndEnabled(t) {
|
||||
const {
|
||||
@@ -19,7 +21,7 @@ export async function textAndEnabled(t) {
|
||||
|
||||
// 10a. Text assets: created through the + menu, placed, and rendered as real text in the scene.
|
||||
await clickText('+');
|
||||
await clickText('Text');
|
||||
await pickAssetType(t, 'Text');
|
||||
const textAssetId = await evaluate(`(__gm.state.project.assets.find(a => a.type === 'text') || {}).id`);
|
||||
check('+ menu creates a text asset', Boolean(textAssetId));
|
||||
await mutate(`
|
||||
|
||||
@@ -327,8 +327,8 @@ export async function textures(t) {
|
||||
await evaluate('__gm.console.clear()');
|
||||
await clickText('+');
|
||||
// By position, not by text: the menu entry "Texture" shares its label with the centre tab, and
|
||||
// `locate` would hand back whichever it walked into first. The menu scrolls once it outgrows the
|
||||
// screen, and "Texture" sits near its end.
|
||||
// `locate` would hand back whichever it walked into first. `pickAssetType` opens the Visuals and
|
||||
// takes the hit inside their submenu.
|
||||
await pickAssetType(t, 'Texture');
|
||||
await sleep(1500);
|
||||
const picker = await json('__gm.imagePicker()');
|
||||
|
||||
@@ -584,14 +584,15 @@ export async function vectorArt(t) {
|
||||
return hits.sort((a, b) => a.y - b.y).at(-1) ?? null;
|
||||
})()`;
|
||||
|
||||
// Vector art is the last of the 25 entries the menu has since the Joint (#154); on a canvas 800
|
||||
// high the menu scrolls, and that one is scrolled to.
|
||||
// Vector art sits in the submenu of the Visuals; should that outgrow a short canvas, it scrolls,
|
||||
// and the entry is scrolled to.
|
||||
await clickText('+');
|
||||
await clickText('Visuals');
|
||||
const plusRows = (await json('__gm.menuRows()')) ?? [];
|
||||
await inView(t, '__gm.overlayRect()', menuRow('Vector art'));
|
||||
const entry = await inOverlay('Vector art');
|
||||
check(
|
||||
'the + menu offers Vector art among the types that only live in the library',
|
||||
'the + menu offers Vector art among the Visuals',
|
||||
plusRows.includes('Vector art') && Boolean(entry),
|
||||
JSON.stringify({ offered: plusRows.includes('Vector art'), entry }),
|
||||
);
|
||||
|
||||
@@ -94,6 +94,7 @@ export async function pixelArt(t) {
|
||||
const palettesBefore = await json("__gm.state.project.assets.filter((a) => a.type === 'palette').length");
|
||||
await showTab('scene');
|
||||
await t.clickText('+');
|
||||
await t.clickText('Visuals');
|
||||
const entry = await inOverlay('Pixel art');
|
||||
check('the + menu offers Pixel art', Boolean(entry), JSON.stringify(await json('__gm.menuRows()')));
|
||||
await click(entry.x, entry.y);
|
||||
|
||||
@@ -55,6 +55,7 @@ export async function curves(t) {
|
||||
// 53a. The + menu makes one; its row opens the Curve tab on it, a straight line of two points.
|
||||
await showTab('scene');
|
||||
await t.clickText('+');
|
||||
await t.clickText('Basics');
|
||||
const offered = (await json('__gm.menuRows()')) ?? [];
|
||||
const entry = await inOverlay('Curve');
|
||||
check('the + menu offers Curve', offered.includes('Curve') && Boolean(entry), JSON.stringify(offered));
|
||||
|
||||
@@ -92,8 +92,9 @@ export async function structTypes(t) {
|
||||
// variables, and each field has a default.
|
||||
await t.showTab('scene');
|
||||
await clickText('+');
|
||||
await clickText('Logics');
|
||||
const offered = (await json('__gm.menuRows()')) ?? [];
|
||||
// Struct sits below the fold of the menu since the Pattern (#333); the lowest hit between the
|
||||
// Should the submenu outgrow a short canvas, Struct is scrolled to; the lowest hit between the
|
||||
// box's sides is the entry, not the tab above it.
|
||||
await inView(
|
||||
t,
|
||||
|
||||
@@ -202,12 +202,15 @@ export async function clickDocumentTab(t, doc) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Picks an asset type from the already-open `+` menu. Since #43 the menu may grow to the height of
|
||||
* the canvas, and since the Instrument (#331) its last entries can sit below the fold of this
|
||||
* window (`Texture` in step 12). `locate` refuses a row scrolled out of its list, so the first look is `find`,
|
||||
* and a hit outside the menu (the centre tab "Texture") counts as a miss.
|
||||
* Picks an asset type from the already-open `+` menu: first its category, which opens the types of
|
||||
* that category beside the menu, then the type in there. A submenu could still outgrow a short
|
||||
* window, and `locate` refuses a row scrolled out of its list, so the first look is `find`, and a
|
||||
* hit outside the submenu (the centre tab "Texture") counts as a miss.
|
||||
*/
|
||||
export async function pickAssetType(t, label) {
|
||||
const category = await t.json(`__gm.addMenuCategory(${JSON.stringify(label)})`);
|
||||
if (!category) throw new Error(`no category of the + menu holds "${label}"`);
|
||||
await t.clickText(category);
|
||||
const box = await t.json('__gm.overlayRect()');
|
||||
let p = await t.find(label, true);
|
||||
let scrolled = false;
|
||||
|
||||
Reference in new issue
Block a user