diff --git a/docs/widgets/layer.md b/docs/widgets/layer.md index 12f926b3..bea8b047 100644 --- a/docs/widgets/layer.md +++ b/docs/widgets/layer.md @@ -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 | @@ -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. diff --git a/packages/core/src/app/__tests__/layerZIndexClamp.test.ts b/packages/core/src/app/__tests__/layerZIndexClamp.test.ts new file mode 100644 index 00000000..702e0528 --- /dev/null +++ b/packages/core/src/app/__tests__/layerZIndexClamp.test.ts @@ -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({ + 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); + }); +}); diff --git a/packages/core/src/app/widgetRenderer.ts b/packages/core/src/app/widgetRenderer.ts index 5adb6910..21bfc6f6 100644 --- a/packages/core/src/app/widgetRenderer.ts +++ b/packages/core/src/app/widgetRenderer.ts @@ -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 }); @@ -2188,7 +2205,7 @@ export class WidgetRenderer { 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); @@ -2390,7 +2407,7 @@ export class WidgetRenderer { 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); diff --git a/packages/core/src/widgets/types.ts b/packages/core/src/widgets/types.ts index f93422ff..48ae45ba 100644 --- a/packages/core/src/widgets/types.ts +++ b/packages/core/src/widgets/types.ts @@ -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;