diff --git a/docs/guide/debugging.md b/docs/guide/debugging.md index c9a9c197..7261b05c 100644 --- a/docs/guide/debugging.md +++ b/docs/guide/debugging.md @@ -7,7 +7,7 @@ Rezi includes a debug trace system for diagnosing rendering, events, and perform Create a debug controller to capture and analyze runtime behavior: ```typescript -import { createDebugController, categoriesToMask } from "@rezi-ui/core"; +import { createDebugController, categoriesToMask, perfPhaseFromNum } from "@rezi-ui/core"; const debug = createDebugController({ maxFrames: 1000 }); @@ -22,12 +22,114 @@ debug.on("error", (err) => console.error(err.message)); // Subscribe to all records debug.on("record", (record) => { - if (record.category === "perf") { - console.log(`${record.phase}: ${record.durationMs}ms`); + if (record.header.category === "perf" && record.payload && "usElapsed" in record.payload) { + const phase = perfPhaseFromNum(record.payload.phase) ?? `phase_${record.payload.phase}`; + console.log(`${phase}: ${(record.payload.usElapsed / 1000).toFixed(2)}ms`); } }); ``` +## Enable Inspector Overlay + +Use `createAppWithInspectorOverlay` to auto-install the overlay and runtime toggle. +Default hotkey is `ctrl+shift+i`. + +```typescript +import { createAppWithInspectorOverlay, createDebugController } from "@rezi-ui/core"; +import { createNodeBackend } from "@rezi-ui/node"; + +const backend = createNodeBackend(); + +const debug = createDebugController({ + backend: backend.debug, + terminalCapsProvider: () => backend.getCaps(), +}); + +await debug.enable({ + minSeverity: "info", + captureRawEvents: false, + captureDrawlistBytes: false, +}); + +const app = createAppWithInspectorOverlay({ + backend, + initialState: { ready: true }, + inspector: { + hotkey: "ctrl+shift+i", + debug, // auto-populates drawlist/diff/us_* timing rows from frame snapshots + }, +}); +``` + +Overlay sections include: +- Focus summary (`focusedId`, active zone, active trap) +- Cursor target summary (position, shape, blink intent when cursor v2 is active) +- Damage + frame summary (`mode`, rect/cell counts, commit/layout/incremental state) +- Frame timing rows (`drawlistBytes`, `diffBytesEmitted`, `usDrawlist`, `usDiff`, `usWrite`) +- Event routing breadcrumbs (last event kind, keybindings vs widget routing path, last action) + +## Export Debug Bundle + +Exporting a debug bundle is deterministic for the same debug state and options. +The API never writes to disk; it returns either a JSON object or UTF-8 bytes. + +```typescript +import { createDebugController } from "@rezi-ui/core/debug"; +import { createNodeBackend } from "@rezi-ui/node"; + +const backend = createNodeBackend(); +const debug = createDebugController({ + backend: backend.debug, + terminalCapsProvider: () => backend.getCaps(), // Optional +}); + +await debug.enable({ + minSeverity: "info", + captureRawEvents: false, + captureDrawlistBytes: false, +}); + +const bundle = await debug.exportBundle({ + maxRecords: 2000, + maxPayloadBytes: 4096, + maxTotalPayloadBytes: 262_144, + maxRecentFrames: 32, +}); + +console.log(bundle.schema); // "rezi-debug-bundle-v1" + +// If you want raw bytes (for transport or persistence): +const bundleBytes = await debug.exportBundleBytes(); +``` + +### Bundle Contents + +- `schema`: Versioned identifier. Current value: `rezi-debug-bundle-v1`. +- `captureFlags`: Capture state used during export (`captureRawEvents`, `captureDrawlistBytes`). +- `bounds`: Export bounds (`maxRecords`, per-record payload cap, total payload cap, frame summary cap). +- `terminalCaps`: Terminal capability snapshot (or `null` if unavailable). +- `stats`: Debug stats snapshot (`totalRecords`, `totalDropped`, counts, ring usage/capacity). +- `queryWindow`: Trace query window metadata (`recordsReturned`, `recordsAvailable`, oldest/newest IDs). +- `trace`: Deterministically ordered trace records (`header` + bounded payload snapshot). +- `recentFrameSummaries`: Optional recent frame summaries when frame snapshots are available. + +Notes: +- `u64` fields are serialized as decimal strings for JSON safety. +- Payload snapshots are hex-encoded and truncated by configured bounds. + +### Privacy Note + +Raw event payloads and raw drawlist byte payloads can contain sensitive data +(for example typed input or rendered text bytes). + +By default, bundle export follows debug capture flags: +- Event payloads are omitted when `captureRawEvents` is `false`. +- Raw drawlist byte payloads are omitted when `captureDrawlistBytes` is `false`. + +To minimize risk in production: +- Keep `captureRawEvents` and `captureDrawlistBytes` disabled unless needed. +- Share bundles only with trusted recipients. + ## Debug Categories | Category | Description | @@ -70,23 +172,26 @@ const inspector = debug.frameInspector; // Get recent frame snapshots const frames = inspector.getSnapshots(10); for (const frame of frames) { - console.log(`Frame ${frame.frameId}: ${frame.renderMs}ms render, ${frame.drawCommands} commands`); + const totalUs = frame.usDrawlist + frame.usDiff + frame.usWrite; + console.log(`Frame ${frame.frameId}: ${(totalUs / 1000).toFixed(2)}ms total, ${frame.drawlistCmds} commands`); } // Compare two frames for changes const diff = inspector.compareFrames(frameA, frameB); -for (const change of diff.changes) { - console.log(`${change.field}: ${change.oldValue} -> ${change.newValue}`); +if (diff) { + for (const change of diff.changed) { + console.log(`${change.field}: ${change.before} -> ${change.after}`); + } } ``` Frame snapshots include: -- Frame ID and timestamp -- Render duration -- Draw command count -- Widget tree depth -- Visible widget count +- `frameId` and `timestamp` +- Terminal dimensions (`cols`, `rows`) +- Drawlist metrics (`drawlistBytes`, `drawlistCmds`) +- Diff/damage metrics (`diffBytesEmitted`, `dirtyLines`, `dirtyCells`, `damageRects`) +- Timing metrics in microseconds (`usDrawlist`, `usDiff`, `usWrite`) ## Event Trace @@ -98,12 +203,11 @@ const trace = debug.eventTrace; // Query recent events const keyEvents = trace.query({ eventTypes: ["key"], - since: Date.now() - 5000, - limit: 100, + minFrameId: 100n, }); -for (const ev of keyEvents) { - console.log(`Key: ${ev.key} at ${ev.timestamp}`); +for (const ev of keyEvents.slice(-100)) { + console.log(`${ev.eventType}: ${ev.parseResult} at ${ev.timestamp}ms`); } ``` @@ -193,7 +297,7 @@ Configure debug behavior at creation: const debug = createDebugController({ maxFrames: 500, // Frame history limit maxEvents: 1000, // Event trace limit - maxErrors: 100, // Error aggregation limit + maxStateChanges: 500, // State timeline limit }); ``` diff --git a/packages/core/src/app/__tests__/inspectorOverlayHelper.test.ts b/packages/core/src/app/__tests__/inspectorOverlayHelper.test.ts new file mode 100644 index 00000000..3503b528 --- /dev/null +++ b/packages/core/src/app/__tests__/inspectorOverlayHelper.test.ts @@ -0,0 +1,119 @@ +import { assert, test } from "@rezi-ui/testkit"; +import { ZR_MOD_CTRL, ZR_MOD_SHIFT, charToKeyCode } from "../../keybindings/keyCodes.js"; +import { ui } from "../../widgets/ui.js"; +import { createAppWithInspectorOverlay } from "../inspectorOverlayHelper.js"; +import { encodeZrevBatchV1, flushMicrotasks, makeBackendBatch } from "./helpers.js"; +import { StubBackend } from "./stubBackend.js"; + +function u32(bytes: Uint8Array, off: number): number { + const dv = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + return dv.getUint32(off, true); +} + +function parseInternedStrings(bytes: Uint8Array): readonly string[] { + const spanOffset = u32(bytes, 28); + const count = u32(bytes, 32); + const bytesOffset = u32(bytes, 36); + const bytesLen = u32(bytes, 40); + if (count === 0) return Object.freeze([]); + + const tableEnd = bytesOffset + bytesLen; + assert.ok(tableEnd <= bytes.byteLength, "string table must be in bounds"); + const out: string[] = []; + const decoder = new TextDecoder(); + for (let i = 0; i < count; i++) { + const span = spanOffset + i * 8; + const start = bytesOffset + u32(bytes, span); + const end = start + u32(bytes, span + 4); + assert.ok(end <= tableEnd, "string span must be in bounds"); + out.push(decoder.decode(bytes.subarray(start, end))); + } + return Object.freeze(out); +} + +async function pushEvents( + backend: StubBackend, + events: NonNullable[0]["events"]>, +): Promise { + backend.pushBatch( + makeBackendBatch({ + bytes: encodeZrevBatchV1({ events }), + }), + ); + await flushMicrotasks(20); +} + +async function settleNextFrame(backend: StubBackend): Promise { + backend.resolveNextFrame(); + await flushMicrotasks(20); +} + +test("createAppWithInspectorOverlay toggles overlay/hotkey and captures only while enabled", async () => { + const backend = new StubBackend(); + const app = createAppWithInspectorOverlay({ + backend, + initialState: 0, + inspector: { + title: "Inspector", + hotkey: "ctrl+shift+i", + }, + }); + + app.view((state) => ui.text(`main-${String(state)}`)); + + await app.start(); + await pushEvents(backend, [{ kind: "resize", timeMs: 1, cols: 80, rows: 24 }]); + + assert.equal(backend.requestedFrames.length, 1); + assert.equal(app.inspectorOverlay.isEnabled(), false); + assert.equal(app.inspectorOverlay.getSnapshot(), null); + const firstFrameStrings = parseInternedStrings(backend.requestedFrames[0] ?? new Uint8Array()); + assert.equal(firstFrameStrings.includes("inspector overlay"), false); + + await settleNextFrame(backend); + + const keyI = charToKeyCode("i"); + assert.notEqual(keyI, null); + await pushEvents(backend, [ + { kind: "key", timeMs: 2, key: keyI ?? 0, mods: ZR_MOD_CTRL | ZR_MOD_SHIFT, action: "down" }, + ]); + + assert.equal(app.inspectorOverlay.isEnabled(), true); + assert.equal(backend.requestedFrames.length, 2); + const secondFrameStrings = parseInternedStrings(backend.requestedFrames[1] ?? new Uint8Array()); + assert.equal(secondFrameStrings.includes("inspector overlay"), true); + assert.equal(secondFrameStrings.includes("event: kind= path="), true); + assert.equal(secondFrameStrings.includes("event: kind=resize path="), false); + + await settleNextFrame(backend); + + await pushEvents(backend, [{ kind: "resize", timeMs: 3, cols: 81, rows: 24 }]); + assert.equal(backend.requestedFrames.length, 3); + const thirdFrameStrings = parseInternedStrings(backend.requestedFrames[2] ?? new Uint8Array()); + assert.equal(thirdFrameStrings.includes("event: kind= path="), true); + + await settleNextFrame(backend); + + const latestSnapshot = app.inspectorOverlay.getSnapshot(); + assert.equal(latestSnapshot?.event.kind, "resize"); + assert.equal(latestSnapshot?.event.path, null); + + app.update(1); + await flushMicrotasks(20); + assert.equal(backend.requestedFrames.length, 4); + const fourthFrameStrings = parseInternedStrings(backend.requestedFrames[3] ?? new Uint8Array()); + assert.equal(fourthFrameStrings.includes("event: kind=resize path="), true); + + await settleNextFrame(backend); + + await pushEvents(backend, [ + { kind: "key", timeMs: 6, key: keyI ?? 0, mods: ZR_MOD_CTRL | ZR_MOD_SHIFT, action: "down" }, + ]); + + assert.equal(app.inspectorOverlay.isEnabled(), false); + assert.equal(backend.requestedFrames.length, 5); + const fifthFrameStrings = parseInternedStrings(backend.requestedFrames[4] ?? new Uint8Array()); + assert.equal(fifthFrameStrings.includes("inspector overlay"), false); + + await settleNextFrame(backend); +}); diff --git a/packages/core/src/app/__tests__/runtimeBreadcrumbs.test.ts b/packages/core/src/app/__tests__/runtimeBreadcrumbs.test.ts new file mode 100644 index 00000000..237b6bd4 --- /dev/null +++ b/packages/core/src/app/__tests__/runtimeBreadcrumbs.test.ts @@ -0,0 +1,230 @@ +import { assert, test } from "@rezi-ui/testkit"; +import type { RuntimeBackend } from "../../backend.js"; +import type { ZrevEvent } from "../../events.js"; +import { ui } from "../../index.js"; +import { ZR_KEY_ENTER, ZR_KEY_TAB } from "../../keybindings/keyCodes.js"; +import { DEFAULT_TERMINAL_CAPS } from "../../terminalCaps.js"; +import { defaultTheme } from "../../theme/defaultTheme.js"; +import { createApp } from "../createApp.js"; +import type { RuntimeBreadcrumbSnapshot } from "../runtimeBreadcrumbs.js"; +import { WidgetRenderer } from "../widgetRenderer.js"; +import { encodeZrevBatchV1, flushMicrotasks, makeBackendBatch } from "./helpers.js"; +import { StubBackend } from "./stubBackend.js"; + +function noRenderHooks(): { enterRender: () => void; exitRender: () => void } { + return { enterRender: () => {}, exitRender: () => {} }; +} + +function keyEvent(key: number): ZrevEvent { + return { kind: "key", timeMs: 1, key, mods: 0, action: "down" }; +} + +function createNoopBackend(): RuntimeBackend { + return { + start: async () => {}, + stop: async () => {}, + dispose: () => {}, + requestFrame: async () => {}, + pollEvents: async () => + new Promise((_) => { + // Unit tests in this file call WidgetRenderer.submitFrame directly. + }), + postUserEvent: () => {}, + getCaps: async () => DEFAULT_TERMINAL_CAPS, + }; +} + +async function pushEvents( + backend: StubBackend, + events: NonNullable[0]["events"]>, +): Promise { + backend.pushBatch( + makeBackendBatch({ + bytes: encodeZrevBatchV1({ events }), + }), + ); + await flushMicrotasks(20); +} + +async function settleNextFrame(backend: StubBackend): Promise { + backend.resolveNextFrame(); + await flushMicrotasks(20); +} + +test("runtime breadcrumbs capture event path/action/focus/cursor deterministically", async () => { + const backend = new StubBackend(); + const renderSnapshots: RuntimeBreadcrumbSnapshot[] = []; + const layoutSnapshots: RuntimeBreadcrumbSnapshot[] = []; + const actionEvents: Array> = []; + let keybindingHits = 0; + let presses = 0; + + const app = createApp({ + backend, + initialState: 0, + config: { + useV2Cursor: true, + internal_onRender: (metrics) => { + const breadcrumbs = ( + metrics as Readonly<{ runtimeBreadcrumbs?: RuntimeBreadcrumbSnapshot }> + ).runtimeBreadcrumbs; + if (breadcrumbs) renderSnapshots.push(breadcrumbs); + }, + internal_onLayout: (snapshot) => { + const breadcrumbs = ( + snapshot as Readonly<{ runtimeBreadcrumbs?: RuntimeBreadcrumbSnapshot }> + ).runtimeBreadcrumbs; + if (breadcrumbs) layoutSnapshots.push(breadcrumbs); + }, + }, + }); + + app.keys({ + x: () => { + keybindingHits++; + }, + }); + + app.onEvent((ev) => { + if (ev.kind === "action") actionEvents.push({ id: ev.id, action: ev.action }); + }); + + app.view(() => + ui.focusTrap( + { + id: "trap", + active: true, + initialFocus: "name", + }, + [ + ui.input({ id: "name", value: "abc" }), + ui.button({ + id: "save", + label: "Save", + onPress: () => { + presses++; + }, + }), + ], + ), + ); + + await app.start(); + + await pushEvents(backend, [{ kind: "resize", timeMs: 1, cols: 40, rows: 10 }]); + assert.equal(renderSnapshots.length, 1); + assert.equal(layoutSnapshots.length, 1); + + const first = renderSnapshots[0]; + assert.ok(first); + assert.equal(first.event.kind, "resize"); + assert.equal(first.event.path, null); + assert.equal(first.focus.focusedId, "name"); + assert.equal(first.focus.activeZoneId, null); + assert.equal(first.focus.activeTrapId, "trap"); + assert.equal(first.cursor?.visible, true); + assert.equal(first.damage.mode, "full"); + assert.equal(first.frame.commit, true); + assert.equal(first.frame.layout, true); + assert.equal(first.frame.incremental, false); + assert.equal(first.frame.renderTimeMs >= 0, true); + assert.deepEqual(layoutSnapshots[0], first); + + await settleNextFrame(backend); + + await pushEvents(backend, [ + { kind: "text", timeMs: 2, codepoint: 120 }, + { kind: "tick", timeMs: 3, dtMs: 16 }, + ]); + assert.equal(keybindingHits, 1); + assert.equal(renderSnapshots.length, 2); + const second = renderSnapshots[1]; + assert.ok(second); + assert.equal(second.event.kind, "text"); + assert.equal(second.event.path, "keybindings"); + assert.equal(second.focus.focusedId, "name"); + assert.equal(second.focus.activeZoneId, null); + assert.equal(second.focus.activeTrapId, "trap"); + assert.equal(second.frame.commit, false); + assert.equal(second.frame.layout, false); + + await settleNextFrame(backend); + + await pushEvents(backend, [{ kind: "key", timeMs: 4, key: ZR_KEY_TAB, action: "down" }]); + assert.equal(renderSnapshots.length, 3); + const third = renderSnapshots[2]; + assert.ok(third); + assert.equal(third.event.kind, "key"); + assert.equal(third.event.path, "widgetRouting"); + assert.equal(third.focus.focusedId, "save"); + assert.equal(third.focus.activeZoneId, null); + assert.equal(third.focus.activeTrapId, "trap"); + + await settleNextFrame(backend); + + await pushEvents(backend, [{ kind: "key", timeMs: 5, key: ZR_KEY_ENTER, action: "down" }]); + assert.equal(renderSnapshots.length, 3); + assert.deepEqual(actionEvents, [{ id: "save", action: "press" }]); + assert.equal(presses, 1); + + await pushEvents(backend, [{ kind: "tick", timeMs: 6, dtMs: 16 }]); + assert.equal(renderSnapshots.length, 4); + const fourth = renderSnapshots[3]; + assert.ok(fourth); + assert.deepEqual(fourth.lastAction, { id: "save", action: "press" }); + assert.equal(fourth.event.kind, "key"); + assert.equal(fourth.event.path, "widgetRouting"); + + await settleNextFrame(backend); +}); + +test("enabling breadcrumb capture does not change widget routing outcomes", () => { + const backend = createNoopBackend(); + const rendererWithout = new WidgetRenderer({ + backend, + requestRender: () => {}, + collectRuntimeBreadcrumbs: false, + }); + const rendererWith = new WidgetRenderer({ + backend, + requestRender: () => {}, + collectRuntimeBreadcrumbs: true, + }); + + const vnode = ui.column({}, [ + ui.input({ id: "name", value: "abc" }), + ui.button({ id: "save", label: "Save" }), + ]); + + const resA = rendererWithout.submitFrame( + () => vnode, + undefined, + { cols: 40, rows: 10 }, + defaultTheme, + noRenderHooks(), + ); + const resB = rendererWith.submitFrame( + () => vnode, + undefined, + { cols: 40, rows: 10 }, + defaultTheme, + noRenderHooks(), + ); + assert.equal(resA.ok, true); + assert.equal(resB.ok, true); + + const eventSeq: readonly ZrevEvent[] = [ + keyEvent(ZR_KEY_TAB), + keyEvent(ZR_KEY_TAB), + keyEvent(ZR_KEY_ENTER), + ]; + for (const ev of eventSeq) { + const outA = rendererWithout.routeEngineEvent(ev); + const outB = rendererWith.routeEngineEvent(ev); + assert.deepEqual(outB, outA); + assert.equal(rendererWith.getFocusedId(), rendererWithout.getFocusedId()); + } + + assert.equal(rendererWithout.getRuntimeBreadcrumbSnapshot(), null); + assert.notEqual(rendererWith.getRuntimeBreadcrumbSnapshot(), null); +}); diff --git a/packages/core/src/app/createApp.ts b/packages/core/src/app/createApp.ts index 02c49a64..49eb6805 100644 --- a/packages/core/src/app/createApp.ts +++ b/packages/core/src/app/createApp.ts @@ -63,6 +63,15 @@ import { coerceToLegacyTheme } from "../theme/interop.js"; import type { Theme } from "../theme/theme.js"; import type { ThemeDefinition } from "../theme/tokens.js"; import { RawRenderer } from "./rawRenderer.js"; +import { + type RuntimeBreadcrumbAction, + type RuntimeBreadcrumbConsumptionPath, + type RuntimeBreadcrumbEventKind, + type RuntimeBreadcrumbSnapshot, + isRuntimeBreadcrumbEventKind, + mergeRuntimeBreadcrumbSnapshot, + toRuntimeBreadcrumbAction, +} from "./runtimeBreadcrumbs.js"; import { AppStateMachine } from "./stateMachine.js"; import { TurnScheduler } from "./turnScheduler.js"; import { type StateUpdater, UpdateQueue } from "./updateQueue.js"; @@ -86,6 +95,11 @@ type ResolvedAppConfig = Readonly<{ internal_onLayout?: ((snapshot: AppLayoutSnapshot) => void) | undefined; }>; +type InternalRenderMetricsWithBreadcrumbs = AppRenderMetrics & + Readonly<{ runtimeBreadcrumbs: RuntimeBreadcrumbSnapshot }>; +type InternalLayoutSnapshotWithBreadcrumbs = AppLayoutSnapshot & + Readonly<{ runtimeBreadcrumbs: RuntimeBreadcrumbSnapshot }>; + /** Default configuration values. */ const DEFAULT_CONFIG: ResolvedAppConfig = Object.freeze({ fpsCap: 60, @@ -100,6 +114,13 @@ const DEFAULT_CONFIG: ResolvedAppConfig = Object.freeze({ internal_onLayout: undefined, }); const SYNC_FRAME_ACK_MARKER = "__reziSyncFrameAck"; +export const APP_INTERNAL_REQUEST_VIEW_LAYOUT_MARKER = "__reziRequestViewLayout"; +export const APP_INTERNAL_SET_RUNTIME_BREADCRUMB_HOOKS_MARKER = "__reziSetRuntimeBreadcrumbHooks"; + +type InternalRuntimeBreadcrumbHooks = Readonly<{ + onRender?: ((metrics: AppRenderMetrics) => void) | undefined; + onLayout?: ((snapshot: AppLayoutSnapshot) => void) | undefined; +}>; function invalidProps(detail: string): never { throw new ZrUiError("ZRUI_INVALID_PROPS", detail); @@ -423,6 +444,14 @@ export function createApp( markDirty(DIRTY_VIEW); } + const baseInternalOnRender = config.internal_onRender; + const baseInternalOnLayout = config.internal_onLayout; + let inspectorInternalOnRender: ((metrics: AppRenderMetrics) => void) | undefined; + let inspectorInternalOnLayout: ((snapshot: AppLayoutSnapshot) => void) | undefined; + + let runtimeBreadcrumbsEnabled = + baseInternalOnRender !== undefined || baseInternalOnLayout !== undefined; + const rawRenderer = new RawRenderer({ backend, maxDrawlistBytes: config.maxDrawlistBytes, @@ -443,12 +472,35 @@ export function createApp( drawlistEncodedStringCacheCap: config.drawlistEncodedStringCacheCap, requestRender: requestRenderFromRenderer, requestView: requestViewFromRenderer, + collectRuntimeBreadcrumbs: runtimeBreadcrumbsEnabled, }); /* --- Keybinding State --- */ let keybindingState: KeybindingManagerState> = createManagerState(); let keybindingsEnabled = false; + let breadcrumbLastEventKind: RuntimeBreadcrumbEventKind | null = null; + let breadcrumbLastConsumptionPath: RuntimeBreadcrumbConsumptionPath | null = null; + let breadcrumbLastAction: RuntimeBreadcrumbAction | null = null; + let breadcrumbEventTracked = false; + + function recomputeRuntimeBreadcrumbCollection(): void { + const next = + baseInternalOnRender !== undefined || + baseInternalOnLayout !== undefined || + inspectorInternalOnRender !== undefined || + inspectorInternalOnLayout !== undefined; + if (next === runtimeBreadcrumbsEnabled) return; + runtimeBreadcrumbsEnabled = next; + widgetRenderer.setRuntimeBreadcrumbCaptureEnabled(next); + if (!next) { + breadcrumbLastEventKind = null; + breadcrumbLastConsumptionPath = null; + breadcrumbLastAction = null; + breadcrumbEventTracked = false; + } + } + function computeKeybindingsEnabled(state: KeybindingManagerState>): boolean { for (const m of state.modes.values()) { if (m.bindings.length > 0) return true; @@ -456,6 +508,40 @@ export function createApp( return false; } + function noteBreadcrumbEvent(kind: string): void { + breadcrumbEventTracked = false; + if (!runtimeBreadcrumbsEnabled) return; + if (!isRuntimeBreadcrumbEventKind(kind)) return; + breadcrumbLastEventKind = kind; + breadcrumbLastConsumptionPath = null; + breadcrumbEventTracked = true; + } + + function noteBreadcrumbConsumptionPath(path: RuntimeBreadcrumbConsumptionPath): void { + if (!runtimeBreadcrumbsEnabled) return; + if (!breadcrumbEventTracked) return; + breadcrumbLastConsumptionPath = path; + } + + function noteBreadcrumbAction(action: NonNullable): void { + if (!runtimeBreadcrumbsEnabled) return; + if (!breadcrumbEventTracked) return; + breadcrumbLastAction = toRuntimeBreadcrumbAction(action); + } + + function buildRuntimeBreadcrumbSnapshot(renderTimeMs: number): RuntimeBreadcrumbSnapshot | null { + if (!runtimeBreadcrumbsEnabled) return null; + const widgetSnapshot = widgetRenderer.getRuntimeBreadcrumbSnapshot(); + if (!widgetSnapshot) return null; + return mergeRuntimeBreadcrumbSnapshot( + widgetSnapshot, + breadcrumbLastEventKind, + breadcrumbLastConsumptionPath, + breadcrumbLastAction, + renderTimeMs, + ); + } + function throwCode(code: ZrUiErrorCode, detail: string): never { throw new ZrUiError(code, detail); } @@ -601,6 +687,7 @@ export function createApp( if (ev.kind === "key" || ev.kind === "text" || ev.kind === "paste" || ev.kind === "mouse") { interactiveBudget = 2; } + noteBreadcrumbEvent(ev.kind); emit({ kind: "engine", event: ev }); if (sm.state !== "Running") return; if (ev.kind === "resize") { @@ -630,7 +717,10 @@ export function createApp( }); const keyResult = routeKeyEvent(keybindingState, ev, keyCtx); keybindingState = keyResult.nextState; - if (keyResult.consumed) continue; // Skip default widget routing + if (keyResult.consumed) { + noteBreadcrumbConsumptionPath("keybindings"); + continue; // Skip default widget routing + } } } @@ -653,13 +743,17 @@ export function createApp( }); const keyResult = routeKeyEvent(keybindingState, syntheticKeyEvent, keyCtx); keybindingState = keyResult.nextState; - if (keyResult.consumed) continue; // Skip default widget routing + if (keyResult.consumed) { + noteBreadcrumbConsumptionPath("keybindings"); + continue; // Skip default widget routing + } } } } let routed: WidgetRoutingOutcome; try { + noteBreadcrumbConsumptionPath("widgetRouting"); routed = widgetRenderer.routeEngineEvent(ev); } catch (e: unknown) { enqueueFatal("ZRUI_USER_CODE_THROW", `widget routing threw: ${describeThrown(e)}`); @@ -667,6 +761,7 @@ export function createApp( } if (routed.needsRender) markDirty(DIRTY_RENDER); if (routed.action) { + noteBreadcrumbAction(routed.action); emit({ kind: "action", ...routed.action }); if (sm.state !== "Running") return; } @@ -757,10 +852,25 @@ export function createApp( } } - function emitInternalRenderMetrics(renderTime: number): boolean { - if (config.internal_onRender === undefined) return true; + function emitInternalRenderMetrics( + renderTime: number, + runtimeBreadcrumbs: RuntimeBreadcrumbSnapshot | null = null, + ): boolean { + if (baseInternalOnRender === undefined && inspectorInternalOnRender === undefined) return true; try { - config.internal_onRender({ renderTime: Math.max(0, renderTime) }); + const clampedRenderTime = Math.max(0, renderTime); + if (runtimeBreadcrumbs) { + const payload: InternalRenderMetricsWithBreadcrumbs = { + renderTime: clampedRenderTime, + runtimeBreadcrumbs, + }; + baseInternalOnRender?.(payload); + inspectorInternalOnRender?.(payload); + } else { + const payload: AppRenderMetrics = { renderTime: clampedRenderTime }; + baseInternalOnRender?.(payload); + inspectorInternalOnRender?.(payload); + } return true; } catch (e: unknown) { fatalNowOrEnqueue("ZRUI_USER_CODE_THROW", `onRender callback threw: ${describeThrown(e)}`); @@ -768,10 +878,24 @@ export function createApp( } } - function emitInternalLayoutSnapshot(): boolean { - if (config.internal_onLayout === undefined) return true; + function emitInternalLayoutSnapshot( + runtimeBreadcrumbs: RuntimeBreadcrumbSnapshot | null = null, + ): boolean { + if (baseInternalOnLayout === undefined && inspectorInternalOnLayout === undefined) return true; try { - config.internal_onLayout({ idRects: widgetRenderer.getRectByIdIndex() }); + const idRects = widgetRenderer.getRectByIdIndex(); + if (runtimeBreadcrumbs) { + const payload: InternalLayoutSnapshotWithBreadcrumbs = { + idRects, + runtimeBreadcrumbs, + }; + baseInternalOnLayout?.(payload); + inspectorInternalOnLayout?.(payload); + } else { + const payload: AppLayoutSnapshot = { idRects }; + baseInternalOnLayout?.(payload); + inspectorInternalOnLayout?.(payload); + } return true; } catch (e: unknown) { fatalNowOrEnqueue("ZRUI_USER_CODE_THROW", `onLayout callback threw: ${describeThrown(e)}`); @@ -863,8 +987,10 @@ export function createApp( fatalNowOrEnqueue(res.code, res.detail); return; } - if (!emitInternalRenderMetrics(perfNow() - renderStart)) return; - if (!emitInternalLayoutSnapshot()) return; + const renderTime = perfNow() - renderStart; + const runtimeBreadcrumbs = buildRuntimeBreadcrumbSnapshot(Math.max(0, renderTime)); + if (!emitInternalRenderMetrics(renderTime, runtimeBreadcrumbs)) return; + if (!emitInternalLayoutSnapshot(runtimeBreadcrumbs)) return; submitFrameStartMs = PERF_ENABLED ? submitToken : null; const buildEndMs = PERF_ENABLED ? perfNow() : null; @@ -1152,5 +1278,28 @@ export function createApp( }, }; + Object.defineProperty(app, APP_INTERNAL_REQUEST_VIEW_LAYOUT_MARKER, { + value: () => { + if (sm.state !== "Running") return; + markDirty(DIRTY_VIEW | DIRTY_LAYOUT); + }, + enumerable: false, + configurable: false, + writable: false, + }); + + Object.defineProperty(app, APP_INTERNAL_SET_RUNTIME_BREADCRUMB_HOOKS_MARKER, { + value: (hooks: InternalRuntimeBreadcrumbHooks | null | undefined) => { + inspectorInternalOnRender = + typeof hooks?.onRender === "function" ? hooks.onRender : undefined; + inspectorInternalOnLayout = + typeof hooks?.onLayout === "function" ? hooks.onLayout : undefined; + recomputeRuntimeBreadcrumbCollection(); + }, + enumerable: false, + configurable: false, + writable: false, + }); + return app; } diff --git a/packages/core/src/app/inspectorOverlayHelper.ts b/packages/core/src/app/inspectorOverlayHelper.ts new file mode 100644 index 00000000..7538283a --- /dev/null +++ b/packages/core/src/app/inspectorOverlayHelper.ts @@ -0,0 +1,281 @@ +/** + * packages/core/src/app/inspectorOverlayHelper.ts — createApp helper with inspector overlay. + * + * Why: Minimizes integration work by wrapping `createApp` with: + * - automatic runtime breadcrumb capture + * - optional hotkey toggle + * - view() auto-wrapping that injects inspectorOverlay into the layer stack + */ + +import type { RuntimeBackend } from "../backend.js"; +import type { FrameSnapshot } from "../debug/frameInspector.js"; +import type { + App, + AppConfig, + AppLayoutSnapshot, + AppRenderMetrics, + DrawFn, + EventHandler, + ViewFn, +} from "../index.js"; +import { defaultTheme } from "../theme/defaultTheme.js"; +import type { Theme } from "../theme/theme.js"; +import type { ThemeDefinition } from "../theme/tokens.js"; +import { + type InspectorOverlayFrameTiming, + type InspectorOverlayPosition, + inspectorOverlay, +} from "../widgets/inspectorOverlay.js"; +import { ui } from "../widgets/ui.js"; +import { + APP_INTERNAL_REQUEST_VIEW_LAYOUT_MARKER, + APP_INTERNAL_SET_RUNTIME_BREADCRUMB_HOOKS_MARKER, + createApp, +} from "./createApp.js"; +import type { RuntimeBreadcrumbSnapshot } from "./runtimeBreadcrumbs.js"; + +type AppCreateOptions = Readonly<{ + backend: RuntimeBackend; + initialState: S; + config?: AppConfig; + theme?: Theme | ThemeDefinition; +}>; + +export type InspectorOverlayHelperOptions = Readonly<{ + enabled?: boolean; + hotkey?: string | false; + id?: string; + title?: string; + position?: InspectorOverlayPosition; + width?: number; + zIndex?: number; + /** + * Optional debug controller-like source used to auto-populate frame timing rows. + */ + debug?: Readonly<{ + frameInspector: Readonly<{ + getSnapshots: (limit?: number) => readonly FrameSnapshot[]; + }>; + }>; + /** + * Optional override for frame timing rows. + * When omitted, helper will use `debug.frameInspector` if available. + */ + frameTiming?: () => InspectorOverlayFrameTiming | null | undefined; +}>; + +export type CreateAppWithInspectorOverlayOptions = AppCreateOptions & + Readonly<{ + inspector?: InspectorOverlayHelperOptions; + }>; + +export type InspectorOverlayController = Readonly<{ + isEnabled: () => boolean; + setEnabled: (next: boolean) => void; + toggle: () => boolean; + getSnapshot: () => RuntimeBreadcrumbSnapshot | null; +}>; + +export type AppWithInspectorOverlay = App & + Readonly<{ + inspectorOverlay: InspectorOverlayController; + }>; + +type RenderMetricsWithBreadcrumbs = AppRenderMetrics & + Readonly<{ + runtimeBreadcrumbs?: RuntimeBreadcrumbSnapshot; + }>; + +type LayoutSnapshotWithBreadcrumbs = AppLayoutSnapshot & + Readonly<{ + runtimeBreadcrumbs?: RuntimeBreadcrumbSnapshot; + }>; + +type InspectorRuntimeBreadcrumbHooks = Readonly<{ + onRender?: ((metrics: AppRenderMetrics) => void) | undefined; + onLayout?: ((snapshot: AppLayoutSnapshot) => void) | undefined; +}>; + +type AppWithInternalInspectorMarkers = App & + Partial void>>> & + Partial< + Readonly< + Record< + typeof APP_INTERNAL_SET_RUNTIME_BREADCRUMB_HOOKS_MARKER, + (hooks: InspectorRuntimeBreadcrumbHooks | null | undefined) => void + > + > + >; + +const DEFAULT_HOTKEY = "ctrl+shift+i"; + +/** + * Create an App wrapper that auto-injects the inspector overlay and a hotkey toggle. + * + * Notes: + * - Overlay state is internal and does not require user state fields. + * - Existing `config.internal_onRender/internal_onLayout` callbacks are preserved. + * - Hotkey binding is registered with low priority so user bindings can override it. + */ +export function createAppWithInspectorOverlay( + opts: CreateAppWithInspectorOverlayOptions, +): AppWithInspectorOverlay { + const inspectorOpts = opts.inspector ?? {}; + + let latestSnapshot: RuntimeBreadcrumbSnapshot | null = null; + let overlayEnabled = inspectorOpts.enabled === true; + let lastThemeInput: Theme | ThemeDefinition = opts.theme ?? defaultTheme; + + const app = createApp({ + backend: opts.backend, + initialState: opts.initialState, + ...(opts.config === undefined ? {} : { config: opts.config }), + ...(opts.theme === undefined ? {} : { theme: opts.theme }), + }); + const appWithInternalMarkers = app as AppWithInternalInspectorMarkers; + + const hotkey = inspectorOpts.hotkey === undefined ? DEFAULT_HOTKEY : inspectorOpts.hotkey; + const hotkeyHint = typeof hotkey === "string" && hotkey.length > 0 ? hotkey : null; + + const resolveFrameTiming = (): InspectorOverlayFrameTiming | null => { + const overridden = inspectorOpts.frameTiming?.(); + if (overridden) return overridden; + + const snapshot = inspectorOpts.debug?.frameInspector.getSnapshots(1)[0]; + if (!snapshot) return null; + + return { + damageRects: snapshot.damageRects, + damageCells: snapshot.dirtyCells, + drawlistBytes: snapshot.drawlistBytes, + diffBytesEmitted: snapshot.diffBytesEmitted, + usDrawlist: snapshot.usDrawlist, + usDiff: snapshot.usDiff, + usWrite: snapshot.usWrite, + }; + }; + + const captureHooks: InspectorRuntimeBreadcrumbHooks = Object.freeze({ + onRender: (metrics: AppRenderMetrics) => { + const snapshot = (metrics as RenderMetricsWithBreadcrumbs).runtimeBreadcrumbs; + if (snapshot) latestSnapshot = snapshot; + }, + onLayout: (snapshot: AppLayoutSnapshot) => { + const breadcrumbs = (snapshot as LayoutSnapshotWithBreadcrumbs).runtimeBreadcrumbs; + if (breadcrumbs) latestSnapshot = breadcrumbs; + }, + }); + + const syncRuntimeCapture = (): void => { + const setHooks = appWithInternalMarkers[APP_INTERNAL_SET_RUNTIME_BREADCRUMB_HOOKS_MARKER]; + if (typeof setHooks !== "function") return; + setHooks(overlayEnabled ? captureHooks : null); + }; + syncRuntimeCapture(); + + const requestInspectorRefresh = (): void => { + const internalRefresh = appWithInternalMarkers[APP_INTERNAL_REQUEST_VIEW_LAYOUT_MARKER]; + if (typeof internalRefresh === "function") { + internalRefresh(); + return; + } + + // Fallback for runtimes that don't expose the internal refresh marker. + app.setTheme(lastThemeInput); + }; + + const controller: InspectorOverlayController = Object.freeze({ + isEnabled: () => overlayEnabled, + setEnabled: (next: boolean) => { + const normalized = next === true; + if (overlayEnabled === normalized) return; + overlayEnabled = normalized; + syncRuntimeCapture(); + requestInspectorRefresh(); + }, + toggle: () => { + overlayEnabled = !overlayEnabled; + syncRuntimeCapture(); + requestInspectorRefresh(); + return overlayEnabled; + }, + getSnapshot: () => latestSnapshot, + }); + + if (hotkeyHint) { + app.keys({ + [hotkeyHint]: { + priority: -1000, + handler: () => { + controller.toggle(); + }, + }, + }); + } + + const wrapView = (viewFn: ViewFn): ViewFn => { + return (state) => { + const root = viewFn(state); + if (!overlayEnabled) return root; + const frameTiming = resolveFrameTiming(); + return ui.layers([ + root, + inspectorOverlay( + Object.freeze({ + snapshot: latestSnapshot, + frameTiming, + ...(inspectorOpts.id === undefined ? {} : { id: inspectorOpts.id }), + ...(inspectorOpts.title === undefined ? {} : { title: inspectorOpts.title }), + ...(inspectorOpts.position === undefined ? {} : { position: inspectorOpts.position }), + ...(inspectorOpts.width === undefined ? {} : { width: inspectorOpts.width }), + ...(inspectorOpts.zIndex === undefined ? {} : { zIndex: inspectorOpts.zIndex }), + ...(hotkeyHint === null ? {} : { hotkeyHint }), + }), + ), + ]); + }; + }; + + const wrapped: AppWithInspectorOverlay = { + view(fn: ViewFn): void { + app.view(wrapView(fn)); + }, + draw(fn: DrawFn): void { + app.draw(fn); + }, + onEvent(handler: EventHandler): () => void { + return app.onEvent(handler); + }, + update(updater: S | ((prev: Readonly) => S)): void { + app.update(updater); + }, + setTheme(theme: Theme | ThemeDefinition): void { + lastThemeInput = theme; + app.setTheme(theme); + }, + start(): Promise { + return app.start(); + }, + stop(): Promise { + return app.stop(); + }, + dispose(): void { + app.dispose(); + }, + keys(bindings): void { + app.keys(bindings); + }, + modes(modes): void { + app.modes(modes); + }, + setMode(modeName: string): void { + app.setMode(modeName); + }, + getMode(): string { + return app.getMode(); + }, + inspectorOverlay: controller, + }; + + return wrapped; +} diff --git a/packages/core/src/app/runtimeBreadcrumbs.ts b/packages/core/src/app/runtimeBreadcrumbs.ts new file mode 100644 index 00000000..e239291a --- /dev/null +++ b/packages/core/src/app/runtimeBreadcrumbs.ts @@ -0,0 +1,150 @@ +/** + * packages/core/src/app/runtimeBreadcrumbs.ts — Runtime breadcrumb snapshot shapes. + * + * Why: Defines metadata shapes consumed by inspector/export plumbing without + * affecting runtime behavior. + */ + +import type { CursorShape } from "../abi.js"; +import type { RoutedAction } from "../runtime/router.js"; + +/** Engine event kinds tracked by runtime breadcrumbs. */ +export type RuntimeBreadcrumbEventKind = "key" | "text" | "paste" | "mouse" | "resize"; + +/** Which routing path handled the last tracked engine event. */ +export type RuntimeBreadcrumbConsumptionPath = "keybindings" | "widgetRouting"; + +/** Last emitted widget action summary. */ +export type RuntimeBreadcrumbAction = Readonly<{ + id: string; + action: RoutedAction["action"]; +}>; + +/** Focus summary for inspector/export snapshots. */ +export type RuntimeBreadcrumbFocusSummary = Readonly<{ + focusedId: string | null; + activeZoneId: string | null; + activeTrapId: string | null; +}>; + +/** Cursor intent summary when v2 cursor protocol is enabled. */ +export type RuntimeBreadcrumbCursorSummary = + | Readonly<{ + visible: false; + shape: CursorShape; + blink: boolean; + }> + | Readonly<{ + visible: true; + x: number; + y: number; + shape: CursorShape; + blink: boolean; + }>; + +/** Damage mode for the submitted frame. */ +export type RuntimeBreadcrumbDamageMode = "none" | "full" | "incremental"; + +/** Damage summary for inspector/export snapshots. */ +export type RuntimeBreadcrumbDamageSummary = Readonly<{ + mode: RuntimeBreadcrumbDamageMode; + rectCount: number; + area: number; +}>; + +/** Frame summary captured during submitFrame(). */ +export type RuntimeBreadcrumbFrameSummary = Readonly<{ + tick: number; + commit: boolean; + layout: boolean; + incremental: boolean; + renderTimeMs: number; +}>; + +/** Renderer-owned runtime metadata snapshot (without app-level event routing fields). */ +export type WidgetRuntimeBreadcrumbSnapshot = Readonly<{ + focus: RuntimeBreadcrumbFocusSummary; + cursor: RuntimeBreadcrumbCursorSummary | null; + damage: RuntimeBreadcrumbDamageSummary; + frame: RuntimeBreadcrumbFrameSummary; +}>; + +/** Full runtime breadcrumb snapshot emitted to internal inspector hooks. */ +export type RuntimeBreadcrumbSnapshot = Readonly< + WidgetRuntimeBreadcrumbSnapshot & { + event: Readonly<{ + kind: RuntimeBreadcrumbEventKind | null; + path: RuntimeBreadcrumbConsumptionPath | null; + }>; + lastAction: RuntimeBreadcrumbAction | null; + } +>; + +const EMPTY_FOCUS: RuntimeBreadcrumbFocusSummary = Object.freeze({ + focusedId: null, + activeZoneId: null, + activeTrapId: null, +}); + +const EMPTY_DAMAGE: RuntimeBreadcrumbDamageSummary = Object.freeze({ + mode: "none", + rectCount: 0, + area: 0, +}); + +const EMPTY_FRAME: RuntimeBreadcrumbFrameSummary = Object.freeze({ + tick: 0, + commit: false, + layout: false, + incremental: false, + renderTimeMs: 0, +}); + +/** Zero-state runtime snapshot used before first captured frame. */ +export const EMPTY_WIDGET_RUNTIME_BREADCRUMBS: WidgetRuntimeBreadcrumbSnapshot = Object.freeze({ + focus: EMPTY_FOCUS, + cursor: null, + damage: EMPTY_DAMAGE, + frame: EMPTY_FRAME, +}); + +/** Convert router action payload to breadcrumb summary shape. */ +export function toRuntimeBreadcrumbAction(action: RoutedAction): RuntimeBreadcrumbAction { + return Object.freeze({ + id: action.id, + action: action.action, + }); +} + +/** Narrow runtime event kinds that breadcrumbs track. */ +export function isRuntimeBreadcrumbEventKind(kind: string): kind is RuntimeBreadcrumbEventKind { + return ( + kind === "key" || kind === "text" || kind === "paste" || kind === "mouse" || kind === "resize" + ); +} + +/** Merge renderer snapshot with app-level event/action metadata. */ +export function mergeRuntimeBreadcrumbSnapshot( + widget: WidgetRuntimeBreadcrumbSnapshot, + eventKind: RuntimeBreadcrumbEventKind | null, + eventPath: RuntimeBreadcrumbConsumptionPath | null, + lastAction: RuntimeBreadcrumbAction | null, + renderTimeMs: number, +): RuntimeBreadcrumbSnapshot { + const frame: RuntimeBreadcrumbFrameSummary = Object.freeze({ + ...widget.frame, + renderTimeMs, + }); + + return Object.freeze({ + focus: widget.focus, + cursor: widget.cursor, + damage: widget.damage, + frame, + event: Object.freeze({ + kind: eventKind, + path: eventPath, + }), + lastAction, + }); +} diff --git a/packages/core/src/app/widgetRenderer.ts b/packages/core/src/app/widgetRenderer.ts index e45d7947..d82b5d28 100644 --- a/packages/core/src/app/widgetRenderer.ts +++ b/packages/core/src/app/widgetRenderer.ts @@ -136,6 +136,12 @@ import type { VirtualListProps, } from "../widgets/types.js"; import { computeVisibleRange, getTotalHeight } from "../widgets/virtualList.js"; +import { + EMPTY_WIDGET_RUNTIME_BREADCRUMBS, + type RuntimeBreadcrumbCursorSummary, + type RuntimeBreadcrumbDamageMode, + type WidgetRuntimeBreadcrumbSnapshot, +} from "./runtimeBreadcrumbs.js"; import { routeCodeEditorKeyDown } from "./widgetRenderer/codeEditorRouting.js"; import { kickoffCommandPaletteItemFetches, @@ -377,6 +383,7 @@ export class WidgetRenderer { private readonly useV2Cursor: boolean; private readonly cursorShape: CursorShape; private readonly cursorBlink: boolean; + private collectRuntimeBreadcrumbs: boolean; private readonly requestRender: () => void; private readonly requestView: () => void; @@ -555,6 +562,7 @@ export class WidgetRenderer { private readonly _pooledPrevToolApprovalDialogIds = new Set(); private readonly _pooledPrevDiffViewerIds = new Set(); private readonly _pooledPrevLogsConsoleIds = new Set(); + private _runtimeBreadcrumbs: WidgetRuntimeBreadcrumbSnapshot = EMPTY_WIDGET_RUNTIME_BREADCRUMBS; constructor( opts: Readonly<{ @@ -574,12 +582,15 @@ export class WidgetRenderer { cursorShape?: CursorShape; /** Whether cursor should blink (default: true) */ cursorBlink?: boolean; + /** Collect runtime breadcrumb snapshots for inspector/export hooks. */ + collectRuntimeBreadcrumbs?: boolean; }>, ) { this.backend = opts.backend; this.useV2Cursor = opts.useV2Cursor === true; this.cursorShape = opts.cursorShape ?? CURSOR_DEFAULTS.input.shape; this.cursorBlink = opts.cursorBlink ?? CURSOR_DEFAULTS.input.blink; + this.collectRuntimeBreadcrumbs = opts.collectRuntimeBreadcrumbs === true; this.requestRender = opts.requestRender ?? (() => {}); this.requestView = opts.requestView ?? (() => {}); @@ -622,6 +633,26 @@ export class WidgetRenderer { return this.rectById; } + /** + * Get the latest runtime breadcrumb snapshot for inspector/export. + * + * Returns null when breadcrumb capture is disabled. + */ + getRuntimeBreadcrumbSnapshot(): WidgetRuntimeBreadcrumbSnapshot | null { + return this.collectRuntimeBreadcrumbs ? this._runtimeBreadcrumbs : null; + } + + /** + * Enable or disable runtime breadcrumb snapshot capture. + */ + setRuntimeBreadcrumbCaptureEnabled(enabled: boolean): void { + if (this.collectRuntimeBreadcrumbs === enabled) return; + this.collectRuntimeBreadcrumbs = enabled; + if (!enabled) { + this._runtimeBreadcrumbs = EMPTY_WIDGET_RUNTIME_BREADCRUMBS; + } + } + /** * Determine whether a key event should bypass the keybinding system. * @@ -2467,25 +2498,100 @@ export class WidgetRenderer { }; } - private emitIncrementalCursor(cursorInfo: CursorInfo | undefined): void { - if (!cursorInfo || !this.useV2Cursor || !isV2Builder(this.builder)) return; + private resolveRuntimeCursorSummary( + cursorInfo: CursorInfo | undefined, + ): RuntimeBreadcrumbCursorSummary | null { + if (!cursorInfo || !this.useV2Cursor) return null; + + const hidden: RuntimeBreadcrumbCursorSummary = Object.freeze({ + visible: false, + shape: cursorInfo.shape, + blink: cursorInfo.blink, + }); + + const focusedId = this.focusState.focusedId; + if (!focusedId) return hidden; + + const input = this.inputById.get(focusedId); + if (input && !input.disabled) { + const rect = this._pooledRectByInstanceId.get(input.instanceId); + if (!rect || rect.w <= 0 || rect.h <= 0) return hidden; + + const graphemeOffset = + this.inputCursorByInstanceId.get(input.instanceId) ?? input.value.length; + const cursorX = measureTextCells(input.value.slice(0, graphemeOffset)); + return Object.freeze({ + visible: true, + x: rect.x + 1 + cursorX, + y: rect.y, + shape: cursorInfo.shape, + blink: cursorInfo.blink, + }); + } + + const editor = this.codeEditorById.get(focusedId); + if (editor) { + const rect = this.rectById.get(editor.id); + if (!rect || rect.w <= 0 || rect.h <= 0) return hidden; + const lineNumWidth = + this.codeEditorRenderCacheById.get(editor.id)?.lineNumWidth ?? + (editor.lineNumbers === false ? 0 : Math.max(4, String(editor.lines.length).length + 1)); + const cy = editor.cursor.line - editor.scrollTop; + if (cy < 0 || cy >= rect.h) return hidden; + const cx = editor.cursor.column - editor.scrollLeft; + const x = rect.x + lineNumWidth + cx; + if (x < rect.x + lineNumWidth || x >= rect.x + rect.w) return hidden; + return Object.freeze({ + visible: true, + x, + y: rect.y + cy, + shape: cursorInfo.shape, + blink: cursorInfo.blink, + }); + } + + const palette = this.commandPaletteById.get(focusedId); + if (palette?.open === true) { + const rect = this.rectById.get(palette.id); + if (!rect || rect.w <= 0 || rect.h <= 0) return hidden; + const inputW = Math.max(0, rect.w - 6); + if (inputW <= 0) return hidden; + const qx = measureTextCells(palette.query); + return Object.freeze({ + visible: true, + x: rect.x + 4 + Math.min(qx, Math.max(0, inputW - 1)), + y: rect.y + 1, + shape: cursorInfo.shape, + blink: cursorInfo.blink, + }); + } + + return hidden; + } + + private emitIncrementalCursor( + cursorInfo: CursorInfo | undefined, + ): RuntimeBreadcrumbCursorSummary | null { + if (!cursorInfo || !this.useV2Cursor || !isV2Builder(this.builder)) { + return this.collectRuntimeBreadcrumbs ? this.resolveRuntimeCursorSummary(cursorInfo) : null; + } const focusedId = this.focusState.focusedId; if (!focusedId) { this.builder.hideCursor(); - return; + return this.collectRuntimeBreadcrumbs ? this.resolveRuntimeCursorSummary(cursorInfo) : null; } const input = this.inputById.get(focusedId); if (!input || input.disabled) { this.builder.hideCursor(); - return; + return this.collectRuntimeBreadcrumbs ? this.resolveRuntimeCursorSummary(cursorInfo) : null; } const rect = this._pooledRectByInstanceId.get(input.instanceId); if (!rect || rect.w <= 0 || rect.h <= 0) { this.builder.hideCursor(); - return; + return this.collectRuntimeBreadcrumbs ? this.resolveRuntimeCursorSummary(cursorInfo) : null; } const graphemeOffset = this.inputCursorByInstanceId.get(input.instanceId) ?? input.value.length; @@ -2497,6 +2603,47 @@ export class WidgetRenderer { visible: true, blink: cursorInfo.blink, }); + + return this.collectRuntimeBreadcrumbs ? this.resolveRuntimeCursorSummary(cursorInfo) : null; + } + + private updateRuntimeBreadcrumbSnapshot( + params: Readonly<{ + tick: number; + commit: boolean; + layout: boolean; + incremental: boolean; + damageMode: RuntimeBreadcrumbDamageMode; + damageRectCount: number; + damageArea: number; + cursor: RuntimeBreadcrumbCursorSummary | null; + }>, + ): void { + if (!this.collectRuntimeBreadcrumbs) return; + const activeTrapId = + this.focusState.trapStack.length > 0 + ? (this.focusState.trapStack[this.focusState.trapStack.length - 1] ?? null) + : null; + this._runtimeBreadcrumbs = Object.freeze({ + focus: Object.freeze({ + focusedId: this.focusState.focusedId, + activeZoneId: this.focusState.activeZoneId, + activeTrapId, + }), + cursor: params.cursor, + damage: Object.freeze({ + mode: params.damageMode, + rectCount: Math.max(0, params.damageRectCount), + area: Math.max(0, params.damageArea), + }), + frame: Object.freeze({ + tick: params.tick, + commit: params.commit, + layout: params.layout, + incremental: params.incremental, + renderTimeMs: 0, + }), + }); } private appendDamageRectForInstanceId(instanceId: InstanceId): boolean { @@ -3492,6 +3639,11 @@ export class WidgetRenderer { const renderToken = perfMarkStart("render"); let usedIncrementalRender = false; + const captureRuntimeBreadcrumbs = this.collectRuntimeBreadcrumbs; + let runtimeCursorSummary: RuntimeBreadcrumbCursorSummary | null = null; + let runtimeDamageMode: RuntimeBreadcrumbDamageMode = "none"; + let runtimeDamageRectCount = 0; + let runtimeDamageArea = 0; if (this.shouldAttemptIncrementalRender(doLayout, viewport, theme)) { this._pooledDamageRects.length = 0; let missingDamageRect = false; @@ -3537,6 +3689,14 @@ export class WidgetRenderer { if (!missingDamageRect) { const damageRects = this.normalizeDamageRects(viewport); if (damageRects.length > 0 && !this.isDamageAreaTooLarge(viewport)) { + if (captureRuntimeBreadcrumbs) { + runtimeDamageMode = "incremental"; + runtimeDamageRectCount = damageRects.length; + runtimeDamageArea = 0; + for (const damageRect of damageRects) { + runtimeDamageArea += damageRect.w * damageRect.h; + } + } for (const damageRect of damageRects) { this.builder.fillRect( damageRect.x, @@ -3577,7 +3737,7 @@ export class WidgetRenderer { ); this.builder.popClip(); } - this.emitIncrementalCursor(cursorInfo); + runtimeCursorSummary = this.emitIncrementalCursor(cursorInfo); usedIncrementalRender = true; } } @@ -3610,6 +3770,12 @@ export class WidgetRenderer { diffRenderCacheById: this.diffRenderCacheById, codeEditorRenderCacheById: this.codeEditorRenderCacheById, }); + if (captureRuntimeBreadcrumbs) { + runtimeDamageMode = "full"; + runtimeDamageRectCount = viewport.cols > 0 && viewport.rows > 0 ? 1 : 0; + runtimeDamageArea = viewport.cols * viewport.rows; + runtimeCursorSummary = this.resolveRuntimeCursorSummary(cursorInfo); + } } perfMarkEnd("render", renderToken); @@ -3623,6 +3789,18 @@ export class WidgetRenderer { detail: `${built.error.code}: ${built.error.detail}`, }; } + if (captureRuntimeBreadcrumbs) { + this.updateRuntimeBreadcrumbSnapshot({ + tick, + commit: doCommit, + layout: doLayout, + incremental: usedIncrementalRender, + damageMode: runtimeDamageMode, + damageRectCount: runtimeDamageRectCount, + damageArea: runtimeDamageArea, + cursor: runtimeCursorSummary, + }); + } this.snapshotRenderedFrameState(viewport, theme, doLayout); // Render hooks are for preventing re-entrant app API calls during user render. diff --git a/packages/core/src/debug/__tests__/debugController.bundle.test.ts b/packages/core/src/debug/__tests__/debugController.bundle.test.ts new file mode 100644 index 00000000..e356f3d3 --- /dev/null +++ b/packages/core/src/debug/__tests__/debugController.bundle.test.ts @@ -0,0 +1,348 @@ +import { assert, describe, test } from "@rezi-ui/testkit"; +import { + DEBUG_BUNDLE_SCHEMA_V1, + DEBUG_CODE_DRAWLIST_CMD, + DEBUG_CODE_DRAWLIST_EXECUTE, + DEBUG_DRAWLIST_RECORD_SIZE, + categoryToNum, + severityToNum, +} from "../constants.js"; +import { type DebugBackend, createDebugController } from "../debugController.js"; +import type { + DebugBundlePayloadSnapshot, + DebugBundleTraceRecord, + DebugCategory, + DebugConfig, + DebugQuery, + DebugRecordHeader, + DebugSeverity, + DebugStats, +} from "../types.js"; + +type MockTraceRecord = Readonly<{ + header: DebugRecordHeader; + payload: Uint8Array | null; +}>; + +const HEADER_SIZE = 40; + +function writeU64(view: DataView, offset: number, value: bigint): void { + view.setUint32(offset, Number(value & 0xffff_ffffn), true); + view.setUint32(offset + 4, Number((value >> 32n) & 0xffff_ffffn), true); +} + +function encodeHeaders(headers: readonly DebugRecordHeader[]): Uint8Array { + const bytes = new Uint8Array(headers.length * HEADER_SIZE); + const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + + for (let i = 0; i < headers.length; i++) { + const header = headers[i]; + if (!header) continue; + + const off = i * HEADER_SIZE; + const category = categoryToNum(header.category); + const severity = severityToNum(header.severity); + if (category === null || severity === null) { + throw new Error("invalid category/severity in test header"); + } + + writeU64(view, off, header.recordId); + writeU64(view, off + 8, header.timestampUs); + writeU64(view, off + 16, header.frameId); + view.setUint32(off + 24, category, true); + view.setUint32(off + 28, severity, true); + view.setUint32(off + 32, header.code, true); + view.setUint32(off + 36, header.payloadSize, true); + } + + return bytes; +} + +function makeHeader( + recordId: bigint, + category: DebugCategory, + severity: DebugSeverity, + payloadSize: number, + frameId = 0n, + code = 7, +): DebugRecordHeader { + return { + recordId, + timestampUs: recordId * 10n, + frameId, + category, + severity, + code, + payloadSize, + }; +} + +function makeFramePayload(frameId: bigint): Uint8Array { + const bytes = new Uint8Array(56); + const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + writeU64(view, 0, frameId); + view.setUint32(8, 120, true); + view.setUint32(12, 40, true); + view.setUint32(16, 2048, true); + view.setUint32(20, 61, true); + view.setUint32(24, 512, true); + view.setUint32(28, 9, true); + view.setUint32(32, 81, true); + view.setUint32(36, 4, true); + view.setUint32(40, 900, true); + view.setUint32(44, 410, true); + view.setUint32(48, 220, true); + view.setUint32(52, 0, true); + return bytes; +} + +function createMockBackend( + recordsInput: readonly MockTraceRecord[], + statsOverrides: Partial = {}, +): DebugBackend { + const records = [...recordsInput].sort((a, b) => + a.header.recordId < b.header.recordId ? -1 : a.header.recordId > b.header.recordId ? 1 : 0, + ); + const payloadById = new Map(records.map((r) => [r.header.recordId, r.payload])); + const stats: DebugStats = { + totalRecords: BigInt(records.length), + totalDropped: 0n, + errorCount: records.filter((r) => r.header.category === "error").length, + warnCount: records.filter((r) => r.header.severity === "warn").length, + currentRingUsage: records.length, + ringCapacity: 4096, + ...statsOverrides, + }; + + return { + debugEnable: async (_config: DebugConfig) => {}, + debugDisable: async () => {}, + debugQuery: async (query: DebugQuery) => { + let selected = records; + if (query.maxRecords !== undefined && query.maxRecords > 0) { + selected = records.slice(0, query.maxRecords); + } + + return { + headers: encodeHeaders(selected.map((r) => r.header)), + result: { + recordsReturned: selected.length, + recordsAvailable: records.length, + oldestRecordId: records[0]?.header.recordId ?? 0n, + newestRecordId: records[records.length - 1]?.header.recordId ?? 0n, + recordsDropped: 0, + }, + }; + }, + debugGetPayload: async (recordId: bigint) => { + const payload = payloadById.get(recordId); + return payload ? payload.slice() : null; + }, + debugGetStats: async () => stats, + debugExport: async () => new Uint8Array(0), + debugReset: async () => {}, + }; +} + +function getRecord( + trace: readonly DebugBundleTraceRecord[], + recordId: string, +): DebugBundleTraceRecord { + const record = trace.find((entry) => entry.header.recordId === recordId); + if (!record) { + throw new Error(`missing trace record id=${recordId}`); + } + return record; +} + +function getPayload(record: DebugBundleTraceRecord): DebugBundlePayloadSnapshot { + if (!record.payload) { + throw new Error(`record ${record.header.recordId} has no payload`); + } + return record.payload; +} + +describe("debug bundle export", () => { + test("uses versioned schema and is deterministic for same run state", async () => { + const framePayload = makeFramePayload(11n); + const records: MockTraceRecord[] = [ + { + header: makeHeader(1n, "frame", "info", framePayload.byteLength, 11n), + payload: framePayload, + }, + { + header: makeHeader(2n, "perf", "info", 4, 11n), + payload: new Uint8Array([1, 2, 3, 4]), + }, + ]; + const backend = createMockBackend(records, { totalDropped: 1n }); + + const debug = createDebugController({ + backend, + terminalCapsProvider: async () => ({ + colorMode: 3, + supportsMouse: true, + supportsBracketedPaste: true, + supportsFocusEvents: true, + supportsOsc52: false, + supportsSyncUpdate: true, + supportsScrollRegion: true, + supportsCursorShape: true, + supportsOutputWaitWritable: false, + sgrAttrsSupported: 0xff, + }), + }); + await debug.enable({ + captureRawEvents: true, + captureDrawlistBytes: true, + }); + + const firstRecord = records[0]; + if (!firstRecord) { + throw new Error("expected first record"); + } + + debug.processRecords(encodeHeaders([firstRecord.header]), new Map([[1n, framePayload]])); + + const first = await debug.exportBundle({ + maxRecords: 16, + maxPayloadBytes: 16, + maxTotalPayloadBytes: 64, + maxRecentFrames: 4, + }); + const second = await debug.exportBundle({ + maxRecords: 16, + maxPayloadBytes: 16, + maxTotalPayloadBytes: 64, + maxRecentFrames: 4, + }); + + assert.equal(first.schema, DEBUG_BUNDLE_SCHEMA_V1); + assert.deepEqual(first, second); + assert.equal(first.trace.length, 2); + assert.equal(first.terminalCaps?.supportsMouse, true); + assert.equal(first.recentFrameSummaries?.length, 1); + assert.equal(first.recentFrameSummaries?.[0]?.frameId, "11"); + + const bytesA = await debug.exportBundleBytes({ maxRecords: 16 }); + const bytesB = await debug.exportBundleBytes({ maxRecords: 16 }); + assert.deepEqual(Array.from(bytesA), Array.from(bytesB)); + + const parsed = JSON.parse(new TextDecoder().decode(bytesA)) as { schema?: string }; + assert.equal(parsed.schema, DEBUG_BUNDLE_SCHEMA_V1); + }); + + test("enforces per-record and total payload bounds", async () => { + const records: MockTraceRecord[] = [ + { header: makeHeader(1n, "perf", "info", 10), payload: new Uint8Array(10).fill(1) }, + { header: makeHeader(2n, "perf", "info", 8), payload: new Uint8Array(8).fill(2) }, + { header: makeHeader(3n, "perf", "info", 4), payload: new Uint8Array(4).fill(3) }, + ]; + const debug = createDebugController({ backend: createMockBackend(records) }); + + const bundle = await debug.exportBundle({ + maxRecords: 3, + maxPayloadBytes: 6, + maxTotalPayloadBytes: 9, + includeRecentFrames: false, + }); + + assert.equal(bundle.trace.length, 3); + + const p1 = getPayload(getRecord(bundle.trace, "1")); + const p2 = getPayload(getRecord(bundle.trace, "2")); + const p3 = getPayload(getRecord(bundle.trace, "3")); + + if (!p1.included || !p2.included || !p3.included) { + throw new Error("expected included payloads for perf records"); + } + + assert.equal(p1.bytesIncluded, 6); + assert.equal(p1.truncated, true); + assert.equal(p2.bytesIncluded, 3); + assert.equal(p2.truncated, true); + assert.equal(p3.bytesIncluded, 0); + assert.equal(p3.truncated, true); + + assert.equal(p1.bytesIncluded + p2.bytesIncluded + p3.bytesIncluded, 9); + assert.equal(bundle.bounds.maxPayloadBytes, 6); + assert.equal(bundle.bounds.maxTotalPayloadBytes, 9); + }); + + test("includes or excludes sensitive payloads based on capture flags", async () => { + const records: MockTraceRecord[] = [ + { header: makeHeader(1n, "event", "info", 6), payload: new Uint8Array([9, 8, 7, 6, 5, 4]) }, + { + header: makeHeader(2n, "drawlist", "info", 64, 0n, DEBUG_CODE_DRAWLIST_CMD), + payload: new Uint8Array(64).fill(0xab), + }, + { + header: makeHeader( + 3n, + "drawlist", + "info", + DEBUG_DRAWLIST_RECORD_SIZE, + 0n, + DEBUG_CODE_DRAWLIST_EXECUTE, + ), + payload: new Uint8Array(DEBUG_DRAWLIST_RECORD_SIZE).fill(0xcd), + }, + { + header: makeHeader( + 4n, + "drawlist", + "info", + DEBUG_DRAWLIST_RECORD_SIZE, + 0n, + DEBUG_CODE_DRAWLIST_CMD, + ), + payload: new Uint8Array(DEBUG_DRAWLIST_RECORD_SIZE).fill(0xef), + }, + ]; + const debug = createDebugController({ backend: createMockBackend(records) }); + + await debug.enable({ + captureRawEvents: false, + captureDrawlistBytes: false, + }); + + const disabled = await debug.exportBundle({ includeRecentFrames: false }); + + const eventDisabled = getPayload(getRecord(disabled.trace, "1")); + const rawDrawlistDisabled = getPayload(getRecord(disabled.trace, "2")); + const structuredDrawlistDisabled = getPayload(getRecord(disabled.trace, "3")); + const rawDrawlistSameSizeDisabled = getPayload(getRecord(disabled.trace, "4")); + + assert.equal(eventDisabled.included, false); + if (eventDisabled.included) throw new Error("event payload should be omitted"); + assert.equal(eventDisabled.reason, "capture-raw-events-disabled"); + + assert.equal(rawDrawlistDisabled.included, false); + if (rawDrawlistDisabled.included) throw new Error("raw drawlist payload should be omitted"); + assert.equal(rawDrawlistDisabled.reason, "capture-drawlist-bytes-disabled"); + + assert.equal(structuredDrawlistDisabled.included, true); + + assert.equal(rawDrawlistSameSizeDisabled.included, false); + if (rawDrawlistSameSizeDisabled.included) { + throw new Error( + "raw drawlist payload should be omitted even when size matches structured record", + ); + } + assert.equal(rawDrawlistSameSizeDisabled.reason, "capture-drawlist-bytes-disabled"); + + await debug.enable({ + captureRawEvents: true, + captureDrawlistBytes: true, + }); + + const enabled = await debug.exportBundle({ includeRecentFrames: false }); + const eventEnabled = getPayload(getRecord(enabled.trace, "1")); + const rawDrawlistEnabled = getPayload(getRecord(enabled.trace, "2")); + const rawDrawlistSameSizeEnabled = getPayload(getRecord(enabled.trace, "4")); + + assert.equal(eventEnabled.included, true); + assert.equal(rawDrawlistEnabled.included, true); + assert.equal(rawDrawlistSameSizeEnabled.included, true); + }); +}); diff --git a/packages/core/src/debug/constants.ts b/packages/core/src/debug/constants.ts index 8382e9fd..514324ea 100644 --- a/packages/core/src/debug/constants.ts +++ b/packages/core/src/debug/constants.ts @@ -36,6 +36,17 @@ export const DEBUG_SEV_INFO = 1; export const DEBUG_SEV_WARN = 2; export const DEBUG_SEV_ERROR = 3; +/* --- Drawlist Record Codes (match zr_debug_code_t) --- */ + +/** Drawlist validate summary record code (structured zr_debug_drawlist_record_t). */ +export const DEBUG_CODE_DRAWLIST_VALIDATE = 0x0300; + +/** Drawlist execute summary record code (structured zr_debug_drawlist_record_t). */ +export const DEBUG_CODE_DRAWLIST_EXECUTE = 0x0301; + +/** Raw drawlist byte-stream record code (payload may contain sensitive render bytes). */ +export const DEBUG_CODE_DRAWLIST_CMD = 0x0302; + /* --- Performance Phase Constants --- */ export const PERF_PHASE_POLL = 0; @@ -78,6 +89,23 @@ export const DEBUG_QUERY_RESULT_SIZE = 32; /** Size of zr_debug_stats_t in bytes. */ export const DEBUG_STATS_SIZE = 32; +/* --- Bundle Export Constants --- */ + +/** Stable schema identifier for JSON debug bundles. */ +export const DEBUG_BUNDLE_SCHEMA_V1 = "rezi-debug-bundle-v1"; + +/** Default maximum number of trace headers exported into a bundle. */ +export const DEBUG_BUNDLE_DEFAULT_MAX_RECORDS = 512; + +/** Default maximum payload bytes included per trace record. */ +export const DEBUG_BUNDLE_DEFAULT_MAX_PAYLOAD_BYTES = 4096; + +/** Default maximum payload bytes included across all records. */ +export const DEBUG_BUNDLE_DEFAULT_MAX_TOTAL_PAYLOAD_BYTES = 262_144; + +/** Default maximum recent frame summaries included when available. */ +export const DEBUG_BUNDLE_DEFAULT_MAX_RECENT_FRAMES = 32; + /* --- Category Mapping --- */ const CATEGORY_NUM_TO_STR: ReadonlyMap = new Map([ diff --git a/packages/core/src/debug/debugController.ts b/packages/core/src/debug/debugController.ts index 1e7ff8c2..87c0a471 100644 --- a/packages/core/src/debug/debugController.ts +++ b/packages/core/src/debug/debugController.ts @@ -17,14 +17,25 @@ * const frames = debug.frameInspector.getSnapshots(10); */ -import { DEBUG_CAT_ERROR, DEBUG_CAT_EVENT, DEBUG_CAT_FRAME } from "./constants.js"; +import type { TerminalCaps } from "../terminalCaps.js"; +import { + DEBUG_BUNDLE_DEFAULT_MAX_PAYLOAD_BYTES, + DEBUG_BUNDLE_DEFAULT_MAX_RECENT_FRAMES, + DEBUG_BUNDLE_DEFAULT_MAX_RECORDS, + DEBUG_BUNDLE_DEFAULT_MAX_TOTAL_PAYLOAD_BYTES, + DEBUG_BUNDLE_SCHEMA_V1, + DEBUG_CODE_DRAWLIST_CMD, + DEBUG_CODE_DRAWLIST_EXECUTE, + DEBUG_CODE_DRAWLIST_VALIDATE, + DEBUG_RECORD_HEADER_SIZE, +} from "./constants.js"; import { type AggregatedError, type ErrorAggregator, createErrorAggregator, } from "./errorAggregator.js"; import { type EventTrace, createEventTrace } from "./eventTrace.js"; -import { type FrameInspector, createFrameInspector } from "./frameInspector.js"; +import { type FrameInspector, type FrameSnapshot, createFrameInspector } from "./frameInspector.js"; import { parseErrorRecord, parseEventRecord, @@ -34,6 +45,13 @@ import { } from "./parsers.js"; import { type StateTimeline, createStateTimeline } from "./stateTimeline.js"; import type { + DebugBundle, + DebugBundleExportOptions, + DebugBundleFrameSummary, + DebugBundleHeader, + DebugBundlePayloadSnapshot, + DebugBundleQueryWindow, + DebugBundleStatsSnapshot, DebugConfig, DebugPayload, DebugQuery, @@ -58,6 +76,119 @@ export type DebugErrorHandler = (error: AggregatedError) => void; */ export type DebugEventType = "record" | "error"; +type TerminalCapsProvider = () => TerminalCaps | null | Promise; + +function normalizeBound(value: number | undefined, fallback: number): number { + if (value === undefined) return fallback; + if (!Number.isFinite(value)) return fallback; + if (!Number.isInteger(value)) return fallback; + if (value < 0) return fallback; + return value; +} + +function toHex(bytes: Uint8Array): string { + let out = ""; + for (const byte of bytes) { + out += byte.toString(16).padStart(2, "0"); + } + return out; +} + +function compareBigInt(a: bigint, b: bigint): number { + if (a < b) return -1; + if (a > b) return 1; + return 0; +} + +function isStructuredDrawlistRecordCode(code: number): boolean { + return code === DEBUG_CODE_DRAWLIST_VALIDATE || code === DEBUG_CODE_DRAWLIST_EXECUTE; +} + +function isRawDrawlistBytesRecordCode(code: number): boolean { + return code === DEBUG_CODE_DRAWLIST_CMD; +} + +function toBundleHeader(header: DebugRecordHeader): DebugBundleHeader { + return { + recordId: header.recordId.toString(), + timestampUs: header.timestampUs.toString(), + frameId: header.frameId.toString(), + category: header.category, + severity: header.severity, + code: header.code, + payloadSize: header.payloadSize, + }; +} + +function toBundleStats(stats: DebugStats): DebugBundleStatsSnapshot { + return { + totalRecords: stats.totalRecords.toString(), + totalDropped: stats.totalDropped.toString(), + errorCount: stats.errorCount, + warnCount: stats.warnCount, + currentRingUsage: stats.currentRingUsage, + ringCapacity: stats.ringCapacity, + }; +} + +function toBundleQueryWindow(query: DebugQueryResult): DebugBundleQueryWindow { + return { + recordsReturned: query.recordsReturned, + recordsAvailable: query.recordsAvailable, + recordsDropped: query.recordsDropped, + oldestRecordId: query.oldestRecordId.toString(), + newestRecordId: query.newestRecordId.toString(), + }; +} + +function toBundleFrameSummary(frame: FrameSnapshot): DebugBundleFrameSummary { + return { + frameId: frame.frameId.toString(), + timestamp: frame.timestamp, + cols: frame.cols, + rows: frame.rows, + drawlistBytes: frame.drawlistBytes, + drawlistCmds: frame.drawlistCmds, + diffBytesEmitted: frame.diffBytesEmitted, + dirtyLines: frame.dirtyLines, + dirtyCells: frame.dirtyCells, + damageRects: frame.damageRects, + usDrawlist: frame.usDrawlist, + usDiff: frame.usDiff, + usWrite: frame.usWrite, + }; +} + +function stableJson(value: unknown): string { + if (value === null || typeof value !== "object") { + return JSON.stringify(value); + } + if (Array.isArray(value)) { + let out = "["; + for (let i = 0; i < value.length; i++) { + if (i > 0) out += ","; + const item = value[i]; + out += stableJson(item === undefined ? null : item); + } + out += "]"; + return out; + } + + const obj = value as Record; + const keys = Object.keys(obj).sort(); + let out = "{"; + let first = true; + for (const key of keys) { + const v = obj[key]; + if (v === undefined) continue; + if (!first) out += ","; + first = false; + out += `${JSON.stringify(key)}:${stableJson(v)}`; + } + out += "}"; + return out; +} + /** * Debug backend interface for engine communication. * This is a subset of RuntimeBackend focused on debug operations. @@ -154,6 +285,16 @@ export interface DebugController { */ export(): Promise; + /** + * Export a deterministic, versioned JSON debug bundle. + */ + exportBundle(options?: DebugBundleExportOptions): Promise; + + /** + * Export the debug bundle as UTF-8 JSON bytes. + */ + exportBundleBytes(options?: DebugBundleExportOptions): Promise; + /* --- High-level Features --- */ /** @@ -220,6 +361,8 @@ export type CreateDebugControllerOptions = Readonly<{ maxEvents?: number; /** Maximum state changes to retain */ maxStateChanges?: number; + /** Optional provider used to snapshot terminal capabilities during bundle export. */ + terminalCapsProvider?: TerminalCapsProvider; }>; /** @@ -227,6 +370,7 @@ export type CreateDebugControllerOptions = Readonly<{ */ export function createDebugController(options: CreateDebugControllerOptions = {}): DebugController { const backend = options.backend ?? NULL_BACKEND; + const terminalCapsProvider = options.terminalCapsProvider; const frameInspector = createFrameInspector(options.maxFrames); const eventTrace = createEventTrace(options.maxEvents); const errorAggregator = createErrorAggregator(); @@ -246,6 +390,18 @@ export function createDebugController(options: CreateDebugControllerOptions = {} } } + async function resolveTerminalCaps( + override: TerminalCaps | null | undefined, + ): Promise { + if (override !== undefined) return override; + if (!terminalCapsProvider) return null; + try { + return (await terminalCapsProvider()) ?? null; + } catch { + return null; + } + } + async function enable(config?: Partial): Promise { const fullConfig: DebugConfig = { enabled: true, @@ -285,10 +441,9 @@ export function createDebugController(options: CreateDebugControllerOptions = {} } const records: DebugRecord[] = []; - const HEADER_SIZE = 40; for (let i = 0; i < result.recordsReturned; i++) { - const offset = i * HEADER_SIZE; + const offset = i * DEBUG_RECORD_HEADER_SIZE; const headerResult = parseRecordHeader(headers, offset); if (!headerResult.ok) { @@ -338,12 +493,166 @@ export function createDebugController(options: CreateDebugControllerOptions = {} return backend.debugExport(); } + async function exportBundle(options?: DebugBundleExportOptions): Promise { + const maxRecords = normalizeBound(options?.maxRecords, DEBUG_BUNDLE_DEFAULT_MAX_RECORDS); + const maxPayloadBytes = normalizeBound( + options?.maxPayloadBytes, + DEBUG_BUNDLE_DEFAULT_MAX_PAYLOAD_BYTES, + ); + const maxTotalPayloadBytes = normalizeBound( + options?.maxTotalPayloadBytes, + DEBUG_BUNDLE_DEFAULT_MAX_TOTAL_PAYLOAD_BYTES, + ); + const maxRecentFrames = normalizeBound( + options?.maxRecentFrames, + DEBUG_BUNDLE_DEFAULT_MAX_RECENT_FRAMES, + ); + const includeRecentFrames = options?.includeRecentFrames !== false; + + const captureRawEvents = options?.includeRawEvents ?? currentConfig?.captureRawEvents ?? false; + const captureDrawlistBytes = + options?.includeDrawlistBytes ?? currentConfig?.captureDrawlistBytes ?? false; + + const stats = await backend.debugGetStats(); + const statsSnapshot = toBundleStats(stats); + const terminalCaps = await resolveTerminalCaps(options?.terminalCaps); + + let queryResult: DebugQueryResult = { + recordsReturned: 0, + recordsAvailable: 0, + oldestRecordId: 0n, + newestRecordId: 0n, + recordsDropped: 0, + }; + let headersBytes: Uint8Array = new Uint8Array(0); + + if (maxRecords > 0) { + const queried = await backend.debugQuery({ maxRecords }); + headersBytes = queried.headers; + queryResult = queried.result; + } + + const parsedHeaders: DebugRecordHeader[] = []; + const count = Math.min( + queryResult.recordsReturned, + Math.floor(headersBytes.byteLength / DEBUG_RECORD_HEADER_SIZE), + ); + + for (let i = 0; i < count; i++) { + const offset = i * DEBUG_RECORD_HEADER_SIZE; + const parsed = parseRecordHeader(headersBytes, offset); + if (parsed.ok) { + parsedHeaders.push(parsed.value); + } + } + + parsedHeaders.sort((a, b) => compareBigInt(a.recordId, b.recordId)); + + let payloadBudgetRemaining = maxTotalPayloadBytes; + const trace: Array<{ + header: DebugBundleHeader; + payload: DebugBundlePayloadSnapshot | null; + }> = []; + + for (const header of parsedHeaders) { + const headerSnapshot = toBundleHeader(header); + let payloadSnapshot: DebugBundlePayloadSnapshot | null = null; + + if (header.payloadSize > 0) { + if (header.category === "event" && !captureRawEvents) { + payloadSnapshot = { + included: false, + reason: "capture-raw-events-disabled", + bytesTotal: header.payloadSize, + }; + } else if ( + header.category === "drawlist" && + !captureDrawlistBytes && + (isRawDrawlistBytesRecordCode(header.code) || + !isStructuredDrawlistRecordCode(header.code)) + ) { + payloadSnapshot = { + included: false, + reason: "capture-drawlist-bytes-disabled", + bytesTotal: header.payloadSize, + }; + } else { + const payloadBytes = await backend.debugGetPayload(header.recordId); + if (!payloadBytes) { + payloadSnapshot = { + included: false, + reason: "payload-unavailable", + bytesTotal: header.payloadSize, + }; + } else { + const bytesIncluded = Math.min( + maxPayloadBytes, + payloadBudgetRemaining, + payloadBytes.byteLength, + ); + const truncated = bytesIncluded < payloadBytes.byteLength; + const bounded = payloadBytes.subarray(0, bytesIncluded); + payloadSnapshot = { + included: true, + encoding: "hex", + data: toHex(bounded), + bytesIncluded, + bytesTotal: payloadBytes.byteLength, + truncated, + }; + payloadBudgetRemaining -= bytesIncluded; + } + } + } + + trace.push({ + header: headerSnapshot, + payload: payloadSnapshot, + }); + } + + const recentFrameSummaries = includeRecentFrames + ? frameInspector.getSnapshots(maxRecentFrames).map(toBundleFrameSummary) + : []; + + const base: Omit = { + schema: DEBUG_BUNDLE_SCHEMA_V1, + captureFlags: { + captureRawEvents, + captureDrawlistBytes, + }, + bounds: { + maxRecords, + maxPayloadBytes, + maxTotalPayloadBytes, + maxRecentFrames, + }, + terminalCaps, + stats: statsSnapshot, + queryWindow: toBundleQueryWindow(queryResult), + trace, + }; + + if (recentFrameSummaries.length > 0) { + return { + ...base, + recentFrameSummaries, + }; + } + + return base; + } + + async function exportBundleBytes(options?: DebugBundleExportOptions): Promise { + const bundle = await exportBundle(options); + return new TextEncoder().encode(stableJson(bundle)); + } + function processRecords(headers: Uint8Array, payloads: Map): void { - const HEADER_SIZE = 40; - const count = Math.floor(headers.byteLength / HEADER_SIZE); + const count = Math.floor(headers.byteLength / DEBUG_RECORD_HEADER_SIZE); for (let i = 0; i < count; i++) { - const offset = i * HEADER_SIZE; + const offset = i * DEBUG_RECORD_HEADER_SIZE; const headerResult = parseRecordHeader(headers, offset); if (!headerResult.ok) { @@ -426,6 +735,8 @@ export function createDebugController(options: CreateDebugControllerOptions = {} getPayload, getStats, export: exportRecords, + exportBundle, + exportBundleBytes, frameInspector, eventTrace, errors: errorAggregator, diff --git a/packages/core/src/debug/index.ts b/packages/core/src/debug/index.ts index 06f26836..070c8941 100644 --- a/packages/core/src/debug/index.ts +++ b/packages/core/src/debug/index.ts @@ -19,6 +19,18 @@ // ============================================================================= export type { + DebugBundle, + DebugBundleBounds, + DebugBundleCaptureFlags, + DebugBundleExportOptions, + DebugBundleFrameSummary, + DebugBundleHeader, + DebugBundlePayloadOmittedReason, + DebugBundlePayloadSnapshot, + DebugBundleQueryWindow, + DebugBundleSchema, + DebugBundleStatsSnapshot, + DebugBundleTraceRecord, DebugCategory, DebugConfig, DebugParseError, @@ -60,6 +72,14 @@ export { DEBUG_SEV_TRACE, DEBUG_SEV_WARN, // Size constants + DEBUG_BUNDLE_DEFAULT_MAX_PAYLOAD_BYTES, + DEBUG_BUNDLE_DEFAULT_MAX_RECORDS, + DEBUG_BUNDLE_DEFAULT_MAX_RECENT_FRAMES, + DEBUG_BUNDLE_DEFAULT_MAX_TOTAL_PAYLOAD_BYTES, + DEBUG_BUNDLE_SCHEMA_V1, + DEBUG_CODE_DRAWLIST_CMD, + DEBUG_CODE_DRAWLIST_EXECUTE, + DEBUG_CODE_DRAWLIST_VALIDATE, DEBUG_CONFIG_SIZE, DEBUG_DRAWLIST_RECORD_SIZE, DEBUG_ERROR_RECORD_SIZE, diff --git a/packages/core/src/debug/types.ts b/packages/core/src/debug/types.ts index 0ec1bf2b..5f3ea675 100644 --- a/packages/core/src/debug/types.ts +++ b/packages/core/src/debug/types.ts @@ -10,6 +10,8 @@ * @see docs/guide/debugging.md */ +import type { TerminalCaps } from "../terminalCaps.js"; + /** * Debug record categories matching zr_debug_category_t. * @@ -253,6 +255,147 @@ export type DebugStats = Readonly<{ ringCapacity: number; }>; +/** + * Stable schema identifier for JSON debug bundles. + */ +export type DebugBundleSchema = "rezi-debug-bundle-v1"; + +/** + * Debug record header shape used in exported bundles. + * BigInt fields are serialized as decimal strings for JSON compatibility. + */ +export type DebugBundleHeader = Readonly<{ + recordId: string; + timestampUs: string; + frameId: string; + category: DebugCategory; + severity: DebugSeverity; + code: number; + payloadSize: number; +}>; + +/** + * Reasons a payload was intentionally omitted from a bundle. + */ +export type DebugBundlePayloadOmittedReason = + | "capture-raw-events-disabled" + | "capture-drawlist-bytes-disabled" + | "payload-unavailable"; + +/** + * Payload snapshot for a trace record inside an exported bundle. + */ +export type DebugBundlePayloadSnapshot = + | Readonly<{ + included: true; + encoding: "hex"; + data: string; + bytesIncluded: number; + bytesTotal: number; + truncated: boolean; + }> + | Readonly<{ + included: false; + reason: DebugBundlePayloadOmittedReason; + bytesTotal: number; + }>; + +/** + * Trace entry included in an exported bundle. + */ +export type DebugBundleTraceRecord = Readonly<{ + header: DebugBundleHeader; + payload: DebugBundlePayloadSnapshot | null; +}>; + +/** + * Debug statistics snapshot serialized for bundle export. + */ +export type DebugBundleStatsSnapshot = Readonly<{ + totalRecords: string; + totalDropped: string; + errorCount: number; + warnCount: number; + currentRingUsage: number; + ringCapacity: number; +}>; + +/** + * Capture flags active when the bundle was exported. + */ +export type DebugBundleCaptureFlags = Readonly<{ + captureRawEvents: boolean; + captureDrawlistBytes: boolean; +}>; + +/** + * Bounds applied while building the bundle. + */ +export type DebugBundleBounds = Readonly<{ + maxRecords: number; + maxPayloadBytes: number; + maxTotalPayloadBytes: number; + maxRecentFrames: number; +}>; + +/** + * Query window metadata for the exported trace set. + */ +export type DebugBundleQueryWindow = Readonly<{ + recordsReturned: number; + recordsAvailable: number; + recordsDropped: number; + oldestRecordId: string; + newestRecordId: string; +}>; + +/** + * Lightweight frame summary included when frame snapshots are available. + */ +export type DebugBundleFrameSummary = Readonly<{ + frameId: string; + timestamp: number; + cols: number; + rows: number; + drawlistBytes: number; + drawlistCmds: number; + diffBytesEmitted: number; + dirtyLines: number; + dirtyCells: number; + damageRects: number; + usDrawlist: number; + usDiff: number; + usWrite: number; +}>; + +/** + * Deterministic debug export bundle. + */ +export type DebugBundle = Readonly<{ + schema: DebugBundleSchema; + captureFlags: DebugBundleCaptureFlags; + bounds: DebugBundleBounds; + terminalCaps: TerminalCaps | null; + stats: DebugBundleStatsSnapshot; + queryWindow: DebugBundleQueryWindow; + trace: readonly DebugBundleTraceRecord[]; + recentFrameSummaries?: readonly DebugBundleFrameSummary[]; +}>; + +/** + * Options for debug bundle export. + */ +export type DebugBundleExportOptions = Readonly<{ + maxRecords?: number; + maxPayloadBytes?: number; + maxTotalPayloadBytes?: number; + includeRecentFrames?: boolean; + maxRecentFrames?: number; + terminalCaps?: TerminalCaps | null; + includeRawEvents?: boolean; + includeDrawlistBytes?: boolean; +}>; + /** * Performance phase identifiers for PerfRecord.phase field. */ diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 78b8bc62..053c3002 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -195,6 +195,12 @@ export type { ToastPosition, } from "./widgets/types.js"; export { ui } from "./widgets/ui.js"; +export type { + InspectorOverlayFrameTiming, + InspectorOverlayPosition, + InspectorOverlayProps, +} from "./widgets/inspectorOverlay.js"; +export { inspectorOverlay } from "./widgets/inspectorOverlay.js"; // ============================================================================= // Widget Composition API (GitHub issue #116) @@ -958,6 +964,25 @@ export { * ``` */ export { createApp } from "./app/createApp.js"; +export type { + AppWithInspectorOverlay, + CreateAppWithInspectorOverlayOptions, + InspectorOverlayController, + InspectorOverlayHelperOptions, +} from "./app/inspectorOverlayHelper.js"; +export { createAppWithInspectorOverlay } from "./app/inspectorOverlayHelper.js"; +export type { + RuntimeBreadcrumbAction, + RuntimeBreadcrumbConsumptionPath, + RuntimeBreadcrumbCursorSummary, + RuntimeBreadcrumbDamageMode, + RuntimeBreadcrumbDamageSummary, + RuntimeBreadcrumbEventKind, + RuntimeBreadcrumbFocusSummary, + RuntimeBreadcrumbFrameSummary, + RuntimeBreadcrumbSnapshot, + WidgetRuntimeBreadcrumbSnapshot, +} from "./app/runtimeBreadcrumbs.js"; // ============================================================================= // Debug Trace System diff --git a/packages/core/src/widgets/__tests__/inspectorOverlay.render.test.ts b/packages/core/src/widgets/__tests__/inspectorOverlay.render.test.ts new file mode 100644 index 00000000..e5271506 --- /dev/null +++ b/packages/core/src/widgets/__tests__/inspectorOverlay.render.test.ts @@ -0,0 +1,165 @@ +import { assert, describe, test } from "@rezi-ui/testkit"; +import type { RuntimeBreadcrumbSnapshot } from "../../app/runtimeBreadcrumbs.js"; +import { type VNode, ZR_CURSOR_SHAPE_BAR, createDrawlistBuilderV1 } from "../../index.js"; +import { layout } from "../../layout/layout.js"; +import { renderToDrawlist } from "../../renderer/renderToDrawlist.js"; +import { commitVNodeTree } from "../../runtime/commit.js"; +import { createInstanceIdAllocator } from "../../runtime/instance.js"; +import { inspectorOverlay } from "../inspectorOverlay.js"; + +function u32(bytes: Uint8Array, off: number): number { + const dv = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + return dv.getUint32(off, true); +} + +function parseInternedStrings(bytes: Uint8Array): readonly string[] { + const spanOffset = u32(bytes, 28); + const count = u32(bytes, 32); + const bytesOffset = u32(bytes, 36); + const bytesLen = u32(bytes, 40); + + if (count === 0) return Object.freeze([]); + + const tableEnd = bytesOffset + bytesLen; + assert.ok(tableEnd <= bytes.byteLength, "string table must be in bounds"); + + const out: string[] = []; + const decoder = new TextDecoder(); + + for (let i = 0; i < count; i++) { + const span = spanOffset + i * 8; + const off = u32(bytes, span); + const len = u32(bytes, span + 4); + const start = bytesOffset + off; + const end = start + len; + assert.ok(end <= tableEnd, "string span must be in bounds"); + out.push(decoder.decode(bytes.subarray(start, end))); + } + + return Object.freeze(out); +} + +function renderStrings( + vnode: VNode, + viewport: Readonly<{ cols: number; rows: number }> = { cols: 100, rows: 30 }, +): readonly string[] { + const allocator = createInstanceIdAllocator(1); + const commitRes = commitVNodeTree(null, vnode, { allocator }); + assert.equal(commitRes.ok, true, "commit should succeed"); + if (!commitRes.ok) return Object.freeze([]); + + const layoutRes = layout( + commitRes.value.root.vnode, + 0, + 0, + viewport.cols, + viewport.rows, + "column", + ); + assert.equal(layoutRes.ok, true, "layout should succeed"); + if (!layoutRes.ok) return Object.freeze([]); + + const builder = createDrawlistBuilderV1(); + renderToDrawlist({ + tree: commitRes.value.root, + layout: layoutRes.value, + viewport, + focusState: Object.freeze({ focusedId: null }), + builder, + }); + const built = builder.build(); + assert.equal(built.ok, true, "drawlist build should succeed"); + if (!built.ok) return Object.freeze([]); + + return parseInternedStrings(built.bytes); +} + +describe("inspectorOverlay render shape", () => { + test("renders focus/cursor/damage/frame/event sections", () => { + const snapshot: RuntimeBreadcrumbSnapshot = Object.freeze({ + focus: Object.freeze({ + focusedId: "input.name", + activeZoneId: "zone.editor", + activeTrapId: "trap.modal", + }), + cursor: Object.freeze({ + visible: true, + x: 12, + y: 4, + shape: ZR_CURSOR_SHAPE_BAR, + blink: true, + }), + damage: Object.freeze({ + mode: "incremental", + rectCount: 3, + area: 48, + }), + frame: Object.freeze({ + tick: 17, + commit: true, + layout: false, + incremental: true, + renderTimeMs: 1.75, + }), + event: Object.freeze({ + kind: "key", + path: "keybindings", + }), + lastAction: Object.freeze({ + id: "save.btn", + action: "press", + }), + }); + + const strings = renderStrings( + inspectorOverlay({ + snapshot, + frameTiming: { + damageRects: 4, + damageCells: 52, + drawlistBytes: 2048, + diffBytesEmitted: 512, + usDrawlist: 101, + usDiff: 22, + usWrite: 7, + }, + title: "Inspector", + hotkeyHint: "ctrl+shift+i", + }), + ); + + assert.equal(strings.includes("inspector overlay"), true); + assert.equal(strings.includes("focus: id=input.name zone=zone.editor trap=trap.modal"), true); + assert.equal( + strings.includes( + `cursor: (12,4) shape=${String(ZR_CURSOR_SHAPE_BAR)} blink=${String("yes")}`, + ), + true, + ); + assert.equal(strings.includes("damage: mode=incremental rects=4 cells=52"), true); + assert.equal( + strings.includes("frame: tick=17 commit=yes layout=no incremental=yes render_ms=1.75"), + true, + ); + assert.equal(strings.includes("bytes: drawlist=2048 diff=512"), true); + assert.equal(strings.includes("timing_us: drawlist=101 diff=22 write=7"), true); + assert.equal(strings.includes("event: kind=key path=keybindings"), true); + assert.equal(strings.includes("action: save.btn.press"), true); + assert.equal(strings.includes("toggle: ctrl+shift+i"), true); + }); + + test("renders explicit fallback rows when snapshot is unavailable", () => { + const strings = renderStrings( + inspectorOverlay({ + snapshot: null, + title: "Inspector", + }), + ); + + assert.equal(strings.includes("focus: id= zone= trap="), true); + assert.equal(strings.includes("cursor: n/a"), true); + assert.equal(strings.includes("damage: mode=none rects=n/a cells=n/a"), true); + assert.equal(strings.includes("event: kind= path="), true); + assert.equal(strings.includes("action: "), true); + }); +}); diff --git a/packages/core/src/widgets/inspectorOverlay.ts b/packages/core/src/widgets/inspectorOverlay.ts new file mode 100644 index 00000000..b4165e34 --- /dev/null +++ b/packages/core/src/widgets/inspectorOverlay.ts @@ -0,0 +1,171 @@ +/** + * packages/core/src/widgets/inspectorOverlay.ts — Runtime inspector overlay widget. + * + * Why: Provides a focused, always-on-top diagnostics overlay that can be + * toggled during development without coupling to app state shape. + */ + +import type { RuntimeBreadcrumbSnapshot } from "../app/runtimeBreadcrumbs.js"; +import { type TextStyle, rgb } from "./style.js"; +import type { VNode } from "./types.js"; +import { ui } from "./ui.js"; + +export type InspectorOverlayPosition = "top-left" | "top-right" | "bottom-left" | "bottom-right"; + +export type InspectorOverlayFrameTiming = Readonly<{ + damageRects?: number; + damageCells?: number; + drawlistBytes?: number; + diffBytesEmitted?: number; + usDrawlist?: number; + usDiff?: number; + usWrite?: number; +}>; + +export type InspectorOverlayProps = Readonly<{ + snapshot: RuntimeBreadcrumbSnapshot | null; + frameTiming?: InspectorOverlayFrameTiming | null; + id?: string; + zIndex?: number; + width?: number; + position?: InspectorOverlayPosition; + title?: string; + hotkeyHint?: string | null; +}>; + +const PANEL_STYLE: TextStyle = Object.freeze({ + bg: rgb(14, 18, 24), + fg: rgb(104, 156, 196), +}); + +const ROW_STYLE: TextStyle = Object.freeze({ + bg: rgb(14, 18, 24), + fg: rgb(214, 224, 236), +}); + +function summarize(value: string | null): string { + if (!value || value.length === 0) return ""; + if (value.length <= 40) return value; + return `${value.slice(0, 37)}...`; +} + +function fmtMaybeInt(value: number | undefined): string { + if (typeof value !== "number" || !Number.isFinite(value)) return "n/a"; + return String(Math.max(0, Math.trunc(value))); +} + +function fmtBool(value: boolean): string { + return value ? "yes" : "no"; +} + +function buildCursorSummary(snapshot: RuntimeBreadcrumbSnapshot | null): string { + if (!snapshot || !snapshot.cursor) return "cursor: n/a"; + if (!snapshot.cursor.visible) { + return `cursor: hidden shape=${String(snapshot.cursor.shape)} blink=${fmtBool(snapshot.cursor.blink)}`; + } + return `cursor: (${String(snapshot.cursor.x)},${String(snapshot.cursor.y)}) shape=${String(snapshot.cursor.shape)} blink=${fmtBool(snapshot.cursor.blink)}`; +} + +function buildRows(props: InspectorOverlayProps): readonly string[] { + const snapshot = props.snapshot; + const frameTiming = props.frameTiming ?? null; + + const focusId = summarize(snapshot?.focus.focusedId ?? null); + const zoneId = summarize(snapshot?.focus.activeZoneId ?? null); + const trapId = summarize(snapshot?.focus.activeTrapId ?? null); + + const damageMode = snapshot?.damage.mode ?? "none"; + const damageRects = frameTiming?.damageRects ?? snapshot?.damage.rectCount; + const damageCells = frameTiming?.damageCells ?? snapshot?.damage.area; + + const frameTick = snapshot?.frame.tick ?? 0; + const frameCommit = snapshot?.frame.commit ?? false; + const frameLayout = snapshot?.frame.layout ?? false; + const frameIncremental = snapshot?.frame.incremental ?? false; + const renderMs = snapshot?.frame.renderTimeMs ?? 0; + + const eventKind = snapshot?.event.kind ?? ""; + const eventPath = snapshot?.event.path ?? ""; + const lastAction = snapshot?.lastAction + ? `${summarize(snapshot.lastAction.id)}.${String(snapshot.lastAction.action)}` + : ""; + + const rows: string[] = []; + rows.push("inspector overlay"); + rows.push(`focus: id=${focusId} zone=${zoneId} trap=${trapId}`); + rows.push(buildCursorSummary(snapshot)); + rows.push( + `damage: mode=${damageMode} rects=${fmtMaybeInt(damageRects)} cells=${fmtMaybeInt(damageCells)}`, + ); + rows.push( + `frame: tick=${String(frameTick)} commit=${fmtBool(frameCommit)} layout=${fmtBool(frameLayout)} incremental=${fmtBool(frameIncremental)} render_ms=${renderMs.toFixed(2)}`, + ); + rows.push( + `bytes: drawlist=${fmtMaybeInt(frameTiming?.drawlistBytes)} diff=${fmtMaybeInt(frameTiming?.diffBytesEmitted)}`, + ); + rows.push( + `timing_us: drawlist=${fmtMaybeInt(frameTiming?.usDrawlist)} diff=${fmtMaybeInt(frameTiming?.usDiff)} write=${fmtMaybeInt(frameTiming?.usWrite)}`, + ); + rows.push(`event: kind=${eventKind} path=${eventPath}`); + rows.push(`action: ${lastAction}`); + + if (props.hotkeyHint && props.hotkeyHint.length > 0) { + rows.push(`toggle: ${props.hotkeyHint}`); + } + + return Object.freeze(rows); +} + +function placePanel(position: InspectorOverlayPosition, panel: VNode): VNode { + const h: "start" | "center" | "end" = position.endsWith("right") + ? "end" + : position.endsWith("left") + ? "start" + : "center"; + const v: "start" | "end" = position.startsWith("bottom") ? "end" : "start"; + return ui.column( + { + width: "100%", + height: "100%", + justify: v, + p: 1, + }, + [ui.row({ width: "100%", justify: h }, [panel])], + ); +} + +/** + * Create an inspector overlay widget VNode. + */ +export function inspectorOverlay(props: InspectorOverlayProps): VNode { + const title = props.title ?? "Inspector"; + const rows = buildRows(props); + const width = typeof props.width === "number" && props.width > 0 ? Math.trunc(props.width) : 76; + const position = props.position ?? "top-right"; + const panelId = props.id ?? "rezi.inspector.overlay"; + + const panel = ui.box( + { + border: "single", + title, + style: PANEL_STYLE, + width, + p: 1, + }, + [ + ui.column( + { gap: 0 }, + rows.map((line) => ui.text(line, { style: ROW_STYLE })), + ), + ], + ); + + return ui.layer({ + id: panelId, + zIndex: props.zIndex ?? 2000000000, + backdrop: "none", + modal: false, + closeOnEscape: false, + content: placePanel(position, panel), + }); +}