From f16801aef30a1cb5df750d5b85fb3108dbb6d86b Mon Sep 17 00:00:00 2001 From: root Date: Tue, 8 Sep 2026 15:05:28 +0200 Subject: [PATCH] Add a decimals option, fix dropdown overflow and the texture filter - NumberField takes a decimals option (default 3); frame rects and the slice dialog use 0, matching their integer model. - Button truncates its label to the drawn width; Dropdown reserves the chevron slot. NodeView reuses the shared truncate(). - Setting scaleMode leaves the style's shared resource id in place, so the old GPU sampler stayed in use until a reload. Call style.update(). - Graph gets a header bar like the Texture tab: GraphEditor is now the frame, the former editor is GraphCanvas. A selection the tab does not concern no longer unloads the behavior. - New e2e step 14 guards text overflow across all widgets. Co-Authored-By: Claude Opus 5 --- README.md | 33 +- src/core/properties/PropertySchema.ts | 17 +- src/editor/Editor.ts | 4 +- src/editor/PlaySession.ts | 2 +- src/editor/debugHook.ts | 45 ++- src/editor/graph/GraphCanvas.ts | 486 ++++++++++++++++++++++++ src/editor/graph/GraphEditor.ts | 528 +++++--------------------- src/editor/graph/NodeView.ts | 18 +- src/editor/inspector/fieldFactory.ts | 1 + src/editor/inspector/frameSection.ts | 4 +- src/editor/texture/SliceDialog.ts | 2 + src/ui/index.ts | 2 +- src/ui/math.ts | 5 +- src/ui/widgets/Button.ts | 24 +- src/ui/widgets/Dropdown.ts | 5 + src/ui/widgets/NumberField.ts | 30 +- src/viewport/TextureLibrary.ts | 9 +- tests/ui/math.test.ts | 11 +- tools/e2e/06-graph.mjs | 33 +- tools/e2e/12-textures.mjs | 50 +++ tools/e2e/14-overflow.mjs | 56 +++ tools/e2e/index.mjs | 2 + 22 files changed, 874 insertions(+), 493 deletions(-) create mode 100644 src/editor/graph/GraphCanvas.ts create mode 100644 tools/e2e/14-overflow.mjs diff --git a/README.md b/README.md index 62d69c44..8fccefd0 100644 --- a/README.md +++ b/README.md @@ -89,8 +89,14 @@ shown on the node and editable in the inspector. (types are checked; a data input holds one link and is replaced), drag nodes, right-click a node or port to delete/disconnect, `Delete` removes the selected node, wheel zooms, dragging the empty canvas pans. Everything is undoable. -- **Selection-bound**: the Graph tab shows the behavior the current selection refers to - (asset, placed object or node); with nothing or a non-behavior selected it is empty. +- **Header bar** (the same one the Texture tab has): the behavior picker, **Add node…** (the + palette, dropping the node in the middle of the view – the right-click nobody finds), **Fit**, + and the size of the graph as "N nodes · M links". +- **Selection-following, not selection-bound**: selecting a behavior asset, a placed behavior + or one of its nodes shows that graph; a selection about anything else (a sprite, nothing at + all) leaves the tab on the behavior you were editing, because checking a sprite's position + must not empty your work. Only a behavior that no longer exists is dropped, and then the tab + falls back to another one – the picker is how you switch on purpose. - **Live view**: Play always switches to the Game tab; switch to Graph while playing and the exec edges and nodes that just ran light up – the same canvas draws editor and game, so this costs nothing extra. @@ -187,7 +193,10 @@ picture becomes ten differently coloured coins, per instance, for nothing. Witho `color` is simply the fill of the box, as before. **`filter`** is the texture's only property: *Smooth* (default) or *Pixel art*, which is the -difference between crisp and mush when a small image is scaled up. +difference between crisp and mush when a small image is scaled up. Switching it needs a +`style.update()` on the texture source, not just the assignment: Pixi's `scaleMode` setter +writes the filters but leaves the style's cached resource id in place, and that id is the key +the GPU sampler is looked up under — so without it the picture only changes on a reload. ### Frames — cutting a sheet up @@ -256,6 +265,12 @@ selected and switched back on. object of the scene – is opened with `{ searchable: true }`, which puts a filter box above the entries (Enter takes the first match). A field that opens a menu is a `Dropdown`: value flush left, chevron drawn flush right rather than written into the label. + Every label that can be wider than its control is cut with an ellipsis (`truncate` in + `Label.ts`, shared by `Label`, `ListRow` and `Button`). A `Button` keeps the full text and + measures from it – only what is drawn is shortened – and a subclass that puts something else + inside the control takes its room off `labelSlot()`, so a long value is cut rather than run + underneath the chevron. `__gm.textOverflows()` guards the whole class of bug: it reports any + visible text drawn wider than the widget owning it (e2e step 14). - **Vite + TypeScript**, **Vitest** for the pure core. - **PHP 8** JSON API (`api/`) storing one JSON file per project in `data/projects/`, with atomic writes and an integer revision that rejects stale saves (HTTP 409). @@ -278,7 +293,7 @@ src/editor/ App → Editor (layout + ticker), PlaySession (the run), src/editor/inspector/ Inspector panel, field factory, previewHandlers (scrub → preview → command), one section per selection kind (asset/object/node) src/editor/panels/ AssetPanel, HierarchyPanel, Toolbar -src/editor/graph/ GraphEditor (canvas), NodeView, NodePalette +src/editor/graph/ GraphEditor (header + picker), GraphCanvas, NodeView, NodePalette api/ PHP backend data/projects/ persisted projects tests/ Vitest unit tests tools/ seed-*, e2e (steps in tools/e2e/), smoke-jumprun, lib/ ``` @@ -323,7 +338,8 @@ rewritten by every e2e run and the **jump-run** project by its seeder. The **pla project is the author's own and is maintained in the editor – every tool refuses to touch it. The e2e run is a list of steps in `tools/e2e/` (hierarchy, inspector, play, assets, drawing, -graph, parenting, colliders, containers, text/enabled, menus) that build on each other's scene state +graph, parenting, colliders, containers, text/enabled, menus, textures, frames, overflow) that build +on each other's scene state and share the CDP driver in `tools/lib/harness.mjs` – `press`, `undo`, `play`, `object(id)`, `world()`, `mutate(js)` and friends, so a check reads like the interaction it exercises. @@ -365,9 +381,10 @@ There is no authentication yet; `data/` is world-readable. Both are phase-2 topi container from the selection · `Ctrl+Enter` play · `Esc` stop · `Ctrl+V` import an image from the clipboard as a texture (ignored while a text field has focus) · `Tab` cycles fields · number fields: drag to scrub (`Shift` ×10, `Alt` ×0.1, snapping to the grid of that increment, so a -step-1 field lands on whole numbers), `↑`/`↓` step, always shown with three -decimals (`ui/math.ts`, `NUMBER_DECIMALS`) so a fractional value never reads as a whole -number · viewport: wheel zooms, +step-1 field lands on whole numbers), `↑`/`↓` step, shown with three decimals (`ui/math.ts`, +`NUMBER_DECIMALS`) so a fractional value never reads as a whole number — except where the +value is integral by nature (frame rectangles, the slice dialog), which set +`NumberField`'s `decimals: 0` and show plain whole numbers · viewport: wheel zooms, middle-drag or `Shift`+drag pans, drag on empty space rubber-bands, `Ctrl`-click toggles an object in the selection (hierarchy rows too). diff --git a/src/core/properties/PropertySchema.ts b/src/core/properties/PropertySchema.ts index ca672b25..873d9027 100644 --- a/src/core/properties/PropertySchema.ts +++ b/src/core/properties/PropertySchema.ts @@ -7,7 +7,22 @@ import type { PropertyBag, PropertyValue } from '../model/Properties'; * these entries, so adding a property to a type is a single line in a schema below. */ export type PropertyDef = - | { key: string; label: string; kind: 'number'; default: number; min?: number; max?: number; step?: number } + /** + * `decimals` is the number of places the field displays; it belongs only on properties that + * are integral by their meaning (counts, indices). Geometry does not qualify: dragging in the + * viewport puts fractions into a position or a size, and a field that hid them would commit + * the rounded number back into the project. + */ + | { + key: string; + label: string; + kind: 'number'; + default: number; + min?: number; + max?: number; + step?: number; + decimals?: number; + } | { key: string; label: string; kind: 'text'; default: string } | { key: string; label: string; kind: 'color'; default: number } | { key: string; label: string; kind: 'boolean'; default: boolean } diff --git a/src/editor/Editor.ts b/src/editor/Editor.ts index d4cfa541..039e75a0 100644 --- a/src/editor/Editor.ts +++ b/src/editor/Editor.ts @@ -78,8 +78,8 @@ export class Editor implements PlayHost { if (this.play.isPlaying) this.play.stop(); }); - this.graphEditor.traceProvider = (assetId) => this.play.traceFor(assetId); - this.graphEditor.timeProvider = () => this.play.time; + this.graphEditor.canvas.traceProvider = (assetId) => this.play.traceFor(assetId); + this.graphEditor.canvas.timeProvider = () => this.play.time; this.toolbar = new Toolbar(state, { openProjectMenu: (anchor) => this.projects.openProjectMenu(anchor), diff --git a/src/editor/PlaySession.ts b/src/editor/PlaySession.ts index 3ae0ffc7..bcbac2c2 100644 --- a/src/editor/PlaySession.ts +++ b/src/editor/PlaySession.ts @@ -132,7 +132,7 @@ export class PlaySession { // A destroyed entity removes its object – that is a structural change the lists must see. if (world.syncToModel()) this.state.preview(); this.state.runtimeTick(); - if (this.host.graphEditor.visible) this.host.graphEditor.redraw(); + if (this.host.graphEditor.visible) this.host.graphEditor.canvas.redraw(); } /** Read-only view of the running world for the check scripts. */ diff --git a/src/editor/debugHook.ts b/src/editor/debugHook.ts index 95c5a943..6293157c 100644 --- a/src/editor/debugHook.ts +++ b/src/editor/debugHook.ts @@ -50,6 +50,42 @@ export function installDebugHook(app: Application, state: EditorState, editor: E walk(ui().overlay); return out; }; + /** + * Visible texts drawn wider than the widget that owns them – the shape of bug where a label + * runs past its own control instead of being shortened to fit. + * + * The owner's box is its declared `w`/`h` at its global position, NOT `getBounds()`: a Pixi + * container has no size of its own, so its bounds are the union of its children – including + * the very text that overflows, which makes every overflow measure as zero. Texts inside a + * scroll container are skipped, where sticking out is the point and the mask hides it. + */ + const textOverflows = (all = false): { text: string; owner: string; over: number }[] => { + const out: { text: string; owner: string; over: number }[] = []; + const walk = (node: Container, owner: Widget | null, clipped: boolean): void => { + if (!node.visible) return; + const isWidget = node instanceof Widget; + const box = isWidget ? node : owner; + // A mask further up – a panel clipping its contents – does not excuse a label spilling + // out of its own control, so the question restarts at every widget. Only a mask at or + // below the owner (a text field's scroll window, a scroll container) makes it intended. + // `Boolean`, not `!== null`: Pixi's mask getter returns `undefined` when nothing masks, + // so comparing against null marks every container as clipped and hides every finding. + const masked = (isWidget ? false : clipped) || Boolean(node.mask); + if (node instanceof Text) { + if (!box || !node.text || (masked && !all)) return; + const text = node.getBounds(); + const origin = box.getGlobalPosition(); + const over = Math.max(text.x + text.width - (origin.x + box.w), origin.x - text.x); + if (over > 1 || all) { + out.push({ text: node.text, owner: box.constructor.name, over: Math.round(over) }); + } + return; + } + for (const child of node.children) walk(child, box, masked); + }; + walk(app.stage, null, false); + return out; + }; /** Screen rectangle of the open popup (the backdrop is not a widget), or null when none is. */ const overlayRect = (): { x: number; y: number; w: number; h: number } | null => { const popups = ui().overlay.children.filter((node): node is Widget => node instanceof Widget); @@ -63,6 +99,7 @@ export function installDebugHook(app: Application, state: EditorState, editor: E locate, overlayTexts, overlayRect, + textOverflows, viewportCenter: () => centreOf(viewport), viewportInfo: () => viewport.debugInfo(), gameInfo: () => gameView.debugInfo(), @@ -77,9 +114,11 @@ export function installDebugHook(app: Application, state: EditorState, editor: E graph: { current: () => graphEditor.currentAssetId, portAt: (nodeId: string, port: string, isOutput: boolean) => - graphEditor.portGlobal(nodeId, port, isOutput), - nodeAt: (nodeId: string) => graphEditor.nodeGlobal(nodeId), - center: () => centreOf(graphEditor), + graphEditor.canvas.portGlobal(nodeId, port, isOutput), + nodeAt: (nodeId: string) => graphEditor.canvas.nodeGlobal(nodeId), + // The canvas, not the whole tab: a point measured through the header would sit above + // the drawing area a right-click test aims at. + center: () => centreOf(graphEditor.canvas), }, texture: { current: () => textureEditor.currentAssetId, diff --git a/src/editor/graph/GraphCanvas.ts b/src/editor/graph/GraphCanvas.ts new file mode 100644 index 00000000..ab525df8 --- /dev/null +++ b/src/editor/graph/GraphCanvas.ts @@ -0,0 +1,486 @@ +import { Container, type FederatedPointerEvent, type FederatedWheelEvent, Graphics, Text } from 'pixi.js'; +import { + AddNodeCommand, + ConnectCommand, + DisconnectCommand, + MoveNodeCommand, + RemoveNodeCommand, +} from '../../core/commands/graphCommands'; +import { + edgesInto, + edgesOutOf, + type GraphEdge, + type NodeDef, + nodeDef, + portsCompatible, +} from '../../core/graph'; +import type { BehaviorAsset } from '../../core/model/Asset'; +import { findAsset } from '../../core/model/Project'; +import { type Trace, TRACE_FADE } from '../../runtime/systems/BehaviorSystem'; +import { clamp, drawGrid, Menu, type Size, Theme, textStyle, Widget, zoomFactor } from '../../ui'; +import type { EditorState } from '../state'; +import { NodePalette } from './NodePalette'; +import { NODE_WIDTH, NodeView, portColor, TITLE_HEIGHT } from './NodeView'; + +const GRID_STEP = 24; +/** Below this on-screen spacing the grid is noise rather than orientation, so it is dropped. */ +const MIN_GRID_STEP = 8; +const MIN_ZOOM = 0.25; +const MAX_ZOOM = 2.5; +/** Stand-in node height for centring the view – real nodes vary with their port count. */ +const NODE_HEIGHT_ESTIMATE = 80; + +interface PortAnchor { + nodeId: string; + port: string; + isOutput: boolean; +} + +/** + * The Graph tab's canvas: the node graph of one behavior asset. Which behavior that is comes + * from the outside (`show`), so the canvas never has an opinion about the selection – the + * GraphEditor around it does. + * + * Interactions: drag nodes, drag from port to port to connect, right-click the canvas for + * the node palette, right-click a node or port for delete/disconnect, wheel to zoom, drag + * the empty canvas to pan. All edits go through commands (undoable). + */ +export class GraphCanvas extends Widget { + private readonly bg = new Graphics(); + private readonly grid = new Graphics(); + private readonly clip = new Graphics(); + private readonly canvas = new Container(); + private readonly edges = new Graphics(); + private readonly nodeLayer = new Container(); + private readonly tempEdge = new Graphics(); + private readonly hint = new Text({ text: '', style: textStyle('muted') }); + private readonly views = new Map(); + private assetId: string | null = null; + private pan = { x: 0, y: 0 }; + private zoom = 1; + private panning: { startX: number; startY: number; panX: number; panY: number } | null = null; + private dragging: { + nodeId: string; + start: { x: number; y: number }; + pointer: { x: number; y: number }; + moved: boolean; + } | null = null; + private connecting: { from: PortAnchor; pointer: { x: number; y: number } } | null = null; + /** Fit the view once the editor has a real size (it may be hidden behind the Scene tab). */ + private needsFit = true; + /** Supplies live execution data while playing (null otherwise). */ + traceProvider: (assetId: string) => Trace | null = () => null; + timeProvider: () => number = () => 0; + + constructor(private readonly state: EditorState) { + super(); + this.canvas.addChild(this.edges, this.nodeLayer, this.tempEdge); + this.addChild(this.bg, this.grid, this.canvas, this.hint, this.clip); + this.canvas.mask = this.clip; + this.cursor = 'default'; + + this.on('pointerdown', (e: FederatedPointerEvent) => this.onCanvasPointerDown(e)); + this.on('globalpointermove', (e: FederatedPointerEvent) => this.onPointerMove(e)); + this.on('pointerup', (e: FederatedPointerEvent) => this.onPointerUp(e)); + this.on('pointerupoutside', (e: FederatedPointerEvent) => this.onPointerUp(e)); + this.on('wheel', (e: FederatedWheelEvent) => { + const local = this.toLocal(e.global); + const before = this.screenToGraph(local.x, local.y); + this.zoom = clamp(this.zoom * zoomFactor(e.deltaY), MIN_ZOOM, MAX_ZOOM); + const after = this.screenToGraph(local.x, local.y); + this.pan.x += (after.x - before.x) * this.zoom; + this.pan.y += (after.y - before.y) * this.zoom; + this.applyView(); + e.preventDefault(); + }); + + // Only the highlight of the selected node depends on the selection; which graph is shown + // is the editor's decision. + state.on('selection', () => this.redraw()); + state.on('project', () => this.sync()); + state.on('mode', () => this.redraw()); + } + + /** The behavior whose graph is shown, if any. */ + get currentAssetId(): string | null { + return this.assetId; + } + + /** Shows a behavior's graph. Switching refits the view, since the next graph is a new subject. */ + show(assetId: string | null): void { + if (this.assetId === assetId) return; + this.assetId = assetId; + this.sync(); + this.needsFit = true; + this.fitView(); + } + + /** Recentres the graph in the view – the "reset view" the header offers. */ + fit(): void { + this.needsFit = true; + this.fitView(); + } + + /** + * The header's "Add node": the same palette the right-click opens, dropped in the middle of + * the view, since a button press says nothing about where the node should go. + */ + addNodeFrom(anchor: Widget): void { + if (!this.assetId || this.state.mode !== 'edit') return; + const centre = this.screenToGraph(this.w / 2, this.h / 2); + new NodePalette((def: NodeDef) => + this.addNode(def, { x: centre.x - NODE_WIDTH / 2, y: centre.y - NODE_HEIGHT_ESTIMATE / 2 }), + ).openBelow(anchor); + } + + override measure(): Size { + return { w: Math.max(this.minWidth, 200), h: Math.max(this.minHeight, 200) }; + } + + // --- model binding ---------------------------------------------------------------------- + + private asset(): BehaviorAsset | null { + const asset = this.assetId ? findAsset(this.state.project, this.assetId) : undefined; + return asset && asset.type === 'behavior' ? asset : null; + } + + /** Rebuilds node views from the model (cheap: views are reused by node id). */ + private sync(): void { + const asset = this.asset(); + if (!asset) { + // A behavior that was deleted (or undone away) must not linger as a graph of a thing + // that no longer exists, so the canvas drops it rather than waiting to be told. + this.assetId = null; + for (const view of this.views.values()) view.destroy({ children: true }); + this.views.clear(); + this.redraw(); + return; + } + const seen = new Set(); + for (const node of asset.graph.nodes) { + const def = nodeDef(node.type); + if (!def) continue; + seen.add(node.id); + let view = this.views.get(node.id); + if (!view || view.node !== node) { + view?.destroy({ children: true }); + view = new NodeView(node, def); + view.on('pointerdown', (e: FederatedPointerEvent) => this.onNodePointerDown(view!, e)); + this.views.set(node.id, view); + this.nodeLayer.addChild(view); + } + view.sync(node, asset.graph, asset, this.state.project); + } + for (const [id, view] of this.views) { + if (!seen.has(id)) { + view.destroy({ children: true }); + this.views.delete(id); + } + } + this.redraw(); + } + + /** Centres the graph's nodes (or the origin) in the view. Waits for a real size. */ + private fitView(): void { + if (this.w === 0 || this.h === 0) return; + this.needsFit = false; + const asset = this.asset(); + if (!asset || asset.graph.nodes.length === 0) { + this.pan = { x: this.w / 2 - NODE_WIDTH / 2, y: this.h / 3 }; + this.zoom = 1; + } else { + const xs = asset.graph.nodes.map((n) => n.x); + const ys = asset.graph.nodes.map((n) => n.y); + const cx = (Math.min(...xs) + Math.max(...xs) + NODE_WIDTH) / 2; + const cy = (Math.min(...ys) + Math.max(...ys) + NODE_HEIGHT_ESTIMATE) / 2; + this.zoom = 1; + this.pan = { x: this.w / 2 - cx, y: this.h / 2 - cy }; + } + this.applyView(); + } + + private applyView(): void { + this.canvas.position.set(Math.round(this.pan.x), Math.round(this.pan.y)); + this.canvas.scale.set(this.zoom); + this.redraw(); + } + + private screenToGraph(sx: number, sy: number): { x: number; y: number } { + return { x: (sx - this.pan.x) / this.zoom, y: (sy - this.pan.y) / this.zoom }; + } + + // --- pointer ----------------------------------------------------------------------------- + + private onCanvasPointerDown(e: FederatedPointerEvent): void { + if (e.target !== this) return; + const local = this.toLocal(e.global); + if (e.button === 2) { + if (this.assetId && this.state.mode === 'edit') + this.openPalette(e.global.x, e.global.y, this.screenToGraph(local.x, local.y)); + return; + } + if (e.button === 0 || e.button === 1) { + if (e.button === 0 && this.assetId && this.state.mode === 'edit') + this.state.select({ kind: 'asset', assetId: this.assetId }); + this.panning = { startX: e.global.x, startY: e.global.y, panX: this.pan.x, panY: this.pan.y }; + } + } + + private onNodePointerDown(view: NodeView, e: FederatedPointerEvent): void { + if (!this.assetId || this.state.mode !== 'edit') return; + e.stopPropagation(); + const local = view.toLocal(e.global); + const hit = view.portAt(local.x, local.y); + if (e.button === 2) { + if (hit) this.openPortMenu(view, hit.port.key, hit.isOutput, e.global.x, e.global.y); + else this.openNodeMenu(view, e.global.x, e.global.y); + return; + } + if (e.button !== 0) return; + if (hit) { + const p = this.toLocal(e.global); + this.connecting = { + from: { nodeId: view.node.id, port: hit.port.key, isOutput: hit.isOutput }, + pointer: this.screenToGraph(p.x, p.y), + }; + this.redraw(); + return; + } + this.state.select({ kind: 'node', assetId: this.assetId, nodeId: view.node.id }); + const p = this.toLocal(e.global); + this.dragging = { + nodeId: view.node.id, + start: { x: view.node.x, y: view.node.y }, + pointer: this.screenToGraph(p.x, p.y), + moved: false, + }; + this.nodeLayer.addChild(view); // raise + } + + private onPointerMove(e: FederatedPointerEvent): void { + if (this.panning) { + this.pan.x = this.panning.panX + (e.global.x - this.panning.startX); + this.pan.y = this.panning.panY + (e.global.y - this.panning.startY); + this.applyView(); + return; + } + const local = this.toLocal(e.global); + const g = this.screenToGraph(local.x, local.y); + if (this.dragging) { + const dx = g.x - this.dragging.pointer.x; + const dy = g.y - this.dragging.pointer.y; + if (!this.dragging.moved && Math.hypot(dx, dy) * this.zoom < 3) return; + this.dragging.moved = true; + const node = this.asset()?.graph.nodes.find((n) => n.id === this.dragging?.nodeId); + if (!node) return; + node.x = Math.round(this.dragging.start.x + dx); + node.y = Math.round(this.dragging.start.y + dy); + this.state.preview(); + } else if (this.connecting) { + this.connecting.pointer = g; + this.redraw(); + } + } + + private onPointerUp(e: FederatedPointerEvent): void { + this.panning = null; + const drag = this.dragging; + this.dragging = null; + if (drag && this.assetId) { + const node = this.asset()?.graph.nodes.find((n) => n.id === drag.nodeId); + if (node && drag.moved) { + // The preview moved the node already; put it back so the command records the start. + const next = { x: node.x, y: node.y }; + node.x = drag.start.x; + node.y = drag.start.y; + this.state.execute(new MoveNodeCommand(this.assetId, drag.nodeId, next.x, next.y)); + } + } + const connecting = this.connecting; + this.connecting = null; + if (connecting && this.assetId) { + const target = this.portUnder(e.global.x, e.global.y); + if (target && target.isOutput !== connecting.from.isOutput) { + const out = connecting.from.isOutput ? connecting.from : target; + const inp = connecting.from.isOutput ? target : connecting.from; + this.state.execute( + new ConnectCommand( + this.assetId, + { node: out.nodeId, port: out.port }, + { node: inp.nodeId, port: inp.port }, + ), + ); + } + } + this.redraw(); + } + + private portUnder(globalX: number, globalY: number): PortAnchor | null { + for (const view of this.views.values()) { + const local = view.toLocal({ x: globalX, y: globalY }); + const hit = view.portAt(local.x, local.y); + if (hit) return { nodeId: view.node.id, port: hit.port.key, isOutput: hit.isOutput }; + } + return null; + } + + // --- menus ------------------------------------------------------------------------------- + + private openPalette(globalX: number, globalY: number, at: { x: number; y: number }): void { + new NodePalette((def: NodeDef) => this.addNode(def, at)).open(globalX, globalY); + } + + private addNode(def: NodeDef, at: { x: number; y: number }): void { + if (!this.assetId) return; + const command = new AddNodeCommand(this.assetId, def.type, Math.round(at.x), Math.round(at.y)); + if (this.state.execute(command)) + this.state.select({ kind: 'node', assetId: this.assetId, nodeId: command.node.id }); + } + + private openNodeMenu(view: NodeView, globalX: number, globalY: number): void { + const assetId = this.assetId; + if (!assetId) return; + this.state.select({ kind: 'node', assetId, nodeId: view.node.id }); + new Menu([ + { + label: 'Delete node', + danger: true, + onSelect: () => this.state.execute(new RemoveNodeCommand(assetId, view.node.id)), + }, + ]).open(globalX, globalY); + } + + private openPortMenu( + view: NodeView, + port: string, + isOutput: boolean, + globalX: number, + globalY: number, + ): void { + const asset = this.asset(); + if (!asset) return; + const edges = isOutput + ? edgesOutOf(asset.graph, view.node.id, port) + : edgesInto(asset.graph, view.node.id, port); + if (edges.length === 0) return; + new Menu([ + { + label: edges.length === 1 ? 'Disconnect' : `Disconnect ${edges.length} links`, + danger: true, + onSelect: () => { + for (const edge of edges) this.state.execute(new DisconnectCommand(asset.id, edge.id)); + }, + }, + ]).open(globalX, globalY); + } + + // --- drawing ----------------------------------------------------------------------------- + + override layout(): void { + this.hint.position.set(Theme.padding, this.h - Theme.padding - 16); + if (this.needsFit) this.fitView(); + } + + override redraw(): void { + const c = Theme.colors; + this.bg.clear().rect(0, 0, this.w, this.h).fill(c.graphBackground); + this.clip.clear().rect(0, 0, this.w, this.h).fill(0xffffff); + this.syncGrid(); + const asset = this.asset(); + this.canvas.visible = asset !== null; + if (!asset) { + this.hint.text = 'No behavior selected – pick one above, or add a behavior asset.'; + return; + } + const playing = this.state.mode === 'play'; + // The header names the behavior; the hint only says what the canvas itself is up to. + this.hint.text = asset.graph.nodes.length === 0 ? 'Right-click to add a node' : playing ? 'live' : ''; + + const trace = playing ? this.traceProvider(asset.id) : null; + const now = this.timeProvider(); + const isHot = (map: Map | undefined, id: string) => { + const t = map?.get(id); + return t !== undefined && now - t <= TRACE_FADE; + }; + const selectedNode = this.state.selection?.kind === 'node' ? this.state.selection.nodeId : null; + for (const view of this.views.values()) { + view.selected = view.node.id === selectedNode; + view.hot = isHot(trace?.nodes, view.node.id); + view.redraw(); + } + + this.edges.clear(); + for (const edge of asset.graph.edges) this.drawEdge(edge, isHot(trace?.edges, edge.id)); + + this.tempEdge.clear(); + if (this.connecting) { + const view = this.views.get(this.connecting.from.nodeId); + const p = view?.portPosition(this.connecting.from.port, this.connecting.from.isOutput); + if (view && p) { + const a = { x: view.x + p.x, y: view.y + p.y }; + const b = this.connecting.pointer; + const [from, to] = this.connecting.from.isOutput ? [a, b] : [b, a]; + bezier(this.tempEdge, from.x, from.y, to.x, to.y); + this.tempEdge.stroke({ width: 2, color: c.textMuted, alpha: 0.9 }); + } + } + } + + private drawEdge(edge: GraphEdge, hot: boolean): void { + const from = this.views.get(edge.from.node); + const to = this.views.get(edge.to.node); + const a = from?.portPosition(edge.from.port, true); + const b = to?.portPosition(edge.to.port, false); + if (!from || !to || !a || !b) return; + const type = from.def.outputs.find((p) => p.key === edge.from.port)?.type ?? 'any'; + const inType = to.def.inputs.find((p) => p.key === edge.to.port)?.type ?? 'any'; + const valid = portsCompatible(type, inType); + bezier(this.edges, from.x + a.x, from.y + a.y, to.x + b.x, to.y + b.y); + this.edges.stroke({ + width: hot ? 4 : type === 'exec' ? 2.5 : 2, + color: hot ? Theme.colors.hot : valid ? portColor(type) : Theme.colors.danger, + alpha: type === 'exec' || hot ? 1 : 0.85, + }); + } + + private syncGrid(): void { + this.grid.clear(); + const step = GRID_STEP * this.zoom; + if (step < MIN_GRID_STEP) return; + // `pan` is where the graph origin sits on screen – but the graph has no origin worth + // marking, so no axis lines. + drawGrid(this.grid, { + originX: this.pan.x, + originY: this.pan.y, + step, + w: this.w, + h: this.h, + alpha: 0.04, + }); + } + + // --- test hooks ----------------------------------------------------------------------------- + + /** Global screen position of a port (tests drive connections with it). */ + portGlobal(nodeId: string, port: string, isOutput: boolean): { x: number; y: number } | null { + const view = this.views.get(nodeId); + const p = view?.portPosition(port, isOutput); + if (!view || !p) return null; + const g = view.toGlobal({ x: p.x, y: p.y }); + return { x: g.x, y: g.y }; + } + + /** Global screen position of a node's title bar centre. */ + nodeGlobal(nodeId: string): { x: number; y: number } | null { + const view = this.views.get(nodeId); + if (!view) return null; + const g = view.toGlobal({ x: view.w / 2, y: TITLE_HEIGHT / 2 }); + return { x: g.x, y: g.y }; + } +} + +/** Horizontal S-curve between two points, as used by every node editor. */ +function bezier(g: Graphics, x1: number, y1: number, x2: number, y2: number): void { + const dx = Math.max(40, Math.abs(x2 - x1) * 0.5); + g.moveTo(x1, y1).bezierCurveTo(x1 + dx, y1, x2 - dx, y2, x2, y2); +} diff --git a/src/editor/graph/GraphEditor.ts b/src/editor/graph/GraphEditor.ts index 4bbdbae1..5098024a 100644 --- a/src/editor/graph/GraphEditor.ts +++ b/src/editor/graph/GraphEditor.ts @@ -1,474 +1,116 @@ -import { Container, type FederatedPointerEvent, type FederatedWheelEvent, Graphics, Text } from 'pixi.js'; -import { - AddNodeCommand, - ConnectCommand, - DisconnectCommand, - MoveNodeCommand, - RemoveNodeCommand, -} from '../../core/commands/graphCommands'; -import { - edgesInto, - edgesOutOf, - type GraphEdge, - type NodeDef, - nodeDef, - portsCompatible, -} from '../../core/graph'; +import { Graphics } from 'pixi.js'; import type { BehaviorAsset } from '../../core/model/Asset'; import { findAsset } from '../../core/model/Project'; -import { type Trace, TRACE_FADE } from '../../runtime/systems/BehaviorSystem'; -import { clamp, drawGrid, Menu, type Size, Theme, textStyle, Widget, zoomFactor } from '../../ui'; +import { Button, Dropdown, HStack, Label, Menu, type MenuItem, Spacer, Theme, VStack } from '../../ui'; import type { EditorState } from '../state'; -import { NodePalette } from './NodePalette'; -import { NODE_WIDTH, NodeView, portColor, TITLE_HEIGHT } from './NodeView'; +import { GraphCanvas } from './GraphCanvas'; -const GRID_STEP = 24; -/** Below this on-screen spacing the grid is noise rather than orientation, so it is dropped. */ -const MIN_GRID_STEP = 8; -const MIN_ZOOM = 0.25; -const MAX_ZOOM = 2.5; -/** Stand-in node height for centring the view – real nodes vary with their port count. */ -const NODE_HEIGHT_ESTIMATE = 80; - -interface PortAnchor { - nodeId: string; - port: string; - isOutput: boolean; -} +/** Room kept for the "N nodes · M links" readout, wide enough for a three-digit graph. */ +const INFO_WIDTH = 150; /** - * The node graph editor for one behavior asset. Shows the graph of whatever behavior the - * selection refers to (asset, placed object or node). When nothing or a non-behavior is - * selected the editor is empty – the graph strictly mirrors the selection. + * The Graph tab: a picker for which behavior is being worked on, the actions that act on the + * whole graph, and the canvas the nodes are drawn on. * - * Interactions: drag nodes, drag from port to port to connect, right-click the canvas for - * the node palette, right-click a node or port for delete/disconnect, wheel to zoom, drag - * the empty canvas to pan. All edits go through commands (undoable). + * It follows the selection – selecting a behavior asset, a placed behavior or one of its nodes + * shows that graph – but it keeps showing the last behavior when the selection moves elsewhere. + * Otherwise clicking a sprite to check its position would empty the graph you are editing. */ -export class GraphEditor extends Widget { - private readonly bg = new Graphics(); - private readonly grid = new Graphics(); - private readonly clip = new Graphics(); - private readonly canvas = new Container(); - private readonly edges = new Graphics(); - private readonly nodeLayer = new Container(); - private readonly tempEdge = new Graphics(); - private readonly hint = new Text({ text: '', style: textStyle('muted') }); - private readonly views = new Map(); - private assetId: string | null = null; - private pan = { x: 0, y: 0 }; - private zoom = 1; - private panning: { startX: number; startY: number; panX: number; panY: number } | null = null; - private dragging: { - nodeId: string; - start: { x: number; y: number }; - pointer: { x: number; y: number }; - moved: boolean; - } | null = null; - private connecting: { from: PortAnchor; pointer: { x: number; y: number } } | null = null; - /** Fit the view once the editor has a real size (it may be hidden behind the Scene tab). */ - private needsFit = true; - /** Supplies live execution data while playing (null otherwise). */ - traceProvider: (assetId: string) => Trace | null = () => null; - timeProvider: () => number = () => 0; +export class GraphEditor extends VStack { + /** The canvas itself: the live redraw while playing and the check scripts reach for it. */ + readonly canvas: GraphCanvas; + private readonly picker: Dropdown; + private readonly info: Label; + private readonly addButton: Button; + private readonly headerBg = new Graphics(); constructor(private readonly state: EditorState) { super(); - this.canvas.addChild(this.edges, this.nodeLayer, this.tempEdge); - this.addChild(this.bg, this.grid, this.canvas, this.hint, this.clip); - this.canvas.mask = this.clip; - this.cursor = 'default'; + const header = new HStack({ gap: Theme.gap, padding: Theme.padding, align: 'center' }); + header.minHeight = Theme.controlHeight + Theme.padding * 2; + header.addChildAt(this.headerBg, 0); - this.on('pointerdown', (e: FederatedPointerEvent) => this.onCanvasPointerDown(e)); - this.on('globalpointermove', (e: FederatedPointerEvent) => this.onPointerMove(e)); - this.on('pointerup', (e: FederatedPointerEvent) => this.onPointerUp(e)); - this.on('pointerupoutside', (e: FederatedPointerEvent) => this.onPointerUp(e)); - this.on('wheel', (e: FederatedWheelEvent) => { - const local = this.toLocal(e.global); - const before = this.screenToGraph(local.x, local.y); - this.zoom = clamp(this.zoom * zoomFactor(e.deltaY), MIN_ZOOM, MAX_ZOOM); - const after = this.screenToGraph(local.x, local.y); - this.pan.x += (after.x - before.x) * this.zoom; - this.pan.y += (after.y - before.y) * this.zoom; - this.applyView(); - e.preventDefault(); - }); + header.add(new Label('Behavior', { style: 'muted' })); + this.picker = new Dropdown('—', { width: 200, onClick: () => this.openPicker() }); + header.add(this.picker); + this.addButton = new Button('Add node…', { onClick: () => this.canvas.addNodeFrom(this.addButton) }); + header.add(this.addButton); + header.add(new Button('Fit', { onClick: () => this.canvas.fit() })); + header.add(new Spacer(), 1); + this.info = new Label('', { style: 'small', align: 'right' }); + // Reserved rather than measured: the label is built empty, and a stack sizes it once from + // the text it had at the time – so without a floor the readout is clipped from then on. + this.info.minWidth = INFO_WIDTH; + header.add(this.info); + + this.canvas = new GraphCanvas(state); + this.add(header); + this.add(this.canvas, 1); state.on('selection', () => this.followSelection()); + state.on('project', () => this.refresh()); + // Adding nodes is an edit, and edits made while playing would be thrown away with the run. + state.on('mode', () => this.refresh()); state.on('load', () => { - this.assetId = null; + this.canvas.show(null); this.followSelection(); }); - state.on('project', () => this.sync()); - state.on('mode', () => this.redraw()); + this.followSelection(); } - /** The behavior whose graph is shown, if any. */ + /** The behavior currently shown – the check scripts read this. */ get currentAssetId(): string | null { - return this.assetId; - } - - override measure(): Size { - return { w: Math.max(this.minWidth, 200), h: Math.max(this.minHeight, 200) }; - } - - // --- model binding ---------------------------------------------------------------------- - - private asset(): BehaviorAsset | null { - const asset = this.assetId ? findAsset(this.state.project, this.assetId) : undefined; - return asset && asset.type === 'behavior' ? asset : null; + return this.canvas.currentAssetId; } private followSelection(): void { - const wanted = this.state.selectedBehaviorId; - if (wanted && wanted !== this.assetId) { - this.assetId = wanted; - this.sync(); - this.needsFit = true; - this.fitView(); - } else if (!wanted || !this.asset()) { - this.assetId = null; - this.sync(); - } else { - this.redraw(); - } + const id = this.state.selectedBehaviorId; + // Null merely means "the selection is about something else"; the tab keeps its subject. + if (id) this.canvas.show(id); + this.refresh(); } - /** Rebuilds node views from the model (cheap: views are reused by node id). */ - private sync(): void { + private asset(): BehaviorAsset | null { + const id = this.canvas.currentAssetId; + const asset = id ? findAsset(this.state.project, id) : undefined; + return asset && asset.type === 'behavior' ? asset : null; + } + + private behaviors(): BehaviorAsset[] { + return this.state.project.assets + .filter((a): a is BehaviorAsset => a.type === 'behavior') + .sort((a, b) => a.name.localeCompare(b.name)); + } + + private openPicker(): void { + const current = this.canvas.currentAssetId; + const items: MenuItem[] = this.behaviors().map((asset) => ({ + label: asset.id === current ? `● ${asset.name}` : asset.name, + onSelect: () => this.state.select({ kind: 'asset', assetId: asset.id }), + })); + if (items.length === 0) items.push({ label: 'No behaviors yet', onSelect: () => undefined }); + new Menu(items, { searchable: true }).openBelow(this.picker); + } + + private refresh(): void { + // A deleted behavior leaves the canvas empty; rather than showing nothing at all the tab + // falls back to whatever behavior is left, the way it starts out on the first one. + if (!this.asset()) this.canvas.show(this.behaviors()[0]?.id ?? null); const asset = this.asset(); - if (!asset) { - this.assetId = null; - for (const view of this.views.values()) view.destroy({ children: true }); - this.views.clear(); - this.redraw(); - return; - } - const seen = new Set(); - for (const node of asset.graph.nodes) { - const def = nodeDef(node.type); - if (!def) continue; - seen.add(node.id); - let view = this.views.get(node.id); - if (!view || view.node !== node) { - view?.destroy({ children: true }); - view = new NodeView(node, def); - view.on('pointerdown', (e: FederatedPointerEvent) => this.onNodePointerDown(view!, e)); - this.views.set(node.id, view); - this.nodeLayer.addChild(view); - } - view.sync(node, asset.graph, asset, this.state.project); - } - for (const [id, view] of this.views) { - if (!seen.has(id)) { - view.destroy({ children: true }); - this.views.delete(id); - } - } - this.redraw(); - } - - /** Centres the graph's nodes (or the origin) in the view. Waits for a real size. */ - private fitView(): void { - if (this.w === 0 || this.h === 0) return; - this.needsFit = false; - const asset = this.asset(); - if (!asset || asset.graph.nodes.length === 0) { - this.pan = { x: this.w / 2 - NODE_WIDTH / 2, y: this.h / 3 }; - this.zoom = 1; - } else { - const xs = asset.graph.nodes.map((n) => n.x); - const ys = asset.graph.nodes.map((n) => n.y); - const cx = (Math.min(...xs) + Math.max(...xs) + NODE_WIDTH) / 2; - const cy = (Math.min(...ys) + Math.max(...ys) + NODE_HEIGHT_ESTIMATE) / 2; - this.zoom = 1; - this.pan = { x: this.w / 2 - cx, y: this.h / 2 - cy }; - } - this.applyView(); - } - - private applyView(): void { - this.canvas.position.set(Math.round(this.pan.x), Math.round(this.pan.y)); - this.canvas.scale.set(this.zoom); - this.redraw(); - } - - private screenToGraph(sx: number, sy: number): { x: number; y: number } { - return { x: (sx - this.pan.x) / this.zoom, y: (sy - this.pan.y) / this.zoom }; - } - - // --- pointer ----------------------------------------------------------------------------- - - private onCanvasPointerDown(e: FederatedPointerEvent): void { - if (e.target !== this) return; - const local = this.toLocal(e.global); - if (e.button === 2) { - if (this.assetId && this.state.mode === 'edit') - this.openPalette(e.global.x, e.global.y, this.screenToGraph(local.x, local.y)); - return; - } - if (e.button === 0 || e.button === 1) { - if (e.button === 0 && this.assetId && this.state.mode === 'edit') - this.state.select({ kind: 'asset', assetId: this.assetId }); - this.panning = { startX: e.global.x, startY: e.global.y, panX: this.pan.x, panY: this.pan.y }; - } - } - - private onNodePointerDown(view: NodeView, e: FederatedPointerEvent): void { - if (!this.assetId || this.state.mode !== 'edit') return; - e.stopPropagation(); - const local = view.toLocal(e.global); - const hit = view.portAt(local.x, local.y); - if (e.button === 2) { - if (hit) this.openPortMenu(view, hit.port.key, hit.isOutput, e.global.x, e.global.y); - else this.openNodeMenu(view, e.global.x, e.global.y); - return; - } - if (e.button !== 0) return; - if (hit) { - const p = this.toLocal(e.global); - this.connecting = { - from: { nodeId: view.node.id, port: hit.port.key, isOutput: hit.isOutput }, - pointer: this.screenToGraph(p.x, p.y), - }; - this.redraw(); - return; - } - this.state.select({ kind: 'node', assetId: this.assetId, nodeId: view.node.id }); - const p = this.toLocal(e.global); - this.dragging = { - nodeId: view.node.id, - start: { x: view.node.x, y: view.node.y }, - pointer: this.screenToGraph(p.x, p.y), - moved: false, - }; - this.nodeLayer.addChild(view); // raise - } - - private onPointerMove(e: FederatedPointerEvent): void { - if (this.panning) { - this.pan.x = this.panning.panX + (e.global.x - this.panning.startX); - this.pan.y = this.panning.panY + (e.global.y - this.panning.startY); - this.applyView(); - return; - } - const local = this.toLocal(e.global); - const g = this.screenToGraph(local.x, local.y); - if (this.dragging) { - const dx = g.x - this.dragging.pointer.x; - const dy = g.y - this.dragging.pointer.y; - if (!this.dragging.moved && Math.hypot(dx, dy) * this.zoom < 3) return; - this.dragging.moved = true; - const node = this.asset()?.graph.nodes.find((n) => n.id === this.dragging?.nodeId); - if (!node) return; - node.x = Math.round(this.dragging.start.x + dx); - node.y = Math.round(this.dragging.start.y + dy); - this.state.preview(); - } else if (this.connecting) { - this.connecting.pointer = g; - this.redraw(); - } - } - - private onPointerUp(e: FederatedPointerEvent): void { - this.panning = null; - const drag = this.dragging; - this.dragging = null; - if (drag && this.assetId) { - const node = this.asset()?.graph.nodes.find((n) => n.id === drag.nodeId); - if (node && drag.moved) { - // The preview moved the node already; put it back so the command records the start. - const next = { x: node.x, y: node.y }; - node.x = drag.start.x; - node.y = drag.start.y; - this.state.execute(new MoveNodeCommand(this.assetId, drag.nodeId, next.x, next.y)); - } - } - const connecting = this.connecting; - this.connecting = null; - if (connecting && this.assetId) { - const target = this.portUnder(e.global.x, e.global.y); - if (target && target.isOutput !== connecting.from.isOutput) { - const out = connecting.from.isOutput ? connecting.from : target; - const inp = connecting.from.isOutput ? target : connecting.from; - this.state.execute( - new ConnectCommand( - this.assetId, - { node: out.nodeId, port: out.port }, - { node: inp.nodeId, port: inp.port }, - ), - ); - } - } - this.redraw(); - } - - private portUnder(globalX: number, globalY: number): PortAnchor | null { - for (const view of this.views.values()) { - const local = view.toLocal({ x: globalX, y: globalY }); - const hit = view.portAt(local.x, local.y); - if (hit) return { nodeId: view.node.id, port: hit.port.key, isOutput: hit.isOutput }; - } - return null; - } - - // --- menus ------------------------------------------------------------------------------- - - private openPalette(globalX: number, globalY: number, at: { x: number; y: number }): void { - new NodePalette((def: NodeDef) => { - if (!this.assetId) return; - const command = new AddNodeCommand(this.assetId, def.type, at.x, at.y); - if (this.state.execute(command)) - this.state.select({ kind: 'node', assetId: this.assetId, nodeId: command.node.id }); - }).open(globalX, globalY); - } - - private openNodeMenu(view: NodeView, globalX: number, globalY: number): void { - const assetId = this.assetId; - if (!assetId) return; - this.state.select({ kind: 'node', assetId, nodeId: view.node.id }); - new Menu([ - { - label: 'Delete node', - danger: true, - onSelect: () => this.state.execute(new RemoveNodeCommand(assetId, view.node.id)), - }, - ]).open(globalX, globalY); - } - - private openPortMenu( - view: NodeView, - port: string, - isOutput: boolean, - globalX: number, - globalY: number, - ): void { - const asset = this.asset(); - if (!asset) return; - const edges = isOutput - ? edgesOutOf(asset.graph, view.node.id, port) - : edgesInto(asset.graph, view.node.id, port); - if (edges.length === 0) return; - new Menu([ - { - label: edges.length === 1 ? 'Disconnect' : `Disconnect ${edges.length} links`, - danger: true, - onSelect: () => { - for (const edge of edges) this.state.execute(new DisconnectCommand(asset.id, edge.id)); - }, - }, - ]).open(globalX, globalY); - } - - // --- drawing ----------------------------------------------------------------------------- - - override layout(): void { - this.hint.position.set(Theme.padding, this.h - Theme.padding - 16); - if (this.needsFit) this.fitView(); + this.picker.setText(asset ? asset.name : 'No behavior'); + const nodes = asset?.graph.nodes.length ?? 0; + const links = asset?.graph.edges.length ?? 0; + this.info.setText( + asset ? `${nodes} ${nodes === 1 ? 'node' : 'nodes'} · ${links} ${links === 1 ? 'link' : 'links'}` : '', + ); + this.addButton.setEnabled(Boolean(asset) && this.state.mode === 'edit'); } override redraw(): void { - const c = Theme.colors; - this.bg.clear().rect(0, 0, this.w, this.h).fill(c.graphBackground); - this.clip.clear().rect(0, 0, this.w, this.h).fill(0xffffff); - this.syncGrid(); - const asset = this.asset(); - this.canvas.visible = asset !== null; - if (!asset) { - this.hint.text = 'Select a behavior asset (or a placed behavior) to edit its graph.'; - return; - } - const playing = this.state.mode === 'play'; - this.hint.text = - asset.graph.nodes.length === 0 - ? `${asset.name} · right-click to add a node` - : `${asset.name}${playing ? ' · live' : ''}`; - - const trace = playing ? this.traceProvider(asset.id) : null; - const now = this.timeProvider(); - const isHot = (map: Map | undefined, id: string) => { - const t = map?.get(id); - return t !== undefined && now - t <= TRACE_FADE; - }; - const selectedNode = this.state.selection?.kind === 'node' ? this.state.selection.nodeId : null; - for (const view of this.views.values()) { - view.selected = view.node.id === selectedNode; - view.hot = isHot(trace?.nodes, view.node.id); - view.redraw(); - } - - this.edges.clear(); - for (const edge of asset.graph.edges) this.drawEdge(edge, isHot(trace?.edges, edge.id)); - - this.tempEdge.clear(); - if (this.connecting) { - const view = this.views.get(this.connecting.from.nodeId); - const p = view?.portPosition(this.connecting.from.port, this.connecting.from.isOutput); - if (view && p) { - const a = { x: view.x + p.x, y: view.y + p.y }; - const b = this.connecting.pointer; - const [from, to] = this.connecting.from.isOutput ? [a, b] : [b, a]; - bezier(this.tempEdge, from.x, from.y, to.x, to.y); - this.tempEdge.stroke({ width: 2, color: c.textMuted, alpha: 0.9 }); - } - } - } - - private drawEdge(edge: GraphEdge, hot: boolean): void { - const from = this.views.get(edge.from.node); - const to = this.views.get(edge.to.node); - const a = from?.portPosition(edge.from.port, true); - const b = to?.portPosition(edge.to.port, false); - if (!from || !to || !a || !b) return; - const type = from.def.outputs.find((p) => p.key === edge.from.port)?.type ?? 'any'; - const inType = to.def.inputs.find((p) => p.key === edge.to.port)?.type ?? 'any'; - const valid = portsCompatible(type, inType); - bezier(this.edges, from.x + a.x, from.y + a.y, to.x + b.x, to.y + b.y); - this.edges.stroke({ - width: hot ? 4 : type === 'exec' ? 2.5 : 2, - color: hot ? Theme.colors.hot : valid ? portColor(type) : Theme.colors.danger, - alpha: type === 'exec' || hot ? 1 : 0.85, - }); - } - - private syncGrid(): void { - this.grid.clear(); - const step = GRID_STEP * this.zoom; - if (step < MIN_GRID_STEP) return; - // `pan` is where the graph origin sits on screen – but the graph has no origin worth - // marking, so no axis lines. - drawGrid(this.grid, { - originX: this.pan.x, - originY: this.pan.y, - step, - w: this.w, - h: this.h, - alpha: 0.04, - }); - } - - // --- test hooks ----------------------------------------------------------------------------- - - /** Global screen position of a port (tests drive connections with it). */ - portGlobal(nodeId: string, port: string, isOutput: boolean): { x: number; y: number } | null { - const view = this.views.get(nodeId); - const p = view?.portPosition(port, isOutput); - if (!view || !p) return null; - const g = view.toGlobal({ x: p.x, y: p.y }); - return { x: g.x, y: g.y }; - } - - /** Global screen position of a node's title bar centre. */ - nodeGlobal(nodeId: string): { x: number; y: number } | null { - const view = this.views.get(nodeId); - if (!view) return null; - const g = view.toGlobal({ x: view.w / 2, y: TITLE_HEIGHT / 2 }); - return { x: g.x, y: g.y }; + super.redraw(); + this.headerBg.clear(); + const h = Theme.controlHeight + Theme.padding * 2; + this.headerBg.rect(0, 0, this.w, h).fill(Theme.colors.panelHeader); + this.headerBg.rect(0, h - 1, this.w, 1).fill(Theme.colors.border); } } - -/** Horizontal S-curve between two points, as used by every node editor. */ -function bezier(g: Graphics, x1: number, y1: number, x2: number, y2: number): void { - const dx = Math.max(40, Math.abs(x2 - x1) * 0.5); - g.moveTo(x1, y1).bezierCurveTo(x1 + dx, y1, x2 - dx, y2, x2, y2); -} diff --git a/src/editor/graph/NodeView.ts b/src/editor/graph/NodeView.ts index aae20d40..a051887f 100644 --- a/src/editor/graph/NodeView.ts +++ b/src/editor/graph/NodeView.ts @@ -1,4 +1,4 @@ -import { CanvasTextMetrics, Graphics, Rectangle, Text } from 'pixi.js'; +import { Graphics, Rectangle, Text } from 'pixi.js'; import { asString, edgesInto, @@ -17,7 +17,7 @@ import type { Project } from '../../core/model/Project'; import type { PropertyValue } from '../../core/model/Properties'; import { assetRefLabel } from '../../core/properties/assetRef'; import type { PropertyDef } from '../../core/properties/PropertySchema'; -import { formatNumber, Theme, textStyle, type TextStyleToken, Widget } from '../../ui'; +import { formatNumber, Theme, textStyle, type TextStyleToken, truncate, Widget } from '../../ui'; import { keyLabel } from '../inspector/fieldFactory'; /** @@ -75,7 +75,7 @@ export interface PortHit { /** * Draws one graph node: a title bar coloured by category, a line per setting and a row per * port pair (inputs left, outputs right). Everything is derived from the NodeDef; the view - * never interprets what a node does. Positioned in graph coordinates by the GraphEditor. + * never interprets what a node does. Positioned in graph coordinates by the GraphCanvas. */ export class NodeView extends Widget { private readonly box = new Graphics(); @@ -166,7 +166,7 @@ export class NodeView extends Widget { align: 'left' | 'right' = 'left', maxWidth = NODE_WIDTH - PAD * 2, ) => { - const node = new Text({ text: fit(text, style, maxWidth), style: textStyle(style) }); + const node = new Text({ text: truncate(text, textStyle(style), maxWidth), style: textStyle(style) }); node.position.set(align === 'left' ? x : x - node.width, y); this.addChild(node); this.texts.push(node); @@ -270,13 +270,3 @@ function formatValue(value: Value): string { if (typeof value === 'boolean') return value ? 'true' : 'false'; return asString(value); } - -/** Truncates with an ellipsis so the text fits `maxWidth`. */ -function fit(text: string, style: TextStyleToken, maxWidth: number): string { - const s = textStyle(style); - if (CanvasTextMetrics.measureText(text, s).width <= maxWidth) return text; - let cut = text; - while (cut.length > 1 && CanvasTextMetrics.measureText(cut + '…', s).width > maxWidth) - cut = cut.slice(0, -1); - return cut + '…'; -} diff --git a/src/editor/inspector/fieldFactory.ts b/src/editor/inspector/fieldFactory.ts index d73420c4..98037bc9 100644 --- a/src/editor/inspector/fieldFactory.ts +++ b/src/editor/inspector/fieldFactory.ts @@ -168,6 +168,7 @@ export function createField( min: def.min, max: def.max, step: def.step ?? 1, + decimals: def.decimals, onInput: handlers.onInput, onChange: handlers.onChange, }); diff --git a/src/editor/inspector/frameSection.ts b/src/editor/inspector/frameSection.ts index fc5ab01c..979207e9 100644 --- a/src/editor/inspector/frameSection.ts +++ b/src/editor/inspector/frameSection.ts @@ -81,7 +81,9 @@ function rectRow( ): void { const row = createFieldRow( host, - { key, label, kind: 'number', default: 0, step: 1 }, + // `decimals: 0` is honest here and only here in the inspector: `normalizeFrameRect` rounds + // every value that reaches the model, so the field never shortens a number it is holding. + { key, label, kind: 'number', default: 0, step: 1, decimals: 0 }, value, rectHandlers(host, assetId, frameId, key), ); diff --git a/src/editor/texture/SliceDialog.ts b/src/editor/texture/SliceDialog.ts index a3db8228..ab07327b 100644 --- a/src/editor/texture/SliceDialog.ts +++ b/src/editor/texture/SliceDialog.ts @@ -90,6 +90,8 @@ export class SliceDialog extends Popup { const field = new NumberField(this.options[key], { min, step: 1, + // Counts of cells and pixel offsets; the value is rounded on change, so 0 hides nothing. + decimals: 0, onChange: (value) => { this.options[key] = Math.round(Number(value)); this.refreshSummary(); diff --git a/src/ui/index.ts b/src/ui/index.ts index c9c3c8e0..1a71a731 100644 --- a/src/ui/index.ts +++ b/src/ui/index.ts @@ -4,7 +4,7 @@ export { Theme, textStyle, type TextStyleToken } from './Theme'; export { initUI, ui, type UIContext } from './UIContext'; export { drawControlBox, drawGrid, type ControlBoxOptions, type GridOptions } from './draw'; export { clamp, centreY, formatNumber, NUMBER_DECIMALS, snapToStep, zoomFactor } from './math'; -export { Label } from './widgets/Label'; +export { Label, truncate } from './widgets/Label'; export { Button, type ButtonOptions } from './widgets/Button'; export { Dropdown } from './widgets/Dropdown'; export { Checkbox } from './widgets/Checkbox'; diff --git a/src/ui/math.ts b/src/ui/math.ts index 1919fb8b..724fdc01 100644 --- a/src/ui/math.ts +++ b/src/ui/math.ts @@ -8,13 +8,14 @@ export function centreY(containerHeight: number, itemHeight: number): number { return Math.round((containerHeight - itemHeight) / 2); } -/** Decimal places every number field shows. */ +/** Decimal places a number field shows unless it asks for its own (`NumberFieldOptions.decimals`). */ export const NUMBER_DECIMALS = 3; /** * Display text for a number. Always shows `decimals` places, so a value can never *look* like * a whole number when it is not: precision used to follow the field's step, which hid the - * decimals that dragging in the viewport or a graph node had put into the value. + * decimals that dragging in the viewport or a graph node had put into the value. Fields whose + * value is integral by nature (a pixel index, a count) pass 0 and are exact all the same. * A value that rounds to zero never carries a minus sign. */ export function formatNumber(value: number, decimals = NUMBER_DECIMALS): string { diff --git a/src/ui/widgets/Button.ts b/src/ui/widgets/Button.ts index f7ad8fb5..66c1793b 100644 --- a/src/ui/widgets/Button.ts +++ b/src/ui/widgets/Button.ts @@ -3,6 +3,7 @@ import { drawControlBox } from '../draw'; import { centreY } from '../math'; import { Theme, textStyle } from '../Theme'; import { type Size, Widget } from '../Widget'; +import { truncate } from './Label'; export interface ButtonOptions { /** Visual weight: 'primary' uses the accent color, 'ghost' has no background until hovered. */ @@ -21,6 +22,8 @@ export interface ButtonOptions { export class Button extends Widget { private readonly bg = new Graphics(); private readonly labelText: Text; + /** The label as set. `labelText` holds the possibly shortened version that is on screen. */ + private fullText: string; private pressed = false; /** Caller-owned toggle state: renders like a primary button but is not flipped by clicks. */ toggled = false; @@ -37,6 +40,7 @@ export class Button extends Widget { this.cursor = 'pointer'; this.minHeight = Theme.controlHeight; if (options.width) this.minWidth = options.width; + this.fullText = text; this.labelText = new Text({ text, style: textStyle(this.variant === 'primary' ? 'accent' : 'body') }); this.addChild(this.bg, this.labelText); @@ -61,13 +65,15 @@ export class Button extends Widget { }); } + /** The full label, not the truncated version on screen. */ get text(): string { - return this.labelText.text; + return this.fullText; } /** Repaints only; the parent must relayout when the measured width matters. */ setText(text: string): void { - this.labelText.text = text; + if (text === this.fullText) return; + this.fullText = text; this.invalidate(); } @@ -83,13 +89,23 @@ export class Button extends Widget { } override measure(): Size { - const metrics = CanvasTextMetrics.measureText(this.labelText.text, this.labelText.style); + // Measured from the full label, never from what is currently on screen: measuring the + // truncated text would let a button that was once too narrow stay too narrow for good. + const metrics = CanvasTextMetrics.measureText(this.fullText, this.labelText.style); return { w: Math.max(this.minWidth, Math.ceil(metrics.width) + Theme.padding * 2), h: Math.max(this.minHeight, Theme.controlHeight), }; } + /** + * Width the label may occupy. Subclasses that draw something else inside the button take + * their room off here, so the text is shortened instead of running underneath it. + */ + protected labelSlot(): number { + return this.w - Theme.padding * 2; + } + override onKeyDown(event: KeyboardEvent): boolean { if (event.key === 'Enter' || event.key === ' ') { this.onClick?.(); @@ -131,8 +147,10 @@ export class Button extends Widget { } else if (stroke !== null) { this.bg.roundRect(0.5, 0.5, this.w - 1, this.h - 1, Theme.radius).stroke({ width: 1, color: stroke }); } + // Style first: it decides the glyph widths that the truncation then measures against. this.labelText.style = textStyle(this.onAccent ? 'accent' : 'body'); const label = this.labelText; + label.text = truncate(this.fullText, label.style, this.labelSlot()); const x = this.align === 'left' ? Theme.padding : (this.w - label.width) / 2; label.position.set(Math.round(x), centreY(this.h, label.height)); } diff --git a/src/ui/widgets/Dropdown.ts b/src/ui/widgets/Dropdown.ts index e97f03ea..d1286339 100644 --- a/src/ui/widgets/Dropdown.ts +++ b/src/ui/widgets/Dropdown.ts @@ -24,6 +24,11 @@ export class Dropdown extends Button { return { w: size.w + CHEVRON_SLOT, h: size.h }; } + /** The chevron's room is not the label's: a long value is shortened, not drawn under it. */ + protected override labelSlot(): number { + return super.labelSlot() - CHEVRON_SLOT; + } + override redraw(): void { super.redraw(); const x = this.w - CHEVRON_SLOT / 2 - Theme.padding / 2; diff --git a/src/ui/widgets/NumberField.ts b/src/ui/widgets/NumberField.ts index 470afc9e..10938820 100644 --- a/src/ui/widgets/NumberField.ts +++ b/src/ui/widgets/NumberField.ts @@ -1,5 +1,5 @@ import type { FederatedPointerEvent } from 'pixi.js'; -import { clamp, formatNumber, snapToStep } from '../math'; +import { clamp, formatNumber, NUMBER_DECIMALS, snapToStep } from '../math'; import { ui } from '../UIContext'; import { TextField, type TextFieldOptions } from './TextField'; @@ -8,9 +8,16 @@ export interface NumberFieldOptions extends Omit void; /** Fires once when a scrub ends or an edit is committed. */ @@ -24,14 +31,15 @@ const SCRUB_THRESHOLD = 3; * Numeric input: click to type, drag horizontally to scrub (Shift = ×10, Alt = ×0.1), * arrow keys step. A scrub snaps to the grid of its increment – a step-1 field therefore * always ends on a whole number, even when it started fractional. Values are clamped to - * min/max and always shown with `NUMBER_DECIMALS` decimals, so the text never hides that - * the value is fractional. + * min/max and shown with `NUMBER_DECIMALS` decimals unless the field declares otherwise, + * so the text never hides that the value is fractional. */ export class NumberField extends TextField { private numeric: number; private readonly min: number; private readonly max: number; private readonly step: number; + private readonly decimals: number; private scrub: { startX: number; startValue: number; moved: boolean } | null = null; onNumberInput?: (value: number) => void; onNumberChange?: (value: number) => void; @@ -41,10 +49,11 @@ export class NumberField extends TextField { this.min = options.min ?? -Infinity; this.max = options.max ?? Infinity; this.step = options.step ?? 1; + this.decimals = options.decimals ?? NUMBER_DECIMALS; this.onNumberInput = options.onInput; this.onNumberChange = options.onChange; this.numeric = this.clampValue(value); - this.valueText = formatNumber(this.numeric); + this.valueText = this.display; this.cursor = 'ew-resize'; this.onInput = (text) => { @@ -57,7 +66,7 @@ export class NumberField extends TextField { this.onChange = (text) => { const n = Number(text.trim()); this.numeric = this.clampValue(Number.isFinite(n) && text.trim() !== '' ? n : this.numeric); - this.valueText = formatNumber(this.numeric); + this.valueText = this.display; this.invalidate(); this.onNumberChange?.(this.numeric); }; @@ -90,11 +99,16 @@ export class NumberField extends TextField { return this.numeric; } + /** The one place the value becomes text, so every path through the field honours `decimals`. */ + private get display(): string { + return formatNumber(this.numeric, this.decimals); + } + /** Model → view update: clamps, and reformats the text unless the user is editing it. */ setNumber(value: number): void { this.numeric = this.clampValue(value); if (!this.isEditing) { - this.valueText = formatNumber(this.numeric); + this.valueText = this.display; this.invalidate(); } } @@ -114,7 +128,7 @@ export class NumberField extends TextField { const dir = event.key === 'ArrowUp' ? 1 : -1; const factor = event.shiftKey ? 10 : event.altKey ? 0.1 : 1; this.numeric = this.clampValue(this.numeric + dir * this.step * factor); - this.valueText = formatNumber(this.numeric); + this.valueText = this.display; ui().keyboard.setValue(this.valueText, 0, this.valueText.length); this.selectAll(); this.onNumberInput?.(this.numeric); diff --git a/src/viewport/TextureLibrary.ts b/src/viewport/TextureLibrary.ts index 87fdd201..68dfa3ed 100644 --- a/src/viewport/TextureLibrary.ts +++ b/src/viewport/TextureLibrary.ts @@ -42,7 +42,14 @@ export class TextureLibrary { return null; } const mode = ref.smooth ? 'linear' : 'nearest'; - if (texture.source.scaleMode !== mode) texture.source.scaleMode = mode; + if (texture.source.scaleMode !== mode) { + texture.source.scaleMode = mode; + // Assigning the mode alone is not enough, and the difference is invisible until a reload: + // Pixi's `scaleMode` setter only writes mag/min/mipmapFilter and leaves the style's cached + // `_sharedResourceId` in place – but that id is the key the GPU sampler is looked up under, + // so the old sampler keeps being used. `update()` drops the id and announces the change. + texture.source.style.update(); + } return texture; } diff --git a/tests/ui/math.test.ts b/tests/ui/math.test.ts index cacda8e3..19952d39 100644 --- a/tests/ui/math.test.ts +++ b/tests/ui/math.test.ts @@ -22,7 +22,7 @@ describe('centreY', () => { }); describe('formatNumber', () => { - it('always shows three decimals', () => { + it('shows three decimals by default', () => { expect(NUMBER_DECIMALS).toBe(3); expect(formatNumber(100)).toBe('100.000'); expect(formatNumber(0.5)).toBe('0.500'); @@ -50,6 +50,15 @@ describe('formatNumber', () => { expect(formatNumber(1.25, 1)).toBe('1.3'); expect(formatNumber(1.25, 0)).toBe('1'); }); + + it('shows a field of integral values without a decimal point at all', () => { + // What `NumberFieldOptions.decimals: 0` buys: a pixel index or a count of tiles reads + // "33" and "4" instead of "33.000" and "4.000". + expect(formatNumber(33, 0)).toBe('33'); + expect(formatNumber(-4, 0)).toBe('-4'); + // The minus-zero rule holds at every precision, not just the default one. + expect(formatNumber(-0.4, 0)).toBe('0'); + }); }); describe('snapToStep', () => { diff --git a/tools/e2e/06-graph.mjs b/tools/e2e/06-graph.mjs index 40a33a1f..6d96d8f5 100644 --- a/tools/e2e/06-graph.mjs +++ b/tools/e2e/06-graph.mjs @@ -186,15 +186,40 @@ export async function graph(t) { await undo(); check('undo removes the variable', (await variableKeys()) === 'speed', await variableKeys()); - // 13h. The graph strictly mirrors the selection: nothing / a non-behavior → empty editor. + // 13h. The tab keeps its subject. A selection about something else (nothing, a camera) leaves + // the graph alone – clicking a sprite to look at it must not empty the editor you are + // working in – and the header picker switches behaviors without any selection at all. + const size = await graphCounts(); + const readout = `${size.nodes} nodes · ${size.edges} links`; + check( + 'the header reports the size of the shown graph', + Boolean(await evaluate(`__gm.locate(${JSON.stringify(readout)})`)), + readout, + ); await select(null); await sleep(100); - check('graph editor empties when nothing is selected', (await evaluate(`__gm.graph.current()`)) === null); + check( + 'graph editor keeps its behavior when nothing is selected', + (await evaluate(`__gm.graph.current()`)) === 'mover', + ); await select({ kind: 'asset', assetId: 'cam' }); await sleep(100); check( - 'graph editor stays empty for a non-behavior asset', - (await evaluate(`__gm.graph.current()`)) === null, + 'graph editor keeps its behavior for a non-behavior asset', + (await evaluate(`__gm.graph.current()`)) === 'mover', + ); + // The picker is the *last* "Mover" on screen: the assets panel shows one too, and the + // inspector is on the camera. + const pickerAt = await locate('Mover', true); + await click(pickerAt.x, pickerAt.y); + await sleep(150); + const spawnerRow = await locate('Spawner', true); + await click(spawnerRow.x, spawnerRow.y); + await sleep(200); + check( + 'the header picker switches the graph to another behavior', + (await evaluate(`__gm.graph.current()`)) === 'spawner', + String(await evaluate(`__gm.graph.current()`)), ); await select({ kind: 'asset', assetId: 'mover' }); await sleep(100); diff --git a/tools/e2e/12-textures.mjs b/tools/e2e/12-textures.mjs index 9cd0ea26..70817b8e 100644 --- a/tools/e2e/12-textures.mjs +++ b/tools/e2e/12-textures.mjs @@ -138,6 +138,56 @@ export async function textures(t) { ); await shot('32-textured-sprite'); + // 19b-i. Switching the Filter property takes effect at once, without a reload. + // + // Read off the screen rather than off `scaleMode`, because the property was always assigned + // correctly – what used to survive the change was the GPU sampler behind it. The measurement + // is what "smooth" means: across the red/green border, nearest sampling shows only the two + // pure colours, linear sampling blends them into something that is neither. + const enlarge = (w, h) => ` + const sprite = __gm.state.project.assets.find((a) => a.id === 'tex-sprite'); + sprite.props.width = ${w}; + sprite.props.height = ${h}`; + await mutate(enlarge(320, 240)); + await sleep(600); + const wide = await json("__gm.objectRect('otex')"); + // Sampled in the upper half so the line crosses only the red/green border, not the corner + // where all four quadrants meet. + const acrossBorder = async () => { + const y = Math.round(wide.y - wide.h * 0.25); + const points = []; + for (let dx = -2; dx <= 2; dx++) { + points.push(await json(`__gm.pixelAt(${Math.round(wide.x + dx)}, ${y})`)); + } + return points; + }; + const setFilter = (value) => + mutate( + `__gm.state.project.assets.find((a) => a.id === ${JSON.stringify(texture.id)}).props.filter = ${JSON.stringify(value)}`, + ); + + await setFilter('nearest'); + await sleep(600); + const hard = await acrossBorder(); + const pure = (c) => colourDistance(c, '#e63c3c') < 60 || colourDistance(c, '#3cc85a') < 60; + check( + 'pixel-art filtering keeps the border between two quadrants hard', + hard.every(pure), + JSON.stringify(hard), + ); + + await setFilter('linear'); + await sleep(600); + const soft = await acrossBorder(); + check( + 'switching the filter back to smooth blends the border without a reload', + soft.some((c) => !pure(c)), + JSON.stringify({ nearest: hard, linear: soft }), + ); + await shot('33-texture-filter-smooth'); + await mutate(enlarge(160, 120)); + await sleep(400); + // 19c. The colour multiplies the picture, so a tinted white quadrant stops being white. await mutate("__gm.state.project.assets.find((a) => a.id === 'tex-sprite').props.color = 0x3366ff"); await sleep(600); diff --git a/tools/e2e/14-overflow.mjs b/tools/e2e/14-overflow.mjs new file mode 100644 index 00000000..697f2aad --- /dev/null +++ b/tools/e2e/14-overflow.mjs @@ -0,0 +1,56 @@ +/** + * Labels must be shortened to fit their control, not written past it. Runs last because it + * renames assets to absurd lengths to provoke the case. + * + * `__gm.textOverflows()` is a general guard rather than a check on one widget: it walks every + * visible text and reports the ones drawn wider than the widget that owns them. It was verified + * to actually catch this class of bug – against a build without the fix it reported the graph + * picker at 106 px over, and nothing else. + */ + +/** A name far wider than any 200 px picker, so a control that does not truncate must spill. */ +const LONG_NAME = 'Player movement with jumping and coyote time'; + +export async function overflow(t) { + const { check, sleep, json, evaluate, shot, mutate } = t; + + await mutate(` + for (const asset of __gm.state.project.assets) { + if (asset.type === 'behavior' || asset.type === 'texture') asset.name = ${JSON.stringify(LONG_NAME)}; + } + __gm.state.select(null)`); + await sleep(600); + + // 21. Every tab, because each one owns a different set of controls: the two header bars with + // their pickers, the inspector's dropdowns, and the toolbar. + for (const tab of ['scene', 'graph', 'texture', 'game']) { + await evaluate(`__gm.showTab(${JSON.stringify(tab)})`); + await sleep(400); + const spills = await json('__gm.textOverflows()'); + check( + `no label is drawn past its control in the ${tab} tab`, + spills.length === 0, + JSON.stringify(spills), + ); + } + + // 21a. Shortened, not simply dropped: the value must still be readable and marked as cut. + await evaluate("__gm.showTab('graph')"); + await sleep(400); + const shown = (await json('__gm.textOverflows(true)')).find((e) => e.text.startsWith('Player movement')); + check( + 'a value too wide for its dropdown is cut with an ellipsis', + Boolean(shown) && shown.text.endsWith('…') && shown.text.length > 8, + JSON.stringify(shown), + ); + await shot('36-long-labels'); + + // 21b. An inspector full of dropdowns – the parent row and the enum fields – with a selection + // whose asset name is now overlong too. + await evaluate("__gm.showTab('scene')"); + await sleep(300); + await evaluate("__gm.state.select({ kind: 'object', objectId: 'o1' })"); + await sleep(500); + const inspector = await json('__gm.textOverflows()'); + check('no label spills in the inspector', inspector.length === 0, JSON.stringify(inspector)); +} diff --git a/tools/e2e/index.mjs b/tools/e2e/index.mjs index bb33ebdf..e58562da 100644 --- a/tools/e2e/index.mjs +++ b/tools/e2e/index.mjs @@ -16,6 +16,7 @@ import { textAndEnabled } from './10-text-and-enabled.mjs'; import { menus } from './11-menus.mjs'; import { textures } from './12-textures.mjs'; import { frames } from './13-frames.mjs'; +import { overflow } from './14-overflow.mjs'; export const STEPS = [ hierarchy, @@ -31,4 +32,5 @@ export const STEPS = [ menus, textures, frames, + overflow, ];