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>
295 lines
12 KiB
JavaScript
295 lines
12 KiB
JavaScript
/**
|
||
* Struct assets as types (#253), with the real mouse: a Struct asset made from the + menu and its
|
||
* fields declared in the inspector, a behavior's struct variable typed by it, an `Expand` pointed at
|
||
* it through its Type setting – then a field of the asset renamed, and the new name has to show up
|
||
* in the variable, in a placed object's override and on the `Expand` port with its edge. "Make
|
||
* struct type" turns a struct's own fields into an asset; deleting the asset leaves the fields.
|
||
*
|
||
* Everything it adds carries an `st54` id or is an asset it made, and goes again at the end.
|
||
*/
|
||
import { inView, MAKE_OBJECT } from './lib/scaffold.mjs';
|
||
|
||
export async function structTypes(t) {
|
||
const {
|
||
check,
|
||
json,
|
||
evaluate,
|
||
mutate,
|
||
click,
|
||
clickText,
|
||
press,
|
||
typeText,
|
||
sleep,
|
||
settle,
|
||
shot,
|
||
select,
|
||
undo,
|
||
} = t;
|
||
const inOverlay = async (text) => {
|
||
const box = await json('__gm.overlayRect()');
|
||
const hits = (await json(`__gm.locateAll(${JSON.stringify(text)})`)) ?? [];
|
||
return (
|
||
hits.find((h) => h.x >= box.x && h.x <= box.x + box.w && h.y >= box.y && h.y <= box.y + box.h) ?? null
|
||
);
|
||
};
|
||
const press_ = async (at, what) => {
|
||
if (!at) throw new Error(`nothing to click: ${what}`);
|
||
await click(at.x, at.y);
|
||
await sleep(150);
|
||
};
|
||
/** A menu entry, always the last drawn: the panels behind a menu carry the same words. */
|
||
const pick = async (label) => press_(await json(`__gm.locate(${JSON.stringify(label)}, true)`), label);
|
||
/**
|
||
* Types into the text field showing `text`, replacing it. The rightmost hit: the inspector is the
|
||
* right-hand panel, and an open graph shows the same names on its ports.
|
||
*/
|
||
const retype = async (text, next) => {
|
||
const hits = (await json(`__gm.locateAll(${JSON.stringify(text)})`)) ?? [];
|
||
await press_(hits.sort((a, b) => b.x - a.x)[0], text);
|
||
await press('a', { ctrl: true });
|
||
await typeText(next);
|
||
await press('Enter');
|
||
await sleep(150);
|
||
};
|
||
const asset = (id) => json(`__gm.state.project.assets.find((a) => a.id === ${JSON.stringify(id)}) ?? null`);
|
||
const chest = () => asset('st54-chest');
|
||
const variable = async (key) => (await chest()).variables.find((v) => v.key === key);
|
||
const override = () =>
|
||
json("__gm.state.activeScene.objects.find((o) => o.id === 'st54-obj')?.overrides.drop ?? null");
|
||
const expand = async () => (await chest()).graph.nodes.find((n) => n.id === 'st54-expand');
|
||
const edgeFrom = async () =>
|
||
(await chest()).graph.edges.find((e) => e.id === 'st54-edge')?.from.port ?? null;
|
||
|
||
// A behavior with two structs holding their own fields, an Expand and a Log in its graph, and one
|
||
// placed object overriding `drop`.
|
||
await mutate(`
|
||
const p = __gm.state.project;
|
||
const scene = __gm.state.activeScene;
|
||
p.assets.push({
|
||
id: 'st54-chest',
|
||
type: 'behavior',
|
||
name: 'Chest 54',
|
||
props: { enabled: true, runInEditor: false, drop: { Title: 'Gem' }, bag: { Gold: 5 } },
|
||
variables: [
|
||
{ key: 'drop', label: 'drop', kind: 'struct', default: { Title: '' }, fields: [{ key: 'Title', kind: 'text' }] },
|
||
{ key: 'bag', label: 'bag', kind: 'struct', default: { Gold: 0 }, fields: [{ key: 'Gold', kind: 'number' }] },
|
||
],
|
||
graph: {
|
||
nodes: [
|
||
{ id: 'st54-expand', type: 'ExpandStruct', x: 0, y: 0, params: {} },
|
||
{ id: 'st54-log', type: 'Log', x: 240, y: 0, params: {} },
|
||
],
|
||
edges: [],
|
||
},
|
||
});
|
||
${MAKE_OBJECT}
|
||
const placed = make('st54-obj', 'st54-chest', '', 400, 'Chest 54');
|
||
placed.overrides = { drop: { Title: 'Ruby' } };
|
||
scene.objects.push(placed);
|
||
`);
|
||
|
||
// 54a. The + menu makes a Struct; its inspector declares fields as a Variables asset declares
|
||
// variables, and each field has a default.
|
||
await t.showTab('scene');
|
||
await clickText('+');
|
||
await clickText('Logics');
|
||
const offered = (await json('__gm.menuRows()')) ?? [];
|
||
// 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,
|
||
'__gm.overlayRect()',
|
||
`(() => {
|
||
const box = __gm.overlayRect();
|
||
const hits = (__gm.locateAll('Struct') ?? []).filter(
|
||
(hit) => hit.x >= box.x && hit.x <= box.x + box.w && hit.y >= box.y,
|
||
);
|
||
return hits.sort((a, b) => a.y - b.y).at(-1) ?? null;
|
||
})()`,
|
||
);
|
||
const entry = await inOverlay('Struct');
|
||
check('the + menu offers Struct', offered.includes('Struct') && Boolean(entry), JSON.stringify(offered));
|
||
if (!entry) return;
|
||
await press_(entry, 'Struct');
|
||
await settle();
|
||
const lootId = (
|
||
await json("__gm.state.project.assets.filter((a) => a.type === 'struct').map((a) => a.id)")
|
||
).at(-1);
|
||
await mutate(`__gm.state.project.assets.find((a) => a.id === ${JSON.stringify(lootId)}).name = 'Loot 54'`);
|
||
const row = await json(`__gm.assetRow(${JSON.stringify(lootId)})`);
|
||
await press_(row, 'the Loot 54 row');
|
||
check(
|
||
'a new Struct selects itself and says it has no fields',
|
||
(await t.has('FIELDS')) && (await t.has('+ Field')) && (await t.has('No fields yet.')),
|
||
);
|
||
await clickText('+ Field');
|
||
await pick('Text');
|
||
await retype('field1', 'Title');
|
||
await clickText('+ Field');
|
||
await pick('Number');
|
||
await retype('field1', 'Amount');
|
||
await press_(await json("__gm.inspectorField('Title')"), 'Title value');
|
||
await typeText('Coin');
|
||
await press('Enter');
|
||
await press_(await json("__gm.inspectorField('Amount')"), 'Amount value');
|
||
await press('a', { ctrl: true });
|
||
await typeText('7');
|
||
await press('Enter');
|
||
await sleep(150);
|
||
const loot = await asset(lootId);
|
||
check(
|
||
'+ Field, a new name and a value give the struct its fields and their defaults',
|
||
JSON.stringify(loot.variables.map((v) => `${v.key}:${v.kind}`)) === '["Title:text","Amount:number"]' &&
|
||
JSON.stringify(loot.props) === '{"Title":"Coin","Amount":7}',
|
||
JSON.stringify({ variables: loot.variables.map((v) => v.key), props: loot.props }),
|
||
);
|
||
await shot('01-struct-fields');
|
||
|
||
// 54b. A struct variable picks the type under its head. It takes the type's fields, keeps what
|
||
// fits of its value and cannot grow fields of its own any more.
|
||
await select({ kind: 'asset', assetId: 'st54-chest' });
|
||
await sleep(150);
|
||
const addsBefore = ((await json("__gm.locateAll('+ Field')")) ?? []).length;
|
||
const typeRows = ((await json("__gm.locateAll('Own fields')")) ?? []).sort((a, b) => a.y - b.y);
|
||
check(
|
||
'each struct variable has a Type row reading "Own fields"',
|
||
typeRows.length === 2,
|
||
JSON.stringify(typeRows),
|
||
);
|
||
await press_(typeRows[0], 'Type of drop');
|
||
await pick('Loot 54');
|
||
const drop = await variable('drop');
|
||
const props = (await chest()).props;
|
||
check(
|
||
'picking Loot 54 gives drop its fields, keeps "Gem" and fills Amount with the default 7',
|
||
drop.structType === lootId &&
|
||
JSON.stringify(drop.fields.map((f) => f.key)) === '["Title","Amount"]' &&
|
||
JSON.stringify(props.drop) === '{"Title":"Gem","Amount":7}',
|
||
JSON.stringify({ drop, value: props.drop }),
|
||
);
|
||
check(
|
||
'and the placed override takes the shape as well',
|
||
JSON.stringify(await override()) === '{"Title":"Ruby","Amount":7}',
|
||
JSON.stringify(await override()),
|
||
);
|
||
const addsAfter = ((await json("__gm.locateAll('+ Field')")) ?? []).length;
|
||
check(
|
||
'a typed struct offers no + Field, its row names the type',
|
||
addsBefore === 2 && addsAfter === 1 && (await t.has('Loot 54')),
|
||
JSON.stringify({ addsBefore, addsAfter }),
|
||
);
|
||
await shot('02-typed-variable');
|
||
|
||
// 54c. Expand's Type setting points it at the asset: its ports are the fields.
|
||
await select({ kind: 'node', assetId: 'st54-chest', nodeId: 'st54-expand' });
|
||
await sleep(150);
|
||
await press_(await json("__gm.inspectorField('Type')"), 'Type of Expand');
|
||
// The asset picker names the kind after each asset.
|
||
await pick('Loot 54 (struct)');
|
||
const ports = (await expand()).ports ?? [];
|
||
check(
|
||
'Expand with the type has one output per field, and its fields are not edited there',
|
||
JSON.stringify(ports.map((p) => p.key)) === '["Title","Amount"]' &&
|
||
(await t.has('The fields of Loot 54. Change them there.')) &&
|
||
!(await t.has('+ Field')),
|
||
JSON.stringify(ports),
|
||
);
|
||
await mutate(`
|
||
const graph = __gm.state.project.assets.find((a) => a.id === 'st54-chest').graph;
|
||
graph.edges.push({ id: 'st54-edge', from: { node: 'st54-expand', port: 'Amount' }, to: { node: 'st54-log', port: 'message' } });
|
||
`);
|
||
await shot('03-typed-expand');
|
||
|
||
// 54d. Renaming a field on the asset reaches every place of the type in one step, values kept.
|
||
await select({ kind: 'asset', assetId: lootId });
|
||
await sleep(150);
|
||
await retype('Amount', 'Count');
|
||
const renamed = {
|
||
fields: (await variable('drop')).fields.map((f) => f.key),
|
||
value: (await chest()).props.drop,
|
||
override: await override(),
|
||
ports: ((await expand()).ports ?? []).map((p) => p.key),
|
||
edge: await edgeFrom(),
|
||
};
|
||
check(
|
||
'renaming Amount to Count reaches the variable, its value, the override, the port and its edge',
|
||
JSON.stringify(renamed) ===
|
||
JSON.stringify({
|
||
fields: ['Title', 'Count'],
|
||
value: { Title: 'Gem', Count: 7 },
|
||
override: { Title: 'Ruby', Count: 7 },
|
||
ports: ['Title', 'Count'],
|
||
edge: 'Count',
|
||
}),
|
||
JSON.stringify(renamed),
|
||
);
|
||
await undo();
|
||
const back = {
|
||
fields: (await variable('drop')).fields.map((f) => f.key),
|
||
value: (await chest()).props.drop,
|
||
override: await override(),
|
||
edge: await edgeFrom(),
|
||
};
|
||
check(
|
||
'one undo takes the rename back everywhere',
|
||
JSON.stringify(back) ===
|
||
JSON.stringify({
|
||
fields: ['Title', 'Amount'],
|
||
value: { Title: 'Gem', Amount: 7 },
|
||
override: { Title: 'Ruby', Amount: 7 },
|
||
edge: 'Amount',
|
||
}),
|
||
JSON.stringify(back),
|
||
);
|
||
|
||
// 54e. "Make struct type" turns the fields bag holds by itself into a Struct asset.
|
||
await select({ kind: 'asset', assetId: 'st54-chest' });
|
||
await sleep(150);
|
||
await press_(await json("__gm.locate('Own fields')"), 'Type of bag');
|
||
await pick('Make struct type');
|
||
const bag = await variable('bag');
|
||
const made = await asset(bag.structType ?? '');
|
||
check(
|
||
'Make struct type makes a Struct asset of bag, its value becoming the defaults',
|
||
made?.type === 'struct' &&
|
||
made.name === 'bag' &&
|
||
JSON.stringify(made.props) === '{"Gold":5}' &&
|
||
JSON.stringify((await chest()).props.bag) === '{"Gold":5}',
|
||
JSON.stringify({ bag, made }),
|
||
);
|
||
|
||
// 54f. Deleting the type asks first and says what happens; the places keep the fields.
|
||
const lootRow = await json(`__gm.assetRow(${JSON.stringify(lootId)})`);
|
||
await click(lootRow.x, lootRow.y, 'right');
|
||
await sleep(150);
|
||
await pick('Delete…');
|
||
const detail = '1 struct(s) and 1 node(s) use it and keep its fields as their own.';
|
||
check('the delete dialog counts the places using the type', await t.has(detail));
|
||
await shot('04-delete-dialog');
|
||
await pick('Delete');
|
||
const kept = await variable('drop');
|
||
check(
|
||
'after deleting, drop keeps Title and Amount and its value',
|
||
(await asset(lootId)) === null &&
|
||
JSON.stringify(kept.fields.map((f) => f.key)) === '["Title","Amount"]' &&
|
||
JSON.stringify((await chest()).props.drop) === '{"Title":"Gem","Amount":7}',
|
||
JSON.stringify(kept),
|
||
);
|
||
await select({ kind: 'asset', assetId: 'st54-chest' });
|
||
await sleep(150);
|
||
check(
|
||
'and its Type row reads "Own fields" again, with + Field back',
|
||
((await json("__gm.locateAll('+ Field')")) ?? []).length === 1 && (await t.has('Own fields')),
|
||
);
|
||
|
||
// Clean up.
|
||
await evaluate('__gm.state.select(null)');
|
||
await mutate(`
|
||
const p = __gm.state.project;
|
||
const scene = __gm.state.activeScene;
|
||
scene.objects = scene.objects.filter((o) => o.id !== 'st54-obj');
|
||
const made = ${JSON.stringify(made?.id ?? '')};
|
||
p.assets = p.assets.filter((a) => a.id !== 'st54-chest' && a.id !== made && a.id !== ${JSON.stringify(lootId)});
|
||
`);
|
||
}
|