Open a sprite's texture or shader from the menus and inspector (#221)
game-maker / gate (push) Successful in 2m52s
game-maker / suite (push) Successful in 8m58s

A sprite's texture and shader were only reachable by hunting for them
in the asset panel. Every place that shows such a reference now leads
straight to it.

- Object menu (hierarchy and scene view): "Open texture" and "Open
  shader" sit right below "Open asset" when the sprite has one. The
  object's own values count, so an overridden shader opens the
  override.
- Asset context menu: the same entries at the top.
- Inspector: every asset field with a value shows its label as a blue,
  underlined link. One click selects the asset, a double click opens
  it. An override stays gold and gets the underline too.
- The first click rebuilds the inspector, so the second one lands on a
  different row. The inspector catches that second click before any
  row sees it.
- New pure rule `references.ts`: the asset behind a reference field. A
  frame leads to its sheet. Kinds without a tab (palette, font, sound)
  get no menu entry.
- `followsPress` in `clicks.ts` keeps the double-click window and
  tolerance in one place.
- `activateAsset` in `actions.ts`: opening means the same everywhere
  as a double click in the asset panel, so a scene also becomes the
  active one. The asset panel uses it too.

Tests:
- ./check.sh: 150 files, 2226 tests, CHECK EXIT 0 (before 148/2214).
- 12 new unit tests: 6 for the reference rule, 4 for the link in
  createFieldRow, 2 for followsPress.
- 5 new e2e checks in 28l-ii: both menus, the link's look, click
  selects, double click opens.
- e2e 1-16,26,28,35,39,40 on the final build: ALL PASSED, 732 checks.
- Counter-probes: no "Open ..." entries, no underline and a second
  click never recognised each fail exactly at their checks.
- Check total 1341 (CI counted 1336 at fd6af84), computed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-09-23 16:44:19 +02:00
1 parent 9b62d572b4
commit 1aae97b335
16 files changed
+512 -30

No files matched your search

+1 -1
View File
@@ -43,7 +43,7 @@ npm run build # writes dist/ and regenerates index.html; the folder is wh
The browser suite and the three example games need a machine with a GPU:
```bash
node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1336 checks (GM_STEPS=20 for one step)
node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1341 checks (GM_STEPS=20 for one step)
# every tool signs in first – see the Accounts page
node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs
node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs
+37
View File
@@ -0,0 +1,37 @@
import { type PropertyBag, type PropertyValue, textOf } from '../model/Properties';
import { findPixelFrame } from '../model/pixelart';
import { findAsset, type Project } from '../model/Project';
import { findFrame } from '../model/texture';
import { isKindWith } from './kinds';
import type { PropertyDef } from './PropertySchema';
/**
* The asset a reference field points at, null when it names nothing or no longer resolves. A frame
* names the texture or pixel art it was cut from – that is the asset one can open (#221).
*/
export function referencedAssetId(project: Project, def: PropertyDef, value: PropertyValue): string | null {
if (!isKindWith('reference', def.kind)) return null;
const id = textOf(value);
if (id === '') return null;
if (findAsset(project, id)) return id;
if (def.kind !== 'texture') return null;
return findFrame(project, id)?.asset.id ?? findPixelFrame(project, id)?.asset.id ?? null;
}
export interface Reference {
label: string;
assetId: string;
}
/** Every reference of a bag that resolves, in schema order – the "Open …" entries of a menu. */
export function referencesOf(
project: Project,
schema: readonly PropertyDef[],
values: PropertyBag,
): Reference[] {
return schema.flatMap((def) => {
const value = values[def.key];
const assetId = value === undefined ? null : referencedAssetId(project, def, value);
return assetId ? [{ label: def.label, assetId }] : [];
});
}
+10 -1
View File
@@ -3,7 +3,7 @@
* menu, keyboard): they run the command and put the selection where the user expects it.
*/
import { annotationNoun, RemoveAnnotationCommand } from '../core/commands/annotationCommands';
import { DeleteAssetCommand } from '../core/commands/assetCommands';
import { DeleteAssetCommand, SetActiveSceneCommand } from '../core/commands/assetCommands';
import { AddColliderCommand, AddRigidbodyCommand, rigidbodyRefusal } from '../core/commands/colliderCommands';
import { oneStep } from '../core/commands/CompoundCommand';
import { CreateContainerCommand } from '../core/commands/containerCommands';
@@ -23,6 +23,15 @@ import { Dialog } from '../ui';
import { type EditorState, objectSelection } from './state';
/** Groups the objects into a new container asset placed in their stead; selects the container. */
/**
* What a double-click on an asset does, wherever it happens (asset panel, "Open …" entries, a link
* in the inspector): open its tab – and a scene, whose tab is the scene view, becomes the active one.
*/
export function activateAsset(state: EditorState, assetId: string): void {
if (findAsset(state.project, assetId)?.type === 'scene') state.execute(new SetActiveSceneCommand(assetId));
state.openAsset(assetId);
}
export function createContainer(state: EditorState, holderId: string, objectIds: readonly string[]): boolean {
const command = new CreateContainerCommand(holderId, objectIds);
if (!state.execute(command)) return false;
+23 -1
View File
@@ -1,5 +1,5 @@
import type { AssetType } from '../../core/model/AssetTypes';
import { Button, Panel, Widget, activeDrag, currentMenu, ui } from '../../ui';
import { Button, Label, Panel, Widget, activeDrag, currentMenu, ui } from '../../ui';
import { Theme } from '../../ui/Theme';
import { currentFontPicker } from '../font/FontPicker';
import { currentSoundPicker } from '../sound/SoundPicker';
@@ -140,6 +140,28 @@ export function uiHooks({ app, state, editor }: HookContext) {
return (last ? hits.at(-1) : hits[0]) ?? null;
},
inspectorField: fieldOf,
/**
* The label of an inspector row as a link (#221): its centre, colour and whether it is underlined
* and takes presses. Searched in the inspector only – the tab row says "Shader" too.
*/
inspectorLink: (text: string) => {
let found: Label | null = null;
const walk = (node: Container): void => {
if (found || !node.visible) return;
if (node instanceof Label && node.text === text) found = node;
else for (const child of node.children) walk(child);
};
walk(inspector);
const label = found as Label | null;
if (!label) return null;
const shown = label.children.find((child): child is Text => child instanceof Text);
return {
...centreOf(label),
color: shown ? new Color(shown.style.fill as ColorSource).toHex() : null,
underlined: label.underlined,
pressable: label.eventMode === 'static',
};
},
/** The inspector panel on screen – what tells whether a row of it can be clicked at all. */
inspectorRect: () => rectOf(inspector),
/** Where the focused widget sits on screen, or `null` – for checks on its focus border. */
+37 -1
View File
@@ -1,5 +1,18 @@
import type { PropertyBag } from '../../core/model/Properties';
import { Label, Panel, ScrollContainer, Theme, ui, VStack, type Widget } from '../../ui';
import type { FederatedPointerEvent } from 'pixi.js';
import {
followsPress,
Label,
Panel,
type Press,
pressOf,
ScrollContainer,
Theme,
ui,
VStack,
type Widget,
} from '../../ui';
import { activateAsset } from '../actions';
import { type EditorState, sameSelection, type Selection } from '../state';
import { assetSignature, buildAssetSection, refreshAssetSection } from './assetSection';
import { annotationSignature, buildAnnotationSection, refreshAnnotationSection } from './annotationSection';
@@ -34,6 +47,7 @@ export class Inspector extends Panel {
/** A Variables asset's live values, set by the editor – the only place that knows the running worlds. */
liveVariables: (assetId: string) => PropertyBag | null = () => null;
private readonly releaseFocusHandler: () => void;
private followedLink: { assetId: string; press: Press } | null = null;
constructor(private readonly state: EditorState) {
super({ title: 'Inspector', padding: 0, gap: 0 });
@@ -47,9 +61,30 @@ export class Inspector extends Panel {
this.releaseFocusHandler = ui().focus.onFocusChange(() => {
if (this.rebuildWhenLeft && !ui().focus.isWithin(this)) this.rebuild();
});
// Capture: the press lands on whatever row the rebuilt panel has there, and must not reach it.
this.addEventListener(
'pointerdown',
(e: FederatedPointerEvent) => {
const link = this.followedLink;
this.followedLink = null;
if (e.button !== 0 || !link || !followsPress(link.press, e)) return;
e.stopPropagation();
activateAsset(this.state, link.assetId);
},
{ capture: true },
);
this.rebuild();
}
/**
* A link in a reference row (#221): a click selects the asset, which rebuilds this panel for it,
* so the second press of a double-click is recognised here rather than on the link.
*/
private followReference(assetId: string, e: FederatedPointerEvent): void {
this.state.select({ kind: 'asset', assetId });
this.followedLink = { assetId, press: pressOf(e) };
}
/** Signing out destroys the panel while the page stays up (#177), so the handler goes with it. */
override destroy(options?: Parameters<Widget['destroy']>[0]): void {
this.releaseFocusHandler();
@@ -67,6 +102,7 @@ export class Inspector extends Panel {
// Read through the state on every call: load and play mode replace the project object.
project: () => this.state.project,
liveVariables: (assetId) => this.liveVariables(assetId),
followReference: (assetId, e) => this.followReference(assetId, e),
body: new VStack({ padding: Theme.padding, gap: Theme.gap }),
fields: new Map(),
nameField: null,
+34 -4
View File
@@ -1,4 +1,4 @@
import { Graphics } from 'pixi.js';
import { type FederatedPointerEvent, Graphics } from 'pixi.js';
import { isList, isStruct, type PropertyValue, textOf } from '../../core/model/Properties';
import type { Project } from '../../core/model/Project';
import {
@@ -22,6 +22,7 @@ import {
type PropertyDef,
type StructFieldDef,
} from '../../core/properties/PropertySchema';
import { referencedAssetId } from '../../core/properties/references';
import { NO_TEXTURE_LABEL, pictureChoices, textureRefLabel } from '../../core/properties/textureRef';
import {
Button,
@@ -52,6 +53,11 @@ import { ROW_ACTION_ICON_SVG } from '../panels/markIcons';
*/
export interface FieldContext {
project(): Project;
/**
* A press on the label of a reference row that names an asset (#221). Without it the label stays
* plain text – only the inspector knows how a second press on a rebuilt panel opens the asset.
*/
followReference?(assetId: string, e: FederatedPointerEvent): void;
}
export interface FieldHandlers {
@@ -220,11 +226,35 @@ export function createFieldRow(
const refreshActions = attachRowActions(row, rowActions);
const follow = ctx.followReference?.bind(ctx);
let modified = false;
let target: string | null = null;
const paintLabel = (): void => {
// An override keeps its colour: that it deviates says more than that it can be followed.
label.setStyle(modified ? 'override' : target ? 'link' : 'muted');
label.setUnderline(target !== null);
label.eventMode = target ? 'static' : 'none';
};
const retarget = (v: PropertyValue): void => {
if (!follow) return;
target = referencedAssetId(ctx.project(), def, v);
paintLabel();
};
if (follow)
label.on('pointerdown', (e: FederatedPointerEvent) => {
if (e.button === 0 && target) follow(target, e);
});
retarget(value);
return {
row,
setValue: field.setValue,
setModified(modified) {
label.setStyle(modified ? 'override' : 'muted');
setValue: (v) => {
field.setValue(v);
retarget(v);
},
setModified(isModified) {
modified = isModified;
paintLabel();
refreshActions();
},
};
+33
View File
@@ -2,9 +2,14 @@ import { UnpackContainerCommand } from '../core/commands/containerCommands';
import { SetParentCommand } from '../core/commands/sceneObjectCommands';
import { findObject } from '../core/model/hierarchy';
import { isLiveInstance, isTemplateContent } from '../core/model/instance';
import type { PropertyBag } from '../core/model/Properties';
import { findAsset, type ObjectHolder } from '../core/model/Project';
import { type PropertyDef, schemaForObject } from '../core/properties/PropertySchema';
import { referencesOf } from '../core/properties/references';
import { resolveSceneObjectProps } from '../core/properties/resolve';
import { Menu, type MenuItem } from '../ui';
import {
activateAsset,
addCollider,
addRigidbody,
canAddCollider,
@@ -51,6 +56,13 @@ export function openObjectMenu(
disabled: !asset || !hasAssetEditor(asset.type),
onSelect: () => object && state.openAsset(object.assetId),
},
...(object && asset
? openReferenceItems(
state,
schemaForObject(state.project, asset, object),
resolveSceneObjectProps(state.project, asset, object),
)
: []),
{
label: 'Add collider',
disabled: !object || many || !canAddCollider(state.project, source, object),
@@ -89,3 +101,24 @@ export function openObjectMenu(
menu.open(x, y);
return menu;
}
/**
* "Open texture", "Open shader", … – one entry per asset the values point at (#221), skipping kinds
* that have no tab to open. The asset panel offers the same for an asset's own values.
*/
export function openReferenceItems(
state: EditorState,
schema: readonly PropertyDef[],
values: PropertyBag,
): MenuItem[] {
return referencesOf(state.project, schema, values).flatMap(({ label, assetId }) => {
const target = findAsset(state.project, assetId);
if (!target || !hasAssetEditor(target.type)) return [];
return [
{
label: `Open ${label.charAt(0).toLowerCase()}${label.slice(1)}`,
onSelect: () => activateAsset(state, assetId),
},
];
});
}
+11 -6
View File
@@ -18,6 +18,8 @@ import type { PropertyBag } from '../../core/model/Properties';
import { findAsset } from '../../core/model/Project';
import { spriteFromTexture } from '../../core/properties/aspect';
import { assetDeviates } from '../../core/properties/deviation';
import { schemaForAsset } from '../../core/properties/PropertySchema';
import { resolveAssetProps } from '../../core/properties/resolve';
import type { FontStore } from '../../core/store/FontStore';
import type { SoundStore } from '../../core/store/SoundStore';
import type { TextureStore } from '../../core/store/TextureStore';
@@ -27,7 +29,6 @@ import {
ListView,
Menu,
type MenuEntry,
type MenuItem,
Panel,
ScrollContainer,
startDrag,
@@ -37,7 +38,8 @@ import { ASSET_GROUP_LABELS, groupAssets } from './assetGroups';
import { ASSET_ICON_SVG } from './assetIcons';
import { assetMenuGroups } from './assetMenu';
import { TOOLBAR_ICON_SVG } from './toolbarIcons';
import { confirmDeleteAsset } from '../actions';
import { activateAsset, confirmDeleteAsset } from '../actions';
import { openReferenceItems } from '../objectMenu';
import type { EditorState } from '../state';
import { openFontPicker } from '../font/FontPicker';
import { openSoundPicker } from '../sound/SoundPicker';
@@ -275,9 +277,7 @@ export class AssetPanel extends Panel {
/** Double-click / Enter opens the asset's tab; a scene also becomes the active one. Placing is a drag. */
private activate(id: string): void {
const asset = findAsset(this.state.project, id);
if (asset?.type === 'scene') this.state.execute(new SetActiveSceneCommand(id));
this.state.openAsset(id);
activateAsset(this.state, id);
}
/**
@@ -317,7 +317,12 @@ export class AssetPanel extends Panel {
private openContextMenu(id: string, x: number, y: number): Menu | undefined {
const asset = findAsset(this.state.project, id);
if (!asset) return undefined;
const items: MenuItem[] = [];
const project = this.state.project;
const items = openReferenceItems(
this.state,
schemaForAsset(project, asset),
resolveAssetProps(project, asset),
);
if (asset.type === 'scene') {
items.push({
label: 'Set as active scene',
+4
View File
@@ -87,6 +87,7 @@ export type TextStyleToken =
| 'bodyBold'
| 'muted'
| 'override'
| 'link'
| 'small'
| 'warning'
| 'live'
@@ -117,6 +118,9 @@ export function textStyle(token: TextStyleToken = 'body'): TextStyle {
case 'override':
style = new TextStyle({ ...base, fill: Theme.colors.override });
break;
case 'link':
style = new TextStyle({ ...base, fill: Theme.colors.accent });
break;
case 'small':
style = new TextStyle({ ...base, fontSize: Theme.smallFontSize, fill: Theme.colors.textMuted });
break;
+21 -13
View File
@@ -5,12 +5,28 @@ const DOUBLE_MS = 400;
/** How far the pointer may travel between them. */
const SLOP = 6;
interface LastPress {
key: unknown;
button: number;
/** When and where a press happened. */
export interface Press {
time: number;
x: number;
y: number;
}
export const pressOf = (e: FederatedPointerEvent): Press => ({
time: e.timeStamp,
x: e.global.x,
y: e.global.y,
});
/** Whether `e` comes soon enough and close enough after `first` to make a double-click with it. */
export function followsPress(first: Press, e: FederatedPointerEvent): boolean {
const gap = e.timeStamp - first.time;
return gap >= 0 && gap <= DOUBLE_MS && Math.hypot(e.global.x - first.x, e.global.y - first.y) <= SLOP;
}
interface LastPress extends Press {
key: unknown;
button: number;
count: number;
}
@@ -21,20 +37,12 @@ let last: LastPress = { key: null, button: -1, time: 0, x: 0, y: 0, count: 0 };
* must survive a rebuild of its widget: the first click's selection can recreate the list rows.
*/
export function pressCount(e: FederatedPointerEvent, key: unknown): number {
const gap = e.timeStamp - last.time;
const repeat =
key === last.key &&
e.button === last.button &&
gap >= 0 &&
gap <= DOUBLE_MS &&
Math.hypot(e.global.x - last.x, e.global.y - last.y) <= SLOP;
const repeat = key === last.key && e.button === last.button && followsPress(last, e);
const count = repeat ? last.count + 1 : 1;
last = {
...pressOf(e),
key,
button: e.button,
time: e.timeStamp,
x: e.global.x,
y: e.global.y,
// A finished double-click ends the run: folding a row open and shut again is two
// double-clicks, and the second must count as one rather than as clicks three and four.
count: count === 2 ? 0 : count,
+1 -1
View File
@@ -70,5 +70,5 @@ export {
rgbToHsv,
toHex,
} from './color';
export { pressCount } from './clicks';
export { followsPress, type Press, pressCount, pressOf } from './clicks';
export { drawSvg, tintSvg } from './svg';
+23 -1
View File
@@ -1,4 +1,4 @@
import { CanvasTextMetrics, Text } from 'pixi.js';
import { CanvasTextMetrics, Graphics, Text } from 'pixi.js';
import { centreY } from '../math';
import { Theme, textStyle, type TextStyleToken } from '../Theme';
import { type Size, Widget } from '../Widget';
@@ -18,6 +18,7 @@ export class Label extends Widget {
private readonly align: NonNullable<LabelOptions['align']>;
private readonly padding: number;
private readonly fixedWidth: number;
private underline: Graphics | null = null;
constructor(text = '', options: LabelOptions = {}) {
super();
@@ -49,6 +50,22 @@ export class Label extends Widget {
this.invalidate();
}
/** A line under the shown text in its own colour – how a label reads as a link. */
setUnderline(on: boolean): void {
if (on === Boolean(this.underline)) return;
if (on) this.underline = this.addChild(new Graphics());
else {
this.underline?.destroy();
this.underline = null;
}
this.invalidate();
}
/** Whether the label is underlined (test hook). */
get underlined(): boolean {
return this.underline !== null;
}
override measure(): Size {
if (this.fixedWidth) return { w: this.fixedWidth, h: this.minHeight };
const metrics = CanvasTextMetrics.measureText(this.fullText, this.textNode.style);
@@ -66,6 +83,11 @@ export class Label extends Widget {
if (this.align === 'center') x = (this.w - tw) / 2;
else if (this.align === 'right') x = this.w - this.padding - tw;
this.textNode.position.set(Math.round(x), centreY(this.h, this.textNode.height));
if (this.underline) {
const y = Math.round(this.textNode.y + this.textNode.height) - 1;
const color = this.textNode.style.fill as number;
this.underline.clear().rect(this.textNode.x, y, tw, 1).fill(color);
}
}
}
+89
View File
@@ -0,0 +1,89 @@
/** Which asset a reference field points at – what "Open texture" and the inspector link open (#221). */
import { describe, expect, it } from 'vitest';
import { CreateAssetCommand, CreateTextureCommand } from '../../src/core/commands/assetCommands';
import { AddFrameCommand } from '../../src/core/commands/frameCommands';
import type { PixelArtAsset } from '../../src/core/model/Asset';
import { findAsset } from '../../src/core/model/Project';
import { type PropertyDef, schemaForAsset } from '../../src/core/properties/PropertySchema';
import { referencedAssetId, referencesOf } from '../../src/core/properties/references';
import { resolveAssetProps } from '../../src/core/properties/resolve';
import { SHADER_KEY } from '../../src/core/properties/shaders';
import { makeSpriteScene } from '../helpers';
const FILE = `${'ab'.repeat(32)}.png`;
const TEXTURE: PropertyDef = { key: 'texture', label: 'Texture', kind: 'texture', default: '' };
const SHADER: PropertyDef = {
key: SHADER_KEY,
label: 'Shader',
kind: 'asset',
default: '',
assetTypes: ['shader'],
};
function setup() {
const fixture = makeSpriteScene();
const texture = new CreateTextureCommand('Sheet', { file: FILE, width: 64, height: 32 });
fixture.stack.execute(texture);
const frame = new AddFrameCommand(texture.asset.id, { x: 0, y: 0, w: 16, h: 16 }, 'Idle');
fixture.stack.execute(frame);
const shader = new CreateAssetCommand('shader', 'Glow');
fixture.stack.execute(shader);
const art = new CreateAssetCommand('pixelart', 'Art');
fixture.stack.execute(art);
return {
...fixture,
textureId: texture.asset.id,
frameId: frame.frameId,
shaderId: shader.asset.id,
art: art.asset as PixelArtAsset,
};
}
describe('referencedAssetId', () => {
it('is the asset a reference names', () => {
const f = setup();
expect(referencedAssetId(f.project, TEXTURE, f.textureId)).toBe(f.textureId);
expect(referencedAssetId(f.project, SHADER, f.shaderId)).toBe(f.shaderId);
});
it('is the sheet for a frame of a texture or of pixel art', () => {
const f = setup();
expect(referencedAssetId(f.project, TEXTURE, f.frameId)).toBe(f.textureId);
f.art.frames.push({ id: 'second', cels: {} });
expect(referencedAssetId(f.project, TEXTURE, 'second')).toBe(f.art.id);
});
it('is nothing for an empty or dangling reference', () => {
const f = setup();
expect(referencedAssetId(f.project, TEXTURE, '')).toBeNull();
expect(referencedAssetId(f.project, SHADER, 'gone')).toBeNull();
});
it('only resolves frames in a texture field', () => {
const f = setup();
expect(referencedAssetId(f.project, SHADER, f.frameId)).toBeNull();
});
it('is nothing for a field that names no asset, whatever it holds', () => {
const f = setup();
const text: PropertyDef = { key: 'name', label: 'Name', kind: 'text', default: '' };
expect(referencedAssetId(f.project, text, f.textureId)).toBeNull();
});
});
describe('referencesOf', () => {
it("lists a sprite asset's texture and shader in schema order, and nothing unset", () => {
const f = setup();
const sprite = findAsset(f.project, f.spriteId)!;
const refs = () =>
referencesOf(f.project, schemaForAsset(f.project, sprite), resolveAssetProps(f.project, sprite));
sprite.props.texture = '';
expect(refs()).toEqual([]);
sprite.props[SHADER_KEY] = f.shaderId;
sprite.props.texture = f.frameId;
expect(refs()).toEqual([
{ label: 'Texture', assetId: f.textureId },
{ label: 'Shader', assetId: f.shaderId },
]);
});
});
+89
View File
@@ -0,0 +1,89 @@
/** The label of a reference row as a link to the asset it names (#221). */
import type { FederatedPointerEvent } from 'pixi.js';
import { describe, expect, it } from 'vitest';
import { CreateAssetCommand } from '../../src/core/commands/assetCommands';
import type { PropertyValue } from '../../src/core/model/Properties';
import type { PropertyDef } from '../../src/core/properties/PropertySchema';
import {
createFieldRow,
type FieldContext,
type FieldRow,
READ_ONLY,
} from '../../src/editor/inspector/fieldFactory';
import type { Label } from '../../src/ui';
import { makeProject } from '../helpers';
const SHADER: PropertyDef = {
key: 'shader',
label: 'Shader',
kind: 'asset',
default: '',
assetTypes: ['shader'],
};
interface Built {
row: FieldRow;
label: Label;
press(button?: number): void;
followed: string[];
shaderId: string;
}
/**
* Builds the row, runs the checks and destroys it before returning: widgets repaint in a microtask,
* and text cannot be measured without a document. The value `'shader'` stands for the shader's id.
*/
function withRow(value: PropertyValue, follows: boolean, checks: (b: Built) => void): void {
const fixture = makeProject();
const shader = new CreateAssetCommand('shader', 'Glow');
fixture.stack.execute(shader);
const followed: string[] = [];
const ctx: FieldContext = {
project: () => fixture.project,
...(follows ? { followReference: (assetId: string) => void followed.push(assetId) } : {}),
};
const row = createFieldRow(ctx, SHADER, value === 'shader' ? shader.asset.id : value, READ_ONLY);
const label = row.row.children[0] as Label;
const press = (button = 0) => label.emit('pointerdown', { button } as FederatedPointerEvent);
try {
checks({ row, label, press, followed, shaderId: shader.asset.id });
} finally {
row.row.destroy({ children: true });
}
}
describe('a reference row label', () => {
it('is a link while the row names an asset, and a press follows it', () => {
withRow('shader', true, (b) => {
expect(b.label.underlined).toBe(true);
expect(b.label.eventMode).toBe('static');
b.press();
expect(b.followed).toEqual([b.shaderId]);
});
});
it('follows nothing on another button', () => {
withRow('shader', true, (b) => {
b.press(2);
expect(b.followed).toEqual([]);
});
});
it('is plain text while the row names nothing, and becomes a link once it does', () => {
withRow('', true, (b) => {
expect(b.label.underlined).toBe(false);
expect(b.label.eventMode).toBe('none');
b.row.setValue(b.shaderId);
expect(b.label.underlined).toBe(true);
b.row.setValue('gone');
expect(b.label.underlined).toBe(false);
});
});
it('stays plain text where nobody can follow it', () => {
withRow('shader', false, (b) => {
expect(b.label.underlined).toBe(false);
expect(b.label.eventMode).toBe('none');
});
});
});
+15 -1
View File
@@ -1,6 +1,6 @@
import type { FederatedPointerEvent } from 'pixi.js';
import { describe, expect, it } from 'vitest';
import { pressCount } from '../../src/ui/clicks';
import { followsPress, pressCount, pressOf } from '../../src/ui/clicks';
interface Press {
time: number;
@@ -51,3 +51,17 @@ describe('pressCount', () => {
expect(counts('row-h', [{ time: 0 }, { time: 100 }, { time: 200 }, { time: 300 }])).toEqual([1, 2, 1, 2]);
});
});
describe('followsPress', () => {
// The second press of a link's double-click lands on a rebuilt panel, so no key is compared (#221).
const first = pressOf(press({ time: 0 }));
it('joins a press soon after at the same spot', () => {
expect(followsPress(first, press({ time: 300, x: 104 }))).toBe(true);
});
it('does not join a late press or one elsewhere', () => {
expect(followsPress(first, press({ time: 500 }))).toBe(false);
expect(followsPress(first, press({ time: 100, y: 120 }))).toBe(false);
});
});
+84
View File
@@ -676,6 +676,90 @@ export async function shader(t) {
{ wide, wider },
);
// 28l-ii. The sprite wears a picture and a shader: both open from the object's menu, from the
// asset's menu and from the inspector, whose Texture and Shader labels are links (#221).
const shadedProps = await json("__gm.state.project.assets.find((a) => a.id === 'shade-sprite').props");
const docs = () => json('__gm.documents()');
const openBefore = (await docs()).docs;
const opened = async () => (await docs()).docs.filter((doc) => !openBefore.includes(doc));
const closeAll = async () => {
for (const doc of await opened()) await evaluate(`__gm.closeDocument(${JSON.stringify(doc)})`);
await showTab('scene');
};
// A missing entry is a failed check, not a dead run: picked only when it is there.
const pick = async (text) => {
const entry = await t.find(text, true);
if (entry) await click(entry.x, entry.y);
else await press('Escape');
};
await showTab('scene');
await click(Math.round(box.x + 0.3 * box.w), Math.round(box.y + 0.3 * box.h), 'right');
const objectRows = ((await json('__gm.menuRows()')) ?? []).filter(Boolean);
await pick('Open shader');
const shaderOpened = await docs();
check(
'the object menu offers Open texture and Open shader, and Open shader opens the one it wears',
objectRows.slice(0, 4).join('|') === 'Select asset|Open asset|Open texture|Open shader' &&
shaderOpened.active === `asset:${shadedProps.shader}`,
JSON.stringify({ objectRows, shaderOpened, shadedProps }),
);
await closeAll();
const spriteRow = await json("__gm.assetRow('shade-sprite')");
await click(spriteRow.x, spriteRow.y, 'right');
const assetRows = ((await json('__gm.menuRows()')) ?? []).filter(Boolean);
await pick('Open texture');
const textureOpened = await docs();
check(
'the asset menu starts with the same two entries, and Open texture opens the picture',
assetRows.slice(0, 2).join('|') === 'Open texture|Open shader' &&
textureOpened.active === `asset:${picture?.id}`,
JSON.stringify({ assetRows, textureOpened }),
);
await closeAll();
await select({ kind: 'object', holderId: 'scene1', objectIds: ['oshade'], objectId: 'oshade' });
const link = (text) => json(`__gm.inspectorLink(${JSON.stringify(text)})`);
const textureLink = await link('Texture');
const nameLink = await link('Name');
check(
'a set Texture reads as a link in the accent colour, underlined; a plain row does not',
textureLink?.underlined === true &&
textureLink.pressable === true &&
textureLink.color === (await json("__gm.themeColor('accent')")) &&
nameLink?.underlined === false &&
nameLink.pressable === false,
JSON.stringify({ textureLink, nameLink }),
);
await shot('28-shader-links');
await click(textureLink.x, textureLink.y);
// Past the double-click window, so the next press is a fresh one.
await sleep(500);
const afterClick = {
selection: await json('__gm.state.selection'),
opened: await opened(),
tab: await json('__gm.tab()'),
};
check(
'a click on the link selects the picture and opens nothing',
afterClick.selection?.kind === 'asset' &&
afterClick.selection.assetId === picture?.id &&
afterClick.opened.length === 0 &&
afterClick.tab === 'scene',
JSON.stringify(afterClick),
);
await select({ kind: 'object', holderId: 'scene1', objectIds: ['oshade'], objectId: 'oshade' });
const shaderLink = await link('Shader');
await doubleClick(shaderLink.x, shaderLink.y);
const afterDouble = { selection: await json('__gm.state.selection'), docs: await docs() };
check(
'a double-click on the Shader link opens the shader, though the first click rebuilt the inspector',
afterDouble.selection?.assetId === shadedProps.shader &&
afterDouble.docs.active === `asset:${shadedProps.shader}`,
JSON.stringify({ shaderLink, afterDouble }),
);
await closeAll();
await select(null);
// 28m. Time: with the clock held the pixel is a function of the second; released, a sprite wearing
// a Time shader redraws by itself in the scene view, where nothing else ticks, and in play.
await redOrBlue(