The theme asks for Source Sans 3 at weight 500, but fonts.ts only
loaded 400, 600 and 700. The browser synthesised the 500 text. The
issue comment also asked for the symbols the font lacks to become SVG
icons. A scan of src/ against the font's cmap found 20 such characters
in 30 places.
- fonts.ts loads source-sans-3-latin-500-normal.woff2 as well.
- Menus draw their ticks and the "current" dot as icons. The dot is a
new MenuItem.current field and no longer part of the label text.
- Inspector action buttons (reset, apply, fit width/height) are
icons. The menu arrow behind them is the drawn Dropdown chevron.
- Kind marks come from a table in markIcons.ts. Picture kinds use an
icon, # and Aa stay text because the font has them.
- Scene marks in the asset panel, sound play/stop and the transition
arrows are icons too.
- Apply is an icon although the font has the arrow. It sits next to
the reset icon and would look different as a glyph.
- A new Icon widget draws on redraw like Button, so Node tests need no
DOMParser.
- Tests find icons through the SVG id. The new hook
__gm.locateIcons(id) and the harness helpers findIcon/clickIcon
replace locate() on the old characters.
Findings:
- Weight 700 is loaded but the theme never uses it. Left as is.
- The old scene-mark check in module 35 accepted any play glyph on
screen. It now checks the mark on the start scene's row.
- Module 44 needs the textures from module 37 and fails without it.
Tests:
- ./check.sh: 145 files, 2187 tests, exit 0 (before 144 / 2143).
- New tests/ui/fonts.test.ts: every theme weight is loaded, and every
character in src/ exists in every shipped face. The cmap reader
matches fontTools (231 characters).
- Icon tests cover the 19 new files. Each kind mark is either an
icon or text, never both.
- e2e 1-16,22,30,34,35,43,44: 586 green, 0 FAIL, then 44 stopped
for lack of module 37. e2e 1-16,37,44,47,48: 618 green.
e2e 1-14 on the final build: 454 green.
- Two new e2e checks: loaded weights in 14, a reset icon pixel in 04.
- Counter-probes all fail as expected: 500 removed, a play glyph put
back, the struct icon removed (one unit test each). In the browser,
500 removed and reset icon hidden fail exactly the two new checks.
- Editor-suite check sum 1312 (1310 measured by CI at 056a622, +2).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
297 lines
11 KiB
TypeScript
297 lines
11 KiB
TypeScript
import {
|
||
AddPortCommand,
|
||
AddPortsCommand,
|
||
nextPortName,
|
||
RemovePortCommand,
|
||
RenamePortCommand,
|
||
RepointPortCommand,
|
||
SetPortWriteCommand,
|
||
} from '../../core/commands/portCommands';
|
||
import {
|
||
BEHAVIOR_START,
|
||
type DeclaredPort,
|
||
type GraphNode,
|
||
isPortOf,
|
||
isTerminalNode,
|
||
missingFieldPorts,
|
||
OBJECT_BEHAVIOR_PARAM,
|
||
OBJECT_BEHAVIOR_SETTING,
|
||
OBJECT_TYPE_SETTING,
|
||
portKindOf,
|
||
VARIABLES_SETTING,
|
||
variablesSourceOf,
|
||
propertyTargetOf,
|
||
structShapeOf,
|
||
targetLabel,
|
||
targetProperties,
|
||
} from '../../core/graph';
|
||
import { findAssetOf, findGraphAsset, type Project } from '../../core/model/Project';
|
||
import { textOf } from '../../core/model/Properties';
|
||
import { DECLARED_PORT_KINDS, VARIABLE_KINDS } from '../../core/properties/kinds';
|
||
import { allShaderParamRows, type PropertyDef, schemaForAsset } from '../../core/properties/PropertySchema';
|
||
import {
|
||
Checkbox,
|
||
Dropdown,
|
||
HStack,
|
||
Label,
|
||
Menu,
|
||
type MenuItem,
|
||
Spacer,
|
||
TextField,
|
||
Theme,
|
||
type Widget,
|
||
} from '../../ui';
|
||
import { NAME_WIDTH, removeButton } from './fieldFactory';
|
||
import { addNote, type InspectorHost } from './host';
|
||
import { kindMenu, markLabel } from './variableRows';
|
||
|
||
/** Whose declarations a Variables node picks its ports from: the asset it names, else the behavior. */
|
||
export function variableChoices(project: Project, assetId: string, node: GraphNode): PropertyDef[] {
|
||
const source = variablesSourceOf(node.params);
|
||
if (source) return findAssetOf(project, source, 'variables')?.variables ?? [];
|
||
const asset = findGraphAsset(project, assetId);
|
||
// A state machine declares no variables; its one node type has none to name.
|
||
return asset?.type === 'behavior' ? asset.variables : [];
|
||
}
|
||
|
||
/**
|
||
* The ports of a Variables node: each one *is* a declared variable, so it is picked from a list
|
||
* rather than named, and its kind follows the declaration. Set makes it written too.
|
||
*/
|
||
export function buildVariablePortSection(
|
||
host: InspectorHost,
|
||
assetId: string,
|
||
node: GraphNode,
|
||
addSetting: (def: PropertyDef) => void,
|
||
): void {
|
||
addSetting(VARIABLES_SETTING);
|
||
const source = variablesSourceOf(node.params);
|
||
const shared = source ? findAssetOf(host.state.project, source, 'variables') : undefined;
|
||
buildPickedPortSection(host, assetId, node, {
|
||
noun: 'Variable',
|
||
choices: variableChoices(host.state.project, assetId, node),
|
||
owner: shared ? `"${shared.name}"` : 'this behavior',
|
||
empty: source
|
||
? shared
|
||
? `"${shared.name}" declares no variables yet.`
|
||
: 'That Variables asset is gone.'
|
||
: 'This behavior has no variables yet.',
|
||
});
|
||
}
|
||
|
||
/** The type decides what the ports are picked from; Any names them by hand, with a kind picked. */
|
||
export function buildPropertyPortSection(
|
||
host: InspectorHost,
|
||
assetId: string,
|
||
node: GraphNode,
|
||
addSetting: (def: PropertyDef) => void,
|
||
): void {
|
||
const target = propertyTargetOf(node.params);
|
||
addSetting(OBJECT_TYPE_SETTING);
|
||
if (target === 'any') {
|
||
buildPickedPortSection(host, assetId, node, { noun: 'Property', choices: [], owner: '', custom: true });
|
||
return;
|
||
}
|
||
if (target !== 'behavior') {
|
||
const owner = `a ${targetLabel(target)}`;
|
||
// A sprite's rows go on with the parameters of the shader it wears – any shader, the node cannot
|
||
// know which, so each entry says whose it is. Keyed by the def itself: a shader parameter and a
|
||
// sprite property may share a name, and only the shader's entry earns the note.
|
||
const shaderRows = target === 'sprite' ? allShaderParamRows(host.state.project) : [];
|
||
const from = new Map(shaderRows.map((row) => [row.def, row.shaders.join(', ')]));
|
||
buildPickedPortSection(host, assetId, node, {
|
||
noun: 'Property',
|
||
choices: [...targetProperties(target), ...shaderRows.map((row) => row.def)],
|
||
owner,
|
||
note: (def) => from.get(def),
|
||
});
|
||
return;
|
||
}
|
||
addSetting(OBJECT_BEHAVIOR_SETTING);
|
||
const id = textOf(node.params[OBJECT_BEHAVIOR_PARAM] ?? '');
|
||
const behavior = id ? findAssetOf(host.state.project, id, 'behavior') : undefined;
|
||
buildPickedPortSection(host, assetId, node, {
|
||
noun: 'Property',
|
||
choices: behavior ? schemaForAsset(host.state.project, behavior) : [],
|
||
owner: behavior ? `"${behavior.name}"` : 'a behavior',
|
||
empty: id ? 'That behavior is gone.' : 'Pick a behavior first.',
|
||
});
|
||
}
|
||
|
||
interface PickedPorts {
|
||
noun: 'Variable' | 'Property';
|
||
choices: readonly PropertyDef[];
|
||
/** Whose ports these are, for the warning on one it does not have. */
|
||
owner: string;
|
||
empty?: string;
|
||
/** Named by hand instead of picked. */
|
||
custom?: boolean;
|
||
/** Where a choice comes from, shown after its key in the menu; nothing for one that speaks for itself. */
|
||
note?: (def: PropertyDef) => string | undefined;
|
||
}
|
||
|
||
function buildPickedPortSection(
|
||
host: InspectorHost,
|
||
assetId: string,
|
||
node: GraphNode,
|
||
{ noun, choices, owner, empty, custom = false, note }: PickedPorts,
|
||
): void {
|
||
const plural = noun === 'Property' ? 'properties' : 'variables';
|
||
const ports = node.ports ?? [];
|
||
const taken = new Set(ports.map((p) => p.key));
|
||
const portOf = (def: PropertyDef): DeclaredPort => ({ key: def.key, kind: portKindOf(def) });
|
||
const choiceLabel = (def: PropertyDef): string => {
|
||
const where = note?.(def);
|
||
return where ? `${def.key} (${where})` : def.key;
|
||
};
|
||
const pick = (own: string, onPick: (port: DeclaredPort) => void): MenuItem[] =>
|
||
choices.map((def) => ({
|
||
label: choiceLabel(def),
|
||
disabled: taken.has(def.key) && def.key !== own,
|
||
onSelect: () => onPick(portOf(def)),
|
||
}));
|
||
const typed = (): MenuItem[] =>
|
||
kindMenu(VARIABLE_KINDS, (kind) =>
|
||
host.state.execute(
|
||
new AddPortCommand(assetId, node.id, { key: nextPortName(node, noun.toLowerCase()), kind }),
|
||
),
|
||
);
|
||
const searchable = noun === 'Property';
|
||
|
||
const header = new HStack({ gap: Theme.gap, align: 'center' });
|
||
header.add(new Label(plural.toUpperCase(), { style: 'title' }), 1);
|
||
const add = new Dropdown(`+ ${noun}`, { variant: 'ghost' });
|
||
add.onClick = () => {
|
||
const items = pick('', (port) => host.state.execute(new AddPortCommand(assetId, node.id, port)));
|
||
new Menu(custom ? typed() : items, { searchable }).openBelow(add);
|
||
};
|
||
add.setEnabled(custom || choices.some((def) => !taken.has(def.key)));
|
||
header.add(add);
|
||
host.body.add(header);
|
||
|
||
if (!custom && choices.length === 0 && empty) addNote(host, empty);
|
||
else if (ports.length === 0) addNote(host, `No ${plural} picked yet. Each one adds a port.`);
|
||
|
||
for (const port of ports) {
|
||
const toggle = writeToggle(host, assetId, node, port);
|
||
if (custom) {
|
||
portRow(host, assetId, node, port, portNameField(host, assetId, node, port), toggle);
|
||
continue;
|
||
}
|
||
const def = choices.find((d) => d.key === port.key);
|
||
const known = def !== undefined && isPortOf(port, def);
|
||
const choose = new Dropdown(port.key, { variant: known ? 'default' : 'danger' });
|
||
choose.onClick = () => {
|
||
const items = pick(port.key, (next) =>
|
||
host.state.execute(new RepointPortCommand(assetId, node.id, port.key, next)),
|
||
);
|
||
new Menu(items, { searchable }).openBelow(choose);
|
||
};
|
||
portRow(host, assetId, node, def && known ? { ...port, kind: portKindOf(def) } : port, choose, toggle);
|
||
if (!known) addNote(host, `"${port.key}" is not a ${noun.toLowerCase()} of ${owner}.`, 'warning');
|
||
}
|
||
}
|
||
|
||
/**
|
||
* The ports a node declares for itself: the signature of a behavior seen as a node, or the fields
|
||
* of a struct. A start node's are named from the caller's side – what it hands into the graph is
|
||
* an *input* of the behavior – which is why the side comes from the node type, not from here.
|
||
*/
|
||
export function buildPortSection(
|
||
host: InspectorHost,
|
||
assetId: string,
|
||
node: GraphNode,
|
||
variables: readonly PropertyDef[] = [],
|
||
): void {
|
||
const onStart = node.type === BEHAVIOR_START;
|
||
const noun = isTerminalNode(node) ? (onStart ? 'Input' : 'Output') : 'Field';
|
||
const base = noun === 'Field' ? 'field' : onStart ? 'in' : 'out';
|
||
|
||
const header = new HStack({ gap: Theme.gap, align: 'center' });
|
||
header.add(new Label(`${noun.toUpperCase()}S`, { style: 'title' }), 1);
|
||
if (noun === 'Field') addShapePicker(host, assetId, node, variables, header);
|
||
const add = new Dropdown(`+ ${noun}`, { variant: 'ghost' });
|
||
add.onClick = () => {
|
||
const items = kindMenu(DECLARED_PORT_KINDS, (kind) =>
|
||
host.state.execute(new AddPortCommand(assetId, node.id, { key: nextPortName(node, base), kind })),
|
||
);
|
||
new Menu(items).openBelow(add);
|
||
};
|
||
header.add(add);
|
||
host.body.add(header);
|
||
|
||
const ports = node.ports ?? [];
|
||
if (ports.length === 0)
|
||
addNote(
|
||
host,
|
||
noun === 'Field'
|
||
? 'No fields yet. Every field added here becomes a port of the struct.'
|
||
: onStart
|
||
? 'No inputs yet. Every input added here becomes a port on the node that runs this behavior.'
|
||
: 'No outputs yet. Every output added here becomes a result of running this behavior.',
|
||
);
|
||
|
||
for (const port of ports) portRow(host, assetId, node, port, portNameField(host, assetId, node, port));
|
||
}
|
||
|
||
function portNameField(host: InspectorHost, assetId: string, node: GraphNode, port: DeclaredPort): TextField {
|
||
return new TextField(port.key, {
|
||
width: NAME_WIDTH,
|
||
onChange: (next) => host.state.execute(new RenamePortCommand(assetId, node.id, port.key, next.trim())),
|
||
});
|
||
}
|
||
|
||
function writeToggle(host: InspectorHost, assetId: string, node: GraphNode, port: DeclaredPort): Widget[] {
|
||
const box = new Checkbox(port.write === true);
|
||
box.onChange = (on) => host.state.execute(new SetPortWriteCommand(assetId, node.id, port.key, on));
|
||
return [new Label('Set', { style: 'muted' }), box];
|
||
}
|
||
|
||
function portRow(
|
||
host: InspectorHost,
|
||
assetId: string,
|
||
node: GraphNode,
|
||
port: DeclaredPort,
|
||
name: Widget,
|
||
extras: readonly Widget[] = [],
|
||
): void {
|
||
const row = new HStack({ gap: Theme.gap, align: 'center' });
|
||
row.minHeight = Theme.rowHeight;
|
||
row.add(markLabel(port.kind));
|
||
row.add(name, 1);
|
||
for (const extra of extras) row.add(extra);
|
||
row.add(new Spacer());
|
||
row.add(removeButton(() => host.state.execute(new RemovePortCommand(assetId, node.id, port.key))));
|
||
host.body.add(row);
|
||
}
|
||
|
||
/**
|
||
* Takes the fields of a Pack/Expand from a variable that already declares the shape, instead of
|
||
* typing them again. Only offered where there is a shape to copy; a field the node already has
|
||
* stays as it is, so this adds and never rewrites.
|
||
*/
|
||
function addShapePicker(
|
||
host: InspectorHost,
|
||
assetId: string,
|
||
node: GraphNode,
|
||
variables: readonly PropertyDef[],
|
||
header: HStack,
|
||
): void {
|
||
const shaped = variables.filter((v) => structShapeOf(v) !== undefined);
|
||
if (shaped.length === 0) return;
|
||
|
||
const pick = new Dropdown('From variable', { variant: 'ghost' });
|
||
pick.onClick = () => {
|
||
const items: MenuItem[] = shaped.map((v) => {
|
||
const ports = missingFieldPorts(node, structShapeOf(v) ?? []);
|
||
return {
|
||
label: v.kind === 'list' ? `${v.key} (item)` : v.key,
|
||
disabled: ports.length === 0,
|
||
onSelect: () => host.state.execute(new AddPortsCommand(assetId, node.id, ports)),
|
||
};
|
||
});
|
||
new Menu(items).openBelow(pick);
|
||
};
|
||
header.add(pick);
|
||
}
|