Console panel: capture console.*, timestamped lines, resizable (#50)

A log layer (src/editor/console/log.ts) keeps a 500-line ring buffer and
wraps console.log/info/debug/warn/error, still calling the original. Notices
and behaviour runtime errors go straight to the layer instead of through
console, so editor tests stay quiet and errors can render red. world.log is
untouched: it is per-run structured state that runtime tests and e2e read
directly, not a text stream.

The panel sits under the visible tab, resizable with Splitter('y'), with a
CONSOLE header and a clear button. Each line carries its wall-clock time in
its own dimmed column, so locate() still finds the message text.

The taller centre area drops the graph fit zoom to 0.60, which broke six e2e
checks that assumed one screen pixel is one graph unit and that a fixed
offset from the canvas centre is empty. They now ask __gm.emptySpot(),
__gm.graph.emptySpot() and __gm.graph.zoom() instead of guessing.

locate/locateAll added the unscaled Pixi text width to the global position,
so click points in the scaled graph canvas landed too far right; they use
getBounds() now.

check.sh: 831 tests. Full e2e: ALL PASSED - 671 checks. Smokes 24/19/20.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
rootandClaude Opus 5 committed 2026-09-12 19:35:40 +02:00
1 parent e7d82bd1a9
commit c47fd5e4dc
25 files changed
+690 -42

No files matched your search

+21 -3
View File
@@ -2,7 +2,8 @@ import type { Application } from 'pixi.js';
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, type Widget } from '../ui';
import { Button, clamp, HStack, Panel, Splitter, Theme, ui, VStack, type Widget } from '../ui';
import { ConsolePanel } from './console/ConsolePanel';
import { GameViewport } from './viewport/GameViewport';
import { SceneViewport } from './viewport/SceneViewport';
import { attachAutosave } from './autosave';
@@ -52,6 +53,9 @@ const MIN_LEFT_WIDTH = 160;
const MAX_LEFT_WIDTH = 500;
const MIN_RIGHT_WIDTH = 200;
const MAX_RIGHT_WIDTH = 600;
// The floor is the console's own header: dragged all the way shut it stays visible to drag open.
const MIN_CONSOLE_HEIGHT = Panel.HEADER_HEIGHT;
const MAX_CONSOLE_HEIGHT = 600;
/**
* Composes toolbar, panels, viewports and graph editor into the one application that edits and
@@ -72,6 +76,7 @@ export class Editor implements PlayHost {
readonly hierarchy: HierarchyPanel;
readonly assets: AssetPanel;
readonly inspector: Inspector;
readonly console = new ConsolePanel();
tab: CentreTab = 'scene';
/** Frame rate, refreshed twice a second; null until the first measurement. */
fps: number | null = null;
@@ -81,6 +86,7 @@ export class Editor implements PlayHost {
private readonly leftColumn: VStack;
private readonly leftSplitter = new Splitter('x');
private readonly rightSplitter = new Splitter('x');
readonly consoleSplitter = new Splitter('y');
private readonly workspace: HStack;
private readonly centre: VStack;
private readonly tabButtons: Record<CentreTab, Button>;
@@ -88,8 +94,9 @@ export class Editor implements PlayHost {
private readonly toolbar: Toolbar;
private fpsFrames = 0;
private fpsElapsed = 0;
private leftWidth = 240;
private rightWidth = 300;
private leftWidth = 250;
private rightWidth = 350;
private consoleHeight = 300;
constructor(
app: Application,
@@ -169,6 +176,16 @@ export class Editor implements PlayHost {
this.panes[tab].visible = tab === 'scene';
}
this.tabButtons.scene.setToggled(true);
// The console sits below whichever tab is showing, so it is a sibling of the panes, not of
// the workspace: switching tabs must not move it.
this.centre.add(this.consoleSplitter);
this.centre.add(this.console);
this.console.minHeight = this.consoleHeight;
this.consoleSplitter.onDrag = (delta) => {
this.consoleHeight = clamp(this.consoleHeight - delta, MIN_CONSOLE_HEIGHT, MAX_CONSOLE_HEIGHT);
this.console.minHeight = this.consoleHeight;
this.centre.relayout();
};
this.leftColumn.minWidth = this.leftWidth;
this.inspector.minWidth = this.rightWidth;
@@ -263,6 +280,7 @@ export class Editor implements PlayHost {
this.measureFps(dt);
this.play.tick(dt);
this.editorRun.tick(dt);
this.console.refresh();
if (this.tab === 'animation') this.animationEditor.tick(dt);
}
+1 -1
View File
@@ -108,7 +108,7 @@ export class EditorRun {
if (!world.stopRequested) return false;
const error = behaviorError(world);
this.stop();
this.state.setNotice(error ?? 'A behavior running in the editor stopped it.');
this.state.setNotice(error ?? 'A behavior running in the editor stopped it.', 'error');
return true;
}
+3 -3
View File
@@ -66,7 +66,7 @@ export class PlaySession {
if (!this.world) return;
this.state.setPaused(!this.state.paused);
ui().focus.blur();
this.state.setNotice(this.playNotice());
this.state.setNotice(this.playNotice(), 'quiet');
}
traceFor(assetId: string): Trace | null {
@@ -92,7 +92,7 @@ export class PlaySession {
this.behaviors = addGameSystems(world, runtime, this.input);
this.host.gameView.setWorld(world);
this.host.showGame();
this.state.setNotice(this.playNotice());
this.state.setNotice(this.playNotice(), 'quiet');
if (world.stopRequested) this.stop();
}
@@ -114,7 +114,7 @@ export class PlaySession {
this.state.exitPlay();
this.host.leaveGame();
const lastError = world && behaviorError(world);
this.state.setNotice(lastError ?? '');
this.state.setNotice(lastError ?? '', 'error');
}
tick(dt: number): void {
+2 -1
View File
@@ -30,6 +30,7 @@ export class ProjectSession {
} catch (error) {
this.state.setNotice(
`Could not load project: ${error instanceof Error ? error.message : String(error)}`,
'error',
);
}
}
@@ -86,7 +87,7 @@ export class ProjectSession {
];
new Menu(items).openBelow(anchor);
};
void open().catch((error) => this.state.setNotice(String(error)));
void open().catch((error) => this.state.setNotice(String(error), 'error'));
}
openSceneMenu(anchor: Widget): void {
+5 -2
View File
@@ -30,10 +30,13 @@ export function attachAutosave(
} catch (error) {
if (error instanceof StaleRevisionError) {
state.setSaveStatus('conflict');
state.setNotice('This project was changed elsewhere. Reload to continue.');
state.setNotice('This project was changed elsewhere. Reload to continue.', 'error');
} else {
state.setSaveStatus('error');
state.setNotice(`Saving failed: ${error instanceof Error ? error.message : String(error)}`);
state.setNotice(
`Saving failed: ${error instanceof Error ? error.message : String(error)}`,
'error',
);
}
} finally {
saving = null;
+89
View File
@@ -0,0 +1,89 @@
import { Button, HStack, Label, Panel, ScrollContainer, type TextStyleToken, VStack } from '../../ui';
import { consoleLog, type ConsoleLog, formatTime, type LogLevel } from './log';
const ROW_HEIGHT = 18;
/** A row is time plus message, not one string: a check that locates a line goes by its text alone. */
interface Row {
readonly box: HStack;
readonly time: Label;
readonly text: Label;
}
const STYLE_OF: Record<LogLevel, TextStyleToken> = {
info: 'mono',
warn: 'monoWarn',
error: 'monoError',
};
/**
* The tail of `ConsoleLog` under the centre tabs. Rebuilding runs from `refresh()` rather than
* from the log event, so a burst of lines costs one rebuild per frame instead of one per line.
*/
export class ConsolePanel extends Panel {
private readonly scroll = new ScrollContainer();
private readonly rows = new VStack();
private readonly views: Row[] = [];
private stale = true;
constructor(private readonly log: ConsoleLog = consoleLog) {
super({ title: 'Console', padding: 0, gap: 0 });
this.header.add(new Button('Clear', { variant: 'ghost', onClick: () => this.log.clear() }));
this.scroll.setContent(this.rows);
this.content.add(this.scroll, 1);
this.log.onChange(() => (this.stale = true));
}
refresh(): void {
if (!this.stale) return;
this.stale = false;
const lines = this.log.lines;
// Following the tail is only wanted while the tail is what you are looking at.
const atBottom = this.scroll.offset >= this.scroll.maxScroll - 1;
while (this.views.length > lines.length) {
const row = this.views.pop();
if (row) this.rows.removeChild(row.box);
}
while (this.views.length < lines.length) {
const time = new Label('', { style: 'monoMuted', padding: 6 });
const text = new Label('', { style: 'mono' });
const box = new HStack({ gap: 6 });
box.minHeight = ROW_HEIGHT;
time.minHeight = ROW_HEIGHT;
text.minHeight = ROW_HEIGHT;
box.add(time);
box.add(text, 1);
this.views.push({ box, time, text });
this.rows.add(box);
}
lines.forEach((line, i) => {
const row = this.views[i]!;
row.time.setText(formatTime(line.time));
row.text.setText(line.text);
row.text.setStyle(STYLE_OF[line.level]);
});
this.scroll.relayoutContent();
if (atBottom) this.scroll.scrollTo(this.scroll.maxScroll);
}
/** What the check scripts read: the lines as drawn, newest last. */
debugInfo(): { lines: { level: LogLevel; text: string; at: string }[]; height: number } {
return {
lines: this.log.lines.map((line) => ({
level: line.level,
text: line.text,
at: formatTime(line.time),
})),
height: this.h,
};
}
/** `offset === max` is the state in which new lines follow the tail. */
scrollInfo(): { offset: number; max: number } {
return { offset: Math.round(this.scroll.offset), max: Math.round(this.scroll.maxScroll) };
}
clear(): void {
this.log.clear();
}
}
+103
View File
@@ -0,0 +1,103 @@
export type LogLevel = 'info' | 'warn' | 'error';
export interface LogLine {
readonly level: LogLevel;
readonly text: string;
/** Wall clock of the call, in ms; the panel draws it as `HH:MM:SS`. */
readonly time: number;
}
/** Local time of `ms` as `HH:MM:SS` – a console tail is read within the session, not across days. */
export function formatTime(ms: number): string {
const at = new Date(ms);
const pad = (n: number) => String(n).padStart(2, '0');
return `${pad(at.getHours())}:${pad(at.getMinutes())}:${pad(at.getSeconds())}`;
}
/** Oldest lines are dropped past this; the panel is a tail, not an archive. */
export const LOG_CAPACITY = 500;
const MAX_VALUE = 200;
function formatValue(value: unknown): string {
if (typeof value === 'string') return value;
// An Error prints its whole stack, which would bury the message under frames nobody reads here.
if (value instanceof Error) return value.stack?.split('\n')[0] ?? `${value.name}: ${value.message}`;
if (typeof value === 'function') return `[function ${value.name || 'anonymous'}]`;
if (typeof value === 'object' && value !== null) {
let json: string;
try {
json = JSON.stringify(value);
} catch {
return '[unserialisable]';
}
return json.length > MAX_VALUE ? `${json.slice(0, MAX_VALUE)}…` : json;
}
return String(value);
}
/** One line out of the arguments of a `console.*` call, the way the browser joins them. */
export function formatArgs(args: readonly unknown[]): string {
return args.map(formatValue).join(' ');
}
export class ConsoleLog {
private buffer: LogLine[] = [];
private readonly listeners = new Set<() => void>();
constructor(private readonly now: () => number = Date.now) {}
/** Appends `text`; an embedded newline starts a new line, so a stack stays readable. */
push(level: LogLevel, text: string): void {
const time = this.now();
for (const part of text.split('\n')) this.buffer.push({ level, text: part, time });
if (this.buffer.length > LOG_CAPACITY) this.buffer = this.buffer.slice(-LOG_CAPACITY);
for (const listener of this.listeners) listener();
}
get lines(): readonly LogLine[] {
return this.buffer;
}
clear(): void {
if (this.buffer.length === 0) return;
this.buffer = [];
for (const listener of this.listeners) listener();
}
onChange(listener: () => void): void {
this.listeners.add(listener);
}
}
export const consoleLog = new ConsoleLog();
const METHODS = ['log', 'info', 'debug', 'warn', 'error'] as const;
type ConsoleMethod = (typeof METHODS)[number];
export type ConsoleLike = Record<ConsoleMethod, (...args: unknown[]) => void>;
const LEVEL_OF: Record<ConsoleMethod, LogLevel> = {
log: 'info',
info: 'info',
debug: 'info',
warn: 'warn',
error: 'error',
};
/**
* Mirrors every `console.*` call into `log` and keeps calling the original, so devtools and the
* check scripts still see it. Returns the undo, which only the tests use.
*/
export function captureConsole(target: ConsoleLike = console, log: ConsoleLog = consoleLog): () => void {
const originals = METHODS.map((method) => [method, target[method]] as const);
for (const [method, original] of originals) {
target[method] = (...args: unknown[]) => {
original.apply(target, args);
log.push(LEVEL_OF[method], formatArgs(args));
};
}
return () => {
for (const [method, original] of originals) target[method] = original;
};
}
+40 -2
View File
@@ -24,8 +24,10 @@ export function installDebugHook(app: Application, state: EditorState, editor: E
const walk = (node: Container): void => {
if (!node.visible) return;
if (node instanceof Text && node.text === text) {
const p = node.getGlobalPosition();
hits.push({ x: p.x + node.width / 2, y: p.y + node.height / 2 });
// Global bounds, not `width`/`height`: those are unscaled, and inside the graph canvas
// (which is scaled by the zoom) that put the point right of the text it was meant to hit.
const box = node.getBounds();
hits.push({ x: box.x + box.width / 2, y: box.y + box.height / 2 });
return;
}
for (const child of node.children) walk(child);
@@ -180,6 +182,30 @@ export function installDebugHook(app: Application, state: EditorState, editor: E
labelsDropped,
shownLabel,
viewportCenter: () => centreOf(viewport),
/** Its drawn box – a step that wants an empty spot must stay inside what the console left over. */
viewportRect: () => {
const p = viewport.getGlobalPosition();
return { x: p.x, y: p.y, w: viewport.w, h: viewport.h };
},
/**
* A point in the viewport that no object covers – steps click there to deselect. A fixed
* offset from the centre lands on an object as soon as the viewport changes size.
*/
emptySpot: () => {
const p = viewport.getGlobalPosition();
const scene = state.activeScene;
const taken = scene
? objectsOf(scene.id)
.map((object) => viewport.objectRect(object.id))
.filter((rect): rect is NonNullable<typeof rect> => rect !== null)
: [];
const clear = (x: number, y: number) =>
!taken.some((r) => x >= r.x - 12 && x <= r.x + r.w + 12 && y >= r.y - 12 && y <= r.y + r.h + 12);
for (let y = p.y + viewport.h - 24; y > p.y + 24; y -= 24) {
for (let x = p.x + 24; x < p.x + viewport.w - 24; x += 24) if (clear(x, y)) return { x, y };
}
return null;
},
viewportInfo: () => viewport.debugInfo(),
/** Where a dragged asset would land in the Scene tab, and why not when it may not. */
dropPreview: () => viewport.dropInfo(),
@@ -216,6 +242,8 @@ export function installDebugHook(app: Application, state: EditorState, editor: E
/** Drawn rectangles on screen: what "fit" has to keep inside `canvasRect`. */
nodeRect: (nodeId: string) => graphEditor.canvas.nodeRect(nodeId),
canvasRect: () => graphEditor.canvas.canvasRect(),
/** Bare canvas below every node – where a step right-clicks to open the palette. */
emptySpot: () => graphEditor.canvas.emptySpotGlobal(),
selected: () => state.selectedNodeIds,
dropTarget: () => graphEditor.canvas.pendingDropTarget(),
danglingEdge: () => graphEditor.canvas.danglingEdgeVisible(),
@@ -228,6 +256,7 @@ export function installDebugHook(app: Application, state: EditorState, editor: E
pickerWidth: () => graphEditor.picker.w,
pickerAt: () => centreOf(graphEditor.picker),
snapping: () => graphEditor.canvas.snapEnabled,
zoom: () => graphEditor.canvas.zoomLevel,
crumbs: () => graphEditor.breadcrumb.crumbsDrawn(),
},
stateMachine: {
@@ -240,6 +269,15 @@ export function installDebugHook(app: Application, state: EditorState, editor: E
center: () => centreOf(editor.stateMachineEditor.canvas),
snapping: () => editor.stateMachineEditor.canvas.snapEnabled,
},
/** The console panel: its lines as kept, its drawn height, and the divider above it. */
console: {
lines: () => editor.console.debugInfo().lines,
height: () => editor.console.h,
clear: () => editor.console.clear(),
scroll: () => editor.console.scrollInfo(),
centre: () => centreOf(editor.console),
splitterAt: () => centreOf(editor.consoleSplitter),
},
/** The open image picker: what it offers, what is picked, and where a tile sits. */
imagePicker: () => currentImagePicker()?.debugInfo() ?? null,
imagePickerTile: (file: string) => currentImagePicker()?.tileGlobal(file) ?? null,
+24
View File
@@ -188,6 +188,11 @@ export class GraphCanvas extends Widget {
return this.snapping;
}
/** Screen pixels per graph unit; the fit picks it from the canvas size, so a check cannot assume 1. */
get zoomLevel(): number {
return this.zoom;
}
override measure(): Size {
return { w: Math.max(this.minWidth, 200), h: Math.max(this.minHeight, 200) };
}
@@ -797,6 +802,25 @@ export class GraphCanvas extends Widget {
return globalRect(this, this.w, this.h);
}
/**
* A drawn point no node covers, with room below and right for a node created there and for the
* palette that creates it. Searched from the bottom up, so no edge between nodes crosses it.
*/
emptySpotGlobal(): { x: number; y: number } | null {
const rect = this.canvasRect();
const room = { below: NODE_HEIGHT_ESTIMATE * this.zoom + 40, right: NODE_WIDTH * this.zoom + 40 };
const taken = [...this.views.keys()].map((id) => this.nodeRect(id)).filter((r): r is Rect => r !== null);
const clear = (x: number, y: number) =>
!taken.some(
(r) => x >= r.x - 16 && x <= r.x + r.w + 16 && y >= r.y - 16 && y <= r.y + r.h + room.below + 16,
);
for (let y = rect.y + rect.h - room.below; y > rect.y + 40; y -= 20) {
// Right to left: a node created here must stay inside the view a later marquee spans.
for (let x = rect.x + rect.w - room.right; x > rect.x + 40; x -= 20) if (clear(x, y)) return { x, y };
}
return null;
}
/** A nested-behavior node takes its title from the behavior it runs. */
nodeLabel(nodeId: string): string | null {
return this.views.get(nodeId)?.def.label ?? null;
+10 -1
View File
@@ -13,6 +13,7 @@ import {
type Project,
} from '../core/model/Project';
import { deserializeProject, serializeProject } from '../core/store/serialize';
import { consoleLog } from './console/log';
export type Selection =
| { kind: 'asset'; assetId: string }
@@ -90,6 +91,9 @@ export type EditorEvent =
type Listener = () => void;
/** How a notice reaches the console panel: as a line, as a red line, or not at all. */
export type NoticeLevel = 'info' | 'error' | 'quiet';
/**
* Single source of truth for the editor. Play mode swaps `project` for a deep clone (same ids)
* with its own undo stack; panels and commands keep working on it, and `exitPlay()` discards it.
@@ -392,8 +396,13 @@ export class EditorState {
this.emit('save');
}
setNotice(message: string): void {
/**
* Shows `message` in the toolbar and, unless `quiet`, keeps it in the console panel. Quiet is
* for the play status lines, which state a mode rather than report an event.
*/
setNotice(message: string, level: NoticeLevel = 'info'): void {
this.notice = message;
if (message !== '' && level !== 'quiet') consoleLog.push(level, message);
this.emit('notice');
}
}
+2 -2
View File
@@ -220,7 +220,7 @@ export function pickImageFiles(): Promise<File[]> {
export function pickAndImport(deps: TextureImportDeps): void {
void pickImageFiles()
.then((files) => importImages(files, deps))
.catch((error: unknown) => deps.state.setNotice(String(error)));
.catch((error: unknown) => deps.state.setNotice(String(error), 'error'));
}
/**
@@ -264,7 +264,7 @@ export function installTextureImport(target: HTMLElement, deps: TextureImportDep
}
function run(files: readonly File[], deps: TextureImportDeps): void {
void importImages(files, deps).catch((error: unknown) => deps.state.setNotice(String(error)));
void importImages(files, deps).catch((error: unknown) => deps.state.setNotice(String(error), 'error'));
}
function imageFilesOf(data: DataTransfer | null): File[] {
+5
View File
@@ -2,10 +2,15 @@ import { Application, Container } from 'pixi.js';
// Static imports on purpose: Pixi registers render pipes (graphics, text, sprite) as a
// module side effect, and a renderer only knows the pipes that exist when it is created.
// Lazy chunks loaded after `app.init()` would therefore fail with "unknown render pipe".
import { captureConsole } from './editor/console/log';
import { startEditor } from './editor/App';
import { mountPlayground } from './playground';
import { initUI, Theme, VStack } from './ui';
// Before anything else: a renderer warns while it is being created, and those warnings are
// exactly the ones worth having in the panel.
captureConsole();
// `desynchronized` bypasses the compositor queue (headless click-to-paint 72 → 16 ms) but may
// tear; `?vsync` opts out. Pixi has no option for it, hence our own context.
function lowLatencyContext(): { canvas?: HTMLCanvasElement; context?: WebGL2RenderingContext } {
+12
View File
@@ -81,6 +81,9 @@ export type TextStyleToken =
| 'live'
| 'title'
| 'mono'
| 'monoMuted'
| 'monoWarn'
| 'monoError'
| 'accent'
| 'viewportLabel'
| 'viewportLabelMuted';
@@ -124,6 +127,15 @@ export function textStyle(token: TextStyleToken = 'body'): TextStyle {
case 'mono':
style = new TextStyle({ ...base, fontFamily: Theme.monoFont });
break;
case 'monoMuted':
style = new TextStyle({ ...base, fontFamily: Theme.monoFont, fill: Theme.colors.textMuted });
break;
case 'monoWarn':
style = new TextStyle({ ...base, fontFamily: Theme.monoFont, fill: Theme.colors.override });
break;
case 'monoError':
style = new TextStyle({ ...base, fontFamily: Theme.monoFont, fill: Theme.colors.danger });
break;
case 'accent':
style = new TextStyle({ ...base, fill: Theme.colors.accentText, fontWeight: '600' });
break;
+3 -1
View File
@@ -14,6 +14,8 @@ export interface PanelOptions {
/** A titled box; the header row can hold extra widgets through `header.add()`. */
export class Panel extends Widget {
/** Height of the title row – the floor for a panel that must stay grabbable when dragged shut. */
static readonly HEADER_HEIGHT = Theme.rowHeight + 4;
private readonly bg = new Graphics();
readonly header: HStack;
readonly content: VStack;
@@ -25,7 +27,7 @@ export class Panel extends Widget {
this.chrome = options.chrome ?? true;
this.addChild(this.bg);
this.header = new HStack({ padding: 0, gap: Theme.gap, align: 'center' });
this.header.minHeight = Theme.rowHeight + 4;
this.header.minHeight = Panel.HEADER_HEIGHT;
if (options.title !== undefined) {
this.titleLabel = new Label(options.title.toUpperCase(), { style: 'title', padding: Theme.padding });
this.header.add(this.titleLabel, 1);
+5
View File
@@ -53,6 +53,11 @@ export class ScrollContainer extends Widget {
return this.content ? this.content.h : 0;
}
/** Current scroll offset; equal to `maxScroll` means the end of the content is showing. */
get offset(): number {
return this.scrollY;
}
/** Largest valid scroll offset; 0 while the content fits. */
get maxScroll(): number {
return Math.max(0, this.contentHeight - this.h);