diff --git a/README.md b/README.md index e8cc1607..3c432020 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/src/core/properties/references.ts b/src/core/properties/references.ts new file mode 100644 index 00000000..6d109130 --- /dev/null +++ b/src/core/properties/references.ts @@ -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 }] : []; + }); +} diff --git a/src/editor/actions.ts b/src/editor/actions.ts index c61ed335..7b866eb4 100644 --- a/src/editor/actions.ts +++ b/src/editor/actions.ts @@ -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; diff --git a/src/editor/debug/uiHooks.ts b/src/editor/debug/uiHooks.ts index 667e2360..17e1758a 100644 --- a/src/editor/debug/uiHooks.ts +++ b/src/editor/debug/uiHooks.ts @@ -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. */ diff --git a/src/editor/inspector/Inspector.ts b/src/editor/inspector/Inspector.ts index 0c6c52c8..dab37e91 100644 --- a/src/editor/inspector/Inspector.ts +++ b/src/editor/inspector/Inspector.ts @@ -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[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, diff --git a/src/editor/inspector/fieldFactory.ts b/src/editor/inspector/fieldFactory.ts index a95cd85a..4949be4a 100644 --- a/src/editor/inspector/fieldFactory.ts +++ b/src/editor/inspector/fieldFactory.ts @@ -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(); }, }; diff --git a/src/editor/objectMenu.ts b/src/editor/objectMenu.ts index 36f8945b..a68fcbeb 100644 --- a/src/editor/objectMenu.ts +++ b/src/editor/objectMenu.ts @@ -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), + }, + ]; + }); +} diff --git a/src/editor/panels/AssetPanel.ts b/src/editor/panels/AssetPanel.ts index 7f81cf73..c0472a74 100644 --- a/src/editor/panels/AssetPanel.ts +++ b/src/editor/panels/AssetPanel.ts @@ -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', diff --git a/src/ui/Theme.ts b/src/ui/Theme.ts index 255bda13..8de46c15 100644 --- a/src/ui/Theme.ts +++ b/src/ui/Theme.ts @@ -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; diff --git a/src/ui/clicks.ts b/src/ui/clicks.ts index b94302ae..0d1cf460 100644 --- a/src/ui/clicks.ts +++ b/src/ui/clicks.ts @@ -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, diff --git a/src/ui/index.ts b/src/ui/index.ts index 0436d30e..ae28e144 100644 --- a/src/ui/index.ts +++ b/src/ui/index.ts @@ -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'; diff --git a/src/ui/widgets/Label.ts b/src/ui/widgets/Label.ts index 90a007c6..3cfceb88 100644 --- a/src/ui/widgets/Label.ts +++ b/src/ui/widgets/Label.ts @@ -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; 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); + } } } diff --git a/tests/core/references.test.ts b/tests/core/references.test.ts new file mode 100644 index 00000000..81e65f0e --- /dev/null +++ b/tests/core/references.test.ts @@ -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 }, + ]); + }); +}); diff --git a/tests/editor/reference-link.test.ts b/tests/editor/reference-link.test.ts new file mode 100644 index 00000000..f99e4fd5 --- /dev/null +++ b/tests/editor/reference-link.test.ts @@ -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'); + }); + }); +}); diff --git a/tests/ui/clicks.test.ts b/tests/ui/clicks.test.ts index 152e6e6f..3b7f1aa1 100644 --- a/tests/ui/clicks.test.ts +++ b/tests/ui/clicks.test.ts @@ -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); + }); +}); diff --git a/tools/e2e/28-shader.mjs b/tools/e2e/28-shader.mjs index fc761270..980e34b7 100644 --- a/tools/e2e/28-shader.mjs +++ b/tools/e2e/28-shader.mjs @@ -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(