Remove comments that only restate the code, keeping the section banners and the notes that carry a reason, a measured fact or an external contract. ButtonOptions.width claimed to be a fixed width but only set minWidth, so the Graph and Texture pickers grew with the name they showed. It is now what it promises, with a separate minWidth for the buttons that have two forms. Narrow buttons shrink their padding, otherwise a 24px icon button had 8px left for its glyph and truncate dropped it entirely. __gm.labelsDropped() guards that class: the overflow guard only saw text that was too wide, never a label truncated to nothing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
116 lines
4.6 KiB
TypeScript
116 lines
4.6 KiB
TypeScript
import { Graphics } from 'pixi.js';
|
||
import type { BehaviorAsset } from '../../core/model/Asset';
|
||
import { findAsset } from '../../core/model/Project';
|
||
import { Button, Dropdown, HStack, Label, Menu, type MenuItem, Spacer, Theme, VStack } from '../../ui';
|
||
import type { EditorState } from '../state';
|
||
import { GraphCanvas } from './GraphCanvas';
|
||
|
||
/** Room kept for the "N nodes · M links" readout, wide enough for a three-digit graph. */
|
||
const INFO_WIDTH = 150;
|
||
|
||
/**
|
||
* The Graph tab. It follows the selection, but keeps showing the last behavior when the
|
||
* selection moves elsewhere – clicking a sprite must not empty the graph being edited.
|
||
*/
|
||
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();
|
||
const header = new HStack({ gap: Theme.gap, padding: Theme.padding, align: 'center' });
|
||
header.minHeight = Theme.controlHeight + Theme.padding * 2;
|
||
header.addChildAt(this.headerBg, 0);
|
||
|
||
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, not measured: a stack sizes the label once, from the empty text it was built with.
|
||
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.canvas.show(null);
|
||
this.followSelection();
|
||
});
|
||
this.followSelection();
|
||
}
|
||
|
||
/** The behavior currently shown – the check scripts read this. */
|
||
get currentAssetId(): string | null {
|
||
return this.canvas.currentAssetId;
|
||
}
|
||
|
||
/** For the check scripts: the picker's width must not follow the name it happens to show. */
|
||
get pickerWidth(): number {
|
||
return this.picker.w;
|
||
}
|
||
|
||
private followSelection(): void {
|
||
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();
|
||
}
|
||
|
||
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; fall back to whatever behavior is left.
|
||
if (!this.asset()) this.canvas.show(this.behaviors()[0]?.id ?? null);
|
||
const asset = this.asset();
|
||
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 {
|
||
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);
|
||
}
|
||
}
|