Skip to content

Commit ad24d3a

Browse files
core: add root padding, responsive viewport, and layout diagnostics (#121)
* core: add root padding, responsive viewport hooks, and layout diagnostics * runtime: plumb viewport snapshot through composite widget context * core: fix viewport resize commit gating and full constraints * core: make layout warnings opt-in in dev mode
1 parent 5656c49 commit ad24d3a

File tree

19 files changed

+618
-69
lines changed

19 files changed

+618
-69
lines changed

docs/guide/debugging.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,18 @@ Overlay sections include:
7474
- Frame timing rows (`drawlistBytes`, `diffBytesEmitted`, `usDrawlist`, `usDiff`, `usWrite`)
7575
- Event routing breadcrumbs (last event kind, keybindings vs widget routing path, last action)
7676

77+
## Quick Layout Overlay
78+
79+
For quick in-app layout diagnostics (without the full inspector), toggle:
80+
81+
```typescript
82+
app.debugLayout(true); // enable
83+
app.debugLayout(false); // disable
84+
```
85+
86+
When enabled, Rezi renders a live layout summary overlay with widget ids and
87+
resolved rects.
88+
7789
## Export Debug Bundle
7890

7991
Exporting a debug bundle is deterministic for the same debug state and options.

docs/widgets/stack.md

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -17,16 +17,18 @@ ui.column({ gap: 1, p: 1 }, [ui.text("A"), ui.text("B")]);
1717
|---|---|---|---|
1818
| `id` | `string` | - | Optional identity (not focusable) |
1919
| `key` | `string` | - | Reconciliation key |
20-
| `gap` | `SpacingValue` | - | Spacing between children |
20+
| `gap` | `SpacingValue` | `1` | Spacing between children |
21+
| `reverse` | `boolean` | `false` | Reverse child visual order |
2122
| `align` | `"start" \| "center" \| "end" \| "stretch"` | `"start"` | Cross-axis alignment |
22-
| `justify` | `"start" \| "end" \| "center" \| "between" \| "around" \| "evenly"` | `"start"` | Main-axis distribution |
23+
| `justify` | `"start" \| "end" \| "center" \| "between" \| "around" \| "evenly"` (also CSS aliases: `"space-between"`, `"space-around"`, `"space-evenly"`) | `"start"` | Main-axis distribution |
2324
| `p`, `px`, `py`, `pt`, `pr`, `pb`, `pl` | `SpacingValue` | - | Padding props |
2425
| `m`, `mx`, `my` | `SpacingValue` | - | Margin props |
25-
| `width`, `height` | `number \| \"auto\" \| \"${number}%\"` | - | Size constraints |
26+
| `width`, `height` | `number \| \"auto\" \| \"full\" \| \"${number}%\"` | - | Size constraints |
2627
| `minWidth`, `maxWidth`, `minHeight`, `maxHeight` | `number` | - | Size bounds (cells) |
2728
| `flex` | `number` | - | Main-axis flex in parent stack |
2829
| `aspectRatio` | `number` | - | Enforce width/height ratio |
29-
| `style` | `TextStyle` | - | Inherited style for children; bg fills rect |
30+
| `style` | `TextStyle` | - | Container style override; bg fills rect |
31+
| `inheritStyle` | `TextStyle` | - | Descendant default style without fill |
3032

3133
## Examples
3234

@@ -57,10 +59,10 @@ ui.column({ height: 6, justify: "between" }, [
5759

5860
Rezi also includes:
5961

60-
- `ui.hstack(...)` — shorthand row with default `gap: 0`
61-
- `ui.vstack(...)` — shorthand column with default `gap: 0`
62-
- `ui.spacedHStack(...)` — shorthand row with default `gap: 1`
63-
- `ui.spacedVStack(...)` — shorthand column with default `gap: 1`
62+
- `ui.hstack(...)` — shorthand row with default `gap: 1`
63+
- `ui.vstack(...)` — shorthand column with default `gap: 1`
64+
- `ui.spacedHStack(...)` — shorthand row with default `gap: 1` (alias)
65+
- `ui.spacedVStack(...)` — shorthand column with default `gap: 1` (alias)
6466

6567
## Related
6668

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

Lines changed: 54 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { assert, test } from "@rezi-ui/testkit";
2-
import { ui } from "../../index.js";
2+
import { defineWidget, ui } from "../../index.js";
33
import { createApp } from "../createApp.js";
44
import { encodeZrevBatchV1, flushMicrotasks, makeBackendBatch } from "./helpers.js";
55
import { StubBackend } from "./stubBackend.js";
@@ -108,6 +108,59 @@ test("resize (DIRTY_LAYOUT) re-layouts without calling view", async () => {
108108
assert.equal(ctx.getViewCalls(), 1, "view NOT re-invoked for layout-only dirty");
109109
});
110110

111+
test("debugLayout toggle marks view dirty and re-runs commit path", async () => {
112+
const ctx = setup();
113+
await bootstrap(ctx);
114+
115+
ctx.app.debugLayout(true);
116+
await flushMicrotasks(10);
117+
118+
assert.equal(ctx.backend.requestedFrames.length, 2, "debug toggle submitted a frame");
119+
assert.equal(ctx.getViewCalls(), 2, "debug toggle re-invoked view immediately");
120+
});
121+
122+
test("resize re-invokes view when composite widgets read viewport", async () => {
123+
const backend = new StubBackend();
124+
let viewCalls = 0;
125+
const app = createApp({ backend, initialState: {} });
126+
127+
const ViewportAware = defineWidget<{ key?: string }>((_props, ctx) => {
128+
const vp = ctx.useViewport?.();
129+
return ui.text(`vp:${String(vp?.width ?? 0)}x${String(vp?.height ?? 0)}`);
130+
});
131+
132+
app.view(() => {
133+
viewCalls++;
134+
return ui.column({}, [ViewportAware({ key: "vp" })]);
135+
});
136+
137+
await app.start();
138+
backend.pushBatch(
139+
makeBackendBatch({
140+
bytes: encodeZrevBatchV1({
141+
events: [{ kind: "resize", timeMs: 1, cols: 40, rows: 10 }],
142+
}),
143+
}),
144+
);
145+
await flushMicrotasks(10);
146+
assert.equal(backend.requestedFrames.length, 1);
147+
assert.equal(viewCalls, 1);
148+
backend.resolveNextFrame();
149+
await flushMicrotasks(5);
150+
151+
backend.pushBatch(
152+
makeBackendBatch({
153+
bytes: encodeZrevBatchV1({
154+
events: [{ kind: "resize", timeMs: 2, cols: 80, rows: 20 }],
155+
}),
156+
}),
157+
);
158+
await flushMicrotasks(10);
159+
160+
assert.equal(backend.requestedFrames.length, 2, "second resize submitted a commit frame");
161+
assert.equal(viewCalls, 2, "viewport-aware composite forced view re-run on resize");
162+
});
163+
111164
test("first frame always runs full pipeline regardless of dirty flags", async () => {
112165
const ctx = setup();
113166
await ctx.app.start();

packages/core/src/app/createApp.ts

Lines changed: 71 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,11 @@ import {
5757
routeKeyEvent,
5858
setMode,
5959
} from "../keybindings/index.js";
60+
import {
61+
type ResponsiveBreakpointThresholds,
62+
normalizeBreakpointThresholds,
63+
} from "../layout/responsive.js";
64+
import type { Rect } from "../layout/types.js";
6065
import { PERF_ENABLED, perfMarkEnd, perfMarkStart, perfNow, perfRecord } from "../perf/perf.js";
6166
import type { EventTimeUnwrapState } from "../protocol/types.js";
6267
import { parseEventBatchV1 } from "../protocol/zrev_v1.js";
@@ -71,6 +76,8 @@ import { defaultTheme } from "../theme/defaultTheme.js";
7176
import { coerceToLegacyTheme } from "../theme/interop.js";
7277
import type { Theme } from "../theme/theme.js";
7378
import type { ThemeDefinition } from "../theme/tokens.js";
79+
import type { VNode } from "../widgets/types.js";
80+
import { ui } from "../widgets/ui.js";
7481
import { RawRenderer } from "./rawRenderer.js";
7582
import {
7683
type RuntimeBreadcrumbAction,
@@ -95,6 +102,8 @@ type ResolvedAppConfig = Readonly<{
95102
fpsCap: number;
96103
maxEventBytes: number;
97104
maxDrawlistBytes: number;
105+
rootPadding: number;
106+
breakpointThresholds: ResponsiveBreakpointThresholds;
98107
useV2Cursor: boolean;
99108
drawlistValidateParams: boolean;
100109
drawlistReuseOutputBuffer: boolean;
@@ -114,6 +123,8 @@ const DEFAULT_CONFIG: ResolvedAppConfig = Object.freeze({
114123
fpsCap: 60,
115124
maxEventBytes: 1 << 20 /* 1 MiB */,
116125
maxDrawlistBytes: 2 << 20 /* 2 MiB */,
126+
rootPadding: 0,
127+
breakpointThresholds: normalizeBreakpointThresholds(undefined),
117128
useV2Cursor: false,
118129
drawlistValidateParams: true,
119130
drawlistReuseOutputBuffer: true,
@@ -232,6 +243,29 @@ async function loadTerminalProfile(backend: RuntimeBackend): Promise<TerminalPro
232243
}
233244
}
234245

246+
function buildLayoutDebugOverlay(rectById: ReadonlyMap<string, Rect>): VNode | null {
247+
if (rectById.size === 0) return null;
248+
const rows = [...rectById.entries()]
249+
.sort((a, b) => a[0].localeCompare(b[0]))
250+
.slice(0, 18)
251+
.map(([id, rect]) =>
252+
ui.text(`${id} ${String(rect.x)},${String(rect.y)} ${String(rect.w)}x${String(rect.h)}`),
253+
);
254+
const panel = ui.box({ border: "single", title: `Layout (${String(rectById.size)})`, p: 1 }, [
255+
ui.column({ gap: 0 }, rows),
256+
]);
257+
return ui.layer({
258+
id: "rezi.layout.debug.overlay",
259+
zIndex: 2_000_000_000,
260+
modal: false,
261+
backdrop: "none",
262+
closeOnEscape: false,
263+
content: ui.column({ width: "100%", height: "100%", justify: "end", p: 1 }, [
264+
ui.row({ width: "100%", justify: "start" }, [panel]),
265+
]),
266+
});
267+
}
268+
235269
/** Apply defaults to user-provided config, validating all values. */
236270
export function resolveAppConfig(config: AppConfig | undefined): ResolvedAppConfig {
237271
if (!config) return DEFAULT_CONFIG;
@@ -247,6 +281,11 @@ export function resolveAppConfig(config: AppConfig | undefined): ResolvedAppConf
247281
config.maxDrawlistBytes === undefined
248282
? DEFAULT_CONFIG.maxDrawlistBytes
249283
: requirePositiveInt("maxDrawlistBytes", config.maxDrawlistBytes);
284+
const rootPadding =
285+
config.rootPadding === undefined
286+
? DEFAULT_CONFIG.rootPadding
287+
: requireNonNegativeInt("rootPadding", config.rootPadding);
288+
const breakpointThresholds = normalizeBreakpointThresholds(config.breakpoints);
250289
const useV2Cursor = config.useV2Cursor === true;
251290
const drawlistValidateParams =
252291
config.drawlistValidateParams === undefined
@@ -276,6 +315,8 @@ export function resolveAppConfig(config: AppConfig | undefined): ResolvedAppConf
276315
fpsCap,
277316
maxEventBytes,
278317
maxDrawlistBytes,
318+
rootPadding,
319+
breakpointThresholds,
279320
useV2Cursor,
280321
drawlistValidateParams,
281322
drawlistReuseOutputBuffer,
@@ -445,6 +486,7 @@ export function createApp<S>(opts: CreateAppStateOptions<S> | CreateAppRoutesOnl
445486
let mode: Mode | null = null;
446487
let drawFn: DrawFn | null = null;
447488
let viewFn: ViewFn<S> | null = null;
489+
let debugLayoutEnabled = false;
448490

449491
const hasInitialState = "initialState" in opts;
450492
let committedState: S = hasInitialState ? (opts.initialState as S) : (Object.freeze({}) as S);
@@ -557,6 +599,8 @@ export function createApp<S>(opts: CreateAppStateOptions<S> | CreateAppRoutesOnl
557599
backend,
558600
drawlistVersion,
559601
maxDrawlistBytes: config.maxDrawlistBytes,
602+
rootPadding: config.rootPadding,
603+
breakpointThresholds: config.breakpointThresholds,
560604
terminalProfile,
561605
useV2Cursor: config.useV2Cursor,
562606
...(opts.config?.drawlistValidateParams === undefined
@@ -846,7 +890,12 @@ export function createApp<S>(opts: CreateAppStateOptions<S> | CreateAppRoutesOnl
846890
const prev = viewport;
847891
if (prev === null || prev.cols !== ev.cols || prev.rows !== ev.rows) {
848892
viewport = Object.freeze({ cols: ev.cols, rows: ev.rows });
849-
markDirty(DIRTY_LAYOUT);
893+
if (widgetRenderer.hasViewportAwareComposites()) {
894+
widgetRenderer.invalidateCompositeWidgets();
895+
markDirty(DIRTY_LAYOUT | DIRTY_VIEW);
896+
} else {
897+
markDirty(DIRTY_LAYOUT);
898+
}
850899
}
851900
}
852901
if (ev.kind === "tick" && mode === "widget") {
@@ -1177,7 +1226,15 @@ export function createApp<S>(opts: CreateAppStateOptions<S> | CreateAppRoutesOnl
11771226

11781227
const renderStart = perfNow();
11791228
const submitToken = perfMarkStart("submit_frame");
1180-
const res = widgetRenderer.submitFrame(vf, snapshot, viewport, theme, hooks, plan);
1229+
const frameView: ViewFn<S> = debugLayoutEnabled
1230+
? (state) => {
1231+
const root = vf(state);
1232+
const overlay = buildLayoutDebugOverlay(widgetRenderer.getRectByIdIndex());
1233+
if (!overlay) return root;
1234+
return ui.layers([root, overlay]);
1235+
}
1236+
: vf;
1237+
const res = widgetRenderer.submitFrame(frameView, snapshot, viewport, theme, hooks, plan);
11811238
perfMarkEnd("submit_frame", submitToken);
11821239
if (!res.ok) {
11831240
fatalNowOrEnqueue(res.code, res.detail);
@@ -1363,6 +1420,18 @@ export function createApp<S>(opts: CreateAppStateOptions<S> | CreateAppRoutesOnl
13631420
requestRenderFromRenderer();
13641421
},
13651422

1423+
debugLayout(enabled?: boolean): boolean {
1424+
assertOperational("debugLayout");
1425+
if (mode === "raw") {
1426+
throwCode("ZRUI_MODE_CONFLICT", "debugLayout: not available in draw mode");
1427+
}
1428+
const next = enabled === undefined ? !debugLayoutEnabled : enabled === true;
1429+
if (next === debugLayoutEnabled) return debugLayoutEnabled;
1430+
debugLayoutEnabled = next;
1431+
requestViewFromRenderer();
1432+
return debugLayoutEnabled;
1433+
},
1434+
13661435
start(): Promise<void> {
13671436
assertOperational("start");
13681437
assertNotReentrant("start");

packages/core/src/app/inspectorOverlayHelper.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -253,6 +253,9 @@ export function createAppWithInspectorOverlay<S>(
253253
lastThemeInput = theme;
254254
app.setTheme(theme);
255255
},
256+
debugLayout(enabled?: boolean): boolean {
257+
return app.debugLayout(enabled);
258+
},
256259
start(): Promise<void> {
257260
return app.start();
258261
},

0 commit comments

Comments
 (0)