diff --git a/README.md b/README.md index 186d6f16..b58fe983 100644 --- a/README.md +++ b/README.md @@ -47,6 +47,15 @@ from texture* and *Delete…*. In **Scene objects** it offers *Select asset*, *Add collider*, *Create container*, *Unpack container*, *Unparent*, *Expand all* / *Collapse all* and *Remove*. +**The console.** Under whichever centre tab is showing sits the **Console** panel, always +visible and resized by the divider above it – dragged all the way shut it keeps its own header +to be dragged open by. It mirrors everything that reaches the browser console: a `Log` node's +message, Pixi's warnings, an uncaught error, and the editor's own notices (a failure in red, a +hint in plain text; the *Playing · …* status line is a mode rather than an event and stays out). +Each line carries the clock time of its call (`HH:MM:SS`, muted, in its own column, so the +message stays exactly what was logged). The newest 500 lines are kept, *Clear* empties them, +and new lines follow the tail only while the tail is what you are looking at. + **Editing while playing.** Play opens the Game tab but leaves the editor fully usable: the Scene tab shows the live positions (symbols, gizmos and dragging included), the hierarchy and inspector show and edit the running objects, the graph can be changed, undo/redo work on the @@ -752,6 +761,7 @@ src/editor/texture/ TextureEditor (header + picker), TextureCanvas (picture + src/editor/animation/ AnimationEditor (header + picker), AnimationCanvas (preview + frame strip), strip (its geometry) src/editor/statemachine/ StateMachineEditor (header + picker, on the graph canvas) src/editor/list/ ListEditor (a row per item), slot (which list is being edited, and how it is written back) +src/editor/console/ log (ring buffer, argument formatting, the console.* capture), ConsolePanel (the tail under the tabs) api/ PHP backend data/projects/ persisted projects tests/ Vitest unit tests tools/ seed-*, e2e (steps in tools/e2e/, rig in tools/e2e/lib/), smoke-*, lib/, assets/ (the walk demo's sheet) ``` @@ -783,7 +793,7 @@ npm run check # typecheck + lint + format:check + tests, what CI would ru # code is lost (tail's is reported), so a red run reads as green npm run build # writes dist/ and regenerates index.html ./build.sh # the same under `set -o pipefail`, for callers that pipe the output -node tools/seed-e2e.mjs && node tools/e2e.mjs # 657 editor checks, screenshots in /tmp/gm-e2e-*.png +node tools/seed-e2e.mjs && node tools/e2e.mjs # 671 editor checks, screenshots in /tmp/gm-e2e-*.png GM_STEPS=20 node tools/e2e.mjs # or 1-12, or 6,20 – a subset while working on one step ./e2e.sh start [GM_STEPS] # the same run detached, see below ./e2e.sh status # live progress, or the last run's result @@ -832,7 +842,7 @@ 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, textures, frames, overflow, aspect, container tab, run-in-editor, pause, nested behaviors, key repeat, list tab, struct variable, -variable nodes, property nodes, animations, state machines) that build on each other's +variable nodes, property nodes, animations, state machines, console) 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. diff --git a/src/editor/Editor.ts b/src/editor/Editor.ts index 6c1474c3..2cb0be6d 100644 --- a/src/editor/Editor.ts +++ b/src/editor/Editor.ts @@ -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; @@ -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); } diff --git a/src/editor/EditorRun.ts b/src/editor/EditorRun.ts index b5f33c7c..a38bbf0d 100644 --- a/src/editor/EditorRun.ts +++ b/src/editor/EditorRun.ts @@ -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; } diff --git a/src/editor/PlaySession.ts b/src/editor/PlaySession.ts index 64b62de2..575e1ad5 100644 --- a/src/editor/PlaySession.ts +++ b/src/editor/PlaySession.ts @@ -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 { diff --git a/src/editor/ProjectSession.ts b/src/editor/ProjectSession.ts index ecab7218..8545dc78 100644 --- a/src/editor/ProjectSession.ts +++ b/src/editor/ProjectSession.ts @@ -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 { diff --git a/src/editor/autosave.ts b/src/editor/autosave.ts index b72780a5..a990e1b2 100644 --- a/src/editor/autosave.ts +++ b/src/editor/autosave.ts @@ -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; diff --git a/src/editor/console/ConsolePanel.ts b/src/editor/console/ConsolePanel.ts new file mode 100644 index 00000000..16aa19d6 --- /dev/null +++ b/src/editor/console/ConsolePanel.ts @@ -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 = { + 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(); + } +} diff --git a/src/editor/console/log.ts b/src/editor/console/log.ts new file mode 100644 index 00000000..8e65a0a7 --- /dev/null +++ b/src/editor/console/log.ts @@ -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 void>; + +const LEVEL_OF: Record = { + 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; + }; +} diff --git a/src/editor/debugHook.ts b/src/editor/debugHook.ts index 1a7e5343..0e58466b 100644 --- a/src/editor/debugHook.ts +++ b/src/editor/debugHook.ts @@ -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 => 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, diff --git a/src/editor/graph/GraphCanvas.ts b/src/editor/graph/GraphCanvas.ts index 13aafbc2..4e664ba8 100644 --- a/src/editor/graph/GraphCanvas.ts +++ b/src/editor/graph/GraphCanvas.ts @@ -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; diff --git a/src/editor/state.ts b/src/editor/state.ts index 68adea0d..7f3922b1 100644 --- a/src/editor/state.ts +++ b/src/editor/state.ts @@ -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'); } } diff --git a/src/editor/textureImport.ts b/src/editor/textureImport.ts index 2244e8ff..f0b2c6b6 100644 --- a/src/editor/textureImport.ts +++ b/src/editor/textureImport.ts @@ -220,7 +220,7 @@ export function pickImageFiles(): Promise { 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[] { diff --git a/src/main.ts b/src/main.ts index f730558f..72aec8d5 100644 --- a/src/main.ts +++ b/src/main.ts @@ -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 } { diff --git a/src/ui/Theme.ts b/src/ui/Theme.ts index 8a3705ca..5d4cf2e7 100644 --- a/src/ui/Theme.ts +++ b/src/ui/Theme.ts @@ -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; diff --git a/src/ui/widgets/Panel.ts b/src/ui/widgets/Panel.ts index 8cfaf21f..9310f59e 100644 --- a/src/ui/widgets/Panel.ts +++ b/src/ui/widgets/Panel.ts @@ -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); diff --git a/src/ui/widgets/ScrollContainer.ts b/src/ui/widgets/ScrollContainer.ts index 1f19f582..756447f4 100644 --- a/src/ui/widgets/ScrollContainer.ts +++ b/src/ui/widgets/ScrollContainer.ts @@ -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); diff --git a/tests/editor/console-log.test.ts b/tests/editor/console-log.test.ts new file mode 100644 index 00000000..45f6ae2f --- /dev/null +++ b/tests/editor/console-log.test.ts @@ -0,0 +1,136 @@ +/** + * The console panel's source: the ring buffer, the way `console.*` arguments become one line, + * and which notices are kept. The panel itself needs a browser and is covered by e2e step 27. + */ +import { describe, expect, it, vi } from 'vitest'; +import { + captureConsole, + ConsoleLog, + type ConsoleLike, + consoleLog, + formatArgs, + formatTime, + LOG_CAPACITY, +} from '../../src/editor/console/log'; +import { EditorState } from '../../src/editor/state'; + +function fakeConsole(): ConsoleLike { + return { log: vi.fn(), info: vi.fn(), debug: vi.fn(), warn: vi.fn(), error: vi.fn() }; +} + +describe('the log buffer', () => { + it('keeps lines newest last, each stamped with the time of its call', () => { + let clock = new Date(2026, 8, 12, 9, 5, 3).getTime(); + const log = new ConsoleLog(() => clock); + log.push('info', 'first'); + clock += 61_000; + log.push('error', 'second'); + expect(log.lines).toEqual([ + { level: 'info', text: 'first', time: new Date(2026, 8, 12, 9, 5, 3).getTime() }, + { level: 'error', text: 'second', time: new Date(2026, 8, 12, 9, 6, 4).getTime() }, + ]); + expect(log.lines.map((l) => formatTime(l.time))).toEqual(['09:05:03', '09:06:04']); + }); + + it('gives every line of a split message the one time it arrived', () => { + // A clock that moves on every read: asked once per line, the second would fall into the next day. + let reads = 0; + const log = new ConsoleLog(() => new Date(2026, 8, 12, 23, 59, 59).getTime() + reads++ * 1000); + log.push('error', 'boom\n at frame'); + expect(log.lines.map((l) => formatTime(l.time))).toEqual(['23:59:59', '23:59:59']); + expect(reads).toBe(1); + }); + + it('drops the oldest lines past the capacity', () => { + const log = new ConsoleLog(); + for (let i = 0; i < LOG_CAPACITY + 10; i += 1) log.push('info', `line ${i}`); + expect(log.lines).toHaveLength(LOG_CAPACITY); + expect(log.lines[0]?.text).toBe('line 10'); + expect(log.lines.at(-1)?.text).toBe(`line ${LOG_CAPACITY + 9}`); + }); + + it('splits an embedded newline into its own line', () => { + const log = new ConsoleLog(); + log.push('error', 'boom\n at frame'); + expect(log.lines.map((l) => l.text)).toEqual(['boom', ' at frame']); + }); + + it('notifies on a push and on a clear, but not on an empty clear', () => { + const log = new ConsoleLog(); + const seen = vi.fn(); + log.onChange(seen); + log.push('info', 'one'); + log.clear(); + log.clear(); + expect(seen).toHaveBeenCalledTimes(2); + expect(log.lines).toEqual([]); + }); +}); + +describe('formatting the arguments of a console call', () => { + it('joins them with a space and leaves strings alone', () => { + expect(formatArgs(['[behavior Crate]', 'hit', 3])).toBe('[behavior Crate] hit 3'); + }); + + it('shows an Error as its first stack line, not the whole stack', () => { + const error = new Error('gone wrong'); + error.stack = 'Error: gone wrong\n at one\n at two'; + expect(formatArgs([error])).toBe('Error: gone wrong'); + }); + + it('writes an object as JSON and a circular one as a placeholder', () => { + expect(formatArgs([{ x: 1, y: [2, 3] }])).toBe('{"x":1,"y":[2,3]}'); + const circular: Record = {}; + circular.self = circular; + expect(formatArgs([circular])).toBe('[unserialisable]'); + }); + + it('keeps null and undefined visible', () => { + expect(formatArgs([null, undefined, false])).toBe('null undefined false'); + }); +}); + +describe('capturing console', () => { + it('mirrors every level into the log and still calls the original', () => { + const target = fakeConsole(); + const log = new ConsoleLog(); + const undo = captureConsole(target, log); + target.log('a'); + target.info('b'); + target.debug('c'); + target.warn('d'); + target.error('e'); + expect(log.lines.map((l) => ({ level: l.level, text: l.text }))).toEqual([ + { level: 'info', text: 'a' }, + { level: 'info', text: 'b' }, + { level: 'info', text: 'c' }, + { level: 'warn', text: 'd' }, + { level: 'error', text: 'e' }, + ]); + undo(); + target.log('after'); + expect(log.lines).toHaveLength(5); + }); + + it('leaves the original reachable, so devtools and the check scripts still see the call', () => { + const target = fakeConsole(); + const original = target.warn; + captureConsole(target, new ConsoleLog())(); + expect(target.warn).toBe(original); + }); +}); + +describe('notices in the console', () => { + it('keeps a plain notice, marks a failure red and stays quiet for a status line', () => { + const state = new EditorState(); + consoleLog.clear(); + state.setNotice('Only image files can be dropped into the editor.'); + state.setNotice('Saving failed: 500', 'error'); + state.setNotice('Playing · edits are not saved', 'quiet'); + state.setNotice(''); + expect(consoleLog.lines.map((l) => ({ level: l.level, text: l.text }))).toEqual([ + { level: 'info', text: 'Only image files can be dropped into the editor.' }, + { level: 'error', text: 'Saving failed: 500' }, + ]); + }); +}); diff --git a/tools/e2e/02-inspector.mjs b/tools/e2e/02-inspector.mjs index 4edeb7db..5fd3f8cd 100644 --- a/tools/e2e/02-inspector.mjs +++ b/tools/e2e/02-inspector.mjs @@ -103,9 +103,12 @@ export async function inspector(t) { // 2f. Type into X, then click straight into the empty viewport: the typed value must be // committed (not reverted) even though the click deselects and rebuilds the inspector. const center = await json('__gm.viewportCenter()'); + // Searched, not guessed: with the console panel below, a fixed offset from the centre can + // land on an object instead of empty space. + const emptySpot = await json('__gm.emptySpot()'); await click(xLabel.x + 160, xLabel.y); // plain click → text editing with everything selected await typeText('300'); - await click(center.x, center.y + 250); + await click(emptySpot.x, emptySpot.y); const xTyped = (await object('o2')).position.x; s = await state(); check( @@ -130,7 +133,7 @@ export async function inspector(t) { check('save status is saved', (await state()).save === 'saved'); // 2h. Undo four times (drag, typed X, scrub, rename) with Ctrl+Z, then redo once. - await click(center.x, center.y + 200); // click empty viewport → deselect, drop focus + await click(emptySpot.x, emptySpot.y); // click empty viewport → deselect, drop focus for (let i = 0; i < 4; i++) await undo(); const afterUndo = (await t.objects()).map((o) => ({ id: o.id, name: o.name, x: o.position.x })); check( diff --git a/tools/e2e/06-graph.mjs b/tools/e2e/06-graph.mjs index 08ef0f5b..cf4d4870 100644 --- a/tools/e2e/06-graph.mjs +++ b/tools/e2e/06-graph.mjs @@ -47,6 +47,8 @@ export async function graph(t) { // 6b. Drag a node: clicking its title selects it, dragging moves it (undoable). const axisBefore = await nodeOf('n-axis'); const axisPos = await json(`__gm.graph.nodeAt('n-axis')`); + // The fit zoom follows the canvas size, so 60 screen pixels are 60/zoom graph units, not 60. + const fitZoom = Number(await evaluate('__gm.graph.zoom()')); await drag(axisPos.x, axisPos.y, axisPos.x + 60, axisPos.y + 40); let s = await state(); const axisAfter = await nodeOf('n-axis'); @@ -54,8 +56,8 @@ export async function graph(t) { 'dragging a node selects and moves it', s.selection?.kind === 'node' && s.selection.nodeId === 'n-axis' && - axisAfter.x === axisBefore.x + 60 && - axisAfter.y === axisBefore.y + 40, + Math.abs(axisAfter.x - (axisBefore.x + 60 / fitZoom)) <= 2 && + Math.abs(axisAfter.y - (axisBefore.y + 40 / fitZoom)) <= 2, `${JSON.stringify(s.selection)} ${axisBefore.x},${axisBefore.y} → ${axisAfter.x},${axisAfter.y}`, ); check( @@ -73,9 +75,10 @@ export async function graph(t) { // 6c. Typing a query and then *clicking* a row adds exactly one node: the search field's // blur-commit must not pick the first match as well. - const gcenter = await json('__gm.graph.center()'); + // Bare canvas, searched rather than guessed: the console panel below shortens the canvas. + const bare = await json('__gm.graph.emptySpot()'); const beforeClickPick = await graphCounts(); - await click(gcenter.x + 250, gcenter.y + 250, 'right'); + await click(bare.x, bare.y, 'right'); await sleep(100); check('node palette opens (row-click case)', Boolean(await evaluate(`__gm.locate('Search nodes…')`))); await typeText('stop'); @@ -95,7 +98,7 @@ export async function graph(t) { // 6d. Right-click the empty canvas → palette → search "log" → Enter adds a Log node. const before13 = await graphCounts(); - await click(gcenter.x + 250, gcenter.y + 250, 'right'); + await click(bare.x, bare.y, 'right'); await sleep(100); check('node palette opens', Boolean(await evaluate(`__gm.locate('Search nodes…')`))); await typeText('log'); @@ -167,7 +170,7 @@ export async function graph(t) { // 13d3. Dropped into empty space, the drag opens the palette filtered to what it could reach, // and the picked node arrives already wired – as one undo step. - const empty = { x: gcenter.x - 250, y: gcenter.y + 250 }; + const empty = await json('__gm.graph.emptySpot()'); const beforeDrop = await graphCounts(); await mouse('mouseMoved', axisX.x, axisX.y); await mouse('mousePressed', axisX.x, axisX.y); @@ -362,6 +365,9 @@ export async function graph(t) { await positions(), ); + // Fit again: the pan just now pushed a node out of view, and a band takes only what it sees. + await clickText('Fit'); + await sleep(250); // An empty spot: after a fit every node sits at least a margin away from the corner. const corner = { x: area.x + 6, y: area.y + 6 }; const opposite = { x: area.x + area.w - 6, y: area.y + area.h - 6 }; @@ -389,18 +395,21 @@ export async function graph(t) { // 6l. The whole selection moves as one, and one undo takes all of it back. const beforeMove = (await asset('mover')).graph.nodes.map((n) => ({ id: n.id, x: n.x, y: n.y })); const grabAt = await json(`__gm.graph.nodeAt('n-axis')`); + // Screen pixels become graph units through the zoom, which the fit picks from the canvas size. + const units = async (px) => px / Number(await evaluate('__gm.graph.zoom()')); + const [mx, my] = [await units(40), await units(30)]; await drag(grabAt.x, grabAt.y, grabAt.x + 40, grabAt.y + 30); const afterMove = await asset('mover'); const movedTogether = beforeMove.every((n) => { const now = afterMove.graph.nodes.find((m) => m.id === n.id); - return now.x === n.x + 40 && now.y === n.y + 30; + return Math.abs(now.x - (n.x + mx)) <= 2 && Math.abs(now.y - (n.y + my)) <= 2; }); check('dragging one of them moves the whole selection', movedTogether, await positions()); await undo(); check('one undo takes the group move back', (await positions()) === before13i, await positions()); - // 13k2. Grid snapping. The graph keeps a zoom of 1 here (13k proved it with an exact +40/+30), - // so a screen delta is a graph delta. + // 13k2. Grid snapping. Distances are stated in graph units, which `units()` reads off the + // current zoom – the canvas is not wide enough here for a zoom of 1. const GRID = 24; const onGrid = (n) => n.x % GRID === 0 && n.y % GRID === 0; check('snapping is off until it is asked for', (await json('__gm.graph.snapping()')) === false); @@ -419,13 +428,14 @@ export async function graph(t) { await sleep(150); const snapPre = await nodeOf('n-axis'); const snapFrom = await json(`__gm.graph.nodeAt('n-axis')`); + const snapBy = await units(50); await drag(snapFrom.x, snapFrom.y, snapFrom.x + 50, snapFrom.y + 50); const snapped = await nodeOf('n-axis'); check( 'a dragged node lands on the grid, not where the pointer let go', onGrid(snapped) && - Math.abs(snapped.x - (snapPre.x + 50)) <= GRID / 2 && - Math.abs(snapped.y - (snapPre.y + 50)) <= GRID / 2, + Math.abs(snapped.x - (snapPre.x + snapBy)) <= GRID / 2 && + Math.abs(snapped.y - (snapPre.y + snapBy)) <= GRID / 2, `${snapPre.x},${snapPre.y} → ${snapped.x},${snapped.y}`, ); const others = (await asset('mover')).graph.nodes.filter((n) => n.id !== 'n-axis'); @@ -445,6 +455,7 @@ export async function graph(t) { JSON.stringify(groupPre.map((n) => `${n.id}:${n.x},${n.y}`)), ); const grabFrom = await json(`__gm.graph.nodeAt('n-axis')`); + const groupBy = await units(30); await drag(grabFrom.x, grabFrom.y, grabFrom.x + 30, grabFrom.y + 30); const groupPost = (await asset('mover')).graph.nodes; check( @@ -452,7 +463,7 @@ export async function graph(t) { groupPost.every(onGrid) && groupPre.every((n) => { const now = groupPost.find((m) => m.id === n.id); - return Math.abs(now.x - (n.x + 30)) <= GRID / 2 && Math.abs(now.y - (n.y + 30)) <= GRID / 2; + return Math.abs(now.x - (n.x + groupBy)) <= GRID / 2 && Math.abs(now.y - (n.y + groupBy)) <= GRID / 2; }), await positions(), ); @@ -467,13 +478,15 @@ export async function graph(t) { await sleep(150); const offPre = await nodeOf('n-axis'); const offFrom = await json(`__gm.graph.nodeAt('n-axis')`); + const offBy = await units(50); await drag(offFrom.x, offFrom.y, offFrom.x + 50, offFrom.y + 50); const offPost = await nodeOf('n-axis'); check( - 'switched off, a node lands exactly where it was dropped again', + 'switched off, a node lands where it was dropped again, off the grid', (await json('__gm.graph.snapping()')) === false && - offPost.x === offPre.x + 50 && - offPost.y === offPre.y + 50, + !onGrid(offPost) && + Math.abs(offPost.x - (offPre.x + offBy)) <= 2 && + Math.abs(offPost.y - (offPre.y + offBy)) <= 2, `${offPre.x},${offPre.y} → ${offPost.x},${offPost.y}`, ); await undo(); @@ -522,8 +535,11 @@ export async function graph(t) { // 6o. Fit really fits: proven against a node parked far outside, and against the fact that the // graph is then wider than the canvas – otherwise centring alone would pass this check. + // The distance follows the canvas: a fixed 2500 units needs a zoom below the 0.25 floor once + // the canvas is short, and then Fit cannot fit anything however correct it is. + const park = Math.round(area.w); await evaluate( - `(() => { const n = __gm.state.project.assets.find(a => a.id === 'mover').graph.nodes.find(n => n.id === 'n-axis'); n.x += 2500; __gm.state.preview(); })()`, + `(() => { const n = __gm.state.project.assets.find(a => a.id === 'mover').graph.nodes.find(n => n.id === 'n-axis'); n.x += ${park}; __gm.state.preview(); })()`, ); await sleep(250); await clickText('Fit'); @@ -547,7 +563,7 @@ export async function graph(t) { ); await shot('22-graph-fitted'); await evaluate( - `(() => { const n = __gm.state.project.assets.find(a => a.id === 'mover').graph.nodes.find(n => n.id === 'n-axis'); n.x -= 2500; __gm.state.preview(); })()`, + `(() => { const n = __gm.state.project.assets.find(a => a.id === 'mover').graph.nodes.find(n => n.id === 'n-axis'); n.x -= ${park}; __gm.state.preview(); })()`, ); await clickText('Fit'); await sleep(250); diff --git a/tools/e2e/19-nested-behavior.mjs b/tools/e2e/19-nested-behavior.mjs index bdf11645..5e26009a 100644 --- a/tools/e2e/19-nested-behavior.mjs +++ b/tools/e2e/19-nested-behavior.mjs @@ -178,8 +178,8 @@ export async function nestedBehavior(t) { // in the list on the left, so the proof is the node that appears, not the row.) const nodeIds = async () => (await asset('nb-caller')).graph.nodes.map((n) => n.id); const idsBefore = await nodeIds(); - const centre = await json('__gm.graph.center()'); - await click(centre.x + 250, centre.y + 250, 'right'); + const bare = await json('__gm.graph.emptySpot()'); + await click(bare.x, bare.y, 'right'); await sleep(150); check('node palette opens', Boolean(await locate('Search nodes…'))); await typeText('Double'); diff --git a/tools/e2e/23-variable-nodes.mjs b/tools/e2e/23-variable-nodes.mjs index 47d8f93d..fed498cc 100644 --- a/tools/e2e/23-variable-nodes.mjs +++ b/tools/e2e/23-variable-nodes.mjs @@ -308,9 +308,9 @@ export async function variableNodes(t) { // it runs `vz = vz + 1` once a frame – its own output feeding its input via Add. const nodeIds = async () => (await asset('vp-logic')).graph.nodes.map((n) => n.id); const idsBefore = await nodeIds(); - const centre = await json('__gm.graph.center()'); - // Below the Owner node; centre + (250, 250) would land on the Pack. - await click(centre.x - 250, centre.y + 250, 'right'); + // Bare canvas below every node, so the click cannot land on the Pack. + const bareSpot = await json('__gm.graph.emptySpot()'); + await click(bareSpot.x, bareSpot.y, 'right'); await sleep(150); // Enter takes the first hit in category order, so search by a phrase only its description has. await typeText('switched to Set'); diff --git a/tools/e2e/27-console.mjs b/tools/e2e/27-console.mjs new file mode 100644 index 00000000..95c242b3 --- /dev/null +++ b/tools/e2e/27-console.mjs @@ -0,0 +1,164 @@ +import { edge, node } from '../lib/builders.mjs'; +import { LANES, scaffoldBehavior } from './lib/scaffold.mjs'; + +/** + * The console panel: every `console.*` call lands in it with its level, a Log node's message + * arrives through the very same path, the play status line deliberately does not, and the + * divider above it resizes down to the bare header. The step removes everything it made. + */ + +const SPOKEN = 'the crate says hello'; + +/** `On start → Log`: the one path a project itself writes a line on. */ +const talkerGraph = { + nodes: [node('c-start', 'OnStart', 0, 0), node('c-log', 'Log', 340, 0, { message: SPOKEN })], + edges: [edge('c-e1', 'c-start', 'exec', 'c-log', 'exec')], +}; + +export async function consolePanel(t) { + const { check, sleep, json, evaluate, shot, drag, wheel, clickText, play, stop } = t; + const lines = () => json('__gm.console.lines()'); + const texts = async () => (await lines()).map((l) => l.text); + const height = () => json('__gm.console.height()'); + const colorOf = (text) => json(`__gm.textColor(${JSON.stringify(text)})`); + /** The panel rebuilds on the frame after a push, so every read waits one out. */ + const settle = () => sleep(300); + + await evaluate('__gm.console.clear()'); + await settle(); + + // 27a. The captured path: each console method with its own level, and the level drawn as colour. + await evaluate(`console.log('plain line 27'); console.warn('warned 27'); console.error('failed 27')`); + await settle(); + const captured = await lines(); + check( + 'every console level lands in the panel, newest last', + JSON.stringify(captured.map((l) => ({ level: l.level, text: l.text }))) === + JSON.stringify([ + { level: 'info', text: 'plain line 27' }, + { level: 'warn', text: 'warned 27' }, + { level: 'error', text: 'failed 27' }, + ]), + captured, + ); + const [info, warn, error] = await Promise.all([ + colorOf('plain line 27'), + colorOf('warned 27'), + colorOf('failed 27'), + ]); + check( + 'a warning is drawn amber and an error red, a plain line in the text colour', + info === '#e6e8ee' && warn === '#f2c84b' && error === '#e0553f', + { info, warn, error }, + ); + + // 27a2. Every line carries the clock time of its call, drawn muted in its own column left of + // the message – so the message itself is still exactly what was logged. + const stamps = captured.map((l) => l.at); + const stampAt = await json(`__gm.locate(${JSON.stringify(captured[0].at)})`); + const messageAt = await json(`__gm.locate('plain line 27')`); + check( + 'each line is stamped HH:MM:SS, drawn muted and left of the message', + stamps.every((at) => /^\d{2}:\d{2}:\d{2}$/.test(at)) && + stampAt.x < messageAt.x && + (await colorOf(captured[0].at)) === '#8a90a0', + JSON.stringify({ stamps, stampAt, messageAt }), + ); + + // 27b. Several arguments become one line, and an Error shows its message instead of [object]. + await evaluate(`console.log('joined 27', 3, { a: 1 }); console.error(new Error('thrown 27'))`); + await settle(); + const formatted = (await texts()).slice(-2); + check( + 'arguments are joined and an Error reads as its message', + formatted[0] === 'joined 27 3 {"a":1}' && formatted[1]?.includes('thrown 27') === true, + formatted, + ); + + // 27c. The real path a project uses: a Log node during play writes through `console.log`. + const rig = await scaffoldBehavior(t, { + assetId: 'talker', + name: 'Talker', + graph: talkerGraph, + boxId: 'c-box', + boxName: 'Talker box', + logicId: 'c-logic', + y: LANES.console, + }); + await evaluate('__gm.console.clear()'); + await play(); + await settle(); + const played = await texts(); + check( + 'a Log node reaches the panel with the behavior it came from', + played.some((line) => line === `[behavior Talker] ${SPOKEN}`), + played, + ); + // 27d. The status line is a mode, not an event: the toolbar says it, the console stays out of it. + const notice = await json('__gm.state.notice'); + check( + 'the play status line shows in the toolbar but is kept out of the console', + notice.startsWith('Playing') && !played.some((line) => line.startsWith('Playing')), + { notice, played }, + ); + await stop(); + await settle(); + await shot('27-console-after-play'); + + // 27e. Clear empties it, and only it – the toolbar notice is not the console's to touch. + await clickText('Clear'); + await settle(); + check('the Clear button empties the panel', (await lines()).length === 0); + + // 27f. The ring buffer keeps the tail, not the head. + await evaluate(`for (let i = 0; i < 520; i += 1) console.log('ring ' + i)`); + await sleep(800); + const ring = await texts(); + check( + 'past its capacity the panel keeps the newest 500 lines', + ring.length === 500 && ring[0] === 'ring 20' && ring.at(-1) === 'ring 519', + { length: ring.length, first: ring[0], last: ring.at(-1) }, + ); + const tail = await json('__gm.console.scroll()'); + check('it follows the tail, so the newest line is the one showing', tail.offset === tail.max, tail); + + // 27g. Reading back means the tail must stop pulling, or the line under the pointer runs away. + // A full buffer would hide that: dropping a line as one arrives leaves the height unchanged. + await evaluate('__gm.console.clear()'); + await settle(); + await evaluate(`for (let i = 0; i < 60; i += 1) console.log('scroll ' + i)`); + await settle(); + const panel = await json('__gm.console.centre()'); + await wheel(panel.x, panel.y, -200); + await settle(); + const scrolledUp = await json('__gm.console.scroll()'); + await evaluate(`console.log('arrived while reading 27')`); + await settle(); + const stayed = await json('__gm.console.scroll()'); + check( + 'a line arriving while you read further up does not yank the view down', + scrolledUp.offset === scrolledUp.max - 200 && + stayed.max > scrolledUp.max && + stayed.offset === scrolledUp.offset, + JSON.stringify({ scrolledUp, stayed }), + ); + await evaluate('__gm.console.clear()'); + await settle(); + + // 27h. The divider resizes the panel, and shutting it leaves the header to grab it by again. + const start = await height(); + const bar = await json('__gm.console.splitterAt()'); + await drag(bar.x, bar.y, bar.x, bar.y - 60, { steps: 6 }); + const taller = await height(); + check('dragging the divider up makes the console taller', taller === start + 60, { start, taller }); + const bar2 = await json('__gm.console.splitterAt()'); + await drag(bar2.x, bar2.y, bar2.x, bar2.y + 400, { steps: 10 }); + const shut = await height(); + check('dragged shut it stops at its own header instead of vanishing', shut === 30, shut); + const bar3 = await json('__gm.console.splitterAt()'); + await drag(bar3.x, bar3.y, bar3.x, bar3.y - (start - 30), { steps: 8 }); + check('and it comes back to the height it started at', (await height()) === start, await height()); + + const left = await rig.remove(); + check('the step leaves nothing behind', left.objects === 0 && left.assets === 0, left); +} diff --git a/tools/e2e/index.mjs b/tools/e2e/index.mjs index 2b2562e7..5d5552c3 100644 --- a/tools/e2e/index.mjs +++ b/tools/e2e/index.mjs @@ -29,6 +29,7 @@ import { variableNodes } from './23-variable-nodes.mjs'; import { propertyNodes } from './24-property-nodes.mjs'; import { animation } from './25-animation.mjs'; import { stateMachine } from './26-state-machine.mjs'; +import { consolePanel } from './27-console.mjs'; export const STEPS = [ hierarchy, @@ -57,4 +58,5 @@ export const STEPS = [ propertyNodes, animation, stateMachine, + consolePanel, ]; diff --git a/tools/e2e/lib/scaffold.mjs b/tools/e2e/lib/scaffold.mjs index 4ee36b7b..ce6c66d7 100644 --- a/tools/e2e/lib/scaffold.mjs +++ b/tools/e2e/lib/scaffold.mjs @@ -15,6 +15,7 @@ export const LANES = { nested: 700, keyRepeat: -300, stateMachine: -700, + console: -1100, }; /** diff --git a/tools/lib/harness.mjs b/tools/lib/harness.mjs index 217bdc9d..555f3ff4 100644 --- a/tools/lib/harness.mjs +++ b/tools/lib/harness.mjs @@ -174,6 +174,12 @@ function makeTools({ cdp, projectId, baseUrl, consoleLog, check, shotPrefix }) { } await sleep(150); }; + /** Mouse wheel over (x, y); positive `dy` scrolls down. */ + const wheel = async (x, y, dy) => { + await mouse('mouseMoved', x, y); + await mouse('mouseWheel', x, y, 'none', { deltaX: 0, deltaY: dy }); + await sleep(150); + }; const clickText = async (text, button = 'left') => { const p = await locate(text); await click(p.x, p.y, button); @@ -340,6 +346,7 @@ function makeTools({ cdp, projectId, baseUrl, consoleLog, check, shotPrefix }) { click, doubleClick, clickText, + wheel, drag, dragAsset, press,