Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
134 changes: 134 additions & 0 deletions packages/core/src/app/__tests__/partialDrawlistEmission.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -489,6 +489,140 @@ describe("WidgetRenderer incremental drawlist emission", () => {
);
});

test("shadowed box redraws on shadow-only damage intersections", () => {
const viewport: Viewport = { cols: 40, rows: 10 };
const backend = createNoopBackend();

const viewFn = (s: Readonly<{ label: string }>) =>
ui.layers([
ui.column({}, [
ui.spacer({ size: 2 }),
ui.row({}, [ui.spacer({ size: 10 }), ui.text(s.label)]),
]),
ui.box({ width: 10, height: 4, border: "single", shadow: true }, [ui.text("panel")]),
]);

const partialBuilder = new RecordingBuilder();
const partialRenderer = new WidgetRenderer<{ label: string }>({
backend,
builder: partialBuilder,
requestRender: () => {},
});

const fullBuilder = new RecordingBuilder();
const fullRenderer = new WidgetRenderer<{ label: string }>({
backend,
builder: fullBuilder,
requestRender: () => {},
});

const planBootstrap = { commit: true, layout: true, checkLayoutStability: true } as const;
const planUpdate = { commit: true, layout: false, checkLayoutStability: false } as const;

const opsA = submitOps(
partialRenderer,
partialBuilder,
viewFn,
{ label: "alpha" },
viewport,
planBootstrap,
);
const framebufferA = applyOps(createFramebuffer(viewport), opsA);

const opsExpected = submitOps(
fullRenderer,
fullBuilder,
viewFn,
{ label: "omega" },
viewport,
planBootstrap,
);
const framebufferExpected = applyOps(createFramebuffer(viewport), opsExpected);

const opsPartial = submitOps(
partialRenderer,
partialBuilder,
viewFn,
{ label: "omega" },
viewport,
planUpdate,
);
const framebufferPartial = applyOps(framebufferA, opsPartial);

assertFramebuffersEqual(framebufferPartial, framebufferExpected);
assert.equal(
opsPartial.some((op) => op.kind === "clearTo"),
false,
);
});

test("shadow toggle on layout-skipped commit matches full render", () => {
const viewport: Viewport = { cols: 40, rows: 10 };
const backend = createNoopBackend();

const viewFn = (s: Readonly<{ shadow: boolean }>) =>
ui.layers([
ui.column({}, [
ui.spacer({ size: 1 }),
ui.row({}, [ui.spacer({ size: 10 }), ui.text("X")]),
]),
ui.box({ width: 10, height: 3, border: "single", shadow: s.shadow }, [ui.text("box")]),
]);

const partialBuilder = new RecordingBuilder();
const partialRenderer = new WidgetRenderer<{ shadow: boolean }>({
backend,
builder: partialBuilder,
requestRender: () => {},
});

const fullBuilder = new RecordingBuilder();
const fullRenderer = new WidgetRenderer<{ shadow: boolean }>({
backend,
builder: fullBuilder,
requestRender: () => {},
});

const planBootstrap = { commit: true, layout: true, checkLayoutStability: true } as const;
const planUpdate = { commit: true, layout: false, checkLayoutStability: false } as const;

const opsA = submitOps(
partialRenderer,
partialBuilder,
viewFn,
{ shadow: false },
viewport,
planBootstrap,
);
const framebufferA = applyOps(createFramebuffer(viewport), opsA);

const opsExpected = submitOps(
fullRenderer,
fullBuilder,
viewFn,
{ shadow: true },
viewport,
planBootstrap,
);
const framebufferExpected = applyOps(createFramebuffer(viewport), opsExpected);

const opsPartial = submitOps(
partialRenderer,
partialBuilder,
viewFn,
{ shadow: true },
viewport,
planUpdate,
);
const framebufferPartial = applyOps(framebufferA, opsPartial);

assertFramebuffersEqual(framebufferPartial, framebufferExpected);
assert.equal(
opsPartial.some((op) => op.kind === "clearTo"),
false,
);
});

test("incremental v2 cursor is emitted even when focused input is outside damage rect", () => {
const viewport: Viewport = { cols: 40, rows: 8 };
const backend = createNoopBackend();
Expand Down
53 changes: 49 additions & 4 deletions packages/core/src/app/widgetRenderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ import { measureTextCells } from "../layout/textMeasure.js";
import type { Rect } from "../layout/types.js";
import { PERF_DETAIL_ENABLED, PERF_ENABLED, perfMarkEnd, perfMarkStart } from "../perf/perf.js";
import { type CursorInfo, renderToDrawlist } from "../renderer/renderToDrawlist.js";
import { getRuntimeNodeDamageRect } from "../renderer/renderToDrawlist/damageBounds.js";
import { renderTree } from "../renderer/renderToDrawlist/renderTree.js";
import { DEFAULT_BASE_STYLE } from "../renderer/renderToDrawlist/textStyle.js";
import { type CommitOk, type RuntimeInstance, commitVNodeTree } from "../runtime/commit.js";
Expand Down Expand Up @@ -517,6 +518,10 @@ export class WidgetRenderer<S> {
private readonly _pooledSplitPaneChildRectsById = new Map<string, readonly Rect[]>();
private readonly _prevFrameRectByInstanceId = new Map<InstanceId, Rect>();
private readonly _prevFrameRectById = new Map<string, Rect>();
private readonly _pooledDamageRectByInstanceId = new Map<InstanceId, Rect>();
private readonly _pooledDamageRectById = new Map<string, Rect>();
private readonly _prevFrameDamageRectByInstanceId = new Map<InstanceId, Rect>();
private readonly _prevFrameDamageRectById = new Map<string, Rect>();
private readonly _pooledDamageRects: Rect[] = [];
private readonly _pooledMergedDamageRects: Rect[] = [];
private _hasRenderedFrame = false;
Expand Down Expand Up @@ -2455,12 +2460,12 @@ export class WidgetRenderer<S> {
}

private appendDamageRectForInstanceId(instanceId: InstanceId): boolean {
const current = this._pooledRectByInstanceId.get(instanceId);
const current = this._pooledDamageRectByInstanceId.get(instanceId);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Recompute shadow damage bounds on layout-skipped commits

appendDamageRectForInstanceId now pulls from _pooledDamageRectByInstanceId, but that map is only rebuilt during the layout-index pass (buildLayoutRectIndexes), so commits that skip layout can use stale shadow-aware bounds. Because shadow changes are not layout-driving, toggling a box shadow on (or increasing offsets) in a commit with layout: false can produce an undersized damage region, so incremental redraw clips away part of the newly visible shadow until a later full/layout render.

Useful? React with 👍 / 👎.

if (current && current.w > 0 && current.h > 0) {
this._pooledDamageRects.push(current);
return true;
}
const prev = this._prevFrameRectByInstanceId.get(instanceId);
const prev = this._prevFrameDamageRectByInstanceId.get(instanceId);
if (prev && prev.w > 0 && prev.h > 0) {
this._pooledDamageRects.push(prev);
return true;
Expand All @@ -2469,19 +2474,46 @@ export class WidgetRenderer<S> {
}

private appendDamageRectForId(id: string): boolean {
const current = this._pooledRectById.get(id);
const current = this._pooledDamageRectById.get(id);
if (current && current.w > 0 && current.h > 0) {
this._pooledDamageRects.push(current);
return true;
}
const prev = this._prevFrameRectById.get(id);
const prev = this._prevFrameDamageRectById.get(id);
if (prev && prev.w > 0 && prev.h > 0) {
this._pooledDamageRects.push(prev);
return true;
}
return false;
}

private refreshDamageRectIndexesForLayoutSkippedCommit(runtimeRoot: RuntimeInstance): void {
this._pooledDamageRectByInstanceId.clear();
this._pooledDamageRectById.clear();
this._pooledRuntimeStack.length = 0;
this._pooledRuntimeStack.push(runtimeRoot);

while (this._pooledRuntimeStack.length > 0) {
const node = this._pooledRuntimeStack.pop();
if (!node) continue;

const rect = this._pooledRectByInstanceId.get(node.instanceId);
if (rect) {
const damageRect = getRuntimeNodeDamageRect(node, rect);
this._pooledDamageRectByInstanceId.set(node.instanceId, damageRect);
const id = (node.vnode as { props?: { id?: unknown } }).props?.id;
if (typeof id === "string" && id.length > 0 && !this._pooledDamageRectById.has(id)) {
this._pooledDamageRectById.set(id, damageRect);
}
}

for (let i = node.children.length - 1; i >= 0; i--) {
const child = node.children[i];
if (child) this._pooledRuntimeStack.push(child);
}
}
}

private collectSpinnerDamageRects(runtimeRoot: RuntimeInstance, layoutRoot: LayoutTree): void {
this._pooledRuntimeStack.length = 0;
this._pooledLayoutStack.length = 0;
Expand Down Expand Up @@ -2554,6 +2586,14 @@ export class WidgetRenderer<S> {
this._prevFrameRectById.set(id, rect);
}
}
this._prevFrameDamageRectByInstanceId.clear();
for (const [instanceId, rect] of this._pooledDamageRectByInstanceId) {
this._prevFrameDamageRectByInstanceId.set(instanceId, rect);
}
this._prevFrameDamageRectById.clear();
for (const [id, rect] of this._pooledDamageRectById) {
this._prevFrameDamageRectById.set(id, rect);
}
this._hasRenderedFrame = true;
this._lastRenderedViewport = Object.freeze({ cols: viewport.cols, rows: viewport.rows });
this._lastRenderedThemeRef = theme;
Expand Down Expand Up @@ -2707,7 +2747,9 @@ export class WidgetRenderer<S> {
nextLayoutTree,
this.committedRoot,
this._pooledRectByInstanceId,
this._pooledDamageRectByInstanceId,
this._pooledRectById,
this._pooledDamageRectById,
this._pooledSplitPaneChildRectsById,
this._pooledLayoutStack,
this._pooledRuntimeStack,
Expand All @@ -2724,6 +2766,9 @@ export class WidgetRenderer<S> {
detail: "widgetRenderer: missing layout tree",
};
}
if (doCommit && !doLayout) {
this.refreshDamageRectIndexesForLayoutSkippedCommit(this.committedRoot);
}

if (doCommit) {
const canSkipMetadataCollect =
Expand Down
8 changes: 8 additions & 0 deletions packages/core/src/app/widgetRenderer/submitFramePipeline.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { LayoutTree } from "../../layout/layout.js";
import { measureTextCells } from "../../layout/textMeasure.js";
import type { Rect } from "../../layout/types.js";
import { getRuntimeNodeDamageRect } from "../../renderer/renderToDrawlist/damageBounds.js";
import type { RuntimeInstance } from "../../runtime/commit.js";
import type { InstanceId } from "../../runtime/instance.js";

Expand Down Expand Up @@ -262,13 +263,17 @@ export function buildLayoutRectIndexes(
layoutTree: LayoutTree,
runtimeRoot: RuntimeInstance,
pooledRectByInstanceId: Map<InstanceId, Rect>,
pooledDamageRectByInstanceId: Map<InstanceId, Rect>,
pooledRectById: Map<string, Rect>,
pooledDamageRectById: Map<string, Rect>,
pooledSplitPaneChildRectsById: Map<string, readonly Rect[]>,
pooledLayoutStack: LayoutTree[],
pooledRuntimeStack: RuntimeInstance[],
): void {
pooledRectByInstanceId.clear();
pooledDamageRectByInstanceId.clear();
pooledRectById.clear();
pooledDamageRectById.clear();
pooledSplitPaneChildRectsById.clear();
pooledLayoutStack.length = 0;
pooledRuntimeStack.length = 0;
Expand All @@ -279,9 +284,12 @@ export function buildLayoutRectIndexes(
const r = pooledRuntimeStack.pop();
if (!n || !r) continue;
pooledRectByInstanceId.set(r.instanceId, n.rect);
const damageRect = getRuntimeNodeDamageRect(r, n.rect);
pooledDamageRectByInstanceId.set(r.instanceId, damageRect);
const id = (r.vnode as { props?: { id?: unknown } }).props?.id;
if (typeof id === "string" && id.length > 0 && !pooledRectById.has(id)) {
pooledRectById.set(id, n.rect);
pooledDamageRectById.set(id, damageRect);
}
if (r.vnode.kind === "splitPane") {
const sid = (r.vnode.props as { id?: unknown }).id;
Expand Down
44 changes: 44 additions & 0 deletions packages/core/src/renderer/renderToDrawlist/damageBounds.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import type { Rect } from "../../layout/types.js";
import type { RuntimeInstance } from "../../runtime/commit.js";
import { getRectWithShadow } from "../shadow.js";

type BoxShadowProps = Readonly<{
shadow?: unknown;
}>;

function readShadowOffset(raw: unknown, fallback: number): number {
if (typeof raw !== "number" || !Number.isFinite(raw)) {
return fallback;
}
const value = Math.trunc(raw);
return value <= 0 ? 0 : value;
}

function resolveShadowOffsets(
shadow: unknown,
): Readonly<{ offsetX: number; offsetY: number }> | null {
if (shadow !== true && (shadow === false || shadow === undefined || shadow === null)) {
return null;
}
if (shadow === true) {
return Object.freeze({ offsetX: 1, offsetY: 1 });
}
if (typeof shadow !== "object") {
return null;
}

const config = shadow as { offsetX?: unknown; offsetY?: unknown };
const offsetX = readShadowOffset(config.offsetX, 1);
const offsetY = readShadowOffset(config.offsetY, 1);
if (offsetX <= 0 && offsetY <= 0) {
return null;
}
return Object.freeze({ offsetX, offsetY });
}

export function getRuntimeNodeDamageRect(node: RuntimeInstance, rect: Rect): Rect {
if (node.vnode.kind !== "box") return rect;
const offsets = resolveShadowOffsets((node.vnode.props as BoxShadowProps).shadow);
if (!offsets) return rect;
return getRectWithShadow(rect, offsets);
}
3 changes: 2 additions & 1 deletion packages/core/src/renderer/renderToDrawlist/renderTree.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import type {
} from "../../runtime/localState.js";
import type { Theme } from "../../theme/theme.js";
import type { CommandItem } from "../../widgets/types.js";
import { getRuntimeNodeDamageRect } from "./damageBounds.js";
import type { IdRectIndex } from "./indices.js";
import type { ResolvedTextStyle } from "./textStyle.js";
import type {
Expand Down Expand Up @@ -93,7 +94,7 @@ export function renderTree(
const node = nodeOrPop;
const vnode = node.vnode;
const rect: Rect = layoutNode.rect;
if (damageRect && !rectIntersects(rect, damageRect)) continue;
if (damageRect && !rectIntersects(getRuntimeNodeDamageRect(node, rect), damageRect)) continue;

// Depth-first preorder: render node, then its children.
switch (vnode.kind) {
Expand Down
Loading
Loading