diff --git a/docs/guide/layout.md b/docs/guide/layout.md index 8024697d..82198e4f 100644 --- a/docs/guide/layout.md +++ b/docs/guide/layout.md @@ -64,10 +64,12 @@ Container widgets accept spacing props (values are **cells**, or named keys like ### Padding (inside) - `p` (all), `px`/`py`, `pt`/`pr`/`pb`/`pl` +- Must be non-negative (`>= 0`) when provided as numbers. ### Margin (outside) -- `m` (all), `mx`/`my` +- `m` (all), `mx`/`my`, `mt`/`mr`/`mb`/`ml` +- May be negative (signed int32), which allows intentional overlap. Example: @@ -83,6 +85,38 @@ Notes: - Padding reduces the available content area for children. - Margin affects how the widget is positioned inside its parent stack. +- Negative margins can move a child outside the parent's origin and can cause overlap. + +### Negative margin examples + +Example 1: overlap siblings in a row + +```typescript +import { ui } from "@rezi-ui/core"; + +ui.row({}, [ + ui.box({ border: "none", width: 12, p: 1 }, [ui.text("Base")]), + ui.box({ border: "rounded", width: 10, ml: -6, p: 1 }, [ui.text("Overlay")]), +]); +``` + +Example 2: pull content upward in a column + +```typescript +import { ui } from "@rezi-ui/core"; + +ui.column({ gap: 1 }, [ + ui.box({ border: "single", p: 1 }, [ui.text("Header block")]), + ui.box({ border: "rounded", mt: -1, p: 1 }, [ui.text("Raised panel")]), +]); +``` + +Rules summary: + +- `m/mx/my/mt/mr/mb/ml` accept signed int32 numbers (and spacing keys). +- `p/px/py/pt/pr/pb/pl`, legacy `pad`, and `gap` must stay non-negative. +- Computed `w/h` are always clamped to non-negative values. +- Computed `x/y` can be negative when margins pull widgets outward. ## Alignment @@ -179,6 +213,22 @@ ui.box({ width: 20, border: "single", p: 1 }, [ ]); ``` +## Overlap hit-testing + +When widgets overlap, input routing is deterministic: + +- Layers: higher `zIndex` wins. +- Layers with equal `zIndex`: later registration wins. +- Regular layout tree (no layer distinction): the last focusable widget in depth-first preorder tree order wins. + - This means later siblings win ties. + +## Gotchas + +- Negative margins can make `x/y` negative; this is expected and supported. +- Large negative margins can significantly increase overlap. Keep fixtures for critical layouts. +- `pad` and `gap` do not allow negatives; use margins when you need pull/overlap effects. +- In overlap regions, tie-breaks follow deterministic order, not visual styling alone. + ## Related - [Concepts](concepts.md) - How VNodes and reconciliation work diff --git a/packages/core/src/layout/__tests__/constraints.golden.test.ts b/packages/core/src/layout/__tests__/constraints.golden.test.ts index 8bf68c3e..5d6ece27 100644 --- a/packages/core/src/layout/__tests__/constraints.golden.test.ts +++ b/packages/core/src/layout/__tests__/constraints.golden.test.ts @@ -9,6 +9,12 @@ function mustLayout(node: VNode, maxW: number, maxH: number): LayoutTree { return res.value; } +function assertNonNegativeRectTree(node: LayoutTree): void { + assert.ok(node.rect.w >= 0, `width must be non-negative, got ${node.rect.w}`); + assert.ok(node.rect.h >= 0, `height must be non-negative, got ${node.rect.h}`); + for (const child of node.children) assertNonNegativeRectTree(child); +} + describe("constraints (deterministic) - golden cases", () => { test("flex:1 + flex:2 in row of width 90 => 30 + 60", () => { const tree = ui.row({}, [ @@ -125,4 +131,30 @@ describe("constraints (deterministic) - golden cases", () => { const out = mustLayout(tree, 10, 10); assert.deepEqual(out.rect, { x: 1, y: 1, w: 4, h: 2 }); }); + + test("aggressive negative row margins preserve non-negative computed sizes", () => { + const tree = ui.row({}, [ + ui.box({ border: "none", width: 1, height: 1, ml: -3, mr: -3, mt: -2, mb: -2 }, []), + ui.box({ border: "none", width: 2, height: 1 }, []), + ]); + + const out = mustLayout(tree, 5, 2); + assertNonNegativeRectTree(out); + assert.deepEqual(out.rect, { x: 0, y: 0, w: 2, h: 1 }); + assert.deepEqual(out.children[0]?.rect, { x: -3, y: -2, w: 6, h: 4 }); + assert.deepEqual(out.children[1]?.rect, { x: 0, y: 0, w: 2, h: 1 }); + }); + + test("aggressive negative column margins preserve non-negative computed sizes", () => { + const tree = ui.column({}, [ + ui.box({ border: "none", width: 2, height: 1, ml: -2, mr: -2, mt: -2, mb: -2 }, []), + ui.box({ border: "none", width: 1, height: 1 }, []), + ]); + + const out = mustLayout(tree, 4, 3); + assertNonNegativeRectTree(out); + assert.deepEqual(out.rect, { x: 0, y: 0, w: 1, h: 1 }); + assert.deepEqual(out.children[0]?.rect, { x: -2, y: -2, w: 4, h: 4 }); + assert.deepEqual(out.children[1]?.rect, { x: 0, y: 0, w: 1, h: 1 }); + }); }); diff --git a/packages/core/src/layout/__tests__/layout.golden.test.ts b/packages/core/src/layout/__tests__/layout.golden.test.ts index d1de0967..a473f4e6 100644 --- a/packages/core/src/layout/__tests__/layout.golden.test.ts +++ b/packages/core/src/layout/__tests__/layout.golden.test.ts @@ -75,6 +75,9 @@ function assertRectsMatch( lNode: LayoutTree, rects: Record, ): void { + assert.ok(lNode.rect.w >= 0, `width must be non-negative for ref=${fNode.ref}`); + assert.ok(lNode.rect.h >= 0, `height must be non-negative for ref=${fNode.ref}`); + const expected = rects[fNode.ref]; assert.ok(expected !== undefined, `missing expected rect for ref=${fNode.ref}`); assert.deepEqual(lNode.rect, expected, `rect mismatch for ref=${fNode.ref}`); diff --git a/packages/core/src/layout/__tests__/spacing.test.ts b/packages/core/src/layout/__tests__/spacing.test.ts index d1895842..877e568d 100644 --- a/packages/core/src/layout/__tests__/spacing.test.ts +++ b/packages/core/src/layout/__tests__/spacing.test.ts @@ -36,4 +36,47 @@ describe("spacing", () => { assert.equal(res.value.px, 3); assert.equal(res.value.my, 1); }); + + test("validateBoxProps: accepts signed int32 margins", () => { + const res = validateBoxProps({ + m: -1, + mx: -2, + my: -3, + mt: -4, + mr: -5, + mb: -6, + ml: -7, + }); + assert.equal(res.ok, true); + if (!res.ok) throw new Error("expected ok"); + assert.equal(res.value.m, -1); + assert.equal(res.value.mx, -2); + assert.equal(res.value.my, -3); + assert.equal(res.value.mt, -4); + assert.equal(res.value.mr, -5); + assert.equal(res.value.mb, -6); + assert.equal(res.value.ml, -7); + }); + + test("validateBoxProps: rejects negative padding with deterministic detail", () => { + const pRes = validateBoxProps({ p: -1 }); + assert.equal(pRes.ok, false); + if (pRes.ok) throw new Error("expected fatal"); + assert.equal(pRes.fatal.code, "ZRUI_INVALID_PROPS"); + assert.equal(pRes.fatal.detail, "box.p must be an int32 >= 0"); + + const padRes = validateBoxProps({ pad: -1 }); + assert.equal(padRes.ok, false); + if (padRes.ok) throw new Error("expected fatal"); + assert.equal(padRes.fatal.code, "ZRUI_INVALID_PROPS"); + assert.equal(padRes.fatal.detail, "box.pad must be an int32 >= 0"); + }); + + test("validateStackProps: rejects negative gap with deterministic detail", () => { + const res = validateStackProps("row", { gap: -1 }); + assert.equal(res.ok, false); + if (res.ok) throw new Error("expected fatal"); + assert.equal(res.fatal.code, "ZRUI_INVALID_PROPS"); + assert.equal(res.fatal.detail, "row.gap must be an int32 >= 0"); + }); }); diff --git a/packages/core/src/layout/hitTest.ts b/packages/core/src/layout/hitTest.ts index dbe55c9d..c008142f 100644 --- a/packages/core/src/layout/hitTest.ts +++ b/packages/core/src/layout/hitTest.ts @@ -5,8 +5,12 @@ * position. Used for mouse-based focus changes and click routing. * * Tie-break rule: When multiple focusable widgets overlap at a point, the - * LAST one in depth-first preorder traversal wins (typically the "topmost" - * visually, though stacking is logical not visual in terminal UI). + * LAST node in depth-first preorder traversal wins. + * + * Explicit direction: + * - Children are traversed left-to-right (tree order). + * - Later tree-order nodes override earlier ones. + * - Among siblings, later siblings win ties. * * @see docs/guide/layout.md */ @@ -70,7 +74,7 @@ function isFocusable(v: VNode): string | null { * Hit test focusable widgets (enabled interactive ids). * * Tie-break is deterministic: if multiple focusable widgets contain the point, - * the winner is the LAST focusable widget in depth-first preorder traversal order. + * the winner is the LAST focusable widget in depth-first preorder tree order. */ export function hitTestFocusable( tree: VNode, diff --git a/packages/core/src/layout/validateProps.ts b/packages/core/src/layout/validateProps.ts index 937118e0..cc3753ef 100644 --- a/packages/core/src/layout/validateProps.ts +++ b/packages/core/src/layout/validateProps.ts @@ -7,7 +7,8 @@ * * Validation rules: * - Numeric props must be int32 >= 0 (pad, gap, size) - * - Spacing props accept int32 >= 0 OR spacing keys ("sm", "md", etc.) + * - Padding props accept int32 >= 0 OR spacing keys ("sm", "md", etc.) + * - Margin props accept signed int32 OR spacing keys ("sm", "md", etc.) * - String props must be non-empty where required (id) * - Enum props must be valid values (align, border) * - Boolean props default to false if undefined @@ -157,6 +158,7 @@ function invalid(detail: string): LayoutResult { return { ok: false, fatal: { code: "ZRUI_INVALID_PROPS", detail } }; } +const I32_MIN = -2147483648; const I32_MAX = 2147483647; function requireIntNonNegative( @@ -172,6 +174,19 @@ function requireIntNonNegative( return { ok: true, value: value as number }; } +function requireIntSigned( + kind: string, + name: string, + v: unknown, + def: number, +): LayoutResult { + const value = v === undefined ? def : v; + if (typeof value !== "number" || !Number.isInteger(value) || value < I32_MIN || value > I32_MAX) { + return invalid(`${kind}.${name} must be an int32`); + } + return { ok: true, value: value as number }; +} + function requireSpacingIntNonNegative( kind: string, name: string, @@ -190,6 +205,24 @@ function requireSpacingIntNonNegative( return requireIntNonNegative(kind, name, value, def); } +function requireSpacingIntSigned( + kind: string, + name: string, + v: unknown, + def: number, +): LayoutResult { + const value = v === undefined ? def : v; + if (typeof value === "string") { + if (!isSpacingKey(value)) { + return invalid( + `${kind}.${name} must be an int32 or a spacing key ("none" | "xs" | "sm" | "md" | "lg" | "xl" | "2xl")`, + ); + } + return { ok: true, value: SPACING_SCALE[value] }; + } + return requireIntSigned(kind, name, value, def); +} + function requireOptionalIntNonNegative( kind: string, name: string, @@ -280,25 +313,11 @@ function validateSpacingProps( kind: string, p: Record, ): LayoutResult { - const keys = [ - "p", - "px", - "py", - "pt", - "pb", - "pl", - "pr", - "m", - "mx", - "my", - "mt", - "mr", - "mb", - "ml", - ] as const; + const paddingKeys = ["p", "px", "py", "pt", "pb", "pl", "pr"] as const; + const marginKeys = ["m", "mx", "my", "mt", "mr", "mb", "ml"] as const; const out: Record = {}; - for (const k of keys) { + for (const k of paddingKeys) { const v = p[k]; if (v === undefined) continue; const r = requireSpacingIntNonNegative(kind, k, v, 0); @@ -306,6 +325,14 @@ function validateSpacingProps( out[k] = r.value; } + for (const k of marginKeys) { + const v = p[k]; + if (v === undefined) continue; + const r = requireSpacingIntSigned(kind, k, v, 0); + if (!r.ok) return r; + out[k] = r.value; + } + return { ok: true, value: out as unknown as ValidatedSpacingProps }; } diff --git a/packages/core/src/runtime/__tests__/mouseRouting.golden.test.ts b/packages/core/src/runtime/__tests__/mouseRouting.golden.test.ts index 7bca9d03..9ae4ab8b 100644 --- a/packages/core/src/runtime/__tests__/mouseRouting.golden.test.ts +++ b/packages/core/src/runtime/__tests__/mouseRouting.golden.test.ts @@ -1,5 +1,9 @@ import { assert, describe, readFixture, test } from "@rezi-ui/testkit"; import type { ZrevEvent } from "../../events.js"; +import type { VNode } from "../../index.js"; +import { hitTestFocusable } from "../../layout/hitTest.js"; +import type { LayoutTree } from "../../layout/layout.js"; +import type { Rect } from "../../layout/types.js"; import { type FocusState, applyPendingFocusChange, requestPendingFocusChange } from "../focus.js"; import { type RoutedAction, routeMouse } from "../router.js"; @@ -32,6 +36,100 @@ async function loadFixture(): Promise { return JSON.parse(json) as MouseRoutingFixture; } +type FixtureRect = Readonly<{ x: number; y: number; w: number; h: number }>; + +type FixtureVNode = + | Readonly<{ ref: string; kind: "text"; text: string; props?: unknown }> + | Readonly<{ ref: string; kind: "spacer"; props?: unknown }> + | Readonly<{ ref: string; kind: "button"; props: unknown }> + | Readonly<{ + ref: string; + kind: "row" | "column" | "box"; + props?: unknown; + children?: readonly FixtureVNode[]; + }>; + +type OverlapExpectedStep = Readonly<{ + hitTestTargetId: string | null; + focusedId: string | null; + pressedId: string | null; + emittedKinds: readonly ("engine" | "action")[]; + action?: RoutedAction; +}>; + +type OverlapFixtureStep = Readonly<{ + event: ZrevEvent; + expected: OverlapExpectedStep; +}>; + +type MouseRoutingOverlapCase = Readonly<{ + name: string; + tree: FixtureVNode; + layoutRects: Record; + enabledById: Readonly>; + initialFocusedId: string | null; + initialPressedId: string | null; + steps: readonly OverlapFixtureStep[]; +}>; + +type MouseRoutingOverlapFixture = Readonly<{ + schemaVersion: 1; + cases: readonly MouseRoutingOverlapCase[]; +}>; + +async function loadOverlapFixture(): Promise { + const bytes = await readFixture("routing/mouse_routing_overlap.json"); + const json = new TextDecoder().decode(bytes); + return JSON.parse(json) as MouseRoutingOverlapFixture; +} + +function toVNode(node: FixtureVNode): VNode { + const props = (node as { props?: unknown }).props ?? {}; + switch (node.kind) { + case "text": + return { kind: "text", text: node.text, props: props as never }; + case "spacer": + return { kind: "spacer", props: props as never }; + case "button": + return { kind: "button", props: node.props as never }; + case "row": + case "column": + case "box": + return { + kind: node.kind, + props: props as never, + children: Object.freeze((node.children ?? []).map(toVNode)), + }; + } +} + +function buildLayoutTree( + node: FixtureVNode, + vnode: VNode, + rects: Record, +): LayoutTree { + const rect = rects[node.ref]; + assert.ok(rect !== undefined, `missing layoutRects entry for ref=${node.ref}`); + + const fChildren = (node as { children?: readonly FixtureVNode[] }).children ?? []; + const vChildren = (vnode as { children?: readonly VNode[] }).children ?? []; + assert.equal( + vChildren.length, + fChildren.length, + `fixture/vnode child mismatch for ref=${node.ref}`, + ); + + const children: LayoutTree[] = []; + for (let i = 0; i < fChildren.length; i++) { + const fc = fChildren[i]; + const vc = vChildren[i]; + if (!fc || !vc) continue; + children.push(buildLayoutTree(fc, vc, rects)); + } + + return { vnode, rect: rect as Rect, children: Object.freeze(children) }; +} + function stepEmittedKinds(action: RoutedAction | undefined): ("engine" | "action")[] { const kinds: ("engine" | "action")[] = ["engine"]; if (action) kinds.push("action"); @@ -73,4 +171,47 @@ describe("routing (locked) - MOUSE golden fixtures", () => { } } }); + + test("mouse_routing_overlap.json", async () => { + const f = await loadOverlapFixture(); + assert.equal(f.schemaVersion, 1); + + for (const c of f.cases) { + const vnode = toVNode(c.tree); + const layoutTree = buildLayoutTree(c.tree, vnode, c.layoutRects); + let focusState: FocusState = Object.freeze({ focusedId: c.initialFocusedId }); + let pressedId: string | null = c.initialPressedId; + const enabledById = new Map(Object.entries(c.enabledById)); + + for (const s of c.steps) { + const hitTestTargetId = + s.event.kind === "mouse" + ? hitTestFocusable(vnode, layoutTree, s.event.x, s.event.y) + : null; + assert.equal(hitTestTargetId, s.expected.hitTestTargetId, `${c.name}: hitTestTargetId`); + + const res = routeMouse(s.event, { + pressedId, + hitTestTargetId, + enabledById, + }); + + const emittedKinds = stepEmittedKinds(res.action); + assert.deepEqual(emittedKinds, s.expected.emittedKinds, `${c.name}: emittedKinds`); + + if (s.expected.action) assert.deepEqual(res.action, s.expected.action, `${c.name}: action`); + else assert.equal(res.action, undefined, `${c.name}: action`); + + if (res.nextPressedId !== undefined) pressedId = res.nextPressedId; + + if (res.nextFocusedId !== undefined) { + focusState = requestPendingFocusChange(focusState, res.nextFocusedId); + } + focusState = applyPendingFocusChange(focusState); + + assert.equal(focusState.focusedId, s.expected.focusedId, `${c.name}: focusedId`); + assert.equal(pressedId, s.expected.pressedId, `${c.name}: pressedId`); + } + } + }); }); diff --git a/packages/core/src/runtime/layers.ts b/packages/core/src/runtime/layers.ts index c844cfef..da28f663 100644 --- a/packages/core/src/runtime/layers.ts +++ b/packages/core/src/runtime/layers.ts @@ -7,6 +7,7 @@ * * Layer concepts: * - Z-order: Higher z-index renders on top and receives input first + * - Tie-break: Equal z-index uses registration order (later registration on top) * - Backdrop: Optional overlay that can dim or block lower layers * - Modal: Blocks input to layers below * - Focus trapping: Modal layers trap focus within their bounds @@ -45,6 +46,7 @@ export type Layer = Readonly<{ type MutableLayer = { id: string; zIndex: number; + registrationOrder: number; rect: Rect; backdrop: BackdropStyle; modal: boolean; @@ -73,7 +75,7 @@ export type LayerRegistry = Readonly<{ unregister: (id: string) => void; /** Get a layer by ID. */ get: (id: string) => Layer | undefined; - /** Get all layers sorted by z-index (lowest first). */ + /** Get all layers sorted by z-index, then registration order (lowest first). */ getAll: () => readonly Layer[]; /** Get the topmost layer. */ getTopmost: () => Layer | undefined; @@ -93,6 +95,7 @@ let nextAutoZIndex = 1000; */ export function createLayerRegistry(): LayerRegistry { const layers = new Map(); + let nextRegistrationOrder = 0; let cacheDirty = true; let sortedSnapshot: readonly Layer[] = Object.freeze([]); let topmostSnapshot: Layer | undefined; @@ -104,14 +107,25 @@ export function createLayerRegistry(): LayerRegistry { function refreshSnapshots(): void { if (!cacheDirty) return; - const sorted = Array.from(layers.values()).sort((a, b) => a.zIndex - b.zIndex); + const sorted = Array.from(layers.values()).sort((a, b) => { + if (a.zIndex !== b.zIndex) return a.zIndex - b.zIndex; + return a.registrationOrder - b.registrationOrder; + }); const nextSorted: Layer[] = []; let nextTopmost: Layer | undefined; let nextTopmostModal: Layer | undefined; for (let i = 0; i < sorted.length; i++) { const layer = sorted[i]; if (!layer) continue; - const snapshot = Object.freeze({ ...layer }); + const snapshot: Layer = Object.freeze({ + id: layer.id, + zIndex: layer.zIndex, + rect: layer.rect, + backdrop: layer.backdrop, + modal: layer.modal, + closeOnEscape: layer.closeOnEscape, + onClose: layer.onClose, + }); nextSorted.push(snapshot); nextTopmost = snapshot; if (snapshot.modal) nextTopmostModal = snapshot; @@ -128,6 +142,7 @@ export function createLayerRegistry(): LayerRegistry { const layer: MutableLayer = { id: layerInput.id, zIndex, + registrationOrder: nextRegistrationOrder++, rect: layerInput.rect, backdrop: layerInput.backdrop, modal: layerInput.modal, @@ -145,7 +160,15 @@ export function createLayerRegistry(): LayerRegistry { get(id: string): Layer | undefined { const layer = layers.get(id); if (!layer) return undefined; - return Object.freeze({ ...layer }); + return Object.freeze({ + id: layer.id, + zIndex: layer.zIndex, + rect: layer.rect, + backdrop: layer.backdrop, + modal: layer.modal, + closeOnEscape: layer.closeOnEscape, + onClose: layer.onClose, + }); }, getAll(): readonly Layer[] { @@ -204,6 +227,7 @@ export type LayerHitTestResult = Readonly<{ /** * Hit test layers to find which layer contains a point. * Returns the topmost layer containing the point, respecting modal blocking. + * Order is deterministic: higher z-index first, then later registration for equal z-index. * * @param registry - Layer registry * @param x - X coordinate to test @@ -213,15 +237,16 @@ export type LayerHitTestResult = Readonly<{ export function hitTestLayers(registry: LayerRegistry, x: number, y: number): LayerHitTestResult { const layers = registry.getAll(); - // Find topmost modal layer for blocking check - let topmostModal: Layer | null = null; + // Find topmost modal layer for blocking check. + let topmostModalIndex = -1; for (let i = layers.length - 1; i >= 0; i--) { const layer = layers[i]; if (layer?.modal) { - topmostModal = layer; + topmostModalIndex = i; break; } } + const topmostModal = topmostModalIndex >= 0 ? (layers[topmostModalIndex] ?? null) : null; let blockingLayer: Layer | null = null; // Check from topmost to bottom @@ -230,8 +255,8 @@ export function hitTestLayers(registry: LayerRegistry, x: number, y: number): La if (!layer) continue; if (containsPoint(layer.rect, x, y)) { - // Check if blocked by a higher modal layer - if (topmostModal && topmostModal.zIndex > layer.zIndex) { + // Block if the topmost modal is above this layer in resolved stack order. + if (topmostModal && topmostModalIndex > i) { blockingLayer = topmostModal; return Object.freeze({ layer: null, diff --git a/packages/core/src/widgets/__tests__/layers.golden.test.ts b/packages/core/src/widgets/__tests__/layers.golden.test.ts index 845b5d2c..a4833fcf 100644 --- a/packages/core/src/widgets/__tests__/layers.golden.test.ts +++ b/packages/core/src/widgets/__tests__/layers.golden.test.ts @@ -11,7 +11,7 @@ * @see docs/widgets/layers.md (GitHub issue #117) */ -import { assert, describe, test } from "@rezi-ui/testkit"; +import { assert, describe, readFixture, test } from "@rezi-ui/testkit"; import { ZR_KEY_DOWN, ZR_KEY_ENTER, ZR_KEY_ESCAPE, ZR_KEY_UP } from "../../keybindings/keyCodes.js"; import { calculateAnchorPosition, @@ -31,6 +31,33 @@ import { import { routeDropdownKey, routeLayerEscape } from "../../runtime/router.js"; import { ui } from "../ui.js"; +type FixtureRect = Readonly<{ x: number; y: number; w: number; h: number }>; + +type LayerHitFixtureCase = Readonly<{ + name: string; + layers: readonly Readonly<{ + id: string; + zIndex: number; + rect: FixtureRect; + modal: boolean; + backdrop: "none" | "dim" | "opaque"; + }>[]; + point: Readonly<{ x: number; y: number }>; + expected: Readonly<{ + layerId: string | null; + blocked: boolean; + blockingLayerId: string | null; + }>; +}>; + +type LayerHitFixture = Readonly<{ schemaVersion: 1; cases: readonly LayerHitFixtureCase[] }>; + +async function loadLayerHitFixture(): Promise { + const bytes = await readFixture("routing/layer_hit_overlap.json"); + const json = new TextDecoder().decode(bytes); + return JSON.parse(json) as LayerHitFixture; +} + /* ========== Layer Registry Tests ========== */ describe("Layer Registry - Basic Operations", () => { @@ -104,6 +131,74 @@ describe("Layer Registry - Basic Operations", () => { assert.equal(layers[2]?.id, "layer3"); // z=200 }); + test("equal z-index keeps deterministic registration order", () => { + const registry = createLayerRegistry(); + + registry.register({ + id: "first", + zIndex: 120, + rect: { x: 0, y: 0, w: 10, h: 10 }, + backdrop: "none", + modal: false, + closeOnEscape: true, + }); + + registry.register({ + id: "second", + zIndex: 120, + rect: { x: 0, y: 0, w: 10, h: 10 }, + backdrop: "none", + modal: false, + closeOnEscape: true, + }); + + const layers = registry.getAll(); + assert.equal(layers.length, 2); + assert.equal(layers[0]?.id, "first"); + assert.equal(layers[1]?.id, "second"); + + const topmost = registry.getTopmost(); + assert.ok(topmost); + assert.equal(topmost.id, "second"); + }); + + test("re-registering equal z-index layer moves it to top deterministically", () => { + const registry = createLayerRegistry(); + + registry.register({ + id: "a", + zIndex: 120, + rect: { x: 0, y: 0, w: 10, h: 10 }, + backdrop: "none", + modal: false, + closeOnEscape: true, + }); + registry.register({ + id: "b", + zIndex: 120, + rect: { x: 0, y: 0, w: 10, h: 10 }, + backdrop: "none", + modal: false, + closeOnEscape: true, + }); + + const before = hitTestLayers(registry, 2, 2); + assert.equal(before.layer?.id, "b"); + + registry.unregister("a"); + registry.register({ + id: "a", + zIndex: 120, + rect: { x: 0, y: 0, w: 10, h: 10 }, + backdrop: "none", + modal: false, + closeOnEscape: true, + }); + + const after = hitTestLayers(registry, 2, 2); + assert.equal(after.layer?.id, "a"); + }); + test("getTopmost returns highest z-index layer", () => { const registry = createLayerRegistry(); @@ -261,6 +356,35 @@ describe("Layer Stack State", () => { /* ========== Hit Testing Tests ========== */ describe("Layer Hit Testing", () => { + test("layer_hit_overlap.json", async () => { + const f = await loadLayerHitFixture(); + assert.equal(f.schemaVersion, 1); + + for (const c of f.cases) { + const registry = createLayerRegistry(); + + for (const layer of c.layers) { + registry.register({ + id: layer.id, + zIndex: layer.zIndex, + rect: layer.rect, + backdrop: layer.backdrop, + modal: layer.modal, + closeOnEscape: true, + }); + } + + const result = hitTestLayers(registry, c.point.x, c.point.y); + assert.equal(result.layer?.id ?? null, c.expected.layerId, `${c.name}: layerId`); + assert.equal(result.blocked, c.expected.blocked, `${c.name}: blocked`); + assert.equal( + result.blockingLayer?.id ?? null, + c.expected.blockingLayerId, + `${c.name}: blockingLayerId`, + ); + } + }); + test("hit test finds layer containing point", () => { const registry = createLayerRegistry(); @@ -400,6 +524,46 @@ describe("Layer Hit Testing", () => { assert.equal(result3.layer.id, "modal1"); assert.equal(result3.blocked, false); }); + + test("equal-z modal re-registration updates blocking order deterministically", () => { + const registry = createLayerRegistry(); + + registry.register({ + id: "modal", + zIndex: 140, + rect: { x: 20, y: 0, w: 8, h: 6 }, + backdrop: "none", + modal: true, + closeOnEscape: true, + }); + registry.register({ + id: "content", + zIndex: 140, + rect: { x: 0, y: 0, w: 12, h: 8 }, + backdrop: "none", + modal: false, + closeOnEscape: true, + }); + + const before = hitTestLayers(registry, 3, 3); + assert.equal(before.layer?.id ?? null, "content"); + assert.equal(before.blocked, false); + + registry.unregister("modal"); + registry.register({ + id: "modal", + zIndex: 140, + rect: { x: 20, y: 0, w: 8, h: 6 }, + backdrop: "none", + modal: true, + closeOnEscape: true, + }); + + const after = hitTestLayers(registry, 3, 3); + assert.equal(after.layer, null); + assert.equal(after.blocked, true); + assert.equal(after.blockingLayer?.id ?? null, "modal"); + }); }); /* ========== Positioning Tests ========== */ diff --git a/packages/testkit/fixtures/layout/hit_test_ties.json b/packages/testkit/fixtures/layout/hit_test_ties.json index 304c93e1..60b25bc6 100644 --- a/packages/testkit/fixtures/layout/hit_test_ties.json +++ b/packages/testkit/fixtures/layout/hit_test_ties.json @@ -38,6 +38,145 @@ "c": { "x": 0, "y": 0, "w": 4, "h": 1 } }, "points": [{ "x": 1, "y": 0, "expectedId": "c" }] + }, + { + "name": "Nested overlap: deeper later descendant wins in same branch", + "tree": { + "ref": "root", + "kind": "column", + "props": {}, + "children": [ + { + "ref": "box1", + "kind": "box", + "props": {}, + "children": [ + { "ref": "a", "kind": "button", "props": { "id": "a", "label": "A" } }, + { + "ref": "box2", + "kind": "box", + "props": {}, + "children": [{ "ref": "b", "kind": "button", "props": { "id": "b", "label": "B" } }] + } + ] + } + ] + }, + "layoutRects": { + "root": { "x": 0, "y": 0, "w": 5, "h": 2 }, + "box1": { "x": 0, "y": 0, "w": 5, "h": 2 }, + "a": { "x": 0, "y": 0, "w": 5, "h": 2 }, + "box2": { "x": 0, "y": 0, "w": 5, "h": 2 }, + "b": { "x": 0, "y": 0, "w": 5, "h": 2 } + }, + "points": [{ "x": 2, "y": 1, "expectedId": "b" }] + }, + { + "name": "Nested overlap across siblings: later subtree still wins", + "tree": { + "ref": "root", + "kind": "column", + "props": {}, + "children": [ + { + "ref": "left", + "kind": "box", + "props": {}, + "children": [ + { "ref": "inner", "kind": "button", "props": { "id": "inner", "label": "I" } } + ] + }, + { + "ref": "right", + "kind": "box", + "props": {}, + "children": [ + { "ref": "tail", "kind": "button", "props": { "id": "tail", "label": "T" } } + ] + } + ] + }, + "layoutRects": { + "root": { "x": 0, "y": 0, "w": 5, "h": 2 }, + "left": { "x": 0, "y": 0, "w": 5, "h": 2 }, + "inner": { "x": 0, "y": 0, "w": 5, "h": 2 }, + "right": { "x": 0, "y": 0, "w": 5, "h": 2 }, + "tail": { "x": 0, "y": 0, "w": 5, "h": 2 } + }, + "points": [{ "x": 1, "y": 0, "expectedId": "tail" }] + }, + { + "name": "Overlap tie ignores disabled later candidate", + "tree": { + "ref": "root", + "kind": "column", + "props": {}, + "children": [ + { "ref": "enabled", "kind": "button", "props": { "id": "enabled", "label": "E" } }, + { + "ref": "disabled", + "kind": "button", + "props": { "id": "disabled", "label": "D", "disabled": true } + } + ] + }, + "layoutRects": { + "root": { "x": 0, "y": 0, "w": 5, "h": 1 }, + "enabled": { "x": 0, "y": 0, "w": 5, "h": 1 }, + "disabled": { "x": 0, "y": 0, "w": 5, "h": 1 } + }, + "points": [{ "x": 2, "y": 0, "expectedId": "enabled" }] + }, + { + "name": "Clipped overlap: later nested child only wins inside ancestor clip", + "tree": { + "ref": "root", + "kind": "column", + "props": {}, + "children": [ + { "ref": "base", "kind": "button", "props": { "id": "base", "label": "Base" } }, + { + "ref": "clipBox", + "kind": "box", + "props": {}, + "children": [ + { "ref": "clipped", "kind": "button", "props": { "id": "clipped", "label": "C" } } + ] + } + ] + }, + "layoutRects": { + "root": { "x": 0, "y": 0, "w": 4, "h": 1 }, + "base": { "x": 0, "y": 0, "w": 4, "h": 1 }, + "clipBox": { "x": 0, "y": 0, "w": 2, "h": 1 }, + "clipped": { "x": 0, "y": 0, "w": 4, "h": 1 } + }, + "points": [ + { "x": 1, "y": 0, "expectedId": "clipped" }, + { "x": 3, "y": 0, "expectedId": "base" } + ] + }, + { + "name": "Partial sibling overlap: later sibling wins only in overlap region", + "tree": { + "ref": "root", + "kind": "column", + "props": {}, + "children": [ + { "ref": "first", "kind": "button", "props": { "id": "first", "label": "F" } }, + { "ref": "second", "kind": "button", "props": { "id": "second", "label": "S" } } + ] + }, + "layoutRects": { + "root": { "x": 0, "y": 0, "w": 4, "h": 1 }, + "first": { "x": 0, "y": 0, "w": 3, "h": 1 }, + "second": { "x": 1, "y": 0, "w": 3, "h": 1 } + }, + "points": [ + { "x": 0, "y": 0, "expectedId": "first" }, + { "x": 2, "y": 0, "expectedId": "second" }, + { "x": 3, "y": 0, "expectedId": "second" } + ] } ] } diff --git a/packages/testkit/fixtures/layout/layout_cases.json b/packages/testkit/fixtures/layout/layout_cases.json index 474196d4..2fe18e8e 100644 --- a/packages/testkit/fixtures/layout/layout_cases.json +++ b/packages/testkit/fixtures/layout/layout_cases.json @@ -119,6 +119,170 @@ "tree": { "ref": "root", "kind": "input", "props": { "id": "i1", "value": "" } }, "expect": { "rects": { "root": { "x": 0, "y": 0, "w": 2, "h": 1 } } } }, + { + "name": "row_negative_horizontal_margin_overlap", + "axis": "row", + "viewport": { "x": 0, "y": 0, "w": 12, "h": 3 }, + "tree": { + "ref": "root", + "kind": "row", + "props": {}, + "children": [ + { + "ref": "b1", + "kind": "box", + "props": { "border": "none", "width": 4, "height": 1, "ml": -1, "mr": -1 } + }, + { "ref": "b2", "kind": "box", "props": { "border": "none", "width": 4, "height": 1 } } + ] + }, + "expect": { + "rects": { + "root": { "x": 0, "y": 0, "w": 6, "h": 1 }, + "b1": { "x": -1, "y": 0, "w": 4, "h": 1 }, + "b2": { "x": 2, "y": 0, "w": 4, "h": 1 } + } + } + }, + { + "name": "column_negative_vertical_margin_overlap", + "axis": "column", + "viewport": { "x": 0, "y": 0, "w": 6, "h": 8 }, + "tree": { + "ref": "root", + "kind": "column", + "props": {}, + "children": [ + { + "ref": "b1", + "kind": "box", + "props": { "border": "none", "width": 3, "height": 3, "mt": -1, "mb": -1 } + }, + { "ref": "b2", "kind": "box", "props": { "border": "none", "width": 3, "height": 2 } } + ] + }, + "expect": { + "rects": { + "root": { "x": 0, "y": 0, "w": 3, "h": 3 }, + "b1": { "x": 0, "y": -1, "w": 3, "h": 3 }, + "b2": { "x": 0, "y": 1, "w": 3, "h": 2 } + } + } + }, + { + "name": "row_negative_left_top_margin_child_negative_xy", + "axis": "row", + "viewport": { "x": 0, "y": 0, "w": 8, "h": 3 }, + "tree": { + "ref": "root", + "kind": "row", + "props": {}, + "children": [ + { + "ref": "b1", + "kind": "box", + "props": { "border": "none", "width": 3, "height": 2, "ml": -2, "mt": -1 } + }, + { "ref": "b2", "kind": "box", "props": { "border": "none", "width": 2, "height": 1 } } + ] + }, + "expect": { + "rects": { + "root": { "x": 0, "y": 0, "w": 3, "h": 1 }, + "b1": { "x": -2, "y": -1, "w": 3, "h": 2 }, + "b2": { "x": 1, "y": 0, "w": 2, "h": 1 } + } + } + }, + { + "name": "row_negative_all_sides_margin_single_child", + "axis": "row", + "viewport": { "x": 0, "y": 0, "w": 6, "h": 3 }, + "tree": { + "ref": "root", + "kind": "row", + "props": {}, + "children": [ + { + "ref": "b1", + "kind": "box", + "props": { + "border": "none", + "width": 4, + "height": 3, + "mt": -1, + "mr": -1, + "mb": -1, + "ml": -1 + } + } + ] + }, + "expect": { + "rects": { + "root": { "x": 0, "y": 0, "w": 2, "h": 1 }, + "b1": { "x": -1, "y": -1, "w": 4, "h": 3 } + } + } + }, + { + "name": "column_negative_horizontal_margin_offsets_left", + "axis": "column", + "viewport": { "x": 0, "y": 0, "w": 7, "h": 6 }, + "tree": { + "ref": "root", + "kind": "column", + "props": {}, + "children": [ + { + "ref": "b1", + "kind": "box", + "props": { "border": "none", "width": 4, "height": 2, "ml": -1, "mr": -1 } + }, + { "ref": "b2", "kind": "box", "props": { "border": "none", "width": 3, "height": 2 } } + ] + }, + "expect": { + "rects": { + "root": { "x": 0, "y": 0, "w": 3, "h": 4 }, + "b1": { "x": -1, "y": 0, "w": 4, "h": 2 }, + "b2": { "x": 0, "y": 2, "w": 3, "h": 2 } + } + } + }, + { + "name": "row_extreme_negative_margins_keep_non_negative_sizes", + "axis": "row", + "viewport": { "x": 0, "y": 0, "w": 5, "h": 2 }, + "tree": { + "ref": "root", + "kind": "row", + "props": {}, + "children": [ + { + "ref": "b1", + "kind": "box", + "props": { + "border": "none", + "width": 1, + "height": 1, + "ml": -3, + "mr": -3, + "mt": -2, + "mb": -2 + } + }, + { "ref": "b2", "kind": "box", "props": { "border": "none", "width": 2, "height": 1 } } + ] + }, + "expect": { + "rects": { + "root": { "x": 0, "y": 0, "w": 2, "h": 1 }, + "b1": { "x": -3, "y": -2, "w": 6, "h": 4 }, + "b2": { "x": 0, "y": 0, "w": 2, "h": 1 } + } + } + }, { "name": "invalid_props_row_align", "axis": "row", diff --git a/packages/testkit/fixtures/routing/layer_hit_overlap.json b/packages/testkit/fixtures/routing/layer_hit_overlap.json new file mode 100644 index 00000000..28b61104 --- /dev/null +++ b/packages/testkit/fixtures/routing/layer_hit_overlap.json @@ -0,0 +1,152 @@ +{ + "schemaVersion": 1, + "cases": [ + { + "name": "higher_z_index_wins_overlap", + "layers": [ + { + "id": "base", + "zIndex": 100, + "rect": { "x": 0, "y": 0, "w": 10, "h": 10 }, + "modal": false, + "backdrop": "none" + }, + { + "id": "top", + "zIndex": 200, + "rect": { "x": 0, "y": 0, "w": 10, "h": 10 }, + "modal": false, + "backdrop": "none" + } + ], + "point": { "x": 5, "y": 5 }, + "expected": { "layerId": "top", "blocked": false, "blockingLayerId": null } + }, + { + "name": "equal_z_index_uses_registration_order_later_wins", + "layers": [ + { + "id": "first", + "zIndex": 150, + "rect": { "x": 0, "y": 0, "w": 10, "h": 10 }, + "modal": false, + "backdrop": "none" + }, + { + "id": "second", + "zIndex": 150, + "rect": { "x": 0, "y": 0, "w": 10, "h": 10 }, + "modal": false, + "backdrop": "none" + } + ], + "point": { "x": 4, "y": 4 }, + "expected": { "layerId": "second", "blocked": false, "blockingLayerId": null } + }, + { + "name": "equal_z_index_partial_overlap_later_wins_only_in_overlap", + "layers": [ + { + "id": "left", + "zIndex": 150, + "rect": { "x": 0, "y": 0, "w": 8, "h": 8 }, + "modal": false, + "backdrop": "none" + }, + { + "id": "right", + "zIndex": 150, + "rect": { "x": 4, "y": 0, "w": 8, "h": 8 }, + "modal": false, + "backdrop": "none" + } + ], + "point": { "x": 5, "y": 3 }, + "expected": { "layerId": "right", "blocked": false, "blockingLayerId": null } + }, + { + "name": "higher_modal_blocks_lower_layers_outside_modal_bounds", + "layers": [ + { + "id": "content", + "zIndex": 100, + "rect": { "x": 0, "y": 0, "w": 12, "h": 8 }, + "modal": false, + "backdrop": "none" + }, + { + "id": "modal", + "zIndex": 200, + "rect": { "x": 20, "y": 0, "w": 8, "h": 6 }, + "modal": true, + "backdrop": "none" + } + ], + "point": { "x": 3, "y": 3 }, + "expected": { "layerId": null, "blocked": true, "blockingLayerId": "modal" } + }, + { + "name": "equal_z_modal_registered_later_blocks_lower", + "layers": [ + { + "id": "content", + "zIndex": 140, + "rect": { "x": 0, "y": 0, "w": 12, "h": 8 }, + "modal": false, + "backdrop": "none" + }, + { + "id": "modal", + "zIndex": 140, + "rect": { "x": 20, "y": 0, "w": 8, "h": 6 }, + "modal": true, + "backdrop": "none" + } + ], + "point": { "x": 2, "y": 2 }, + "expected": { "layerId": null, "blocked": true, "blockingLayerId": "modal" } + }, + { + "name": "equal_z_modal_registered_earlier_does_not_block_later_non_modal", + "layers": [ + { + "id": "modal", + "zIndex": 140, + "rect": { "x": 20, "y": 0, "w": 8, "h": 6 }, + "modal": true, + "backdrop": "none" + }, + { + "id": "content", + "zIndex": 140, + "rect": { "x": 0, "y": 0, "w": 12, "h": 8 }, + "modal": false, + "backdrop": "none" + } + ], + "point": { "x": 3, "y": 3 }, + "expected": { "layerId": "content", "blocked": false, "blockingLayerId": null } + }, + { + "name": "equal_z_modal_registered_later_wins_when_both_contain_point", + "layers": [ + { + "id": "content", + "zIndex": 140, + "rect": { "x": 0, "y": 0, "w": 12, "h": 8 }, + "modal": false, + "backdrop": "none" + }, + { + "id": "modal", + "zIndex": 140, + "rect": { "x": 0, "y": 0, "w": 12, "h": 8 }, + "modal": true, + "backdrop": "none" + } + ], + "point": { "x": 6, "y": 4 }, + "expected": { "layerId": "modal", "blocked": false, "blockingLayerId": null } + } + ] +} diff --git a/packages/testkit/fixtures/routing/mouse_routing_overlap.json b/packages/testkit/fixtures/routing/mouse_routing_overlap.json new file mode 100644 index 00000000..2001c9c7 --- /dev/null +++ b/packages/testkit/fixtures/routing/mouse_routing_overlap.json @@ -0,0 +1,260 @@ +{ + "schemaVersion": 1, + "cases": [ + { + "name": "sibling_overlap_later_sibling_routes_press", + "tree": { + "ref": "root", + "kind": "column", + "props": {}, + "children": [ + { "ref": "base", "kind": "button", "props": { "id": "base", "label": "Base" } }, + { "ref": "top", "kind": "button", "props": { "id": "top", "label": "Top" } } + ] + }, + "layoutRects": { + "root": { "x": 0, "y": 0, "w": 4, "h": 1 }, + "base": { "x": 0, "y": 0, "w": 4, "h": 1 }, + "top": { "x": 0, "y": 0, "w": 4, "h": 1 } + }, + "enabledById": { "base": true, "top": true }, + "initialFocusedId": null, + "initialPressedId": null, + "steps": [ + { + "event": { + "kind": "mouse", + "timeMs": 0, + "x": 1, + "y": 0, + "mouseKind": 3, + "mods": 0, + "buttons": 1, + "wheelX": 0, + "wheelY": 0 + }, + "expected": { + "hitTestTargetId": "top", + "focusedId": "top", + "pressedId": "top", + "emittedKinds": ["engine"] + } + }, + { + "event": { + "kind": "mouse", + "timeMs": 1, + "x": 1, + "y": 0, + "mouseKind": 4, + "mods": 0, + "buttons": 0, + "wheelX": 0, + "wheelY": 0 + }, + "expected": { + "hitTestTargetId": "top", + "focusedId": "top", + "pressedId": null, + "emittedKinds": ["engine", "action"], + "action": { "id": "top", "action": "press" } + } + } + ] + }, + { + "name": "nested_overlap_later_subtree_wins_for_routing", + "tree": { + "ref": "root", + "kind": "column", + "props": {}, + "children": [ + { + "ref": "left", + "kind": "box", + "props": {}, + "children": [ + { "ref": "inner", "kind": "button", "props": { "id": "inner", "label": "Inner" } } + ] + }, + { "ref": "tail", "kind": "button", "props": { "id": "tail", "label": "Tail" } } + ] + }, + "layoutRects": { + "root": { "x": 0, "y": 0, "w": 5, "h": 1 }, + "left": { "x": 0, "y": 0, "w": 5, "h": 1 }, + "inner": { "x": 0, "y": 0, "w": 5, "h": 1 }, + "tail": { "x": 0, "y": 0, "w": 5, "h": 1 } + }, + "enabledById": { "inner": true, "tail": true }, + "initialFocusedId": null, + "initialPressedId": null, + "steps": [ + { + "event": { + "kind": "mouse", + "timeMs": 0, + "x": 3, + "y": 0, + "mouseKind": 3, + "mods": 0, + "buttons": 1, + "wheelX": 0, + "wheelY": 0 + }, + "expected": { + "hitTestTargetId": "tail", + "focusedId": "tail", + "pressedId": "tail", + "emittedKinds": ["engine"] + } + }, + { + "event": { + "kind": "mouse", + "timeMs": 1, + "x": 3, + "y": 0, + "mouseKind": 4, + "mods": 0, + "buttons": 0, + "wheelX": 0, + "wheelY": 0 + }, + "expected": { + "hitTestTargetId": "tail", + "focusedId": "tail", + "pressedId": null, + "emittedKinds": ["engine", "action"], + "action": { "id": "tail", "action": "press" } + } + } + ] + }, + { + "name": "disabled_later_overlap_falls_back_to_enabled_earlier", + "tree": { + "ref": "root", + "kind": "column", + "props": {}, + "children": [ + { "ref": "enabled", "kind": "button", "props": { "id": "enabled", "label": "Enabled" } }, + { + "ref": "disabled", + "kind": "button", + "props": { "id": "disabled", "label": "Disabled", "disabled": true } + } + ] + }, + "layoutRects": { + "root": { "x": 0, "y": 0, "w": 5, "h": 1 }, + "enabled": { "x": 0, "y": 0, "w": 5, "h": 1 }, + "disabled": { "x": 0, "y": 0, "w": 5, "h": 1 } + }, + "enabledById": { "enabled": true, "disabled": false }, + "initialFocusedId": null, + "initialPressedId": null, + "steps": [ + { + "event": { + "kind": "mouse", + "timeMs": 0, + "x": 2, + "y": 0, + "mouseKind": 3, + "mods": 0, + "buttons": 1, + "wheelX": 0, + "wheelY": 0 + }, + "expected": { + "hitTestTargetId": "enabled", + "focusedId": "enabled", + "pressedId": "enabled", + "emittedKinds": ["engine"] + } + }, + { + "event": { + "kind": "mouse", + "timeMs": 1, + "x": 2, + "y": 0, + "mouseKind": 4, + "mods": 0, + "buttons": 0, + "wheelX": 0, + "wheelY": 0 + }, + "expected": { + "hitTestTargetId": "enabled", + "focusedId": "enabled", + "pressedId": null, + "emittedKinds": ["engine", "action"], + "action": { "id": "enabled", "action": "press" } + } + } + ] + }, + { + "name": "overlap_target_change_between_down_up_cancels_press", + "tree": { + "ref": "root", + "kind": "column", + "props": {}, + "children": [ + { "ref": "a", "kind": "button", "props": { "id": "a", "label": "A" } }, + { "ref": "b", "kind": "button", "props": { "id": "b", "label": "B" } } + ] + }, + "layoutRects": { + "root": { "x": 0, "y": 0, "w": 4, "h": 1 }, + "a": { "x": 0, "y": 0, "w": 3, "h": 1 }, + "b": { "x": 1, "y": 0, "w": 3, "h": 1 } + }, + "enabledById": { "a": true, "b": true }, + "initialFocusedId": null, + "initialPressedId": null, + "steps": [ + { + "event": { + "kind": "mouse", + "timeMs": 0, + "x": 2, + "y": 0, + "mouseKind": 3, + "mods": 0, + "buttons": 1, + "wheelX": 0, + "wheelY": 0 + }, + "expected": { + "hitTestTargetId": "b", + "focusedId": "b", + "pressedId": "b", + "emittedKinds": ["engine"] + } + }, + { + "event": { + "kind": "mouse", + "timeMs": 1, + "x": 0, + "y": 0, + "mouseKind": 4, + "mods": 0, + "buttons": 0, + "wheelX": 0, + "wheelY": 0 + }, + "expected": { + "hitTestTargetId": "a", + "focusedId": "b", + "pressedId": null, + "emittedKinds": ["engine"] + } + } + ] + } + ] +}