diff --git a/docs/guide/layout.md b/docs/guide/layout.md index 6d2718d4..45adc2e3 100644 --- a/docs/guide/layout.md +++ b/docs/guide/layout.md @@ -320,6 +320,14 @@ Conservative fallback: - When signatures are stable, the expensive `layout(...)` pass is skipped; the skip decision and reuse of the prior layout tree are O(1). - After a commit with skipped layout, damage-rect indexes are refreshed by walking runtime nodes. +### Runtime dirty flags and clean-subtree skipping + +- Runtime nodes carry a per-node `dirty` flag used by incremental rendering. +- Commit marks nodes dirty when props change, when child composition/order changes, and when any child is dirty (propagates to root). +- Layout indexing marks nodes dirty when their layout rect changes versus the previous rendered frame. +- Incremental `renderTree(...)` skips clean subtrees during DFS, but preserves clip stack correctness for dirty ancestors (balanced `pushClip`/`popClip`). +- After a successful frame build, dirty flags are cleared for the committed runtime tree. + ### Clarified invariants and edge cases - The first signature pass against an empty previous map reports changed (bootstrap relayout). diff --git a/packages/core/src/app/widgetRenderer.ts b/packages/core/src/app/widgetRenderer.ts index 2168894b..59c78cea 100644 --- a/packages/core/src/app/widgetRenderer.ts +++ b/packages/core/src/app/widgetRenderer.ts @@ -284,6 +284,10 @@ function unionRect(a: Rect, b: Rect): Rect { return { x: x0, y: y0, w: x1 - x0, h: y1 - y0 }; } +function rectEquals(a: Rect, b: Rect): boolean { + return a.x === b.x && a.y === b.y && a.w === b.w && a.h === b.h; +} + function isV2Builder(builder: DrawlistBuilderV1 | DrawlistBuilderV2): builder is DrawlistBuilderV2 { return typeof (builder as DrawlistBuilderV2).setCursor === "function"; } @@ -2395,6 +2399,81 @@ export class WidgetRenderer { return true; } + private propagateDirtyFromPredicate( + runtimeRoot: RuntimeInstance, + isNodeDirty: (node: RuntimeInstance) => boolean, + ): void { + this._pooledRuntimeStack.length = 0; + this._pooledPrevRuntimeStack.length = 0; + this._pooledRuntimeStack.push(runtimeRoot); + + while (this._pooledRuntimeStack.length > 0) { + const node = this._pooledRuntimeStack.pop(); + if (!node) continue; + this._pooledPrevRuntimeStack.push(node); + for (let i = node.children.length - 1; i >= 0; i--) { + const child = node.children[i]; + if (child) this._pooledRuntimeStack.push(child); + } + } + + for (let i = this._pooledPrevRuntimeStack.length - 1; i >= 0; i--) { + const node = this._pooledPrevRuntimeStack[i]; + if (!node) continue; + const markedSelfDirty = isNodeDirty(node); + if (markedSelfDirty) node.selfDirty = true; + let dirty = node.dirty || markedSelfDirty; + for (const child of node.children) { + if (child.dirty) { + dirty = true; + break; + } + } + node.dirty = dirty; + } + this._pooledPrevRuntimeStack.length = 0; + } + + private markLayoutDirtyNodes(runtimeRoot: RuntimeInstance): void { + this.propagateDirtyFromPredicate(runtimeRoot, (node) => { + const nextRect = this._pooledRectByInstanceId.get(node.instanceId); + if (!nextRect) return false; + const prevRect = this._prevFrameRectByInstanceId.get(node.instanceId); + return !prevRect || !rectEquals(nextRect, prevRect); + }); + } + + private markTransientDirtyNodes( + runtimeRoot: RuntimeInstance, + prevFocusedId: string | null, + nextFocusedId: string | null, + includeSpinners: boolean, + ): void { + if (prevFocusedId === nextFocusedId && !includeSpinners) return; + this.propagateDirtyFromPredicate(runtimeRoot, (node) => { + if (includeSpinners && node.vnode.kind === "spinner") return true; + if (prevFocusedId === null && nextFocusedId === null) return false; + const id = (node.vnode as { props?: { id?: unknown } }).props?.id; + if (typeof id !== "string" || id.length === 0) return false; + return id === prevFocusedId || id === nextFocusedId; + }); + } + + private clearRuntimeDirtyNodes(runtimeRoot: RuntimeInstance): void { + this._pooledRuntimeStack.length = 0; + this._pooledRuntimeStack.push(runtimeRoot); + while (this._pooledRuntimeStack.length > 0) { + const node = this._pooledRuntimeStack.pop(); + if (!node) continue; + node.dirty = false; + node.selfDirty = false; + for (let i = node.children.length - 1; i >= 0; i--) { + const child = node.children[i]; + if (child) this._pooledRuntimeStack.push(child); + } + } + } + private collectSubtreeDamageAndRouting( root: RuntimeInstance, outInstanceIds: InstanceId[], @@ -2969,6 +3048,7 @@ export class WidgetRenderer { if (PERF_DETAIL_ENABLED) perfMarkEnd("layout_indexes", layoutIndexesToken); this.rectById = this._pooledRectById; this.splitPaneChildRectsById = this._pooledSplitPaneChildRectsById; + this.markLayoutDirtyNodes(this.committedRoot); } if (!this.layoutTree) { @@ -3683,6 +3763,14 @@ export class WidgetRenderer { let runtimeDamageRectCount = 0; let runtimeDamageArea = 0; if (this.shouldAttemptIncrementalRender(doLayout, viewport, theme)) { + if (!doCommit) { + this.markTransientDirtyNodes( + this.committedRoot, + this._lastRenderedFocusedId, + this.focusState.focusedId, + true, + ); + } this._pooledDamageRects.length = 0; let missingDamageRect = false; @@ -3827,6 +3915,7 @@ export class WidgetRenderer { detail: `${built.error.code}: ${built.error.detail}`, }; } + this.clearRuntimeDirtyNodes(this.committedRoot); if (captureRuntimeBreadcrumbs) { this.updateRuntimeBreadcrumbSnapshot({ tick, diff --git a/packages/core/src/layout/__tests__/layout.perf-invariants.test.ts b/packages/core/src/layout/__tests__/layout.perf-invariants.test.ts index 9ce6d5e3..8310b30b 100644 --- a/packages/core/src/layout/__tests__/layout.perf-invariants.test.ts +++ b/packages/core/src/layout/__tests__/layout.perf-invariants.test.ts @@ -74,6 +74,8 @@ function runtimeNode( instanceId, vnode, children: Object.freeze([...children]), + dirty: false, + selfDirty: false, }; } @@ -128,9 +130,11 @@ function createCountingRuntimeFactory(): CountingRuntimeFactory { children: readonly RuntimeInstance[] = [], ): RuntimeInstance => { const frozenChildren = Object.freeze([...children]); - const node = { instanceId, vnode } as { + const node = { instanceId, vnode, dirty: false, selfDirty: false } as { instanceId: InstanceId; vnode: VNode; + dirty: boolean; + selfDirty: boolean; children?: readonly RuntimeInstance[]; }; Object.defineProperty(node, "children", { diff --git a/packages/core/src/layout/__tests__/layout.stability-signature.test.ts b/packages/core/src/layout/__tests__/layout.stability-signature.test.ts index 17c86674..571e850d 100644 --- a/packages/core/src/layout/__tests__/layout.stability-signature.test.ts +++ b/packages/core/src/layout/__tests__/layout.stability-signature.test.ts @@ -72,6 +72,8 @@ function runtimeNode( instanceId, vnode, children: Object.freeze([...children]), + dirty: false, + selfDirty: false, }; } diff --git a/packages/core/src/renderer/__tests__/renderer.partial.perf.test.ts b/packages/core/src/renderer/__tests__/renderer.partial.perf.test.ts new file mode 100644 index 00000000..25e38531 --- /dev/null +++ b/packages/core/src/renderer/__tests__/renderer.partial.perf.test.ts @@ -0,0 +1,358 @@ +import { assert, describe, test } from "@rezi-ui/testkit"; +import type { Viewport, WidgetRenderPlan } from "../../app/widgetRenderer.js"; +import { WidgetRenderer } from "../../app/widgetRenderer.js"; +import type { RuntimeBackend } from "../../backend.js"; +import type { DrawlistBuildResult, DrawlistBuilderV1 } from "../../drawlist/index.js"; +import type { DrawlistTextRunSegment } from "../../drawlist/types.js"; +import type { ZrevEvent } from "../../events.js"; +import type { VNode } from "../../index.js"; +import { ui } from "../../index.js"; +import { ZR_KEY_TAB } from "../../keybindings/keyCodes.js"; +import { DEFAULT_TERMINAL_CAPS } from "../../terminalCaps.js"; +import { defaultTheme } from "../../theme/defaultTheme.js"; + +class CountingBuilder implements DrawlistBuilderV1 { + private opCount = 0; + private lastBuiltCount = 0; + + getLastBuiltOpCount(): number { + return this.lastBuiltCount; + } + + clear(): void { + this.opCount++; + } + + clearTo(_cols: number, _rows: number): void { + this.opCount++; + } + + fillRect(_x: number, _y: number, _w: number, _h: number): void { + this.opCount++; + } + + drawText(_x: number, _y: number, _text: string): void { + this.opCount++; + } + + pushClip(_x: number, _y: number, _w: number, _h: number): void { + this.opCount++; + } + + popClip(): void { + this.opCount++; + } + + addBlob(_bytes: Uint8Array): number | null { + return null; + } + + addTextRunBlob(_segments: readonly DrawlistTextRunSegment[]): number | null { + return null; + } + + drawTextRun(_x: number, _y: number, _blobIndex: number): void {} + + build(): DrawlistBuildResult { + this.lastBuiltCount = this.opCount; + return { ok: true, bytes: new Uint8Array([this.opCount & 0xff]) }; + } + + reset(): void { + this.opCount = 0; + } +} + +const FULL_PLAN: WidgetRenderPlan = Object.freeze({ + commit: true, + layout: true, + checkLayoutStability: true, +}); +const PARTIAL_COMMIT_PLAN: WidgetRenderPlan = Object.freeze({ + commit: true, + layout: false, + checkLayoutStability: false, +}); +const PARTIAL_RENDER_ONLY_PLAN: WidgetRenderPlan = Object.freeze({ + commit: false, + layout: false, + checkLayoutStability: false, +}); + +function createNoopBackend(): RuntimeBackend { + return { + start: async () => {}, + stop: async () => {}, + dispose: () => {}, + requestFrame: async () => {}, + pollEvents: async () => + new Promise((_) => { + // submitFrame tests do not use backend polling. + }), + postUserEvent: () => {}, + getCaps: async () => DEFAULT_TERMINAL_CAPS, + }; +} + +function noRenderHooks(): { enterRender: () => void; exitRender: () => void } { + return { enterRender: () => {}, exitRender: () => {} }; +} + +function submitAndCount( + renderer: WidgetRenderer, + builder: CountingBuilder, + viewFn: (snapshot: Readonly) => VNode, + snapshot: Readonly, + viewport: Viewport, + plan: WidgetRenderPlan, +): number { + const res = renderer.submitFrame(viewFn, snapshot, viewport, defaultTheme, noRenderHooks(), plan); + assert.equal(res.ok, true); + return builder.getLastBuiltOpCount(); +} + +function tabEvent(timeMs: number): ZrevEvent { + return { + kind: "key", + timeMs, + key: ZR_KEY_TAB, + action: "down", + mods: 0, + }; +} + +function wheelEvent(timeMs: number, x: number, y: number, wheelY: number): ZrevEvent { + return { + kind: "mouse", + timeMs, + x, + y, + mouseKind: 5, + mods: 0, + buttons: 0, + wheelX: 0, + wheelY, + }; +} + +function denseListView(editIndex: number, rows: number): VNode { + const children: VNode[] = []; + for (let i = 0; i < rows; i++) { + const label = + i === editIndex ? `edit-${String(i).padStart(4, "0")}` : `item-${String(i).padStart(4, "0")}`; + children.push( + ui.row({ gap: 1 }, [ + ui.text(label), + ui.text("payload"), + ui.text(`line-${String(i).padStart(4, "0")}`), + ui.text("meta"), + ]), + ); + } + return ui.column({ p: 1, gap: 0 }, children); +} + +function focusButtonsView(buttonCount: number): VNode { + const children: VNode[] = []; + for (let i = 0; i < buttonCount; i++) { + children.push(ui.button({ id: `btn-${String(i)}`, label: `Button ${String(i)}` })); + } + return ui.column({ p: 1, gap: 0 }, children); +} + +function virtualListView(itemCount: number): VNode { + const items = Array.from({ length: itemCount }, (_, i) => `row-${String(i).padStart(4, "0")}`); + return ui.column({ p: 0 }, [ + ui.text("VirtualList"), + ui.virtualList({ + id: "list", + items, + itemHeight: 1, + renderItem: (item, index, focused) => + ui.text(`${focused ? ">" : " "} ${String(index).padStart(4, "0")} ${item}`), + }), + ]); +} + +function reductionRatio(fullCount: number, partialCount: number): number { + if (fullCount <= 0) return 0; + return (fullCount - partialCount) / fullCount; +} + +function measureCommitReduction( + viewport: Viewport, + rows: number, + fromIndex: number, + toIndex: number, +): Readonly<{ full: number; partial: number; reduction: number }> { + const fullBuilder = new CountingBuilder(); + const partialBuilder = new CountingBuilder(); + const fullRenderer = new WidgetRenderer<{ editIndex: number }>({ + backend: createNoopBackend(), + builder: fullBuilder, + }); + const partialRenderer = new WidgetRenderer<{ editIndex: number }>({ + backend: createNoopBackend(), + builder: partialBuilder, + }); + const viewFn = (snapshot: Readonly<{ editIndex: number }>) => + denseListView(snapshot.editIndex, rows); + + const initial = Object.freeze({ editIndex: fromIndex }); + submitAndCount(fullRenderer, fullBuilder, viewFn, initial, viewport, FULL_PLAN); + submitAndCount(partialRenderer, partialBuilder, viewFn, initial, viewport, FULL_PLAN); + + const next = Object.freeze({ editIndex: toIndex }); + const full = submitAndCount(fullRenderer, fullBuilder, viewFn, next, viewport, FULL_PLAN); + const partial = submitAndCount( + partialRenderer, + partialBuilder, + viewFn, + next, + viewport, + PARTIAL_COMMIT_PLAN, + ); + return Object.freeze({ + full, + partial, + reduction: reductionRatio(full, partial), + }); +} + +function measureFocusReduction( + viewport: Viewport, + buttonCount: number, + tabCount: number, +): Readonly<{ full: number; partial: number; reduction: number }> { + const fullBuilder = new CountingBuilder(); + const partialBuilder = new CountingBuilder(); + const fullRenderer = new WidgetRenderer({ + backend: createNoopBackend(), + builder: fullBuilder, + }); + const partialRenderer = new WidgetRenderer({ + backend: createNoopBackend(), + builder: partialBuilder, + }); + const viewFn = () => focusButtonsView(buttonCount); + + submitAndCount(fullRenderer, fullBuilder, viewFn, undefined, viewport, FULL_PLAN); + submitAndCount(partialRenderer, partialBuilder, viewFn, undefined, viewport, FULL_PLAN); + + for (let i = 0; i < tabCount; i++) { + const ev = tabEvent(100 + i); + const fullRoute = fullRenderer.routeEngineEvent(ev); + const partialRoute = partialRenderer.routeEngineEvent(ev); + assert.equal(fullRoute.needsRender, partialRoute.needsRender); + } + + const full = submitAndCount(fullRenderer, fullBuilder, viewFn, undefined, viewport, FULL_PLAN); + const partial = submitAndCount( + partialRenderer, + partialBuilder, + viewFn, + undefined, + viewport, + PARTIAL_RENDER_ONLY_PLAN, + ); + return Object.freeze({ + full, + partial, + reduction: reductionRatio(full, partial), + }); +} + +function measureScrollReduction( + viewport: Viewport, + itemCount: number, + wheelSequence: readonly number[], +): Readonly<{ full: number; partial: number; reduction: number }> { + const fullBuilder = new CountingBuilder(); + const partialBuilder = new CountingBuilder(); + const fullRenderer = new WidgetRenderer({ + backend: createNoopBackend(), + builder: fullBuilder, + }); + const partialRenderer = new WidgetRenderer({ + backend: createNoopBackend(), + builder: partialBuilder, + }); + const viewFn = () => virtualListView(itemCount); + + submitAndCount(fullRenderer, fullBuilder, viewFn, undefined, viewport, FULL_PLAN); + submitAndCount(partialRenderer, partialBuilder, viewFn, undefined, viewport, FULL_PLAN); + + for (let i = 0; i < wheelSequence.length; i++) { + const wheelY = wheelSequence[i] ?? 0; + const ev = wheelEvent(200 + i, 2, 3, wheelY); + const fullRoute = fullRenderer.routeEngineEvent(ev); + const partialRoute = partialRenderer.routeEngineEvent(ev); + assert.equal(fullRoute.needsRender, partialRoute.needsRender); + } + + const full = submitAndCount(fullRenderer, fullBuilder, viewFn, undefined, viewport, FULL_PLAN); + const partial = submitAndCount( + partialRenderer, + partialBuilder, + viewFn, + undefined, + viewport, + PARTIAL_RENDER_ONLY_PLAN, + ); + return Object.freeze({ + full, + partial, + reduction: reductionRatio(full, partial), + }); +} + +function assertStrongReduction( + result: Readonly<{ full: number; partial: number; reduction: number }>, +): void { + assert.equal(result.full > 0, true, "full command count must be positive"); + assert.equal(result.partial < result.full, true, "partial must emit fewer commands"); + assert.equal( + result.reduction >= 0.8, + true, + `expected >=80% reduction, got ${String(result.reduction)}`, + ); +} + +describe("renderer partial rendering perf invariants", () => { + const viewport: Viewport = Object.freeze({ cols: 120, rows: 42 }); + + const commitCases = Object.freeze([ + Object.freeze({ rows: 160, from: -1, to: 4 }), + Object.freeze({ rows: 240, from: -1, to: 8 }), + Object.freeze({ rows: 320, from: -1, to: 12 }), + Object.freeze({ rows: 480, from: -1, to: 16 }), + Object.freeze({ rows: 640, from: -1, to: 20 }), + Object.freeze({ rows: 640, from: -1, to: 0 }), + Object.freeze({ rows: 640, from: -1, to: 24 }), + ]); + + for (let i = 0; i < commitCases.length; i++) { + const c = commitCases[i]; + if (!c) continue; + test(`single-node text edit reduction case ${String(i + 1)} (${String(c.rows)} rows)`, () => { + const result = measureCommitReduction(viewport, c.rows, c.from, c.to); + assertStrongReduction(result); + }); + } + + const focusCases = Object.freeze([ + Object.freeze({ buttonCount: 200, tabs: 1 }), + Object.freeze({ buttonCount: 300, tabs: 1 }), + Object.freeze({ buttonCount: 420, tabs: 1 }), + Object.freeze({ buttonCount: 540, tabs: 2 }), + ]); + + for (let i = 0; i < focusCases.length; i++) { + const c = focusCases[i]; + if (!c) continue; + test(`focus-change reduction case ${String(i + 1)} (${String(c.buttonCount)} buttons)`, () => { + const result = measureFocusReduction(viewport, c.buttonCount, c.tabs); + assertStrongReduction(result); + }); + } +}); diff --git a/packages/core/src/renderer/__tests__/renderer.partial.test.ts b/packages/core/src/renderer/__tests__/renderer.partial.test.ts new file mode 100644 index 00000000..d6dd0564 --- /dev/null +++ b/packages/core/src/renderer/__tests__/renderer.partial.test.ts @@ -0,0 +1,599 @@ +import { assert, describe, test } from "@rezi-ui/testkit"; +import type { Viewport, WidgetRenderPlan } from "../../app/widgetRenderer.js"; +import { WidgetRenderer } from "../../app/widgetRenderer.js"; +import type { RuntimeBackend } from "../../backend.js"; +import type { DrawlistBuildResult, DrawlistBuilderV1 } from "../../drawlist/index.js"; +import type { DrawlistTextRunSegment } from "../../drawlist/types.js"; +import type { ZrevEvent } from "../../events.js"; +import type { TextStyle, VNode } from "../../index.js"; +import { ui } from "../../index.js"; +import { ZR_KEY_TAB } from "../../keybindings/keyCodes.js"; +import { DEFAULT_TERMINAL_CAPS } from "../../terminalCaps.js"; +import { defaultTheme } from "../../theme/defaultTheme.js"; + +type RecordedOp = + | Readonly<{ kind: "clear" }> + | Readonly<{ kind: "clearTo"; cols: number; rows: number; style?: TextStyle }> + | Readonly<{ kind: "fillRect"; x: number; y: number; w: number; h: number; style?: TextStyle }> + | Readonly<{ kind: "drawText"; x: number; y: number; text: string; style?: TextStyle }> + | Readonly<{ kind: "pushClip"; x: number; y: number; w: number; h: number }> + | Readonly<{ kind: "popClip" }>; + +class RecordingBuilder implements DrawlistBuilderV1 { + private ops: RecordedOp[] = []; + private lastBuiltOps: readonly RecordedOp[] = Object.freeze([]); + + getLastBuiltOps(): readonly RecordedOp[] { + return this.lastBuiltOps; + } + + clear(): void { + this.ops.push({ kind: "clear" }); + } + + clearTo(cols: number, rows: number, style?: TextStyle): void { + this.ops.push({ kind: "clearTo", cols, rows, ...(style ? { style } : {}) }); + } + + fillRect(x: number, y: number, w: number, h: number, style?: TextStyle): void { + this.ops.push({ kind: "fillRect", x, y, w, h, ...(style ? { style } : {}) }); + } + + drawText(x: number, y: number, text: string, style?: TextStyle): void { + this.ops.push({ kind: "drawText", x, y, text, ...(style ? { style } : {}) }); + } + + pushClip(x: number, y: number, w: number, h: number): void { + this.ops.push({ kind: "pushClip", x, y, w, h }); + } + + popClip(): void { + this.ops.push({ kind: "popClip" }); + } + + addBlob(_bytes: Uint8Array): number | null { + return null; + } + + addTextRunBlob(_segments: readonly DrawlistTextRunSegment[]): number | null { + return null; + } + + drawTextRun(_x: number, _y: number, _blobIndex: number): void {} + + build(): DrawlistBuildResult { + this.lastBuiltOps = this.ops.slice(); + return { ok: true, bytes: new Uint8Array([this.ops.length & 0xff]) }; + } + + reset(): void { + this.ops = []; + } +} + +type Framebuffer = Readonly<{ + cols: number; + rows: number; + chars: string[]; + styles: string[]; +}>; + +const FULL_PLAN: WidgetRenderPlan = Object.freeze({ + commit: true, + layout: true, + checkLayoutStability: true, +}); +const PARTIAL_COMMIT_PLAN: WidgetRenderPlan = Object.freeze({ + commit: true, + layout: false, + checkLayoutStability: true, +}); +const PARTIAL_COMMIT_NO_STABILITY_PLAN: WidgetRenderPlan = Object.freeze({ + commit: true, + layout: false, + checkLayoutStability: false, +}); +const PARTIAL_RENDER_ONLY_PLAN: WidgetRenderPlan = Object.freeze({ + commit: false, + layout: false, + checkLayoutStability: false, +}); + +function createNoopBackend(): RuntimeBackend { + return { + start: async () => {}, + stop: async () => {}, + dispose: () => {}, + requestFrame: async () => {}, + pollEvents: async () => + new Promise((_) => { + // submitFrame tests do not use backend polling. + }), + postUserEvent: () => {}, + getCaps: async () => DEFAULT_TERMINAL_CAPS, + }; +} + +function noRenderHooks(): { enterRender: () => void; exitRender: () => void } { + return { enterRender: () => {}, exitRender: () => {} }; +} + +function colorKey(value: unknown): string { + if (value === undefined || value === null) return ""; + if (typeof value === "string") return `s:${value}`; + if (typeof value === "object") { + const rgb = value as { r?: unknown; g?: unknown; b?: unknown }; + if ( + typeof rgb.r === "number" && + typeof rgb.g === "number" && + typeof rgb.b === "number" && + Number.isFinite(rgb.r) && + Number.isFinite(rgb.g) && + Number.isFinite(rgb.b) + ) { + return `rgb:${String(rgb.r)},${String(rgb.g)},${String(rgb.b)}`; + } + } + return "?"; +} + +function styleKey(style: TextStyle | undefined): string { + if (!style) return ""; + return [ + style.bold ? "b1" : "b0", + style.dim ? "d1" : "d0", + style.italic ? "i1" : "i0", + style.underline ? "u1" : "u0", + style.inverse ? "v1" : "v0", + style.strikethrough ? "s1" : "s0", + style.overline ? "o1" : "o0", + style.blink ? "k1" : "k0", + `fg:${colorKey(style.fg)}`, + `bg:${colorKey(style.bg)}`, + ].join("|"); +} + +function createFramebuffer(viewport: Viewport): Framebuffer { + const total = viewport.cols * viewport.rows; + return { + cols: viewport.cols, + rows: viewport.rows, + chars: new Array(total).fill(" "), + styles: new Array(total).fill(""), + }; +} + +function cloneFramebuffer(framebuffer: Framebuffer): Framebuffer { + return { + cols: framebuffer.cols, + rows: framebuffer.rows, + chars: framebuffer.chars.slice(), + styles: framebuffer.styles.slice(), + }; +} + +function applyOps(framebuffer: Framebuffer, ops: readonly RecordedOp[]): Framebuffer { + const out = cloneFramebuffer(framebuffer); + const clipStack: Array> = []; + const inViewport = (x: number, y: number) => x >= 0 && x < out.cols && y >= 0 && y < out.rows; + const inClip = (x: number, y: number) => { + for (const clip of clipStack) { + if (x < clip.x || x >= clip.x + clip.w || y < clip.y || y >= clip.y + clip.h) return false; + } + return true; + }; + const writeCell = (x: number, y: number, ch: string, style: string) => { + if (!inViewport(x, y) || !inClip(x, y)) return; + const idx = y * out.cols + x; + out.chars[idx] = ch; + out.styles[idx] = style; + }; + const fillViewport = (cols: number, rows: number, style: string) => { + const w = Math.min(cols, out.cols); + const h = Math.min(rows, out.rows); + for (let y = 0; y < h; y++) { + const base = y * out.cols; + for (let x = 0; x < w; x++) { + out.chars[base + x] = " "; + out.styles[base + x] = style; + } + } + }; + + for (const op of ops) { + switch (op.kind) { + case "clear": + fillViewport(out.cols, out.rows, ""); + break; + case "clearTo": + fillViewport(op.cols, op.rows, styleKey(op.style)); + break; + case "fillRect": { + const key = styleKey(op.style); + for (let y = op.y; y < op.y + op.h; y++) { + for (let x = op.x; x < op.x + op.w; x++) { + writeCell(x, y, " ", key); + } + } + break; + } + case "drawText": { + const key = styleKey(op.style); + for (let i = 0; i < op.text.length; i++) { + writeCell(op.x + i, op.y, op.text[i] ?? " ", key); + } + break; + } + case "pushClip": + clipStack.push({ x: op.x, y: op.y, w: op.w, h: op.h }); + break; + case "popClip": + if (clipStack.length > 0) clipStack.pop(); + break; + default: + break; + } + } + + return out; +} + +function assertFramebuffersEqual(actual: Framebuffer, expected: Framebuffer): void { + assert.equal(actual.cols, expected.cols); + assert.equal(actual.rows, expected.rows); + assert.equal(actual.chars.length, expected.chars.length); + assert.equal(actual.styles.length, expected.styles.length); + for (let i = 0; i < actual.chars.length; i++) { + assert.equal(actual.chars[i], expected.chars[i], `char mismatch at ${String(i)}`); + assert.equal(actual.styles[i], expected.styles[i], `style mismatch at ${String(i)}`); + } +} + +function submitOps( + renderer: WidgetRenderer, + builder: RecordingBuilder, + viewFn: (snapshot: Readonly) => VNode, + snapshot: Readonly, + viewport: Viewport, + plan: WidgetRenderPlan, +): readonly RecordedOp[] { + const res = renderer.submitFrame(viewFn, snapshot, viewport, defaultTheme, noRenderHooks(), plan); + assert.equal(res.ok, true); + return builder.getLastBuiltOps(); +} + +function tabEvent(timeMs: number): ZrevEvent { + return { + kind: "key", + timeMs, + key: ZR_KEY_TAB, + action: "down", + mods: 0, + }; +} + +function wheelEvent(timeMs: number, x: number, y: number, wheelY: number): ZrevEvent { + return { + kind: "mouse", + timeMs, + x, + y, + mouseKind: 5, + mods: 0, + buttons: 0, + wheelX: 0, + wheelY, + }; +} + +type ScenarioResult = Readonly<{ + fullOps: readonly RecordedOp[]; + partialOps: readonly RecordedOp[]; + fullFrame: Framebuffer; + partialFrame: Framebuffer; +}>; + +function runCommitScenario( + viewFn: (snapshot: Readonly) => VNode, + initialSnapshot: Readonly, + nextSnapshot: Readonly, + viewport: Viewport, + partialPlan: WidgetRenderPlan = PARTIAL_COMMIT_PLAN, +): ScenarioResult { + const fullBuilder = new RecordingBuilder(); + const partialBuilder = new RecordingBuilder(); + const fullRenderer = new WidgetRenderer({ + backend: createNoopBackend(), + builder: fullBuilder, + }); + const partialRenderer = new WidgetRenderer({ + backend: createNoopBackend(), + builder: partialBuilder, + }); + + const blank = createFramebuffer(viewport); + const fullBootstrap = submitOps( + fullRenderer, + fullBuilder, + viewFn, + initialSnapshot, + viewport, + FULL_PLAN, + ); + const partialBootstrap = submitOps( + partialRenderer, + partialBuilder, + viewFn, + initialSnapshot, + viewport, + FULL_PLAN, + ); + const fullBootstrapFrame = applyOps(blank, fullBootstrap); + const partialBootstrapFrame = applyOps(blank, partialBootstrap); + assertFramebuffersEqual(fullBootstrapFrame, partialBootstrapFrame); + + const fullOps = submitOps(fullRenderer, fullBuilder, viewFn, nextSnapshot, viewport, FULL_PLAN); + const partialOps = submitOps( + partialRenderer, + partialBuilder, + viewFn, + nextSnapshot, + viewport, + partialPlan, + ); + const fullFrame = applyOps(fullBootstrapFrame, fullOps); + const partialFrame = applyOps(partialBootstrapFrame, partialOps); + return { fullOps, partialOps, fullFrame, partialFrame }; +} + +function runEventScenario( + viewFn: (snapshot: Readonly) => VNode, + snapshot: Readonly, + viewport: Viewport, + events: readonly ZrevEvent[], +): ScenarioResult { + const fullBuilder = new RecordingBuilder(); + const partialBuilder = new RecordingBuilder(); + const fullRenderer = new WidgetRenderer({ + backend: createNoopBackend(), + builder: fullBuilder, + }); + const partialRenderer = new WidgetRenderer({ + backend: createNoopBackend(), + builder: partialBuilder, + }); + + const blank = createFramebuffer(viewport); + const fullBootstrap = submitOps(fullRenderer, fullBuilder, viewFn, snapshot, viewport, FULL_PLAN); + const partialBootstrap = submitOps( + partialRenderer, + partialBuilder, + viewFn, + snapshot, + viewport, + FULL_PLAN, + ); + const fullBootstrapFrame = applyOps(blank, fullBootstrap); + const partialBootstrapFrame = applyOps(blank, partialBootstrap); + assertFramebuffersEqual(fullBootstrapFrame, partialBootstrapFrame); + + let sawNeedsRender = false; + for (const event of events) { + const fullOutcome = fullRenderer.routeEngineEvent(event); + const partialOutcome = partialRenderer.routeEngineEvent(event); + assert.equal(fullOutcome.needsRender, partialOutcome.needsRender); + sawNeedsRender = sawNeedsRender || fullOutcome.needsRender; + } + assert.equal(sawNeedsRender, true, "expected routed events to require render"); + + const fullOps = submitOps(fullRenderer, fullBuilder, viewFn, snapshot, viewport, FULL_PLAN); + const partialOps = submitOps( + partialRenderer, + partialBuilder, + viewFn, + snapshot, + viewport, + PARTIAL_RENDER_ONLY_PLAN, + ); + const fullFrame = applyOps(fullBootstrapFrame, fullOps); + const partialFrame = applyOps(partialBootstrapFrame, partialOps); + return { fullOps, partialOps, fullFrame, partialFrame }; +} + +type EditSnapshot = Readonly<{ editIndex: number }>; +function denseListView(snapshot: Readonly, count: number): VNode { + const rows: VNode[] = []; + for (let i = 0; i < count; i++) { + const label = + i === snapshot.editIndex + ? `edit-${String(i).padStart(3, "0")}` + : `item-${String(i).padStart(3, "0")}`; + rows.push( + ui.row({ gap: 1 }, [ + ui.text(label), + ui.text("payload"), + ui.text(`line-${String(i).padStart(3, "0")}`), + ]), + ); + } + return ui.column({ p: 1, gap: 0 }, rows); +} + +function focusListView(buttonCount: number): VNode { + const buttons: VNode[] = []; + for (let i = 0; i < buttonCount; i++) { + buttons.push(ui.button({ id: `btn-${String(i)}`, label: `Button ${String(i)}` })); + } + return ui.column({ p: 1, gap: 0 }, buttons); +} + +function virtualListView(itemCount: number): VNode { + const items = Array.from({ length: itemCount }, (_, i) => `row-${String(i).padStart(3, "0")}`); + return ui.column({ p: 0 }, [ + ui.text("VirtualList"), + ui.virtualList({ + id: "logs", + items, + itemHeight: 1, + renderItem: (item, index, focused) => + ui.text(`${focused ? ">" : " "} ${String(index).padStart(3, "0")} ${item}`), + }), + ]); +} + +type LayoutSnapshot = Readonly<{ wide: boolean }>; +function layoutSensitiveView(snapshot: Readonly): VNode { + return ui.row({ gap: 1 }, [ + ui.text(snapshot.wide ? "WIDE-LABEL" : "N........"), + ui.button({ id: "save", label: "Save" }), + ui.button({ id: "cancel", label: "Cancel" }), + ]); +} + +type OrderSnapshot = Readonly<{ reverse: boolean }>; +function keyedOrderView(snapshot: Readonly): VNode { + const values = snapshot.reverse ? [4, 3, 2, 1, 0] : [0, 1, 2, 3, 4]; + return ui.row( + { gap: 1 }, + values.map((value) => ui.text(`item-${String(value)}`, { key: `k-${String(value)}` })), + ); +} + +function keyedColumnOrderView(snapshot: Readonly): VNode { + const values = snapshot.reverse ? [4, 3, 2, 1, 0] : [0, 1, 2, 3, 4]; + return ui.column( + { p: 1, gap: 0 }, + values.map((value) => ui.text(`item-${String(value)}`, { key: `kc-${String(value)}` })), + ); +} + +function nestedClipView(snapshot: Readonly): VNode { + const rows: VNode[] = []; + for (let i = 0; i < 160; i++) { + const label = + i === snapshot.editIndex + ? `edit-${String(i).padStart(3, "0")}` + : `item-${String(i).padStart(3, "0")}`; + rows.push(ui.text(label)); + } + return ui.box({ border: "single", overflow: "scroll", p: 1 }, [ + ui.box({ border: "single", overflow: "scroll", p: 1 }, [ui.column({ gap: 0 }, rows)]), + ]); +} + +describe("renderer partial dirty-subtree correctness", () => { + const viewport: Viewport = Object.freeze({ cols: 120, rows: 40 }); + + for (const editIndex of [0, 1, 2, 7, 15, 31, 63, 95, 127, 159, 191, 223]) { + test(`single text change @${String(editIndex)} matches full framebuffer`, () => { + const scenario = runCommitScenario( + (snapshot) => denseListView(snapshot, 240), + Object.freeze({ editIndex: -1 }), + Object.freeze({ editIndex }), + viewport, + ); + assertFramebuffersEqual(scenario.partialFrame, scenario.fullFrame); + }); + } + + for (const tabCount of [1, 2, 3, 4, 5, 7]) { + test(`focus change (${String(tabCount)} tab presses) matches full framebuffer`, () => { + const events: ZrevEvent[] = []; + for (let i = 0; i < tabCount; i++) { + events.push(tabEvent(100 + i)); + } + const scenario = runEventScenario( + () => focusListView(180), + Object.freeze({}), + viewport, + events, + ); + assertFramebuffersEqual(scenario.partialFrame, scenario.fullFrame); + }); + } + + const wheelSequences: readonly (readonly number[])[] = Object.freeze([ + Object.freeze([1]), + Object.freeze([2]), + Object.freeze([1, -1]), + Object.freeze([1, 1, -1, 2]), + ]); + for (let i = 0; i < wheelSequences.length; i++) { + const seq = wheelSequences[i] ?? []; + test(`virtualList scroll sequence ${String(i + 1)} matches full framebuffer`, () => { + const events: ZrevEvent[] = []; + for (let j = 0; j < seq.length; j++) { + const wheelY = seq[j] ?? 0; + events.push(wheelEvent(200 + j, 2, 3, wheelY)); + } + const scenario = runEventScenario( + () => virtualListView(320), + Object.freeze({}), + viewport, + events, + ); + assertFramebuffersEqual(scenario.partialFrame, scenario.fullFrame); + }); + } + + test("layout change commit path still matches full framebuffer", () => { + const scenario = runCommitScenario( + layoutSensitiveView, + Object.freeze({ wide: false }), + Object.freeze({ wide: true }), + viewport, + ); + assertFramebuffersEqual(scenario.partialFrame, scenario.fullFrame); + }); + + test("keyed reorder commit path still matches full framebuffer", () => { + const scenario = runCommitScenario( + keyedOrderView, + Object.freeze({ reverse: false }), + Object.freeze({ reverse: true }), + viewport, + ); + assertFramebuffersEqual(scenario.partialFrame, scenario.fullFrame); + }); + + test("keyed column reorder with layout-stability check disabled matches full framebuffer", () => { + const scenario = runCommitScenario( + keyedColumnOrderView, + Object.freeze({ reverse: false }), + Object.freeze({ reverse: true }), + viewport, + PARTIAL_COMMIT_NO_STABILITY_PLAN, + ); + assertFramebuffersEqual(scenario.partialFrame, scenario.fullFrame); + }); + + test("no-change render-only pass still matches full framebuffer", () => { + const scenario = runCommitScenario( + (snapshot) => denseListView(snapshot, 180), + Object.freeze({ editIndex: -1 }), + Object.freeze({ editIndex: -1 }), + viewport, + PARTIAL_RENDER_ONLY_PLAN, + ); + assertFramebuffersEqual(scenario.partialFrame, scenario.fullFrame); + }); + + test("ancestor clips remain balanced when clean siblings are skipped", () => { + const scenario = runCommitScenario( + nestedClipView, + Object.freeze({ editIndex: -1 }), + Object.freeze({ editIndex: 120 }), + viewport, + ); + assertFramebuffersEqual(scenario.partialFrame, scenario.fullFrame); + + let pushCount = 0; + let popCount = 0; + for (const op of scenario.partialOps) { + if (op.kind === "pushClip") pushCount++; + if (op.kind === "popClip") popCount++; + } + assert.equal(pushCount > 0, true, "expected incremental frame to emit clips"); + assert.equal(pushCount, popCount, "pushClip/popClip must be balanced"); + }); +}); diff --git a/packages/core/src/renderer/renderToDrawlist/renderTree.ts b/packages/core/src/renderer/renderToDrawlist/renderTree.ts index 5f6e8f0f..f08dcdfe 100644 --- a/packages/core/src/renderer/renderToDrawlist/renderTree.ts +++ b/packages/core/src/renderer/renderToDrawlist/renderTree.ts @@ -30,7 +30,10 @@ import { renderOverlayWidget } from "./widgets/overlays.js"; type RenderNodeTask = RuntimeInstance | null; type ClipRect = Readonly; -export type RenderTreeOptions = Readonly<{ damageRect?: Rect | undefined }>; +export type RenderTreeOptions = Readonly<{ + damageRect?: Rect | undefined; + skipCleanSubtrees?: boolean | undefined; +}>; export type ResolvedCursor = Readonly<{ x: number; @@ -73,6 +76,7 @@ export function renderTree( ): ResolvedCursor | null { let resolvedCursor: ResolvedCursor | null = null; const damageRect = opts?.damageRect; + const skipCleanSubtrees = opts?.skipCleanSubtrees ?? damageRect !== undefined; const nodeStack: RenderNodeTask[] = [tree]; const styleStack: ResolvedTextStyle[] = [inheritedStyle]; @@ -97,6 +101,7 @@ export function renderTree( const node = nodeOrPop; const vnode = node.vnode; const rect: Rect = layoutNode.rect; + if (skipCleanSubtrees && !node.dirty) continue; if (damageRect && !rectIntersects(getRuntimeNodeDamageRect(node, rect), damageRect)) continue; const currentTheme = themeByNode.get(node) ?? theme; @@ -143,6 +148,8 @@ export function renderTree( layoutStack, clipStack, damageRect, + skipCleanSubtrees, + node.selfDirty, ); break; } diff --git a/packages/core/src/renderer/renderToDrawlist/widgets/containers.ts b/packages/core/src/renderer/renderToDrawlist/widgets/containers.ts index dcd16cce..a8b26226 100644 --- a/packages/core/src/renderer/renderToDrawlist/widgets/containers.ts +++ b/packages/core/src/renderer/renderToDrawlist/widgets/containers.ts @@ -120,6 +120,8 @@ function pushChildrenWithLayout( clipStack: (ClipRect | undefined)[], clip: ClipRect | undefined, damageRect: Rect | undefined, + skipCleanSubtrees: boolean, + forceSubtreeRender: boolean, stackDirection: "row" | "column" | undefined = undefined, ): void { const childCount = Math.min(node.children.length, layoutNode.children.length); @@ -148,8 +150,13 @@ function pushChildrenWithLayout( if ( c && lc && + (!skipCleanSubtrees || forceSubtreeRender || c.dirty) && (!damageRect || rectIntersects(getRuntimeNodeDamageRect(c, lc.rect), damageRect)) ) { + if (forceSubtreeRender) { + c.dirty = true; + c.selfDirty = true; + } nodeStack.push(c); styleStack.push(style); layoutStack.push(lc); @@ -451,8 +458,11 @@ export function renderContainerWidget( layoutStack: LayoutTree[], clipStack: (ClipRect | undefined)[], damageRect: Rect | undefined, + skipCleanSubtrees: boolean, + forceSubtreeRender: boolean, ): void { const vnode = node.vnode; + const forceChildrenRender = forceSubtreeRender || node.selfDirty; switch (vnode.kind) { case "row": @@ -507,6 +517,8 @@ export function renderContainerWidget( clipStack, childClip, damageRect, + skipCleanSubtrees, + forceChildrenRender, vnode.kind === "row" || vnode.kind === "column" ? vnode.kind : undefined, ); break; @@ -594,6 +606,8 @@ export function renderContainerWidget( clipStack, childClip, damageRect, + skipCleanSubtrees, + forceChildrenRender, ); break; } @@ -654,6 +668,8 @@ export function renderContainerWidget( clipStack, childClip, damageRect, + skipCleanSubtrees, + forceChildrenRender, ); break; } @@ -670,6 +686,8 @@ export function renderContainerWidget( clipStack, currentClip, damageRect, + skipCleanSubtrees, + forceChildrenRender, ); break; } @@ -686,6 +704,8 @@ export function renderContainerWidget( clipStack, currentClip, damageRect, + skipCleanSubtrees, + forceChildrenRender, ); break; } @@ -743,6 +763,8 @@ export function renderContainerWidget( clipStack, childClip, damageRect, + skipCleanSubtrees, + forceChildrenRender, ); break; } @@ -770,6 +792,8 @@ export function renderContainerWidget( clipStack, childClip, damageRect, + skipCleanSubtrees, + forceChildrenRender, ); // Render dividers between panels @@ -827,6 +851,8 @@ export function renderContainerWidget( clipStack, childClip, damageRect, + skipCleanSubtrees, + forceChildrenRender, ); break; } @@ -849,6 +875,8 @@ export function renderContainerWidget( clipStack, childClip, damageRect, + skipCleanSubtrees, + forceChildrenRender, ); break; } diff --git a/packages/core/src/runtime/commit.ts b/packages/core/src/runtime/commit.ts index cf135b62..7a746fdd 100644 --- a/packages/core/src/runtime/commit.ts +++ b/packages/core/src/runtime/commit.ts @@ -35,11 +35,13 @@ import { type ReconcileFatal, reconcileChildren } from "./reconcile.js"; * Committed runtime instance with stable ID and children. * Mirrors VNode structure but with lifecycle tracking. */ -export type RuntimeInstance = Readonly<{ +export type RuntimeInstance = { instanceId: InstanceId; vnode: VNode; children: readonly RuntimeInstance[]; -}>; + dirty: boolean; + selfDirty: boolean; +}; /** Shared frozen empty array for leaf RuntimeInstance children. Avoids per-node allocation. */ const EMPTY_CHILDREN: readonly RuntimeInstance[] = Object.freeze([]); @@ -350,6 +352,24 @@ function canFastReuseContainerSelf(prev: VNode, next: VNode): boolean { } } +function runtimeChildrenChanged( + prevChildren: readonly RuntimeInstance[], + nextChildren: readonly RuntimeInstance[], +): boolean { + if (prevChildren.length !== nextChildren.length) return true; + for (let i = 0; i < prevChildren.length; i++) { + if (prevChildren[i] !== nextChildren[i]) return true; + } + return false; +} + +function hasDirtyChild(children: readonly RuntimeInstance[]): boolean { + for (const child of children) { + if (child.dirty) return true; + } + return false; +} + /** Fatal errors from tree commitment. */ export type CommitFatal = | ReconcileFatal @@ -528,6 +548,8 @@ function commitNode( // don't pay per-node validation overhead. if (prev && prev.vnode.kind === vnode.kind && leafVNodeEqual(prev.vnode, vnode)) { if (ctx.collectLifecycleInstanceIds) ctx.lists.reused.push(instanceId); + prev.dirty = false; + prev.selfDirty = false; return { ok: true, value: { root: prev } }; } @@ -726,6 +748,16 @@ function commitNode( res.value.newInstanceIds.length === 0 && res.value.unmountedInstanceIds.length === 0 && res.value.nextChildren.length === prevChildren.length; + let childOrderStable = true; + if (canTryFastReuse) { + for (let i = 0; i < res.value.nextChildren.length; i++) { + const child = res.value.nextChildren[i]; + if (!child || child.prevIndex !== i) { + childOrderStable = false; + break; + } + } + } // Avoid allocating nextChildren/committedChildVNodes for the common case where // everything is reused (e.g., list updates where only a couple rows change). @@ -779,9 +811,12 @@ function commitNode( if ( allChildrenSame && prev !== null && + childOrderStable && canFastReuseContainerSelf(prev.vnode, vnodeForCommit) ) { // All children are identical references → reuse parent entirely. + prev.dirty = false; + prev.selfDirty = false; return { ok: true, value: { root: prev } }; } } else { @@ -807,11 +842,23 @@ function commitNode( } if (!nextChildren || !committedChildVNodes) { - // canTryFastReuse=true and there was at least one mismatch, so arrays must exist. - nextChildren = prevChildren; - committedChildVNodes = prevChildren.map((c) => c.vnode); + // All committed children matched existing instances, but we still need to + // materialize the next order (e.g., keyed reorders) when parent reuse is disallowed. + const reorderedChildren: RuntimeInstance[] = []; + const reorderedVNodes: VNode[] = []; + for (const child of res.value.nextChildren) { + const reused = child.prevIndex !== null ? byPrevIndex[child.prevIndex] : null; + if (!reused) continue; + reorderedChildren.push(reused); + reorderedVNodes.push(reused.vnode); + } + nextChildren = reorderedChildren; + committedChildVNodes = reorderedVNodes; } + const propsChanged = prev === null || !canFastReuseContainerSelf(prev.vnode, vnodeForCommit); + const childrenChanged = prev === null || runtimeChildrenChanged(prevChildren, nextChildren); + const selfDirty = propsChanged || childrenChanged; return { ok: true, value: { @@ -819,6 +866,8 @@ function commitNode( instanceId, vnode: rewriteCommittedVNode(vnodeForCommit, committedChildVNodes), children: nextChildren, + dirty: selfDirty || childrenChanged || hasDirtyChild(nextChildren), + selfDirty, }, }, }; @@ -827,7 +876,7 @@ function commitNode( return { ok: true, value: { - root: { instanceId, vnode, children: EMPTY_CHILDREN }, + root: { instanceId, vnode, children: EMPTY_CHILDREN, dirty: true, selfDirty: true }, }, }; }