Give the three asset tabs one base, and move the viewports into the editor

src/viewport/ held three classes only src/editor/ ever used, and framing.ts sat
under graph/ although the scene view wants the same bounds sum. The graph, the
texture and the container tab repeated the same header, subscription and
disposal code with two silent divergences: the graph tab said "nothing shown"
with null instead of '', and the texture tab refell back to the first asset on
a selection change but not on a project event.

These arrive together because GraphCanvas ties them: it reads framing from its
new home, hands its fold marks to folding.ts, and its '' change is what
GraphEditor must agree with.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
rootandClaude Opus 5 committed 2026-09-10 07:14:38 +02:00
1 parent a2f2f8465c
commit 11981b40d0
15 files changed
+408 -425

No files matched your search

+156
View File
@@ -0,0 +1,156 @@
import { Graphics } from 'pixi.js';
import type { Asset } from '../core/model/Asset';
import { findAsset } from '../core/model/Project';
import {
Button,
Dropdown,
HStack,
Label,
Menu,
type MenuItem,
Spacer,
Theme,
VStack,
type Widget,
} from '../ui';
import type { EditorState } from './state';
/** The canvas each tab wraps: the header's Fit button and the tab's own plumbing need this much. */
export interface TabCanvas {
readonly currentAssetId: string;
show(assetId: string): void;
fit(): void;
}
export interface AssetTabOptions {
/** Shown left of the picker, and in the empty state as "No <noun>". */
noun: string;
/** Width reserved for the right-hand readout; a stack measures the label once, while empty. */
infoWidth: number;
}
/**
* The Graph, Texture and Container tabs. Each follows the selection but keeps showing its last
* asset when the selection moves elsewhere – clicking a sprite must not empty the tab being
* worked in. "Nothing shown" is `''` throughout.
*
* A subclass finishes construction by calling `mount()`: the header needs the canvas (its Fit
* button) and the canvas needs `state`, so the base cannot build one before the other exists.
*/
export abstract class AssetTabEditor<
A extends Asset,
C extends TabCanvas & Widget = TabCanvas & Widget,
> extends VStack {
protected readonly picker: Dropdown;
protected readonly info: Label;
/** Assigned by `mount`. Public because the check hooks and the play loop reach for it. */
canvas!: C;
private readonly headerBg = new Graphics();
constructor(
protected readonly state: EditorState,
private readonly options: AssetTabOptions,
) {
super();
this.picker = new Dropdown('—', { width: 200, onClick: () => this.openPicker() });
this.info = new Label('', { style: 'small', align: 'right' });
this.info.minWidth = options.infoWidth;
}
/** Builds the header around `canvas`, wires the state events and shows the first asset. */
protected mount(canvas: C, extras: readonly Widget[] = []): void {
this.canvas = canvas;
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(this.options.noun, { style: 'muted' }));
header.add(this.picker);
for (const widget of extras) header.add(widget);
header.add(new Button('Fit', { onClick: () => this.canvas.fit() }));
header.add(new Spacer(), 1);
header.add(this.info);
this.add(header);
this.add(canvas, 1);
this.state.on('selection', () => this.followSelection());
this.state.on('project', () => this.refresh());
this.state.on('load', () => {
this.canvas.show('');
this.followSelection();
});
this.followSelection();
}
/** The asset currently shown, `''` for none – the check scripts read this. */
get currentAssetId(): string {
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;
}
show(assetId: string): void {
this.canvas.show(assetId);
this.refresh();
}
/** The selection's id for this kind of asset, `''` when the selection is about something else. */
protected abstract selectedId(): string;
protected abstract isMine(asset: Asset): asset is A;
/** The right-hand readout; `null` is the empty state. */
protected abstract infoText(asset: A | null): string;
/** Enable/disable whatever `extras` the subclass passed to `mount`. */
protected syncExtras(_asset: A | null): void {}
protected asset(): A | null {
const asset = findAsset(this.state.project, this.canvas.currentAssetId);
return asset && this.isMine(asset) ? asset : null;
}
protected candidates(): A[] {
return this.state.project.assets
.filter((a): a is A => this.isMine(a))
.sort((a, b) => a.name.localeCompare(b.name));
}
protected followSelection(): void {
const id = this.selectedId();
// An empty id merely means "the selection is about something else"; the tab keeps its subject.
if (id) this.canvas.show(id);
this.refresh();
}
protected refresh(): void {
// A deleted asset leaves the canvas empty; fall back to whatever is left of this kind.
if (!this.asset()) this.canvas.show(this.candidates()[0]?.id ?? '');
const asset = this.asset();
this.picker.setText(asset ? asset.name : `No ${this.options.noun.toLowerCase()}`);
this.info.setText(asset ? this.infoText(asset) : '');
this.syncExtras(asset);
}
private openPicker(): void {
const current = this.canvas.currentAssetId;
const items: MenuItem[] = this.candidates().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 ${this.options.noun.toLowerCase()}s yet`, onSelect: () => undefined });
new Menu(items, { searchable: true }).openBelow(this.picker);
}
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);
}
}
+2 -6
View File
@@ -3,8 +3,8 @@ import { findAsset, findObjectHolder } from '../core/model/Project';
import type { ProjectStore } from '../core/store/ProjectStore';
import type { TextureStore } from '../core/store/TextureStore';
import { Button, HStack, Splitter, Theme, ui, VStack } from '../ui';
import { GameViewport } from '../viewport/GameViewport';
import { SceneViewport } from '../viewport/SceneViewport';
import { GameViewport } from './viewport/GameViewport';
import { SceneViewport } from './viewport/SceneViewport';
import { attachAutosave } from './autosave';
import { ContainerEditor } from './container/ContainerEditor';
import { EditorRun } from './EditorRun';
@@ -207,8 +207,6 @@ export class Editor implements PlayHost {
return this.projects.boot();
}
// --- PlayHost --------------------------------------------------------------------------------
showGame(): void {
this.showTab('game');
}
@@ -217,8 +215,6 @@ export class Editor implements PlayHost {
if (this.tab === 'game') this.showTab('scene');
}
// --- frame -----------------------------------------------------------------------------------
private tick(dt: number): void {
this.measureFps(dt);
this.play.tick(dt);
+1 -1
View File
@@ -9,7 +9,7 @@ import { CollisionSystem } from '../runtime/systems/CollisionSystem';
import { Input } from '../runtime/systems/Input';
import { World } from '../runtime/World';
import { ui } from '../ui';
import type { GameViewport } from '../viewport/GameViewport';
import type { GameViewport } from './viewport/GameViewport';
import type { GraphEditor } from './graph/GraphEditor';
import type { EditorState } from './state';
+1 -1
View File
@@ -2,7 +2,7 @@ import { Graphics } from 'pixi.js';
import { findAsset, type ObjectHolder } from '../../core/model/Project';
import { Theme } from '../../ui';
import type { DrawItem } from '../../viewport/items';
import { SceneViewport } from '../../viewport/SceneViewport';
import { SceneViewport } from '../viewport/SceneViewport';
import { type View, worldToScreen } from '../../viewport/view';
import type { EditorState } from '../state';
+14 -103
View File
@@ -1,76 +1,16 @@
import { Graphics } from 'pixi.js';
import type { ContainerAsset } from '../../core/model/Asset';
import { findAsset, listContainers } from '../../core/model/Project';
import {
activeDrag,
Button,
Dropdown,
HStack,
Label,
Menu,
type MenuItem,
Spacer,
Theme,
VStack,
} from '../../ui';
import type { Asset, ContainerAsset } from '../../core/model/Asset';
import { activeDrag } from '../../ui';
import { AssetTabEditor } from '../AssetTabEditor';
import type { EditorState } from '../state';
import { ContainerCanvas } from './ContainerCanvas';
/** Room kept for the "N objects" readout, wide enough for a three-digit template. */
const INFO_WIDTH = 110;
/**
* The Container tab. It follows the selection, but keeps showing the last container when the
* selection moves elsewhere – clicking a sprite must not empty the tab being worked in.
*/
export class ContainerEditor extends VStack {
private readonly canvas: ContainerCanvas;
private readonly picker: Dropdown;
private readonly info: Label;
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('Container', { style: 'muted' }));
this.picker = new Dropdown('—', { width: 200, onClick: () => this.openPicker() });
header.add(this.picker);
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 ContainerCanvas(state);
this.add(header);
this.add(this.canvas, 1);
state.on('selection', () => this.followSelection());
state.on('project', () => this.refresh());
state.on('load', () => {
this.canvas.show('');
this.followSelection();
});
this.followSelection();
}
/** The container currently shown – the check scripts read this. */
get currentAssetId(): string {
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;
}
show(assetId: string): void {
this.canvas.show(assetId);
this.refresh();
export class ContainerEditor extends AssetTabEditor<ContainerAsset, ContainerCanvas> {
constructor(state: EditorState) {
super(state, { noun: 'Container', infoWidth: INFO_WIDTH });
this.mount(new ContainerCanvas(state));
}
/** The pending drop of an asset dragged over the template (test hook). */
@@ -89,48 +29,19 @@ export class ContainerEditor extends VStack {
return { x: p.x, y: p.y, w: this.canvas.w, h: this.canvas.h };
}
private followSelection(): void {
const id = this.state.selectedContainerId;
protected override selectedId(): string {
// A dragged row selects on release, and whether that lands before or after the drop is up
// to Pixi's event order. Following it in between would swap the tab to the very container
// being dragged, which would then nest in itself, so the target stays put while a drag runs.
if (id && !activeDrag()) this.canvas.show(id);
this.refresh();
return activeDrag() ? '' : (this.state.selectedContainerId ?? '');
}
private asset(): ContainerAsset | null {
const asset = findAsset(this.state.project, this.canvas.currentAssetId);
return asset && asset.type === 'container' ? asset : null;
protected override isMine(asset: Asset): asset is ContainerAsset {
return asset.type === 'container';
}
private containers(): ContainerAsset[] {
return listContainers(this.state.project).sort((a, b) => a.name.localeCompare(b.name));
}
private openPicker(): void {
const current = this.canvas.currentAssetId;
const items: MenuItem[] = this.containers().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 containers yet', onSelect: () => undefined });
new Menu(items, { searchable: true }).openBelow(this.picker);
}
private refresh(): void {
// A deleted container leaves the canvas empty; fall back to whatever container is left.
if (!this.asset()) this.canvas.show(this.containers()[0]?.id ?? '');
const asset = this.asset();
this.picker.setText(asset ? asset.name : 'No container');
const count = asset?.objects.length ?? 0;
this.info.setText(asset ? `${count} ${count === 1 ? 'object' : 'objects'}` : '');
}
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);
protected override infoText(asset: ContainerAsset): string {
const count = asset.objects.length;
return `${count} ${count === 1 ? 'object' : 'objects'}`;
}
}
@@ -40,9 +40,38 @@ export function boxesInBand(boxes: readonly Box[], band: Band): string[] {
.map((b) => b.id);
}
export interface Bounds {
minX: number;
minY: number;
maxX: number;
maxY: number;
}
/**
* The rectangle enclosing every box, or null for none. `seed` starts the sum off – the scene view
* passes the origin, because that is where a container is grabbed when placed and a template drawn
* far to one side would otherwise hide it.
*/
export function boundsOf(boxes: readonly Box[], seed?: Bounds): Bounds | null {
let { minX, minY, maxX, maxY } = seed ?? {
minX: Infinity,
minY: Infinity,
maxX: -Infinity,
maxY: -Infinity,
};
for (const b of boxes) {
minX = Math.min(minX, b.x);
minY = Math.min(minY, b.y);
maxX = Math.max(maxX, b.x + b.w);
maxY = Math.max(maxY, b.y + b.h);
}
return Number.isFinite(minX) ? { minX, minY, maxX, maxY } : null;
}
/**
* The view that shows every box at once, centred, with `margin` screen pixels to spare.
* Never zooms past 1: a two-node graph blown up to fill the screen helps nobody.
* Never zooms past 1: a two-node graph blown up to fill the screen helps nobody. The scene view
* deliberately does not share that cap, which is why only the bounds sum is common.
*/
export function frameBoxes(
boxes: readonly Box[],
@@ -50,17 +79,9 @@ export function frameBoxes(
margin: number,
limits: { min: number; max: number },
): Framing {
let minX = Infinity;
let minY = Infinity;
let maxX = -Infinity;
let maxY = -Infinity;
for (const b of boxes) {
minX = Math.min(minX, b.x);
minY = Math.min(minY, b.y);
maxX = Math.max(maxX, b.x + b.w);
maxY = Math.max(maxY, b.y + b.h);
}
if (!Number.isFinite(minX)) return { zoom: 1, panX: view.w / 2, panY: view.h / 2 };
const bounds = boundsOf(boxes);
if (!bounds) return { zoom: 1, panX: view.w / 2, panY: view.h / 2 };
const { minX, minY, maxX, maxY } = bounds;
const width = Math.max(maxX - minX, 1);
const height = Math.max(maxY - minY, 1);
const fitted = Math.min((view.w - margin * 2) / width, (view.h - margin * 2) / height, 1);
+32 -59
View File
@@ -40,6 +40,7 @@ import {
Menu,
type MenuItem,
onDragChange,
pressCount,
type Size,
Theme,
textStyle,
@@ -47,11 +48,18 @@ import {
Widget,
zoomFactor,
} from '../../ui';
import { pressCount } from '../../ui/clicks';
import { removeNodes } from '../actions';
import { type EditorState, nodeSelection } from '../state';
import { foldedEdgeShown, foldMarkKey, foldMarkLabel } from './folding';
import { type Box, boxesInBand, frameBoxes } from './framing';
import {
buildFoldMarks,
foldedEdgeShown,
type FoldMarkEnd,
foldMarkAt,
foldMarkKey,
foldMarkLabel,
foldMarkRadius,
} from './folding';
import { type Box, boxesInBand, frameBoxes } from '../framing';
import { NodePalette } from './NodePalette';
import { type PaletteEntry, paletteEntries } from './paletteEntries';
import { NODE_WIDTH, NodeView, portColor, TITLE_HEIGHT } from './NodeView';
@@ -83,14 +91,6 @@ interface FoldMark {
text: string;
}
/** A mark while the edges are still adding themselves to it. */
interface PendingMark extends Omit<FoldMark, 'text'> {
names: string[];
portX: number;
color: number;
hot: boolean;
}
/**
* 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.
@@ -107,7 +107,7 @@ export class GraphCanvas extends Widget {
private readonly markLabels: Text[] = [];
private markLabelCount = 0;
private foldMarks: FoldMark[] = [];
private readonly foldPending = new Map<string, PendingMark>();
private foldEnds: FoldMarkEnd[] = [];
/** Edges the last redraw really stroked as a curve – a hidden one is missing from it. */
private readonly strokedEdges = new Set<string>();
private readonly nodeLayer = new Container();
@@ -116,7 +116,7 @@ export class GraphCanvas extends Widget {
private readonly band = new Graphics();
private readonly hint = new Text({ text: '', style: textStyle('muted') });
private readonly views = new Map<string, NodeView>();
private assetId: string | null = null;
private assetId = '';
private pan = { x: 0, y: 0 };
private zoom = 1;
private panning: { startX: number; startY: number; panX: number; panY: number } | null = null;
@@ -180,12 +180,12 @@ export class GraphCanvas extends Widget {
state.on('mode', () => this.redraw());
}
get currentAssetId(): string | null {
get currentAssetId(): string {
return this.assetId;
}
/** Switching refits the view, since the next graph is a new subject. */
show(assetId: string | null): void {
show(assetId: string): void {
if (this.assetId === assetId) return;
this.assetId = assetId;
this.sync();
@@ -211,8 +211,6 @@ export class GraphCanvas extends Widget {
return { w: Math.max(this.minWidth, 200), h: Math.max(this.minHeight, 200) };
}
// --- model binding ----------------------------------------------------------------------
private behaviors(): BehaviorLookup {
return behaviorLookup(this.state.project);
}
@@ -227,7 +225,7 @@ export class GraphCanvas extends Widget {
const asset = this.asset();
if (!asset) {
// A behavior that was deleted (or undone away) must not linger as a graph.
this.assetId = null;
this.assetId = '';
for (const view of this.views.values()) view.destroy({ children: true });
this.views.clear();
this.redraw();
@@ -306,8 +304,6 @@ export class GraphCanvas extends Widget {
return { x: gx * this.zoom + this.pan.x, y: gy * this.zoom + this.pan.y };
}
// --- pointer -----------------------------------------------------------------------------
private onCanvasPointerDown(e: FederatedPointerEvent): void {
// The middle button pans from anywhere, a node under the pointer included – it is the one
// gesture that never means "grab this". Nodes let it through for exactly that reason.
@@ -613,8 +609,6 @@ export class GraphCanvas extends Widget {
}
}
// --- menus -------------------------------------------------------------------------------
private openPalette(globalX: number, globalY: number, at: { x: number; y: number }): void {
new NodePalette(this.entries(), (entry) => this.addNode(entry, at)).open(globalX, globalY);
}
@@ -750,8 +744,6 @@ export class GraphCanvas extends Widget {
]).open(globalX, globalY);
}
// --- drawing -----------------------------------------------------------------------------
override layout(): void {
this.hint.position.set(Theme.padding, this.h - Theme.padding - 16);
if (this.needsFit) this.fitView();
@@ -792,7 +784,7 @@ export class GraphCanvas extends Widget {
this.foldMarks = [];
this.markLabelCount = 0;
this.strokedEdges.clear();
this.foldPending.clear();
this.foldEnds = [];
for (const edge of asset.graph.edges) this.drawEdge(edge, isHot(trace?.edges, edge.id), selectedNodes);
// After the curves, so no line drawn later crosses a mark that stands for a hidden one.
this.drawFoldMarks();
@@ -871,7 +863,7 @@ export class GraphCanvas extends Widget {
this.strokedEdges.add(edge.id);
}
/** Adds one end of a hidden link to its port's mark, creating that mark on first sight. */
/** Records one end of a hidden link; `drawFoldMarks` groups them per port afterwards. */
private addFoldMark(
edge: GraphEdge,
port: { x: number; y: number },
@@ -881,38 +873,25 @@ export class GraphCanvas extends Widget {
hot: boolean,
): void {
const [side, other] = isOutput ? [edge.from, edge.to.node] : [edge.to, edge.from.node];
const key = foldMarkKey(side, isOutput);
let mark = this.foldPending.get(key);
if (!mark) {
mark = {
edgeIds: [],
others: [],
names: [],
isOutput,
x: port.x + (isOutput ? FOLD_STUB : -FOLD_STUB),
y: port.y,
portX: port.x,
color,
hot,
};
this.foldPending.set(key, mark);
}
mark.edgeIds.push(edge.id);
mark.names.push(otherLabel);
if (!mark.others.includes(other)) mark.others.push(other);
// A pulse anywhere in the bundle colours the whole mark: it is the one thing worth spotting.
if (hot && !mark.hot) {
mark.hot = true;
mark.color = color;
}
this.foldEnds.push({
edgeId: edge.id,
key: foldMarkKey(side, isOutput),
other,
otherLabel,
isOutput,
portX: port.x,
portY: port.y,
color,
hot,
});
}
/** A stub out of the port, a dot, and what waits at the other end – one per port, not per link. */
private drawFoldMarks(): void {
for (const mark of this.foldPending.values()) {
for (const mark of buildFoldMarks(this.foldEnds, FOLD_STUB)) {
const { x, y, color, isOutput } = mark;
this.edges.moveTo(mark.portX, y).lineTo(x, y).stroke({ width: 2, color, alpha: 0.9 });
const radius = mark.edgeIds.length > 1 ? FOLD_MARK_RADIUS + 2 : FOLD_MARK_RADIUS;
const radius = foldMarkRadius(mark.edgeIds.length, FOLD_MARK_RADIUS);
this.edges.circle(x, y, radius).fill(color);
this.edges.circle(x, y, radius).stroke({ width: 1, color: Theme.colors.node });
const label = this.markLabel();
@@ -941,11 +920,7 @@ export class GraphCanvas extends Widget {
}
private markAt(g: { x: number; y: number }): FoldMark | null {
for (const m of this.foldMarks) {
const radius = m.edgeIds.length > 1 ? FOLD_MARK_RADIUS + 2 : FOLD_MARK_RADIUS;
if (Math.hypot(g.x - m.x, g.y - m.y) <= radius + 4) return m;
}
return null;
return foldMarkAt(this.foldMarks, g, FOLD_MARK_RADIUS);
}
private syncGrid(): void {
@@ -963,8 +938,6 @@ export class GraphCanvas extends Widget {
});
}
// --- 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);
+16 -93
View File
@@ -1,115 +1,38 @@
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 { Asset, BehaviorAsset } from '../../core/model/Asset';
import { Button } from '../../ui';
import { AssetTabEditor } from '../AssetTabEditor';
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;
export class GraphEditor extends AssetTabEditor<BehaviorAsset, GraphCanvas> {
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);
constructor(state: EditorState) {
super(state, { noun: 'Behavior', infoWidth: INFO_WIDTH });
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());
this.mount(new GraphCanvas(state), [this.addButton]);
// 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;
protected override selectedId(): string {
return this.state.selectedBehaviorId ?? '';
}
/** For the check scripts: the picker's width must not follow the name it happens to show. */
get pickerWidth(): number {
return this.picker.w;
protected override isMine(asset: Asset): asset is BehaviorAsset {
return asset.type === 'behavior';
}
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();
protected override infoText(asset: BehaviorAsset): string {
const nodes = asset.graph.nodes.length;
const links = asset.graph.edges.length;
return `${nodes} ${nodes === 1 ? 'node' : 'nodes'} · ${links} ${links === 1 ? 'link' : 'links'}`;
}
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'}` : '',
);
protected override syncExtras(asset: BehaviorAsset | null): void {
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);
}
}
Binary file not shown.
+17 -95
View File
@@ -1,8 +1,7 @@
import { Graphics } from 'pixi.js';
import { SliceFramesCommand } from '../../core/commands/frameCommands';
import type { TextureAsset } 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 { Asset, TextureAsset } from '../../core/model/Asset';
import { Button } from '../../ui';
import { AssetTabEditor } from '../AssetTabEditor';
import type { EditorState } from '../state';
import { SliceDialog } from './SliceDialog';
import { TextureCanvas } from './TextureCanvas';
@@ -10,61 +9,13 @@ import { TextureCanvas } from './TextureCanvas';
/** Room kept for the "W × H px · N frames" readout, wide enough for a four-digit sheet. */
const INFO_WIDTH = 190;
/**
* The Texture tab. It follows the selection, but keeps showing the last texture when the
* selection moves elsewhere – clicking a sprite must not empty the tab being worked in.
*/
export class TextureEditor extends VStack {
private readonly canvas: TextureCanvas;
private readonly picker: Dropdown;
private readonly info: Label;
export class TextureEditor extends AssetTabEditor<TextureAsset, TextureCanvas> {
private readonly sliceButton: 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('Texture', { style: 'muted' }));
this.picker = new Dropdown('—', { width: 200, onClick: () => this.openPicker() });
header.add(this.picker);
constructor(state: EditorState) {
super(state, { noun: 'Texture', infoWidth: INFO_WIDTH });
this.sliceButton = new Button('Slice…', { onClick: () => this.openSlice() });
header.add(this.sliceButton);
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 TextureCanvas(state);
this.add(header);
this.add(this.canvas, 1);
state.on('selection', () => this.followSelection());
state.on('project', () => this.refresh());
state.on('load', () => {
this.canvas.show('');
this.followSelection();
});
this.followSelection();
}
/** The texture currently shown – the check scripts read this. */
get currentAssetId(): string {
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;
}
show(assetId: string): void {
this.canvas.show(assetId);
this.refresh();
this.mount(new TextureCanvas(state), [this.sliceButton]);
}
/** Where an image pixel of the shown sheet sits on screen – for the check scripts. */
@@ -72,32 +23,22 @@ export class TextureEditor extends VStack {
return this.canvas.imageToScreen(x, y);
}
private followSelection(): void {
const id = this.state.selectedTextureId;
// Null merely means "the selection is about something else"; the tab keeps its subject.
if (id) this.canvas.show(id);
else if (!this.asset()) this.canvas.show(this.textures()[0]?.id ?? '');
this.refresh();
protected override selectedId(): string {
return this.state.selectedTextureId ?? '';
}
private asset(): TextureAsset | null {
const asset = findAsset(this.state.project, this.canvas.currentAssetId);
return asset && asset.type === 'texture' ? asset : null;
protected override isMine(asset: Asset): asset is TextureAsset {
return asset.type === 'texture';
}
private textures(): TextureAsset[] {
return this.state.project.assets
.filter((a): a is TextureAsset => a.type === 'texture')
.sort((a, b) => a.name.localeCompare(b.name));
protected override infoText(asset: TextureAsset): string {
const count = asset.frames.length;
return `${asset.width} × ${asset.height} px · ${count} ${count === 1 ? 'frame' : 'frames'}`;
}
private openPicker(): void {
const items: MenuItem[] = this.textures().map((asset) => ({
label: asset.id === this.canvas.currentAssetId ? `● ${asset.name}` : asset.name,
onSelect: () => this.state.select({ kind: 'asset', assetId: asset.id }),
}));
if (items.length === 0) items.push({ label: 'No textures yet', onSelect: () => undefined });
new Menu(items, { searchable: true }).openBelow(this.picker);
protected override syncExtras(asset: TextureAsset | null): void {
// Slicing needs a known picture size; without one it could only fail with a notice.
this.sliceButton.setEnabled(Boolean(asset && asset.width > 0 && asset.height > 0));
}
private openSlice(): void {
@@ -107,23 +48,4 @@ export class TextureEditor extends VStack {
this.state.execute(new SliceFramesCommand(asset.id, options, replace));
}).openCentered();
}
private refresh(): void {
const asset = this.asset();
this.picker.setText(asset ? asset.name : 'No texture');
const count = asset?.frames.length ?? 0;
this.info.setText(
asset ? `${asset.width} × ${asset.height} px · ${count} ${count === 1 ? 'frame' : 'frames'}` : '',
);
// Slicing needs a known picture size; without one it could only fail with a notice.
this.sliceButton.setEnabled(Boolean(asset && asset.width > 0 && asset.height > 0));
}
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);
}
}
@@ -3,13 +3,13 @@
* playing, the model as a still preview otherwise.
*/
import { type FederatedPointerEvent, Text } from 'pixi.js';
import { isSymbolType } from '../core/model/AssetTypes';
import type { EditorState } from '../editor/state';
import type { World } from '../runtime/World';
import { textStyle } from '../ui';
import { type ActiveCamera, cameraOfItems, cameraOfWorld } from './activeCamera';
import { collectHolderItems, collectWorldItems } from './items';
import { cameraView, isInside, screenToWorld, type View } from './view';
import { isSymbolType } from '../../core/model/AssetTypes';
import type { EditorState } from '../state';
import type { World } from '../../runtime/World';
import { textStyle } from '../../ui';
import { type ActiveCamera, cameraOfItems, cameraOfWorld } from '../../viewport/activeCamera';
import { collectHolderItems, collectWorldItems } from '../../viewport/items';
import { cameraView, isInside, screenToWorld, type View } from '../../viewport/view';
import { type Frame, WorldViewport } from './WorldViewport';
const LETTERBOX_COLOR = 0x000000;
@@ -4,16 +4,17 @@
* work on the same shape of data, so the Container tab subclasses this and swaps `holder()`.
*/
import { type FederatedPointerEvent, type FederatedWheelEvent, Graphics, Text } from 'pixi.js';
import { CompoundCommand } from '../core/commands/CompoundCommand';
import { MoveSceneObjectCommand, PlaceAssetCommand } from '../core/commands/sceneObjectCommands';
import { isSymbolType } from '../core/model/AssetTypes';
import { findObject, subtreeRoots } from '../core/model/hierarchy';
import { objectRef, resolvedHolder } from '../core/model/instance';
import { placementRejection } from '../core/model/placement';
import type { Position, Vec2 } from '../core/model/Properties';
import { findAsset, type ObjectHolder } from '../core/model/Project';
import { footprintOf } from '../core/properties/bounds';
import { type EditorState, objectSelection } from '../editor/state';
import { CompoundCommand } from '../../core/commands/CompoundCommand';
import { MoveSceneObjectCommand, PlaceAssetCommand } from '../../core/commands/sceneObjectCommands';
import { isSymbolType } from '../../core/model/AssetTypes';
import { findObject, subtreeRoots } from '../../core/model/hierarchy';
import { objectRef, resolvedHolder } from '../../core/model/instance';
import { placementRejection } from '../../core/model/placement';
import type { Position, Vec2 } from '../../core/model/Properties';
import { findAsset, type ObjectHolder } from '../../core/model/Project';
import { footprintOf } from '../../core/properties/bounds';
import { boundsOf } from '../framing';
import { type EditorState, objectSelection } from '../state';
import {
activeDrag,
clamp,
@@ -24,10 +25,10 @@ import {
Theme,
textStyle,
zoomFactor,
} from '../ui';
import { collectHolderItems, type DrawItem } from './items';
import { dashedLine } from './symbols';
import { freeView, screenToWorld, type View, worldToScreen } from './view';
} from '../../ui';
import { collectHolderItems, type DrawItem } from '../../viewport/items';
import { dashedLine } from '../../viewport/symbols';
import { freeView, screenToWorld, type View, worldToScreen } from '../../viewport/view';
import { type Frame, WorldViewport } from './WorldViewport';
/** Pixels of travel before a press turns into a drag. */
@@ -122,17 +123,11 @@ export class SceneViewport extends WorldViewport {
*/
fit(): void {
const { items } = this.collectFrame();
let minX = 0;
let minY = 0;
let maxX = 0;
let maxY = 0;
for (const item of items) {
const boxes = items.map((item) => {
const { w, h } = footprintOf(item.type, item.props);
minX = Math.min(minX, item.x - w / 2);
maxX = Math.max(maxX, item.x + w / 2);
minY = Math.min(minY, item.y - h / 2);
maxY = Math.max(maxY, item.y + h / 2);
}
return { id: item.id, x: item.x - w / 2, y: item.y - h / 2, w, h };
});
const { minX, minY, maxX, maxY } = boundsOf(boxes, { minX: 0, minY: 0, maxX: 0, maxY: 0 })!;
const width = Math.max(maxX - minX, 1);
const height = Math.max(maxY - minY, 1);
this.view = {
@@ -147,8 +142,6 @@ export class SceneViewport extends WorldViewport {
this.redraw();
}
// --- the view -------------------------------------------------------------------------------
protected override currentView(): View {
return freeView(this.view.x, this.view.y, this.view.zoom, this.w, this.h);
}
@@ -170,8 +163,6 @@ export class SceneViewport extends WorldViewport {
return true;
}
// --- pointer --------------------------------------------------------------------------------
private onPointerDown(e: FederatedPointerEvent): void {
if (e.button === 1 || (e.button === 0 && e.target === this && e.shiftKey)) {
this.panning = { startX: e.global.x, startY: e.global.y, viewX: this.view.x, viewY: this.view.y };
@@ -352,8 +343,6 @@ export class SceneViewport extends WorldViewport {
this.state.select(objectSelection(holder.id, ids));
}
// --- decorations ----------------------------------------------------------------------------
protected override beforeItems(): void {
this.syncGrid();
this.gizmos.clear();
@@ -1,14 +1,14 @@
import { Container, type FederatedPointerEvent, Graphics, Text, TextStyle } from 'pixi.js';
import { isSymbolType } from '../core/model/AssetTypes';
import { sortByZ, type Vec2 } from '../core/model/Properties';
import { footprintOf, textAnchor } from '../core/properties/bounds';
import { rectAround, rectsIntersect } from '../core/properties/camera';
import type { EditorState } from '../editor/state';
import { type Size, Theme, Widget } from '../ui';
import type { DrawItem } from './items';
import { drawItem } from './symbols';
import { textureLibrary } from './TextureLibrary';
import { type View, worldBounds, worldToScreen } from './view';
import { isSymbolType } from '../../core/model/AssetTypes';
import { sortByZ, type Vec2 } from '../../core/model/Properties';
import { footprintOf, textAnchor } from '../../core/properties/bounds';
import { rectAround, rectsIntersect } from '../../core/properties/camera';
import type { EditorState } from '../state';
import { type Size, Theme, Widget } from '../../ui';
import type { DrawItem } from '../../viewport/items';
import { drawItem } from '../../viewport/symbols';
import { textureLibrary } from '../../viewport/TextureLibrary';
import { type View, worldBounds, worldToScreen } from '../../viewport/view';
export interface Frame {
items: DrawItem[];
@@ -52,8 +52,6 @@ export abstract class WorldViewport extends Widget {
return { w: Math.max(this.minWidth, 200), h: Math.max(this.minHeight, 200) };
}
// --- what the two views differ in ------------------------------------------------------------
protected abstract currentView(): View | null;
/** Only called when `currentView()` is non-null. */
@@ -81,8 +79,6 @@ export abstract class WorldViewport extends Widget {
protected onItemPointerDown(_objectId: string, _e: FederatedPointerEvent): void {}
// --- test hooks ------------------------------------------------------------------------------
debugInfo(): { rendering: boolean; visibleItems: number } {
return { rendering: this.currentView() !== null, visibleItems: this.visibleCount };
}
@@ -124,8 +120,6 @@ export abstract class WorldViewport extends Widget {
};
}
// --- drawing ---------------------------------------------------------------------------------
override redraw(): void {
const view = this.currentView();
this.bg.clear().rect(0, 0, this.w, this.h).fill(this.baseColor);
+73 -1
View File
@@ -5,7 +5,15 @@ import { GraphInterpreter } from '../../src/core/graph';
import type { BehaviorAsset } from '../../src/core/model/Asset';
import { findAsset } from '../../src/core/model/Project';
import { deserializeProject, serializeProject } from '../../src/core/store/serialize';
import { foldedEdgeShown, foldMarkKey, foldMarkLabel } from '../../src/editor/graph/folding';
import {
buildFoldMarks,
foldedEdgeShown,
type FoldMarkEnd,
foldMarkAt,
foldMarkKey,
foldMarkLabel,
foldMarkRadius,
} from '../../src/editor/graph/folding';
import { fakeContext, graphEditor, makeBehavior, makeProject, roundTrip } from '../helpers';
/** "On start → Move(self, dx = 7)": two edges, so a fold can hide one and leave the other. */
@@ -145,3 +153,67 @@ describe('several hidden links at one port', () => {
expect(foldMarkLabel([])).toBe('');
});
});
/** The bundling that keeps two hidden links out of the same port from stacking on one pixel. */
describe('fold mark bundling', () => {
const end = (over: Partial<FoldMarkEnd> & { edgeId: string; key: string }): FoldMarkEnd => ({
other: 'far',
otherLabel: 'Far',
isOutput: true,
portX: 100,
portY: 50,
color: 1,
hot: false,
...over,
});
it('gives one port one mark, however many links it hides', () => {
const marks = buildFoldMarks(
[
end({ edgeId: 'e1', key: 'a', other: 'n1', otherLabel: 'Add' }),
end({ edgeId: 'e2', key: 'a', other: 'n2', otherLabel: 'Compare' }),
end({ edgeId: 'e3', key: 'b', other: 'n3', otherLabel: 'Move' }),
],
16,
);
expect(marks).toHaveLength(2);
expect(marks[0]!.edgeIds).toEqual(['e1', 'e2']);
expect(foldMarkLabel(marks[0]!.names)).toBe('Add +1');
expect(marks[1]!.edgeIds).toEqual(['e3']);
});
it('dedupes the far nodes, so two links to one node select it once', () => {
const marks = buildFoldMarks(
[end({ edgeId: 'e1', key: 'a', other: 'n1' }), end({ edgeId: 'e2', key: 'a', other: 'n1' })],
16,
);
expect(marks[0]!.others).toEqual(['n1']);
});
it('puts the mark a stub out of its port, on the side the port faces', () => {
const [out] = buildFoldMarks([end({ edgeId: 'e1', key: 'a', isOutput: true })], 16);
const [into] = buildFoldMarks([end({ edgeId: 'e2', key: 'b', isOutput: false })], 16);
expect(out!.x).toBe(116);
expect(into!.x).toBe(84);
});
it('lets a pulse anywhere in the bundle colour the whole mark', () => {
const marks = buildFoldMarks(
[
end({ edgeId: 'e1', key: 'a', color: 1, hot: false }),
end({ edgeId: 'e2', key: 'a', color: 9, hot: true }),
],
16,
);
expect(marks[0]!.hot).toBe(true);
expect(marks[0]!.color).toBe(9);
});
it('grows the dot for a bundle and finds it under a click', () => {
expect(foldMarkRadius(1, 5)).toBe(5);
expect(foldMarkRadius(2, 5)).toBe(7);
const marks = [{ edgeIds: ['e1', 'e2'], x: 100, y: 50 }];
expect(foldMarkAt(marks, { x: 108, y: 50 }, 5)).toBe(marks[0]);
expect(foldMarkAt(marks, { x: 120, y: 50 }, 5)).toBeNull();
});
});
+27 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest';
import { type Box, boxesInBand, frameBoxes } from '../../src/editor/graph/framing';
import { boundsOf, type Box, boxesInBand, frameBoxes } from '../../src/editor/framing';
const LIMITS = { min: 0.25, max: 2.5 };
@@ -83,3 +83,29 @@ describe('fit', () => {
expect(f).toEqual({ zoom: 1, panX: 100, panY: 50 });
});
});
describe('boundsOf', () => {
it('encloses every box', () => {
const boxes: Box[] = [
{ id: 'a', x: 10, y: 20, w: 100, h: 60 },
{ id: 'b', x: -40, y: 5, w: 20, h: 10 },
];
expect(boundsOf(boxes)).toEqual({ minX: -40, minY: 5, maxX: 110, maxY: 80 });
});
it('reports nothing for no boxes at all', () => {
expect(boundsOf([])).toBeNull();
});
/** The scene view seeds the origin so a template drawn far to one side cannot hide it. */
it('keeps a seed inside the result even when every box is far away', () => {
const far: Box[] = [{ id: 'a', x: 500, y: 500, w: 10, h: 10 }];
const origin = { minX: 0, minY: 0, maxX: 0, maxY: 0 };
expect(boundsOf(far, origin)).toEqual({ minX: 0, minY: 0, maxX: 510, maxY: 510 });
});
it('still answers for no boxes when a seed is given', () => {
const origin = { minX: 0, minY: 0, maxX: 0, maxY: 0 };
expect(boundsOf([], origin)).toEqual(origin);
});
});