Skip to content

Commit 8e624d5

Browse files
core: stabilize animation tick cadence and single-select table focus (#94)
* core: throttle spinner tick repaints and stabilize table single-selection focus * core: apply biome formatting for lint CI
1 parent 9417f60 commit 8e624d5

6 files changed

Lines changed: 200 additions & 6 deletions

File tree

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

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { assert, test } from "@rezi-ui/testkit";
2+
import { ui } from "../../widgets/ui.js";
23
import { createApp } from "../createApp.js";
34
import { encodeZrevBatchV1, flushMicrotasks, makeBackendBatch } from "./helpers.js";
45
import { StubBackend } from "./stubBackend.js";
@@ -87,3 +88,86 @@ test("raw mode tick does not force render when no state changed", async () => {
8788
await flushMicrotasks(5);
8889
assert.equal(backend.requestedFrames.length, 1);
8990
});
91+
92+
test("widget mode tick does not force render when no spinner exists", async () => {
93+
const backend = new StubBackend();
94+
const app = createApp({ backend, initialState: 0 });
95+
96+
app.view(() => ui.text("static"));
97+
98+
await app.start();
99+
backend.pushBatch(
100+
makeBackendBatch({
101+
bytes: encodeZrevBatchV1({
102+
flags: 0,
103+
events: [{ kind: "resize", timeMs: 1, cols: 80, rows: 24 }],
104+
}),
105+
}),
106+
);
107+
await flushMicrotasks(10);
108+
assert.equal(backend.requestedFrames.length, 1);
109+
110+
backend.resolveNextFrame();
111+
await flushMicrotasks(3);
112+
113+
const bytes = encodeZrevBatchV1({
114+
flags: 0,
115+
events: [{ kind: "tick", timeMs: 10 }],
116+
});
117+
backend.pushBatch(makeBackendBatch({ bytes }));
118+
119+
await flushMicrotasks(5);
120+
assert.equal(backend.requestedFrames.length, 1);
121+
});
122+
123+
test("widget mode spinner tick rendering is throttled", async () => {
124+
const backend = new StubBackend();
125+
const app = createApp({ backend, initialState: 0 });
126+
127+
app.view(() => ui.spinner({ variant: "line", label: "Loading" }));
128+
129+
await app.start();
130+
backend.pushBatch(
131+
makeBackendBatch({
132+
bytes: encodeZrevBatchV1({
133+
flags: 0,
134+
events: [{ kind: "resize", timeMs: 1, cols: 80, rows: 24 }],
135+
}),
136+
}),
137+
);
138+
await flushMicrotasks(10);
139+
assert.equal(backend.requestedFrames.length, 1);
140+
141+
backend.resolveNextFrame();
142+
await flushMicrotasks(3);
143+
144+
backend.pushBatch(
145+
makeBackendBatch({
146+
bytes: encodeZrevBatchV1({
147+
flags: 0,
148+
events: [
149+
{ kind: "tick", timeMs: 10 },
150+
{ kind: "tick", timeMs: 20 },
151+
{ kind: "tick", timeMs: 30 },
152+
{ kind: "tick", timeMs: 40 },
153+
],
154+
}),
155+
}),
156+
);
157+
await flushMicrotasks(5);
158+
assert.equal(backend.requestedFrames.length, 2);
159+
160+
backend.resolveNextFrame();
161+
await flushMicrotasks(3);
162+
163+
backend.pushBatch(
164+
makeBackendBatch({
165+
bytes: encodeZrevBatchV1({
166+
flags: 0,
167+
events: [{ kind: "tick", timeMs: 200 }],
168+
}),
169+
}),
170+
);
171+
await flushMicrotasks(5);
172+
assert.equal(backend.requestedFrames.length, 3);
173+
});

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

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,8 @@ test("runtime breadcrumbs capture event path/action/focus/cursor deterministical
105105
presses++;
106106
},
107107
}),
108+
// Keep an animated widget in-tree so tick events produce render snapshots.
109+
ui.spinner({ variant: "dots", label: "" }),
108110
],
109111
),
110112
);
@@ -167,7 +169,7 @@ test("runtime breadcrumbs capture event path/action/focus/cursor deterministical
167169
assert.deepEqual(actionEvents, [{ id: "save", action: "press" }]);
168170
assert.equal(presses, 1);
169171

170-
await pushEvents(backend, [{ kind: "tick", timeMs: 6, dtMs: 16 }]);
172+
await pushEvents(backend, [{ kind: "tick", timeMs: 200, dtMs: 16 }]);
171173
assert.equal(renderSnapshots.length, 4);
172174
const fourth = renderSnapshots[3];
173175
assert.ok(fourth);

packages/core/src/app/createApp.ts

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -369,13 +369,17 @@ export function createApp<S>(
369369
const DIRTY_RENDER = 1 << 0;
370370
const DIRTY_LAYOUT = 1 << 1;
371371
const DIRTY_VIEW = 1 << 2;
372+
const spinnerTickMinIntervalMs = Math.max(1, Math.floor(1000 / Math.min(config.fpsCap, 8)));
372373

373374
let dirtyFlags = 0;
374375
let dirtyRenderVersion = 0;
375376
let dirtyLayoutVersion = 0;
376377
let dirtyViewVersion = 0;
377378
let framesInFlight = 0;
378379
let interactiveBudget = 0;
380+
let lastSpinnerRenderTickMs = Number.NEGATIVE_INFINITY;
381+
let lastObservedSpinnerTickEventMs = Number.NEGATIVE_INFINITY;
382+
let lastSpinnerRenderPerfMs = Number.NEGATIVE_INFINITY;
379383
let viewport: Readonly<{ cols: number; rows: number }> | null = null;
380384
const timeUnwrap: EventTimeUnwrapState = { epochMs: 0, lastRawMs: null };
381385

@@ -722,8 +726,26 @@ export function createApp<S>(
722726
}
723727
}
724728
if (ev.kind === "tick" && mode === "widget") {
725-
// Tick events drive render-only animation frames (e.g., Spinner).
726-
markDirty(DIRTY_RENDER);
729+
// Tick events drive render-only animation frames for animated widgets
730+
// (currently spinner). Throttle to avoid repaint storms/flicker.
731+
//
732+
// Prefer backend tick timestamps when they advance, but fall back to
733+
// local monotonic time for runtimes/terminals where tick time is
734+
// constant or non-monotonic.
735+
if (widgetRenderer.hasAnimatedWidgets()) {
736+
const tickMs = ev.timeMs;
737+
const perfMs = perfNow();
738+
const eventClockAdvances = tickMs > lastObservedSpinnerTickEventMs;
739+
if (eventClockAdvances) lastObservedSpinnerTickEventMs = tickMs;
740+
const elapsedMs = eventClockAdvances
741+
? tickMs - lastSpinnerRenderTickMs
742+
: perfMs - lastSpinnerRenderPerfMs;
743+
if (elapsedMs >= spinnerTickMinIntervalMs) {
744+
lastSpinnerRenderTickMs = tickMs;
745+
lastSpinnerRenderPerfMs = perfMs;
746+
markDirty(DIRTY_RENDER);
747+
}
748+
}
727749
}
728750

729751
const isWidgetRoutableEvent =

packages/core/src/app/widgetRenderer.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -504,6 +504,7 @@ export class WidgetRenderer<S> {
504504

505505
// Tracks whether the currently committed tree needs routing rebuild traversals.
506506
private hadRoutingWidgets = false;
507+
private hasAnimatedWidgetsInCommittedTree = false;
507508

508509
/* --- Render Caches (avoid per-frame recompute) --- */
509510
private readonly tableRenderCacheById = new Map<string, TableRenderCache>();
@@ -639,6 +640,29 @@ export class WidgetRenderer<S> {
639640
}
640641
}
641642

643+
hasAnimatedWidgets(): boolean {
644+
return this.hasAnimatedWidgetsInCommittedTree;
645+
}
646+
647+
private recomputeAnimatedWidgetPresence(runtimeRoot: RuntimeInstance): void {
648+
this._pooledRuntimeStack.length = 0;
649+
this._pooledRuntimeStack.push(runtimeRoot);
650+
while (this._pooledRuntimeStack.length > 0) {
651+
const node = this._pooledRuntimeStack.pop();
652+
if (!node) continue;
653+
if (node.vnode.kind === "spinner") {
654+
this.hasAnimatedWidgetsInCommittedTree = true;
655+
this._pooledRuntimeStack.length = 0;
656+
return;
657+
}
658+
for (let i = node.children.length - 1; i >= 0; i--) {
659+
const child = node.children[i];
660+
if (child) this._pooledRuntimeStack.push(child);
661+
}
662+
}
663+
this.hasAnimatedWidgetsInCommittedTree = false;
664+
}
665+
642666
/**
643667
* Get the currently focused widget ID.
644668
*
@@ -2988,6 +3012,7 @@ export class WidgetRenderer<S> {
29883012
}
29893013
commitRes = commitRes0.value;
29903014
this.committedRoot = commitRes.root;
3015+
this.recomputeAnimatedWidgetPresence(this.committedRoot);
29913016

29923017
const damageToken = PERF_DETAIL_ENABLED ? perfMarkStart("damage_identity_diff") : 0;
29933018
identityDamageFromCommit = this.computeIdentityDiffDamage(

packages/core/src/renderer/renderToDrawlist/widgets/collections.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -411,13 +411,20 @@ export function renderCollectionWidget(
411411
}
412412
}
413413
const isFocusedRow = focusState.focusedId === props.id && i === tableState.focusedRowIndex;
414+
const suppressFocusedStyle =
415+
selectionMode === "single" && needsSelection && isFocusedRow && !isSelected;
416+
const showFocusedStyle = isFocusedRow && !suppressFocusedStyle;
414417

415418
const yRow = bodyY + i * safeRowHeight - effectiveScrollTop;
416419
if (yRow >= bodyY + bodyH) break;
417420
if (yRow + safeRowHeight <= bodyY) continue;
418421

419422
const rowStripeBg = stripedRows ? ((i & 1) === 1 ? stripeOddBg : stripeEvenBg) : undefined;
420-
const rowBg = isFocusedRow ? undefined : isSelected ? theme.colors.secondary : rowStripeBg;
423+
const rowBg = showFocusedStyle
424+
? undefined
425+
: isSelected
426+
? theme.colors.secondary
427+
: rowStripeBg;
421428
if (rowBg) {
422429
builder.fillRect(innerX, yRow, innerW, safeRowHeight, { bg: rowBg });
423430
}
@@ -437,7 +444,7 @@ export function renderCollectionWidget(
437444
? rawValue
438445
: String(rawValue);
439446

440-
const style = isFocusedRow ? { inverse: true } : rowBg ? { bg: rowBg } : undefined;
447+
const style = showFocusedStyle ? { inverse: true } : rowBg ? { bg: rowBg } : undefined;
441448

442449
if (col.render) {
443450
const cellVNode = col.render(rawValue, row, i);

packages/core/src/widgets/__tests__/renderer.regressions.test.ts

Lines changed: 55 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,7 @@ function renderBytes(
146146
virtualListStore?: VirtualListStateStore;
147147
tableStore?: TableStateStore;
148148
treeStore?: TreeStateStore;
149+
focusedId?: string | null;
149150
}>,
150151
): Uint8Array {
151152
const allocator = createInstanceIdAllocator(1);
@@ -169,7 +170,7 @@ function renderBytes(
169170
tree: committed.value.root,
170171
layout: layoutRes.value,
171172
viewport,
172-
focusState: Object.freeze({ focusedId: null }),
173+
focusState: Object.freeze({ focusedId: stores?.focusedId ?? null }),
173174
builder,
174175
virtualListStore: stores?.virtualListStore,
175176
tableStore: stores?.tableStore,
@@ -183,6 +184,7 @@ function renderBytes(
183184

184185
describe("renderer regressions", () => {
185186
const noop = (..._args: readonly unknown[]) => undefined;
187+
const ATTR_INVERSE = 1 << 3;
186188

187189
test("box shadow renders shade glyphs when enabled", () => {
188190
const withShadow = parseInternedStrings(
@@ -252,6 +254,58 @@ describe("renderer regressions", () => {
252254
assert.equal(withFillCount > withoutFillCount, true);
253255
});
254256

257+
test("table single-selection suppresses focused inverse style when focused row is not selected", () => {
258+
const tableStore = createTableStateStore();
259+
tableStore.set("tbl-single-active", { focusedRowIndex: 1 });
260+
261+
const bytes = renderBytes(
262+
ui.table({
263+
id: "tbl-single-active",
264+
columns: [{ key: "name", header: "Name", width: 10 }],
265+
data: [
266+
{ id: "r0", name: "A" },
267+
{ id: "r1", name: "B" },
268+
],
269+
getRowKey: (row) => row.id,
270+
selectionMode: "single",
271+
selection: ["r0"],
272+
border: "none",
273+
}),
274+
{ cols: 40, rows: 8 },
275+
{ tableStore, focusedId: "tbl-single-active" },
276+
);
277+
278+
const styles = parseCommandStyles(bytes);
279+
const hasInverse = styles.some((s) => (s.attrs & ATTR_INVERSE) !== 0);
280+
assert.equal(hasInverse, false);
281+
});
282+
283+
test("table keeps focused inverse style when focused row is selected", () => {
284+
const tableStore = createTableStateStore();
285+
tableStore.set("tbl-single-active-selected", { focusedRowIndex: 1 });
286+
287+
const bytes = renderBytes(
288+
ui.table({
289+
id: "tbl-single-active-selected",
290+
columns: [{ key: "name", header: "Name", width: 10 }],
291+
data: [
292+
{ id: "r0", name: "A" },
293+
{ id: "r1", name: "B" },
294+
],
295+
getRowKey: (row) => row.id,
296+
selectionMode: "single",
297+
selection: ["r1"],
298+
border: "none",
299+
}),
300+
{ cols: 40, rows: 8 },
301+
{ tableStore, focusedId: "tbl-single-active-selected" },
302+
);
303+
304+
const styles = parseCommandStyles(bytes);
305+
const hasInverse = styles.some((s) => (s.attrs & ATTR_INVERSE) !== 0);
306+
assert.equal(hasInverse, true);
307+
});
308+
255309
test("table column overflow policies render ellipsis, middle, and clip deterministically", () => {
256310
const strings = parseInternedStrings(
257311
renderBytes(

0 commit comments

Comments
 (0)