diff --git a/README.md b/README.md index 3c432020..61ca1b88 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, 1341 checks (GM_STEPS=20 for one step) +node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1345 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/editor/debug/uiHooks.ts b/src/editor/debug/uiHooks.ts index 17e1758a..ea35627e 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, Label, Panel, Widget, activeDrag, currentMenu, ui } from '../../ui'; +import { Button, Label, Panel, TextField, Widget, activeDrag, currentMenu, ui } from '../../ui'; import { Theme } from '../../ui/Theme'; import { currentFontPicker } from '../font/FontPicker'; import { currentSoundPicker } from '../sound/SoundPicker'; @@ -142,7 +142,8 @@ export function uiHooks({ app, state, editor }: HookContext) { 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. + * and takes presses, and a text field right of it (value, read-only). Searched in the inspector + * only – the tab row says "Shader" too. */ inspectorLink: (text: string) => { let found: Label | null = null; @@ -155,7 +156,10 @@ export function uiHooks({ app, state, editor }: HookContext) { const label = found as Label | null; if (!label) return null; const shown = label.children.find((child): child is Text => child instanceof Text); + const row = label.parent; + const next = row?.children[row.children.indexOf(label) + 1]; return { + field: next instanceof TextField ? { value: next.value, readOnly: next.readOnly } : null, ...centreOf(label), color: shown ? new Color(shown.style.fill as ColorSource).toHex() : null, underlined: label.underlined, @@ -238,6 +242,8 @@ export function uiHooks({ app, state, editor }: HookContext) { /** An asset's row, its group unfolded first as a user would; `assetRowShown` leaves it folded. */ assetRow: (id: string) => assets.rowGlobal(id, true), assetRowShown: (id: string) => assets.rowGlobal(id), + /** The asset row lighting up after a ping (#221) and how brightly, or null once it has faded. */ + assetPing: () => assets.pingState(), assetRowColor: (id: string) => assets.rowColor(id), /** The asset panel's groups in order; a heading's row is `assetRow('group:')`. */ assetGroups: () => assets.groups(), diff --git a/src/editor/inspector/Inspector.ts b/src/editor/inspector/Inspector.ts index dab37e91..9bfd9624 100644 --- a/src/editor/inspector/Inspector.ts +++ b/src/editor/inspector/Inspector.ts @@ -1,17 +1,6 @@ import type { PropertyBag } from '../../core/model/Properties'; import type { FederatedPointerEvent } from 'pixi.js'; -import { - followsPress, - Label, - Panel, - type Press, - pressOf, - ScrollContainer, - Theme, - ui, - VStack, - type Widget, -} from '../../ui'; +import { Label, Panel, pressCount, 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'; @@ -47,7 +36,6 @@ 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 }); @@ -61,28 +49,17 @@ 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. + * A link in a reference row (#221): a click pings the asset in the asset panel, a double-click + * opens it. Pinging leaves the selection alone, so this panel keeps showing the object and both + * presses land on the same link. */ private followReference(assetId: string, e: FederatedPointerEvent): void { - this.state.select({ kind: 'asset', assetId }); - this.followedLink = { assetId, press: pressOf(e) }; + if (pressCount(e, `inspector-link:${assetId}`) === 2) activateAsset(this.state, assetId); + else this.state.pingAsset(assetId); } /** Signing out destroys the panel while the page stays up (#177), so the handler goes with it. */ diff --git a/src/editor/inspector/fieldFactory.ts b/src/editor/inspector/fieldFactory.ts index 4949be4a..8decf1c1 100644 --- a/src/editor/inspector/fieldFactory.ts +++ b/src/editor/inspector/fieldFactory.ts @@ -55,7 +55,7 @@ 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. + * plain text – only the inspector knows what a click (ping) and a double-click (open) do. */ followReference?(assetId: string, e: FederatedPointerEvent): void; } @@ -226,24 +226,13 @@ export function createFieldRow( const refreshActions = attachRowActions(row, rowActions); - const follow = ctx.followReference?.bind(ctx); + const paintLabel = linkLabel(ctx, label); 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(); + paintLabel(target, modified); }; - if (follow) - label.on('pointerdown', (e: FederatedPointerEvent) => { - if (e.button === 0 && target) follow(target, e); - }); retarget(value); return { @@ -254,12 +243,36 @@ export function createFieldRow( }, setModified(isModified) { modified = isModified; - paintLabel(); + paintLabel(target, modified); refreshActions(); }, }; } +/** + * Turns a row label into a link to an asset (#221): blue and underlined while it names one, a + * click pings it, a double-click opens it. Returns the repaint, to be called whenever the target + * or the modified state changes. Without `followReference` the label stays plain text. + */ +export function linkLabel( + ctx: FieldContext, + label: Label, +): (target: string | null, modified?: boolean) => void { + const follow = ctx.followReference?.bind(ctx); + let target: string | null = null; + if (follow) + label.on('pointerdown', (e: FederatedPointerEvent) => { + if (e.button === 0 && target) follow(target, e); + }); + return (next, modified = false) => { + target = follow ? next : null; + // 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'; + }; +} + interface Field { widget: Widget; /** Whether the widget should take the remaining row width. */ diff --git a/src/editor/inspector/objectSection.ts b/src/editor/inspector/objectSection.ts index 78adf3c0..03076506 100644 --- a/src/editor/inspector/objectSection.ts +++ b/src/editor/inspector/objectSection.ts @@ -25,7 +25,7 @@ import { type ObjectRef, resolvedHolder, } from '../../core/model/instance'; -import type { PropertyValue } from '../../core/model/Properties'; +import { type PropertyValue, textOf } from '../../core/model/Properties'; import { findAsset, findObjectHolder, type Project } from '../../core/model/Project'; import type { SceneObject } from '../../core/model/SceneObject'; import { @@ -42,11 +42,18 @@ import { deviatesFromTemplate, resolveSceneObjectProps, } from '../../core/properties/resolve'; -import { Button, Theme } from '../../ui'; +import { Button, TextField, Theme } from '../../ui'; import { createContainer, removeObjects } from '../actions'; import { aspectActions, sizeCommand } from './aspectAction'; import { defSignature } from './assetSection'; -import { actionIf, createFieldRow, type FieldRow, labelledRow, type RowActions } from './fieldFactory'; +import { + actionIf, + createFieldRow, + type FieldRow, + labelledRow, + linkLabel, + type RowActions, +} from './fieldFactory'; import { addGap, addNameField, addNote, addTitle, type InspectorHost } from './host'; import type { EditorState, ListSlot } from '../state'; import { parentRow } from './parentRow'; @@ -139,10 +146,10 @@ export function buildObjectSection(host: InspectorHost, holderId: string, object placeholder: asset.name, onChange: (name) => host.state.execute(new RenameSceneObjectCommand(holderId, objectId, name.trim())), }); - assetLink(host, 'Asset', asset); + assetLink(host, ASSET_ROW, 'Asset', asset); host.body.add(parentRow(host, view, object, target !== undefined)); // Content of a placed container: values compare against the template, not just the asset. - if (target) assetLink(host, 'Part of', target.asset); + if (target) assetLink(host, PART_OF_ROW, 'Part of', target.asset); // The master switch, right where it is looked for: above everything type-specific. const resolved = resolveSceneObjectProps(host.state.project, asset, object); @@ -241,16 +248,21 @@ function gridEdgesSection(host: InspectorHost, holderId: string, object: SceneOb } else addNote(host, 'The painted cells stay where they are in the scene.', 'small'); } -function assetLink(host: InspectorHost, label: string, asset: Asset): void { - const { row } = labelledRow(label); - row.add( - new Button(asset.name, { - variant: 'ghost', - onClick: () => host.state.select({ kind: 'asset', assetId: asset.id }), - }), - 1, - ); +/** Registry keys of the two asset rows; `#` keeps them clear of property keys. */ +const ASSET_ROW = '#asset'; +const PART_OF_ROW = '#part-of'; + +/** + * What the object was placed from: the label is a link like on any reference row (click pings, + * double-click opens), the name next to it only reads – changing it would mean another object. + */ +function assetLink(host: InspectorHost, key: string, text: string, asset: Asset): void { + const { row, label } = labelledRow(text); + linkLabel(host, label)(asset.id); + const name = new TextField(asset.name, { readOnly: true }); + row.add(name, 1); host.body.add(row); + host.fields.set(key, { row, setValue: (v) => name.setValue(textOf(v)), setModified: () => {} }); } /** @@ -295,12 +307,16 @@ export function refreshObjectSection(host: InspectorHost, holderId: string, obje const asset = object && findAsset(project, object.assetId); if (!view || !object || !asset) return false; host.nameField?.setValue(object.name); + // A rename of the asset changes no structure, so it arrives here rather than as a rebuild. + host.fields.get(ASSET_ROW)?.setValue(asset.name); + const target = targetOf(project, holderId, objectId); + if (target) host.fields.get(PART_OF_ROW)?.setValue(target.asset.name); for (const axis of POSITION_AXES) host.fields.get(axis)?.setValue(object.position[axis]); for (const axis of OFFSET_AXES) host.fields.get(offsetKey(axis))?.setValue(object.editorOffset[axis]); const values = resolveSceneObjectProps(project, asset, object); for (const def of schemaForObject(project, asset, object)) host.fields.get(def.key)?.setValue(values[def.key] ?? def.default); - markObjectRows(host, object, asset, targetOf(project, holderId, objectId)); + markObjectRows(host, object, asset, target); return true; } diff --git a/src/editor/panels/AssetPanel.ts b/src/editor/panels/AssetPanel.ts index c0472a74..0874b322 100644 --- a/src/editor/panels/AssetPanel.ts +++ b/src/editor/panels/AssetPanel.ts @@ -96,9 +96,23 @@ export class AssetPanel extends Panel { this.refresh(); }); state.on('selection', () => this.syncSelection()); + state.on('ping', () => this.ping()); this.refresh(); } + /** Unfolds and scrolls to the pinged asset and lets its row light up; the selection stays. */ + private ping(): void { + const id = this.state.pingedAssetId; + if (!id || !findAsset(this.state.project, id)) return; + this.reveal(id); + this.list.ping(id); + } + + /** The row lighting up after a ping and how brightly, or null (test hook). */ + pingState(): { id: string; alpha: number } | null { + return this.list.pingState(); + } + /** One entry per asset type, the groups of `assetMenuGroups` kept apart by a line. */ private addMenuEntries(): MenuEntry[] { const entry = (type: AssetType): MenuEntry => ({ @@ -289,7 +303,7 @@ export class AssetPanel extends Panel { return this.list.rowGlobal(assetId); } - /** Unfolds the asset's group and scrolls to its row, as a user would before clicking it (test hooks). */ + /** Unfolds the asset's group and scrolls to its row – before a ping, and as a user would before clicking it (test hooks). */ private reveal(assetId: string): void { const type = findAsset(this.state.project, assetId)?.type; if (type && !this.unfolded.has(type)) this.setFolded(type, false); diff --git a/src/editor/state.ts b/src/editor/state.ts index b715a951..3ee6250f 100644 --- a/src/editor/state.ts +++ b/src/editor/state.ts @@ -185,6 +185,8 @@ export type EditorEvent = | 'list' /** An asset was opened (double-click, Enter, or just created): its tab should show. */ | 'open' + /** An asset should light up where it is listed (`pingedAssetId`), without being selected. */ + | 'ping' | 'mode' | 'save' | 'notice' @@ -223,6 +225,8 @@ export class EditorState { * working rather than something the project remembers. */ tilemapPaint: TilemapPaint = { tool: 'select', tileId: '', layerId: '', focus: 'all' }; + /** The asset the last `pingAsset` pointed at. */ + pingedAssetId: string | null = null; /** The real (saved) model and its undo stack while `project` is the play-mode copy. */ private edit: { project: Project; commands: CommandStack } | null = null; @@ -414,6 +418,15 @@ export class EditorState { this.emit('open'); } + /** + * Shows where an asset is listed without selecting it – what one click on a link in the inspector + * does (#221), so the inspector stays on the object the link belongs to. + */ + pingAsset(assetId: string): void { + this.pingedAssetId = assetId; + this.emit('ping'); + } + select(selection: Selection): void { // Test hooks pass the short form `{ kind: 'object', holderId, objectId }` without `objectIds`. // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition diff --git a/src/ui/index.ts b/src/ui/index.ts index ae28e144..0436d30e 100644 --- a/src/ui/index.ts +++ b/src/ui/index.ts @@ -70,5 +70,5 @@ export { rgbToHsv, toHex, } from './color'; -export { followsPress, type Press, pressCount, pressOf } from './clicks'; +export { pressCount } from './clicks'; export { drawSvg, tintSvg } from './svg'; diff --git a/src/ui/widgets/ListView.ts b/src/ui/widgets/ListView.ts index 889b2561..f7fed84e 100644 --- a/src/ui/widgets/ListView.ts +++ b/src/ui/widgets/ListView.ts @@ -1,6 +1,7 @@ import { type FederatedPointerEvent, Graphics, Text } from 'pixi.js'; import { pressCount } from '../clicks'; import { centreY } from '../math'; +import { cancelFrameWork, onFrame } from '../render'; import { Theme, textStyle } from '../Theme'; import { type Size, Widget } from '../Widget'; import { ListRow } from './ListRow'; @@ -32,6 +33,24 @@ export interface ListItem { /** Pixels of travel before a press turns into a drag. */ const DRAG_THRESHOLD = 4; +/** How long a pinged row glows. */ +export const PING_MS = 1000; +/** Opacity of the glow at its brightest. */ +const PING_ALPHA = 0.55; + +/** + * Opacity of a ping `elapsed` ms after it started: full for the first fifth, so the eye finds the + * row, then fading out to nothing at `PING_MS`. + */ +export function pingAlpha(elapsed: number): number { + const hold = PING_MS / 5; + if (elapsed < 0 || elapsed >= PING_MS) return 0; + if (elapsed <= hold) return PING_ALPHA; + return PING_ALPHA * (1 - (elapsed - hold) / (PING_MS - hold)); +} + +let instances = 0; + /** Modifier keys held while a row was clicked. */ export interface PickModifiers { /** Ctrl or ⌘. */ @@ -86,13 +105,19 @@ export class ListView extends Widget { emptyText = 'Nothing here yet'; private readonly emptyLabel = new Text({ text: '', style: textStyle('muted') }); private readonly dropMarker = new Graphics(); + private readonly pingMarker = new Graphics(); private drag: DragState | null = null; + /** The row that glows after `ping`, and when it started. */ + private pinged: { id: string; start: number } | null = null; + private readonly frameKey = `list-ping:${(instances += 1)}`; constructor() { super(); this.focusable = true; this.dropMarker.eventMode = 'none'; - this.addChild(this.emptyLabel, this.dropMarker); + this.pingMarker.eventMode = 'none'; + this.pingMarker.visible = false; + this.addChild(this.emptyLabel, this.pingMarker, this.dropMarker); this.on('pointerdown', (e: FederatedPointerEvent) => { if (e.target === this) this.select(null); }); @@ -114,7 +139,7 @@ export class ListView extends Widget { this.addChild(row); return row; }); - this.addChild(this.dropMarker); // keep the marker above the rows + this.addChild(this.pingMarker, this.dropMarker); // keep the markers above the rows if (this.selectedId && !items.some((i) => i.id === this.selectedId)) this.selectedId = null; if (this.contextId && !items.some((i) => i.id === this.contextId)) this.contextId = null; this.layout(); @@ -141,6 +166,44 @@ export class ListView extends Widget { this.redraw(); } + /** + * Lets a row light up and fade out without selecting it – "here it is" for something named + * elsewhere, like Unity's ping. The owner makes the row visible first. + */ + ping(id: string): void { + this.pinged = { id, start: performance.now() }; + this.stepPing(); + } + + /** The row glowing right now and how brightly, or null (test hook). */ + pingState(): { id: string; alpha: number } | null { + return this.pinged && this.pingMarker.visible + ? { id: this.pinged.id, alpha: this.pingMarker.alpha } + : null; + } + + /** One frame of the glow; it asks for the next one itself until it has faded. */ + private stepPing(): void { + const ping = this.pinged; + const index = ping ? this.items.findIndex((i) => i.id === ping.id) : -1; + const alpha = ping && index >= 0 && !this.destroyed ? pingAlpha(performance.now() - ping.start) : 0; + this.pingMarker.clear(); + this.pingMarker.visible = alpha > 0; + if (alpha <= 0) { + this.pinged = null; + return; + } + // Drawn every frame: a rebuild of the rows during the glow can move the row or change the width. + this.pingMarker.rect(0, index * this.rowHeight, this.w, this.rowHeight).fill(Theme.colors.accent); + this.pingMarker.alpha = alpha; + onFrame(this.frameKey, () => this.stepPing()); + } + + override destroy(options?: Parameters[0]): void { + cancelFrameWork(this.frameKey); + super.destroy(options); + } + /** Label colour of a row as `#rrggbb`; null when no such row is shown (test hook). */ rowColor(id: string): string | null { return this.rows.find((r) => r.item.id === id)?.labelColor ?? null; diff --git a/src/ui/widgets/TextField.ts b/src/ui/widgets/TextField.ts index 98910d95..8566566c 100644 --- a/src/ui/widgets/TextField.ts +++ b/src/ui/widgets/TextField.ts @@ -21,6 +21,8 @@ export interface TextFieldOptions { onSubmit?: (value: string) => void; /** A password: drawn as bullets, and never on screen as itself. */ secret?: boolean; + /** Shows a value that is changed elsewhere: no focus, no caret, no hover. */ + readOnly?: boolean; } /** What a masked field shows per character. */ @@ -48,6 +50,7 @@ export class TextField extends Widget implements TextEditTarget { private dragSelecting = false; placeholder: string; private readonly secret: boolean; + readonly readOnly: boolean; onInput?: (value: string) => void; onChange?: (value: string) => void; onSubmit?: (value: string) => void; @@ -60,8 +63,9 @@ export class TextField extends Widget implements TextEditTarget { this.onChange = options.onChange; this.onSubmit = options.onSubmit; this.secret = options.secret ?? false; - this.focusable = true; - this.cursor = 'text'; + this.readOnly = options.readOnly ?? false; + this.focusable = !this.readOnly; + this.cursor = this.readOnly ? 'default' : 'text'; this.minHeight = Theme.controlHeight; this.minWidth = options.width ?? 80; this.textNode = new Text({ text: value, style: textStyle(options.style ?? 'body') }); @@ -102,7 +106,7 @@ export class TextField extends Widget implements TextEditTarget { } protected handlePointerDown(e: FederatedPointerEvent): void { - if (e.button !== 0) return; + if (e.button !== 0 || this.readOnly) return; e.stopPropagation(); const index = this.indexAt(this.toLocal(e.global).x); const presses = pressCount(e, this); @@ -260,7 +264,7 @@ export class TextField extends Widget implements TextEditTarget { const innerW = this.w - pad * 2; this.bg.clear(); drawControlBox(this.bg, this.w, this.h, { - fill: this.hovered && !this.editing ? c.controlHover : c.control, + fill: this.hovered && !this.editing && !this.readOnly ? c.controlHover : c.control, border: this.focused ? c.accent : c.border, }); this.clipShape diff --git a/tests/ui/list-ping.test.ts b/tests/ui/list-ping.test.ts new file mode 100644 index 00000000..ef78a557 --- /dev/null +++ b/tests/ui/list-ping.test.ts @@ -0,0 +1,33 @@ +/** + * A row lighting up where an asset is listed, without being selected (#221, Unity's ping). The row + * itself needs text measurement, which Node lacks – e2e 28l-ii looks at it in the browser. + */ +import { describe, expect, it } from 'vitest'; +import { EditorState } from '../../src/editor/state'; +import { PING_MS, pingAlpha } from '../../src/ui/widgets/ListView'; + +describe('pingAlpha', () => { + it('holds full for the first fifth, then fades to nothing at PING_MS', () => { + const full = pingAlpha(0); + expect(full).toBeGreaterThan(0); + expect(pingAlpha(PING_MS / 5)).toBe(full); + expect(pingAlpha(PING_MS * 0.6)).toBeCloseTo(full / 2); + expect(pingAlpha(PING_MS * 0.9)).toBeLessThan(pingAlpha(PING_MS * 0.6)); + expect(pingAlpha(PING_MS)).toBe(0); + expect(pingAlpha(-1)).toBe(0); + }); +}); + +describe('EditorState.pingAsset', () => { + it('names the asset and tells the listeners, but leaves the selection alone', () => { + const state = new EditorState(); + state.select({ kind: 'asset', assetId: 'sprite' }); + const heard: string[] = []; + state.on('ping', () => heard.push(state.pingedAssetId ?? '')); + state.on('selection', () => heard.push('selection')); + state.on('open', () => heard.push('open')); + state.pingAsset('texture'); + expect(heard).toEqual(['texture']); + expect(state.selection).toEqual({ kind: 'asset', assetId: 'sprite' }); + }); +}); diff --git a/tools/e2e/28-shader.mjs b/tools/e2e/28-shader.mjs index 980e34b7..f629e991 100644 --- a/tools/e2e/28-shader.mjs +++ b/tools/e2e/28-shader.mjs @@ -731,33 +731,97 @@ export async function shader(t) { JSON.stringify({ textureLink, nameLink }), ); await shot('28-shader-links'); + // Folded first, so the ping has to find the row itself. + const textures = await json("__gm.assetRow('group:texture')"); + await click(textures.x, textures.y); + const foldedBefore = + (await json('__gm.assetGroups()')).find((g) => g.type === 'texture').expanded === false; + const rebuildsBefore = (await json('__gm.inspectorInfo()')).rebuilds; await click(textureLink.x, textureLink.y); - // Past the double-click window, so the next press is a fresh one. - await sleep(500); const afterClick = { + ping: await json('__gm.assetPing()'), + row: await json(`__gm.assetRowShown(${JSON.stringify(picture?.id)})`), selection: await json('__gm.state.selection'), + rebuilds: (await json('__gm.inspectorInfo()')).rebuilds - rebuildsBefore, opened: await opened(), tab: await json('__gm.tab()'), }; + await shot('28-shader-ping'); check( - 'a click on the link selects the picture and opens nothing', - afterClick.selection?.kind === 'asset' && - afterClick.selection.assetId === picture?.id && + 'a click on the link pings the picture in the asset panel: its group unfolds, its row lights up, the inspector stays', + foldedBefore && + afterClick.ping?.id === picture?.id && + afterClick.ping.alpha > 0 && + Boolean(afterClick.row) && + afterClick.selection?.kind === 'object' && + afterClick.selection.objectId === 'oshade' && + afterClick.rebuilds === 0 && afterClick.opened.length === 0 && afterClick.tab === 'scene', - JSON.stringify(afterClick), + JSON.stringify({ foldedBefore, afterClick }), ); - await select({ kind: 'object', holderId: 'scene1', objectIds: ['oshade'], objectId: 'oshade' }); + // Past the glow and the double-click window, so the next press is a fresh one. + await sleep(1200); + const faded = await json('__gm.assetPing()'); + check('the glow fades out by itself', faded === null, JSON.stringify(faded)); + // Still the sprite's inspector after the ping; were it not, the link is missing and the check fails. const shaderLink = await link('Shader'); - await doubleClick(shaderLink.x, shaderLink.y); + if (shaderLink) 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', + 'a double-click on the Shader link opens the shader', afterDouble.selection?.assetId === shadedProps.shader && afterDouble.docs.active === `asset:${shadedProps.shader}`, JSON.stringify({ shaderLink, afterDouble }), ); await closeAll(); + + // What the object was placed from is a link too: its name beside it only reads. + await select({ kind: 'object', holderId: 'scene1', objectIds: ['oshade'], objectId: 'oshade' }); + const spriteName = await json("__gm.state.project.assets.find((a) => a.id === 'shade-sprite').name"); + const assetLink = await link('Asset'); + if (assetLink?.field) await click(assetLink.x + 150, assetLink.y); + const focusedAfter = await json('__gm.focusedRect()'); + check( + 'the Asset row is a link with the name in a read-only field that takes no focus', + assetLink?.underlined === true && + assetLink.pressable === true && + assetLink.color === (await json("__gm.themeColor('accent')")) && + assetLink.field?.value === spriteName && + assetLink.field.readOnly === true && + focusedAfter === null, + JSON.stringify({ assetLink, spriteName, focusedAfter }), + ); + const assetRebuilds = (await json('__gm.inspectorInfo()')).rebuilds; + if (assetLink) await click(assetLink.x, assetLink.y); + const assetPing = { + ping: await json('__gm.assetPing()'), + selection: await json('__gm.state.selection'), + rebuilds: (await json('__gm.inspectorInfo()')).rebuilds - assetRebuilds, + }; + await mutate("__gm.state.project.assets.find((a) => a.id === 'shade-sprite').name = 'Renamed sprite'"); + const renamedField = (await link('Asset'))?.field?.value; + await mutate( + `__gm.state.project.assets.find((a) => a.id === 'shade-sprite').name = ${JSON.stringify(spriteName)}`, + ); + check( + 'a click pings the sprite and the inspector stays; renaming the sprite updates the field', + assetPing.ping?.id === 'shade-sprite' && + assetPing.selection?.objectId === 'oshade' && + assetPing.rebuilds === 0 && + renamedField === 'Renamed sprite', + JSON.stringify({ assetPing, renamedField }), + ); + await sleep(1200); + const assetAgain = await link('Asset'); + if (assetAgain) await doubleClick(assetAgain.x, assetAgain.y); + const assetOpened = await json('__gm.state.selection'); + check( + 'a double-click on the Asset link opens the sprite', + assetOpened?.kind === 'asset' && assetOpened.assetId === 'shade-sprite', + JSON.stringify({ assetAgain, assetOpened }), + ); + await closeAll(); await select(null); // 28m. Time: with the clock held the pixel is a function of the second; released, a sprite wearing