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
3 changes: 2 additions & 1 deletion docs/widgets/layer.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ ui.layer({
| Prop | Type | Default | Description |
|------|------|---------|-------------|
| `id` | `string` | **required** | Layer identifier |
| `zIndex` | `number` | insertion order | Higher values render on top |
| `zIndex` | `number` | insertion order | Higher values render on top (clamped for deterministic ordering) |
| `backdrop` | `"none" \| "dim" \| "opaque"` | `"none"` | Backdrop behind the layer |
| `modal` | `boolean` | `false` | Block input to lower layers |
| `closeOnEscape` | `boolean` | `true` | Close on Escape key |
Expand All @@ -33,6 +33,7 @@ ui.layer({
## Notes

- Use [`Layers`](layers.md) to manage stacking order and modals.
- `zIndex` is truncated to an integer and clamped to the safe range (`±9,007,199,253`) so very large values don't break ordering.
- `BackdropStyle` values: `"none"`, `"dim"`, `"opaque"`.
- Backdrops are rendered behind the layer. `"dim"` uses a light shade pattern; `"opaque"` clears the area behind the layer to the theme background color.

Expand Down
85 changes: 85 additions & 0 deletions packages/core/src/app/__tests__/layerZIndexClamp.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import { assert, describe, test } from "@rezi-ui/testkit";
import type { RuntimeBackend } from "../../backend.js";
import { ui } from "../../index.js";
import { DEFAULT_TERMINAL_CAPS } from "../../terminalCaps.js";
import { defaultTheme } from "../../theme/defaultTheme.js";
import { WidgetRenderer } from "../widgetRenderer.js";

function noRenderHooks(): { enterRender: () => void; exitRender: () => void } {
return { enterRender: () => {}, exitRender: () => {} };
}

function createNoopBackend(): RuntimeBackend {
return {
start: async () => {},
stop: async () => {},
dispose: () => {},
requestFrame: async () => {},
pollEvents: async () =>
new Promise((_) => {
// Not used by WidgetRenderer unit-style tests.
}),
postUserEvent: () => {},
getCaps: async () => DEFAULT_TERMINAL_CAPS,
};
}

describe("layer zIndex clamping", () => {
test("ui.layer zIndex is clamped to a safe integer range", () => {
const backend = createNoopBackend();
const renderer = new WidgetRenderer<void>({
backend,
requestRender: () => {},
});

const view = () =>
ui.layers([
ui.text("base"),
ui.layer({
id: "a",
zIndex: Number.MAX_SAFE_INTEGER,
content: ui.text("A"),
}),
ui.layer({
id: "b",
zIndex: Number.MAX_SAFE_INTEGER,
content: ui.text("B"),
}),
]);

const res = renderer.submitFrame(
() => view(),
undefined,
{ cols: 20, rows: 6 },
defaultTheme,
noRenderHooks(),
);
assert.ok(res.ok);

const internal = renderer as unknown as {
layerRegistry: {
getAll: () => readonly Readonly<{ id: string; zIndex: number }>[];
};
};
const layers = internal.layerRegistry.getAll();

const a = layers.find((l) => l.id === "a");
const b = layers.find((l) => l.id === "b");
assert.ok(a);
assert.ok(b);

// Should not overflow safe integer range (JS precision would collapse ordering).
assert.ok(Number.isSafeInteger(a.zIndex));
assert.ok(Number.isSafeInteger(b.zIndex));
assert.ok(a.zIndex <= Number.MAX_SAFE_INTEGER);
assert.ok(b.zIndex <= Number.MAX_SAFE_INTEGER);

// Later children should render on top when clamped to the same base z-index.
assert.ok(a.zIndex < b.zIndex);

const scale = 1_000_000;
const maxBase = Math.floor((Number.MAX_SAFE_INTEGER - (scale - 1)) / scale);
assert.equal(Math.trunc(a.zIndex / scale), maxBase);
assert.equal(Math.trunc(b.zIndex / scale), maxBase);
});
});
21 changes: 19 additions & 2 deletions packages/core/src/app/widgetRenderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,23 @@ function isI32NonNegative(n: number): boolean {
return Number.isInteger(n) && n >= 0 && n <= 2147483647;
}

const LAYER_ZINDEX_SCALE = 1_000_000;
const LAYER_ZINDEX_MAX_BASE = Math.floor(
(Number.MAX_SAFE_INTEGER - (LAYER_ZINDEX_SCALE - 1)) / LAYER_ZINDEX_SCALE,
);

function clampInt(v: number, min: number, max: number): number {
if (v < min) return min;
if (v > max) return max;
return v;
}

function encodeLayerZIndex(baseZ: number | null, overlaySeq: number): number {
if (baseZ === null) return overlaySeq;
const clampedBaseZ = clampInt(baseZ, -LAYER_ZINDEX_MAX_BASE, LAYER_ZINDEX_MAX_BASE);
return clampedBaseZ * LAYER_ZINDEX_SCALE + overlaySeq;
}

const EMPTY_ROUTING: RoutingResult = Object.freeze({});
const EMPTY_STRING_ARRAY: readonly string[] = Object.freeze([]);
const ROUTE_RENDER: WidgetRoutingOutcome = Object.freeze({ needsRender: true });
Expand Down Expand Up @@ -2188,7 +2205,7 @@ export class WidgetRenderer<S> {
typeof p.zIndex === "number" && Number.isFinite(p.zIndex)
? Math.trunc(p.zIndex)
: null;
const zIndex = baseZ !== null ? baseZ * 1_000_000 + overlaySeq++ : overlaySeq++;
const zIndex = encodeLayerZIndex(baseZ, overlaySeq++);
const modal = p.modal === true;
const canClose = p.closeOnEscape !== false;
this._pooledCloseOnEscape.set(id, canClose);
Expand Down Expand Up @@ -2390,7 +2407,7 @@ export class WidgetRenderer<S> {
typeof p.zIndex === "number" && Number.isFinite(p.zIndex)
? Math.trunc(p.zIndex)
: null;
const zIndex = baseZ !== null ? baseZ * 1_000_000 + overlaySeq++ : overlaySeq++;
const zIndex = encodeLayerZIndex(baseZ, overlaySeq++);
const modal = p.modal === true;
const canClose = p.closeOnEscape !== false;
this._pooledCloseOnEscape.set(id, canClose);
Expand Down
5 changes: 4 additions & 1 deletion packages/core/src/widgets/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -619,7 +619,10 @@ export type DropdownProps = Readonly<{
export type LayerProps = Readonly<{
id: string;
key?: string;
/** Z-index for layer ordering (higher = on top). Default is insertion order. */
/**
* Z-index for layer ordering (higher = on top). Default is insertion order.
* Values are truncated to integers and clamped to `±9,007,199,253` for deterministic ordering.
*/
zIndex?: number;
/** Backdrop to render behind content. */
backdrop?: BackdropStyle;
Expand Down
Loading