Skip to content

Commit c11f8e0

Browse files
fix(core): shadow-aware incremental damage bounds (#49)
* fix(core): include box shadow bounds in incremental redraw * fix(core): refresh shadow damage bounds on layout-skipped commits
1 parent 993f91e commit c11f8e0

7 files changed

Lines changed: 264 additions & 13 deletions

File tree

packages/core/src/app/__tests__/partialDrawlistEmission.test.ts

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -489,6 +489,140 @@ describe("WidgetRenderer incremental drawlist emission", () => {
489489
);
490490
});
491491

492+
test("shadowed box redraws on shadow-only damage intersections", () => {
493+
const viewport: Viewport = { cols: 40, rows: 10 };
494+
const backend = createNoopBackend();
495+
496+
const viewFn = (s: Readonly<{ label: string }>) =>
497+
ui.layers([
498+
ui.column({}, [
499+
ui.spacer({ size: 2 }),
500+
ui.row({}, [ui.spacer({ size: 10 }), ui.text(s.label)]),
501+
]),
502+
ui.box({ width: 10, height: 4, border: "single", shadow: true }, [ui.text("panel")]),
503+
]);
504+
505+
const partialBuilder = new RecordingBuilder();
506+
const partialRenderer = new WidgetRenderer<{ label: string }>({
507+
backend,
508+
builder: partialBuilder,
509+
requestRender: () => {},
510+
});
511+
512+
const fullBuilder = new RecordingBuilder();
513+
const fullRenderer = new WidgetRenderer<{ label: string }>({
514+
backend,
515+
builder: fullBuilder,
516+
requestRender: () => {},
517+
});
518+
519+
const planBootstrap = { commit: true, layout: true, checkLayoutStability: true } as const;
520+
const planUpdate = { commit: true, layout: false, checkLayoutStability: false } as const;
521+
522+
const opsA = submitOps(
523+
partialRenderer,
524+
partialBuilder,
525+
viewFn,
526+
{ label: "alpha" },
527+
viewport,
528+
planBootstrap,
529+
);
530+
const framebufferA = applyOps(createFramebuffer(viewport), opsA);
531+
532+
const opsExpected = submitOps(
533+
fullRenderer,
534+
fullBuilder,
535+
viewFn,
536+
{ label: "omega" },
537+
viewport,
538+
planBootstrap,
539+
);
540+
const framebufferExpected = applyOps(createFramebuffer(viewport), opsExpected);
541+
542+
const opsPartial = submitOps(
543+
partialRenderer,
544+
partialBuilder,
545+
viewFn,
546+
{ label: "omega" },
547+
viewport,
548+
planUpdate,
549+
);
550+
const framebufferPartial = applyOps(framebufferA, opsPartial);
551+
552+
assertFramebuffersEqual(framebufferPartial, framebufferExpected);
553+
assert.equal(
554+
opsPartial.some((op) => op.kind === "clearTo"),
555+
false,
556+
);
557+
});
558+
559+
test("shadow toggle on layout-skipped commit matches full render", () => {
560+
const viewport: Viewport = { cols: 40, rows: 10 };
561+
const backend = createNoopBackend();
562+
563+
const viewFn = (s: Readonly<{ shadow: boolean }>) =>
564+
ui.layers([
565+
ui.column({}, [
566+
ui.spacer({ size: 1 }),
567+
ui.row({}, [ui.spacer({ size: 10 }), ui.text("X")]),
568+
]),
569+
ui.box({ width: 10, height: 3, border: "single", shadow: s.shadow }, [ui.text("box")]),
570+
]);
571+
572+
const partialBuilder = new RecordingBuilder();
573+
const partialRenderer = new WidgetRenderer<{ shadow: boolean }>({
574+
backend,
575+
builder: partialBuilder,
576+
requestRender: () => {},
577+
});
578+
579+
const fullBuilder = new RecordingBuilder();
580+
const fullRenderer = new WidgetRenderer<{ shadow: boolean }>({
581+
backend,
582+
builder: fullBuilder,
583+
requestRender: () => {},
584+
});
585+
586+
const planBootstrap = { commit: true, layout: true, checkLayoutStability: true } as const;
587+
const planUpdate = { commit: true, layout: false, checkLayoutStability: false } as const;
588+
589+
const opsA = submitOps(
590+
partialRenderer,
591+
partialBuilder,
592+
viewFn,
593+
{ shadow: false },
594+
viewport,
595+
planBootstrap,
596+
);
597+
const framebufferA = applyOps(createFramebuffer(viewport), opsA);
598+
599+
const opsExpected = submitOps(
600+
fullRenderer,
601+
fullBuilder,
602+
viewFn,
603+
{ shadow: true },
604+
viewport,
605+
planBootstrap,
606+
);
607+
const framebufferExpected = applyOps(createFramebuffer(viewport), opsExpected);
608+
609+
const opsPartial = submitOps(
610+
partialRenderer,
611+
partialBuilder,
612+
viewFn,
613+
{ shadow: true },
614+
viewport,
615+
planUpdate,
616+
);
617+
const framebufferPartial = applyOps(framebufferA, opsPartial);
618+
619+
assertFramebuffersEqual(framebufferPartial, framebufferExpected);
620+
assert.equal(
621+
opsPartial.some((op) => op.kind === "clearTo"),
622+
false,
623+
);
624+
});
625+
492626
test("incremental v2 cursor is emitted even when focused input is outside damage rect", () => {
493627
const viewport: Viewport = { cols: 40, rows: 8 };
494628
const backend = createNoopBackend();

packages/core/src/app/widgetRenderer.ts

Lines changed: 49 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ import { measureTextCells } from "../layout/textMeasure.js";
5858
import type { Rect } from "../layout/types.js";
5959
import { PERF_DETAIL_ENABLED, PERF_ENABLED, perfMarkEnd, perfMarkStart } from "../perf/perf.js";
6060
import { type CursorInfo, renderToDrawlist } from "../renderer/renderToDrawlist.js";
61+
import { getRuntimeNodeDamageRect } from "../renderer/renderToDrawlist/damageBounds.js";
6162
import { renderTree } from "../renderer/renderToDrawlist/renderTree.js";
6263
import { DEFAULT_BASE_STYLE } from "../renderer/renderToDrawlist/textStyle.js";
6364
import { type CommitOk, type RuntimeInstance, commitVNodeTree } from "../runtime/commit.js";
@@ -522,6 +523,10 @@ export class WidgetRenderer<S> {
522523
private readonly _pooledSplitPaneChildRectsById = new Map<string, readonly Rect[]>();
523524
private readonly _prevFrameRectByInstanceId = new Map<InstanceId, Rect>();
524525
private readonly _prevFrameRectById = new Map<string, Rect>();
526+
private readonly _pooledDamageRectByInstanceId = new Map<InstanceId, Rect>();
527+
private readonly _pooledDamageRectById = new Map<string, Rect>();
528+
private readonly _prevFrameDamageRectByInstanceId = new Map<InstanceId, Rect>();
529+
private readonly _prevFrameDamageRectById = new Map<string, Rect>();
525530
private readonly _pooledDamageRects: Rect[] = [];
526531
private readonly _pooledMergedDamageRects: Rect[] = [];
527532
private _hasRenderedFrame = false;
@@ -2495,12 +2500,12 @@ export class WidgetRenderer<S> {
24952500
}
24962501

24972502
private appendDamageRectForInstanceId(instanceId: InstanceId): boolean {
2498-
const current = this._pooledRectByInstanceId.get(instanceId);
2503+
const current = this._pooledDamageRectByInstanceId.get(instanceId);
24992504
if (current && current.w > 0 && current.h > 0) {
25002505
this._pooledDamageRects.push(current);
25012506
return true;
25022507
}
2503-
const prev = this._prevFrameRectByInstanceId.get(instanceId);
2508+
const prev = this._prevFrameDamageRectByInstanceId.get(instanceId);
25042509
if (prev && prev.w > 0 && prev.h > 0) {
25052510
this._pooledDamageRects.push(prev);
25062511
return true;
@@ -2509,19 +2514,46 @@ export class WidgetRenderer<S> {
25092514
}
25102515

25112516
private appendDamageRectForId(id: string): boolean {
2512-
const current = this._pooledRectById.get(id);
2517+
const current = this._pooledDamageRectById.get(id);
25132518
if (current && current.w > 0 && current.h > 0) {
25142519
this._pooledDamageRects.push(current);
25152520
return true;
25162521
}
2517-
const prev = this._prevFrameRectById.get(id);
2522+
const prev = this._prevFrameDamageRectById.get(id);
25182523
if (prev && prev.w > 0 && prev.h > 0) {
25192524
this._pooledDamageRects.push(prev);
25202525
return true;
25212526
}
25222527
return false;
25232528
}
25242529

2530+
private refreshDamageRectIndexesForLayoutSkippedCommit(runtimeRoot: RuntimeInstance): void {
2531+
this._pooledDamageRectByInstanceId.clear();
2532+
this._pooledDamageRectById.clear();
2533+
this._pooledRuntimeStack.length = 0;
2534+
this._pooledRuntimeStack.push(runtimeRoot);
2535+
2536+
while (this._pooledRuntimeStack.length > 0) {
2537+
const node = this._pooledRuntimeStack.pop();
2538+
if (!node) continue;
2539+
2540+
const rect = this._pooledRectByInstanceId.get(node.instanceId);
2541+
if (rect) {
2542+
const damageRect = getRuntimeNodeDamageRect(node, rect);
2543+
this._pooledDamageRectByInstanceId.set(node.instanceId, damageRect);
2544+
const id = (node.vnode as { props?: { id?: unknown } }).props?.id;
2545+
if (typeof id === "string" && id.length > 0 && !this._pooledDamageRectById.has(id)) {
2546+
this._pooledDamageRectById.set(id, damageRect);
2547+
}
2548+
}
2549+
2550+
for (let i = node.children.length - 1; i >= 0; i--) {
2551+
const child = node.children[i];
2552+
if (child) this._pooledRuntimeStack.push(child);
2553+
}
2554+
}
2555+
}
2556+
25252557
private collectSpinnerDamageRects(runtimeRoot: RuntimeInstance, layoutRoot: LayoutTree): void {
25262558
this._pooledRuntimeStack.length = 0;
25272559
this._pooledLayoutStack.length = 0;
@@ -2594,6 +2626,14 @@ export class WidgetRenderer<S> {
25942626
this._prevFrameRectById.set(id, rect);
25952627
}
25962628
}
2629+
this._prevFrameDamageRectByInstanceId.clear();
2630+
for (const [instanceId, rect] of this._pooledDamageRectByInstanceId) {
2631+
this._prevFrameDamageRectByInstanceId.set(instanceId, rect);
2632+
}
2633+
this._prevFrameDamageRectById.clear();
2634+
for (const [id, rect] of this._pooledDamageRectById) {
2635+
this._prevFrameDamageRectById.set(id, rect);
2636+
}
25972637
this._hasRenderedFrame = true;
25982638
this._lastRenderedViewport = Object.freeze({ cols: viewport.cols, rows: viewport.rows });
25992639
this._lastRenderedThemeRef = theme;
@@ -2747,7 +2787,9 @@ export class WidgetRenderer<S> {
27472787
nextLayoutTree,
27482788
this.committedRoot,
27492789
this._pooledRectByInstanceId,
2790+
this._pooledDamageRectByInstanceId,
27502791
this._pooledRectById,
2792+
this._pooledDamageRectById,
27512793
this._pooledSplitPaneChildRectsById,
27522794
this._pooledLayoutStack,
27532795
this._pooledRuntimeStack,
@@ -2764,6 +2806,9 @@ export class WidgetRenderer<S> {
27642806
detail: "widgetRenderer: missing layout tree",
27652807
};
27662808
}
2809+
if (doCommit && !doLayout) {
2810+
this.refreshDamageRectIndexesForLayoutSkippedCommit(this.committedRoot);
2811+
}
27672812

27682813
if (doCommit) {
27692814
const canSkipMetadataCollect =

packages/core/src/app/widgetRenderer/submitFramePipeline.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import type { LayoutTree } from "../../layout/layout.js";
22
import { measureTextCells } from "../../layout/textMeasure.js";
33
import type { Rect } from "../../layout/types.js";
4+
import { getRuntimeNodeDamageRect } from "../../renderer/renderToDrawlist/damageBounds.js";
45
import type { RuntimeInstance } from "../../runtime/commit.js";
56
import type { InstanceId } from "../../runtime/instance.js";
67

@@ -262,13 +263,17 @@ export function buildLayoutRectIndexes(
262263
layoutTree: LayoutTree,
263264
runtimeRoot: RuntimeInstance,
264265
pooledRectByInstanceId: Map<InstanceId, Rect>,
266+
pooledDamageRectByInstanceId: Map<InstanceId, Rect>,
265267
pooledRectById: Map<string, Rect>,
268+
pooledDamageRectById: Map<string, Rect>,
266269
pooledSplitPaneChildRectsById: Map<string, readonly Rect[]>,
267270
pooledLayoutStack: LayoutTree[],
268271
pooledRuntimeStack: RuntimeInstance[],
269272
): void {
270273
pooledRectByInstanceId.clear();
274+
pooledDamageRectByInstanceId.clear();
271275
pooledRectById.clear();
276+
pooledDamageRectById.clear();
272277
pooledSplitPaneChildRectsById.clear();
273278
pooledLayoutStack.length = 0;
274279
pooledRuntimeStack.length = 0;
@@ -279,9 +284,12 @@ export function buildLayoutRectIndexes(
279284
const r = pooledRuntimeStack.pop();
280285
if (!n || !r) continue;
281286
pooledRectByInstanceId.set(r.instanceId, n.rect);
287+
const damageRect = getRuntimeNodeDamageRect(r, n.rect);
288+
pooledDamageRectByInstanceId.set(r.instanceId, damageRect);
282289
const id = (r.vnode as { props?: { id?: unknown } }).props?.id;
283290
if (typeof id === "string" && id.length > 0 && !pooledRectById.has(id)) {
284291
pooledRectById.set(id, n.rect);
292+
pooledDamageRectById.set(id, damageRect);
285293
}
286294
if (r.vnode.kind === "splitPane") {
287295
const sid = (r.vnode.props as { id?: unknown }).id;
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import type { Rect } from "../../layout/types.js";
2+
import type { RuntimeInstance } from "../../runtime/commit.js";
3+
import { getRectWithShadow } from "../shadow.js";
4+
5+
type BoxShadowProps = Readonly<{
6+
shadow?: unknown;
7+
}>;
8+
9+
function readShadowOffset(raw: unknown, fallback: number): number {
10+
if (typeof raw !== "number" || !Number.isFinite(raw)) {
11+
return fallback;
12+
}
13+
const value = Math.trunc(raw);
14+
return value <= 0 ? 0 : value;
15+
}
16+
17+
function resolveShadowOffsets(
18+
shadow: unknown,
19+
): Readonly<{ offsetX: number; offsetY: number }> | null {
20+
if (shadow !== true && (shadow === false || shadow === undefined || shadow === null)) {
21+
return null;
22+
}
23+
if (shadow === true) {
24+
return Object.freeze({ offsetX: 1, offsetY: 1 });
25+
}
26+
if (typeof shadow !== "object") {
27+
return null;
28+
}
29+
30+
const config = shadow as { offsetX?: unknown; offsetY?: unknown };
31+
const offsetX = readShadowOffset(config.offsetX, 1);
32+
const offsetY = readShadowOffset(config.offsetY, 1);
33+
if (offsetX <= 0 && offsetY <= 0) {
34+
return null;
35+
}
36+
return Object.freeze({ offsetX, offsetY });
37+
}
38+
39+
export function getRuntimeNodeDamageRect(node: RuntimeInstance, rect: Rect): Rect {
40+
if (node.vnode.kind !== "box") return rect;
41+
const offsets = resolveShadowOffsets((node.vnode.props as BoxShadowProps).shadow);
42+
if (!offsets) return rect;
43+
return getRectWithShadow(rect, offsets);
44+
}

packages/core/src/renderer/renderToDrawlist/renderTree.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import type {
1010
} from "../../runtime/localState.js";
1111
import type { Theme } from "../../theme/theme.js";
1212
import type { CommandItem } from "../../widgets/types.js";
13+
import { getRuntimeNodeDamageRect } from "./damageBounds.js";
1314
import type { IdRectIndex } from "./indices.js";
1415
import type { ResolvedTextStyle } from "./textStyle.js";
1516
import type {
@@ -93,7 +94,7 @@ export function renderTree(
9394
const node = nodeOrPop;
9495
const vnode = node.vnode;
9596
const rect: Rect = layoutNode.rect;
96-
if (damageRect && !rectIntersects(rect, damageRect)) continue;
97+
if (damageRect && !rectIntersects(getRuntimeNodeDamageRect(node, rect), damageRect)) continue;
9798

9899
// Depth-first preorder: render node, then its children.
99100
switch (vnode.kind) {

0 commit comments

Comments
 (0)