diff --git a/examples/README.md b/examples/README.md index 9009edc..ada54de 100644 --- a/examples/README.md +++ b/examples/README.md @@ -81,3 +81,25 @@ What it shows: This example is useful if you want to embed transient or animated UI output into a normal command-line workflow instead of switching to a full-screen alternate buffer interface. + +## Flex Layout Controls + +Path: `examples/flex-layout-controls/index.ts` + +Run it with: + +```sh +deno run examples/flex-layout-controls/index.ts +# or +node examples/flex-layout-controls/index.ts +``` + +What it shows: + +- every supported `layout.alignSelf` value: `auto`, `normal`, `stretch`, + `center`, `start`, `end`, `flex-start`, and `flex-end` +- `stretch()` as explicit cross-axis fill sizing +- `normal`/`stretch` align-self behavior for auto-like cross sizes +- definite cross sizes (`fixed()` and `percent()`) staying definite under + stretch alignment +- the practical guidance to use `grow()` for main-axis free-space distribution diff --git a/examples/flex-layout-controls/index.ts b/examples/flex-layout-controls/index.ts new file mode 100644 index 0000000..c4b5517 --- /dev/null +++ b/examples/flex-layout-controls/index.ts @@ -0,0 +1,290 @@ +/** + * Flex layout controls demo — showcases `layout.alignSelf` and `stretch()`. + * + * The colored rows make per-child cross-axis alignment visible: + * - `alignSelf` overrides parent alignment for one child at a time. + * - `stretch()` explicitly fills the parent's cross-axis content extent. + * - `normal` / `stretch` align-self values stretch auto-like cross sizes. + * - definite cross sizes remain definite under stretch alignment. + */ + +import { Buffer } from "node:buffer"; +import process from "node:process"; +import { + close, + createTerm, + CSI, + fit, + fixed, + grow, + type Op, + open, + percent, + rgba, + stretch, + text, +} from "../../mod.ts"; +import { validated } from "../../validate.ts"; + +const write = (b: Uint8Array) => process.stdout.write(Buffer.from(b)); + +const FG = rgba(238, 238, 232); +const MUTED = rgba(150, 155, 165); +const TITLE = rgba(255, 220, 140); +const BG = rgba(18, 20, 28); +const CARD = rgba(30, 34, 48); +const BORDER = rgba(90, 96, 130); +const BLUE = rgba(52, 102, 166); +const GREEN = rgba(48, 126, 84); +const PURPLE = rgba(111, 76, 160); +const ORANGE = rgba(168, 104, 48); +const RED = rgba(150, 68, 70); +const TEAL = rgba(50, 128, 132); + +let { columns } = terminalSize(); +let width = Math.max(72, Math.min(columns, 96)); +let height = 42; +let term = validated(await createTerm({ width, height })); +let result = term.render(render(width, height), { mode: "line" }); + +write(result.output); +write(CSI("0m")); +write(new TextEncoder().encode("\n")); + +function render(width: number, height: number): Op[] { + let ops: Op[] = [ + open("root", { + layout: { + width: fixed(width), + height: fixed(height), + direction: "ttb", + padding: { left: 1, right: 1, top: 1 }, + gap: 1, + }, + bg: BG, + }), + ]; + + ops.push( + open("title", { layout: { width: grow(), height: fixed(1) } }), + text("Clayterm flex layout controls", { color: TITLE }), + text(" alignSelf + stretch()", { color: MUTED }), + close(), + ); + + alignSelfCard(ops); + stretchCard(ops); + rulesCard(ops); + + ops.push( + open("footer", { layout: { width: grow(), height: fixed(1) } }), + text("Tip: colored backgrounds show each element's actual bounds.", { + color: MUTED, + }), + close(), + close(), + ); + + return ops; +} + +function alignSelfCard(ops: Op[]) { + ops.push( + open("align-self-card", { + layout: { + width: grow(), + height: fixed(19), + direction: "ttb", + padding: { left: 2, right: 2, top: 1, bottom: 1 }, + gap: 1, + alignX: "right", + }, + bg: CARD, + border: { color: BORDER, left: 1, right: 1, top: 1, bottom: 1 }, + }), + ); + + badge(ops, "align-title", "Parent alignX is right; each row opts in/out", { + alignSelf: "start", + color: TITLE, + }); + badge(ops, "align-auto", 'auto: follows parent alignX="right"', { + alignSelf: "auto", + bg: BLUE, + }); + badge(ops, "align-start", "start: cross-start (left)", { + alignSelf: "start", + bg: GREEN, + }); + badge(ops, "align-flex-start", "flex-start: same as start", { + alignSelf: "flex-start", + bg: GREEN, + }); + badge(ops, "align-center", "center: centered per child", { + alignSelf: "center", + bg: PURPLE, + }); + badge(ops, "align-end", "end: cross-end (right)", { + alignSelf: "end", + bg: ORANGE, + }); + badge(ops, "align-flex-end", "flex-end: same as end", { + alignSelf: "flex-end", + bg: ORANGE, + }); + badge(ops, "align-stretch", "stretch: auto-like width fills content box", { + alignSelf: "stretch", + bg: TEAL, + }); + badge(ops, "align-normal", "normal: stretches auto-like cross size too", { + alignSelf: "normal", + bg: TEAL, + }); + + ops.push(close()); +} + +function stretchCard(ops: Op[]) { + ops.push( + open("stretch-card", { + layout: { + width: grow(), + height: fixed(8), + direction: "ttb", + padding: { left: 2, right: 2, top: 1 }, + }, + bg: CARD, + border: { color: BORDER, left: 1, right: 1, top: 1, bottom: 1 }, + }), + ); + + badge(ops, "stretch-title", "stretch() helper", { + alignSelf: "start", + color: TITLE, + }); + + ops.push( + open("width-stretch", { + layout: { width: stretch(), height: fixed(1) }, + bg: GREEN, + }), + text("width: stretch() in a top-to-bottom parent fills horizontally", { + color: FG, + }), + close(), + ); + + ops.push( + open("height-demo-row", { + layout: { width: grow(), height: fixed(4), direction: "ltr", gap: 2 }, + }), + open("height-stretch", { + layout: { + width: fixed(24), + height: stretch(), + direction: "ttb", + padding: { left: 1, top: 1 }, + }, + bg: BLUE, + border: { color: BORDER, left: 1, right: 1, top: 1, bottom: 1 }, + }), + text("height: stretch()", { color: FG }), + close(), + open("height-note", { + layout: { width: grow(), height: fixed(4), direction: "ttb" }, + }), + text("In a left-to-right parent, the cross axis is vertical.", { + color: FG, + }), + text("Use grow() for main-axis free-space distribution.", { + color: MUTED, + }), + close(), + close(), + ); + + ops.push(close()); +} + +function rulesCard(ops: Op[]) { + ops.push( + open("rules-card", { + layout: { + width: grow(), + height: fixed(7), + direction: "ttb", + padding: { left: 2, right: 2, top: 1 }, + alignX: "right", + }, + bg: CARD, + border: { color: BORDER, left: 1, right: 1, top: 1, bottom: 1 }, + }), + ); + + badge(ops, "rules-title", "Stretch alignment rules", { + alignSelf: "start", + color: TITLE, + }); + badge(ops, "rules-auto", "auto still follows parent right alignment", { + alignSelf: "auto", + bg: BLUE, + }); + badge(ops, "rules-normal", "normal + omitted width fills the content box", { + alignSelf: "normal", + bg: TEAL, + }); + badge(ops, "rules-fixed", "fixed(28) + stretch stays fixed at start", { + alignSelf: "stretch", + width: fixed(28), + bg: RED, + }); + badge(ops, "rules-percent", "percent(50%) + stretch stays definite", { + alignSelf: "stretch", + width: percent(0.5), + bg: ORANGE, + }); + + ops.push(close()); +} + +function badge( + ops: Op[], + id: string, + label: string, + options: { + alignSelf?: + | "auto" + | "normal" + | "stretch" + | "center" + | "start" + | "end" + | "flex-start" + | "flex-end"; + width?: ReturnType; + bg?: number; + color?: number; + } = {}, +) { + ops.push( + open(id, { + layout: { + width: options.width ?? fit(), + height: fixed(1), + alignSelf: options.alignSelf, + }, + bg: options.bg, + }), + text(` ${label} `, { color: options.color ?? FG }), + close(), + ); +} + +function terminalSize(): { columns: number; rows: number } { + return process.stdout.isTTY + ? { + columns: process.stdout.columns ?? 96, + rows: process.stdout.rows ?? 42, + } + : { columns: 96, rows: 42 }; +} diff --git a/ops.ts b/ops.ts index 131a796..ec64e4b 100644 --- a/ops.ts +++ b/ops.ts @@ -12,6 +12,17 @@ const PROP_BORDER = 0x08; const PROP_CLIP = 0x10; const PROP_FLOATING = 0x20; +const ALIGN_SELF: Record = { + auto: 0, + normal: 1, + stretch: 2, + center: 3, + start: 4, + end: 5, + "flex-start": 6, + "flex-end": 7, +}; + const encoder = new TextEncoder(); function packAxis(view: DataView, offset: number, axis: SizingAxis): number { @@ -49,6 +60,17 @@ function packAxis(view: DataView, offset: number, axis: SizingAxis): number { view.setFloat32(o, 0, true); o += 4; break; + case "stretch": + // Public stretch() maps to Clay's existing GROW primitive. The + // normative guarantee is cross-axis fill; main-axis behavior remains + // grow-like per the flex-layout-controls spec. + view.setUint32(o, 1, true); + o += 4; + view.setFloat32(o, 0, true); + o += 4; + view.setFloat32(o, 0, true); + o += 4; + break; } return o; } @@ -142,6 +164,9 @@ export function pack( view.setUint32(o, alignX | (alignY << 8), true); o += 4; + + view.setUint32(o, ALIGN_SELF[l.alignSelf ?? "auto"], true); + o += 4; } if (op.bg !== undefined) { @@ -278,7 +303,8 @@ export type SizingAxis = | { type: "fit"; min?: number; max?: number } | { type: "grow"; min?: number; max?: number } | { type: "percent"; value: number } - | { type: "fixed"; value: number }; + | { type: "fixed"; value: number } + | { type: "stretch" }; export const fit = (min = 0, max = 0): SizingAxis => ({ type: "fit", @@ -295,6 +321,17 @@ export const percent = (value: number): SizingAxis => ({ value, }); export const fixed = (value: number): SizingAxis => ({ type: "fixed", value }); +export const stretch = (): SizingAxis => ({ type: "stretch" }); + +export type AlignSelf = + | "auto" + | "normal" + | "stretch" + | "center" + | "start" + | "end" + | "flex-start" + | "flex-end"; export interface CloseElement { directive: typeof OP_CLOSE_ELEMENT; @@ -311,6 +348,7 @@ export interface OpenElement { direction?: "ltr" | "ttb"; alignX?: "left" | "center" | "right"; alignY?: "top" | "center" | "bottom"; + alignSelf?: AlignSelf; }; bg?: number; cornerRadius?: { tl?: number; tr?: number; bl?: number; br?: number }; @@ -450,7 +488,7 @@ function packSize(ops: Op[]): number { n += 4; // opcode n += 4 + Math.ceil(encoder.encode(op.id).length / 4) * 4; // id string n += 4; // mask - if (op.layout) n += 6 * 4 + 4 + 4 + 4; // 2 axes (3 words each) + pad + gap + align + if (op.layout) n += 6 * 4 + 4 + 4 + 4 + 4; // 2 axes (3 words each) + pad + gap + align + alignSelf if (op.bg !== undefined) n += 4; if (op.cornerRadius) n += 4; if (op.border) n += 12; diff --git a/src/clayterm.c b/src/clayterm.c index c21db3f..a237be7 100644 --- a/src/clayterm.c +++ b/src/clayterm.c @@ -34,6 +34,17 @@ #define PROP_CLIP 0x10 #define PROP_FLOATING 0x20 +#define ALIGN_SELF_AUTO 0 +#define ALIGN_SELF_NORMAL 1 +#define ALIGN_SELF_STRETCH 2 +#define ALIGN_SELF_CENTER 3 +#define ALIGN_SELF_START 4 +#define ALIGN_SELF_END 5 +#define ALIGN_SELF_FLEX_START 6 +#define ALIGN_SELF_FLEX_END 7 + +#define USER_ELEMENT_STACK_MAX 4096 + /* ── Instance state ───────────────────────────────────────────────── */ #define MAX_ERRORS 32 @@ -53,6 +64,11 @@ struct Clayterm { int error_count; }; +typedef struct UserElementFrame { + Clay_LayoutDirection direction; + int close_align_self_wrapper; +} UserElementFrame; + /* Memory layout inside the arena provided by the host: * [Clayterm struct] [front cells] [back cells] [output buffer] * @@ -396,6 +412,104 @@ static Clay_SizingAxis decode_axis(uint32_t *buf, int len, int *i) { return axis; } +static Clay_SizingAxis sizing_grow(float min, float max) { + Clay_SizingAxis axis = {0}; + axis.type = CLAY__SIZING_TYPE_GROW; + axis.size.minMax.min = min; + axis.size.minMax.max = max; + return axis; +} + +static Clay_SizingAxis grow_like_axis(Clay_SizingAxis axis) { + if (axis.type == CLAY__SIZING_TYPE_FIT || + axis.type == CLAY__SIZING_TYPE_GROW) { + return sizing_grow(axis.size.minMax.min, axis.size.minMax.max); + } + return sizing_grow(0, 0); +} + +static int axis_is_definite(Clay_SizingAxis axis) { + return axis.type == CLAY__SIZING_TYPE_FIXED || + axis.type == CLAY__SIZING_TYPE_PERCENT; +} + +static int should_move_main_axis_to_wrapper(Clay_SizingAxis axis) { + return axis.type == CLAY__SIZING_TYPE_FIXED || + axis.type == CLAY__SIZING_TYPE_PERCENT || + axis.type == CLAY__SIZING_TYPE_GROW; +} + +static Clay_LayoutAlignmentX align_self_x(uint32_t align_self) { + switch (align_self) { + case ALIGN_SELF_CENTER: + return CLAY_ALIGN_X_CENTER; + case ALIGN_SELF_END: + case ALIGN_SELF_FLEX_END: + return CLAY_ALIGN_X_RIGHT; + default: + return CLAY_ALIGN_X_LEFT; + } +} + +static Clay_LayoutAlignmentY align_self_y(uint32_t align_self) { + switch (align_self) { + case ALIGN_SELF_CENTER: + return CLAY_ALIGN_Y_CENTER; + case ALIGN_SELF_END: + case ALIGN_SELF_FLEX_END: + return CLAY_ALIGN_Y_BOTTOM; + default: + return CLAY_ALIGN_Y_TOP; + } +} + +static void stretch_align_self_cross_axis(Clay_ElementDeclaration *decl, + Clay_LayoutDirection parent_dir, + uint32_t align_self) { + if (align_self != ALIGN_SELF_STRETCH && align_self != ALIGN_SELF_NORMAL) + return; + + Clay_SizingAxis *cross = parent_dir == CLAY_TOP_TO_BOTTOM + ? &decl->layout.sizing.width + : &decl->layout.sizing.height; + if (!axis_is_definite(*cross)) { + *cross = grow_like_axis(*cross); + } +} + +static void +move_main_axis_to_align_self_wrapper(Clay_ElementDeclaration *decl, + Clay_LayoutDirection parent_dir) { + Clay_SizingAxis *main = parent_dir == CLAY_TOP_TO_BOTTOM + ? &decl->layout.sizing.height + : &decl->layout.sizing.width; + if (should_move_main_axis_to_wrapper(*main)) { + *main = sizing_grow(0, 0); + } +} + +static void open_align_self_wrapper(Clay_LayoutDirection parent_dir, + uint32_t align_self, + Clay_ElementDeclaration child_decl) { + Clay_ElementDeclaration wrapper = {0}; + wrapper.layout.layoutDirection = parent_dir; + + if (parent_dir == CLAY_TOP_TO_BOTTOM) { + wrapper.layout.sizing.width = sizing_grow(0, 0); + wrapper.layout.sizing.height = child_decl.layout.sizing.height; + wrapper.layout.childAlignment.x = align_self_x(align_self); + wrapper.layout.childAlignment.y = CLAY_ALIGN_Y_TOP; + } else { + wrapper.layout.sizing.width = child_decl.layout.sizing.width; + wrapper.layout.sizing.height = sizing_grow(0, 0); + wrapper.layout.childAlignment.x = CLAY_ALIGN_X_LEFT; + wrapper.layout.childAlignment.y = align_self_y(align_self); + } + + Clay__OpenElement(); + Clay__ConfigureOpenElement(wrapper); +} + /* ── Public API ───────────────────────────────────────────────────── */ static int align64(int n) { return (n + 63) & ~63; } @@ -474,6 +588,8 @@ struct Clayterm *init(void *mem, int w, int h) { void reduce(struct Clayterm *ct, uint32_t *buf, int len, int mode, int row) { int i = 0; + UserElementFrame user_stack[USER_ELEMENT_STACK_MAX]; + int user_depth = 0; ct->error_count = 0; Clay_BeginLayout(); @@ -489,17 +605,10 @@ void reduce(struct Clayterm *ct, uint32_t *buf, int len, int mode, int row) { char *id_chars = (char *)&buf[i]; i += id_words; - if (id_len > 0) { - Clay_String str = {.length = (int32_t)id_len, .chars = id_chars}; - Clay_ElementId eid = Clay__HashString(str, 0); - Clay__OpenElementWithId(eid); - } else { - Clay__OpenElement(); - } - /* read property mask */ uint32_t mask = rd(buf, len, &i); Clay_ElementDeclaration decl = {0}; + uint32_t align_self = ALIGN_SELF_AUTO; if (mask & PROP_LAYOUT) { decl.layout.sizing.width = decode_axis(buf, len, &i); @@ -518,6 +627,8 @@ void reduce(struct Clayterm *ct, uint32_t *buf, int len, int mode, int row) { uint32_t al = rd(buf, len, &i); decl.layout.childAlignment.x = al & 0xff; decl.layout.childAlignment.y = (al >> 8) & 0xff; + + align_self = rd(buf, len, &i); } if (mask & PROP_BG_COLOR) { @@ -568,7 +679,37 @@ void reduce(struct Clayterm *ct, uint32_t *buf, int len, int mode, int row) { decl.floating.zIndex = (int16_t)(fd >> 8); } + int close_align_self_wrapper = 0; + int has_normal_flow_parent = user_depth > 0; + int is_floating = (mask & PROP_FLOATING) != 0; + Clay_LayoutDirection parent_dir = + has_normal_flow_parent ? user_stack[user_depth - 1].direction + : CLAY_LEFT_TO_RIGHT; + + if (has_normal_flow_parent && !is_floating && + align_self != ALIGN_SELF_AUTO) { + stretch_align_self_cross_axis(&decl, parent_dir, align_self); + open_align_self_wrapper(parent_dir, align_self, decl); + move_main_axis_to_align_self_wrapper(&decl, parent_dir); + close_align_self_wrapper = 1; + } + + if (id_len > 0) { + Clay_String str = {.length = (int32_t)id_len, .chars = id_chars}; + Clay_ElementId eid = Clay__HashString(str, 0); + Clay__OpenElementWithId(eid); + } else { + Clay__OpenElement(); + } + Clay__ConfigureOpenElement(decl); + + if (user_depth < USER_ELEMENT_STACK_MAX) { + user_stack[user_depth++] = (UserElementFrame){ + .direction = decl.layout.layoutDirection, + .close_align_self_wrapper = close_align_self_wrapper, + }; + } break; } @@ -596,9 +737,19 @@ void reduce(struct Clayterm *ct, uint32_t *buf, int len, int mode, int row) { break; } - case OP_CLOSE_ELEMENT: + case OP_CLOSE_ELEMENT: { + int close_align_self_wrapper = 0; + if (user_depth > 0) { + close_align_self_wrapper = + user_stack[user_depth - 1].close_align_self_wrapper; + user_depth--; + } Clay__CloseElement(); + if (close_align_self_wrapper) { + Clay__CloseElement(); + } break; + } default: break; diff --git a/test/flex-layout-controls.test.ts b/test/flex-layout-controls.test.ts new file mode 100644 index 0000000..473ec35 --- /dev/null +++ b/test/flex-layout-controls.test.ts @@ -0,0 +1,575 @@ +import { describe, expect, it } from "./suite.ts"; +import * as mod from "../mod.ts"; +import { + close, + fit, + fixed, + grow, + type Op, + open, + pack, + percent, + snapshot, + stretch, + text, +} from "../ops.ts"; +import { createTerm, type RenderResult } from "../term.ts"; +import { validate } from "../validate.ts"; + +const decode = (bytes: Uint8Array) => new TextDecoder().decode(bytes); + +function bounds(result: RenderResult, id: string) { + let info = result.info.get(id); + expect(info).toBeDefined(); + return info!.bounds; +} + +function ttbRoot(width: number, height: number, extraLayout = {}) { + return open("root", { + layout: { + width: fixed(width), + height: fixed(height), + direction: "ttb" as const, + ...extraLayout, + }, + }); +} + +function ltrRoot(width: number, height: number, extraLayout = {}) { + return open("root", { + layout: { + width: fixed(width), + height: fixed(height), + direction: "ltr" as const, + ...extraLayout, + }, + }); +} + +describe("flex layout controls", () => { + it("exports stretch() as a plain sizing object and does not export #80 helpers", () => { + expect(stretch()).toEqual({ type: "stretch" }); + expect(mod.stretch()).toEqual({ type: "stretch" }); + expect(Object.getPrototypeOf(stretch())).toBe(Object.prototype); + + let exported = mod as unknown as Record; + expect("basis" in exported).toBe(false); + expect("flexBasis" in exported).toBe(false); + expect("shrinkWeight" in exported).toBe(false); + expect("flexShrink" in exported).toBe(false); + }); + + it("validates supported alignSelf values and stretch axes", () => { + let values = [ + "auto", + "normal", + "stretch", + "center", + "start", + "end", + "flex-start", + "flex-end", + ] as const; + + for (let alignSelf of values) { + expect(validate([ + open("root"), + open("child", { layout: { alignSelf } }), + close(), + close(), + ])).toBe(true); + } + + expect(validate([ + open("root", { layout: { width: stretch(), height: stretch() } }), + close(), + ])).toBe(true); + }); + + it("rejects invalid alignSelf values and invalid sizing discriminators", () => { + expect(validate([ + { directive: 0x02, id: "x", layout: { alignSelf: "baseline" } }, + { directive: 0x04 }, + ])).toBe(false); + + expect(validate([ + { directive: 0x02, id: "x", layout: { alignSelf: 1 } }, + { directive: 0x04 }, + ])).toBe(false); + + expect(validate([ + { directive: 0x02, id: "x", layout: { width: { type: "stretched" } } }, + { directive: 0x04 }, + ])).toBe(false); + + for (let alignSelf of ["self-start", "safe center", "unsafe center"]) { + expect(validate([ + { directive: 0x02, id: "x", layout: { alignSelf } }, + { directive: 0x04 }, + ])).toBe(false); + } + }); + + it("packs stretch axes and snapshots ops containing stretch and alignSelf", () => { + let widthStretch = [ + open("root", { layout: { width: stretch(), height: fixed(1) } }), + close(), + ]; + let heightStretch = [ + open("root", { layout: { width: fixed(1), height: stretch() } }), + close(), + ]; + let alignSelf = [ + ttbRoot(12, 1), + open("child", { layout: { width: fit(), alignSelf: "flex-end" } }), + text("B"), + close(), + close(), + ]; + + expect(() => pack(widthStretch, new ArrayBuffer(512), 0, 512)).not + .toThrow(); + expect(() => pack(heightStretch, new ArrayBuffer(512), 0, 512)).not + .toThrow(); + expect(() => snapshot(widthStretch)).not.toThrow(); + expect(() => snapshot(heightStretch)).not.toThrow(); + expect(() => snapshot(alignSelf)).not.toThrow(); + }); + + it("aligns one ttb child to cross-end without moving siblings", async () => { + let term = await createTerm({ width: 12, height: 4 }); + let result = term.render([ + ttbRoot(12, 4), + open("a", { layout: { width: fit(), height: fixed(1) } }), + text("A"), + close(), + open("b", { + layout: { width: fit(), height: fixed(1), alignSelf: "flex-end" }, + }), + text("B"), + close(), + close(), + ]); + + expect(bounds(result, "a")).toEqual({ x: 0, y: 0, width: 1, height: 1 }); + expect(bounds(result, "b")).toEqual({ x: 11, y: 1, width: 1, height: 1 }); + expect(result.info.get("")).toBeUndefined(); + }); + + it("aligns one ltr child to cross-end", async () => { + let term = await createTerm({ width: 3, height: 5 }); + let result = term.render([ + ltrRoot(3, 5), + open("b", { + layout: { width: fixed(1), height: fit(), alignSelf: "flex-end" }, + }), + text("B"), + close(), + close(), + ]); + + expect(bounds(result, "b")).toEqual({ x: 0, y: 4, width: 1, height: 1 }); + }); + + it("maps start and flex-start to cross-start in both directions", async () => { + let term = await createTerm({ width: 12, height: 6 }); + + for (let alignSelf of ["start", "flex-start"] as const) { + let ttb = term.render([ + ttbRoot(12, 3, { alignX: "right" as const }), + open("child", { + layout: { width: fit(), height: fixed(1), alignSelf }, + }), + text("C"), + close(), + close(), + ]); + expect(bounds(ttb, "child").x).toBe(0); + + let ltr = term.render([ + ltrRoot(3, 6, { alignY: "bottom" as const }), + open("child", { + layout: { width: fixed(1), height: fit(), alignSelf }, + }), + text("C"), + close(), + close(), + ]); + expect(bounds(ltr, "child").y).toBe(0); + } + }); + + it("centers alignSelf children within the parent content extent", async () => { + let term = await createTerm({ width: 12, height: 6 }); + + let ttb = term.render([ + ttbRoot(12, 3), + open("child", { + layout: { width: fixed(2), height: fixed(1), alignSelf: "center" }, + }), + close(), + close(), + ]); + expect(bounds(ttb, "child").x).toBe(5); + + let ltr = term.render([ + ltrRoot(3, 6), + open("child", { + layout: { width: fixed(1), height: fixed(2), alignSelf: "center" }, + }), + close(), + close(), + ]); + expect(bounds(ltr, "child").y).toBe(2); + }); + + it("omitted alignSelf and auto follow parent cross-axis alignment", async () => { + let withoutAuto = await createTerm({ width: 12, height: 2 }); + let withAuto = await createTerm({ width: 12, height: 2 }); + + let omittedOps = [ + ttbRoot(12, 2, { alignX: "right" as const }), + open("child", { layout: { width: fit(), height: fixed(1) } }), + text("B"), + close(), + close(), + ]; + let autoOps = [ + ttbRoot(12, 2, { alignX: "right" as const }), + open("child", { + layout: { width: fit(), height: fixed(1), alignSelf: "auto" }, + }), + text("B"), + close(), + close(), + ]; + + let omitted = withoutAuto.render(omittedOps, { mode: "line" }); + let auto = withAuto.render(autoOps, { mode: "line" }); + + expect(bounds(omitted, "child")).toEqual(bounds(auto, "child")); + expect(bounds(auto, "child").x).toBe(11); + expect(decode(auto.output)).toBe(decode(omitted.output)); + }); + + it("stretches auto-like cross sizes for stretch and normal alignSelf", async () => { + let term = await createTerm({ width: 12, height: 4 }); + let result = term.render([ + ttbRoot(12, 4), + open("omitted", { layout: { height: fixed(1), alignSelf: "stretch" } }), + text("O"), + close(), + open("fit", { + layout: { width: fit(), height: fixed(1), alignSelf: "stretch" }, + }), + text("F"), + close(), + open("normal", { + layout: { width: fit(), height: fixed(1), alignSelf: "normal" }, + }), + text("N"), + close(), + close(), + ]); + + expect(bounds(result, "omitted").width).toBe(12); + expect(bounds(result, "fit").width).toBe(12); + expect(bounds(result, "normal").width).toBe(12); + }); + + it("preserves definite cross sizes under stretch alignSelf", async () => { + let term = await createTerm({ width: 12, height: 3 }); + let result = term.render([ + ttbRoot(12, 3, { alignX: "right" as const }), + open("fixed", { + layout: { width: fixed(4), height: fixed(1), alignSelf: "stretch" }, + }), + text("F"), + close(), + open("percent", { + layout: { width: percent(0.5), height: fixed(1), alignSelf: "stretch" }, + }), + text("P"), + close(), + close(), + ]); + + expect(bounds(result, "fixed")).toEqual({ + x: 0, + y: 0, + width: 4, + height: 1, + }); + expect(bounds(result, "percent")).toEqual({ + x: 0, + y: 1, + width: 6, + height: 1, + }); + }); + + it("uses parent padding as the cross-axis content extent", async () => { + let term = await createTerm({ width: 12, height: 6 }); + + let alignStretch = term.render([ + ttbRoot(12, 3, { padding: { left: 1, right: 1 } }), + open("child", { layout: { height: fixed(1), alignSelf: "stretch" } }), + text("S"), + close(), + close(), + ]); + expect(bounds(alignStretch, "child")).toEqual({ + x: 1, + y: 0, + width: 10, + height: 1, + }); + + let stretchWidth = term.render([ + ttbRoot(12, 3, { padding: { left: 1, right: 1 } }), + open("child", { layout: { width: stretch(), height: fixed(1) } }), + text("S"), + close(), + close(), + ]); + expect(bounds(stretchWidth, "child")).toEqual({ + x: 1, + y: 0, + width: 10, + height: 1, + }); + + let stretchHeight = term.render([ + ltrRoot(3, 6, { padding: { top: 1, bottom: 1 } }), + open("child", { layout: { width: fixed(1), height: stretch() } }), + text("S"), + close(), + close(), + ]); + expect(bounds(stretchHeight, "child")).toEqual({ + x: 0, + y: 1, + width: 1, + height: 4, + }); + }); + + it("preserves descendant alignment and main-axis grow sizing", async () => { + let term = await createTerm({ width: 12, height: 4 }); + let descendant = term.render([ + ttbRoot(12, 4), + open("outer", { + layout: { + width: fixed(4), + height: fixed(2), + direction: "ttb", + alignX: "left", + alignSelf: "flex-end", + }, + }), + open("inner", { layout: { width: fit(), height: fixed(1) } }), + text("I"), + close(), + close(), + close(), + ]); + + expect(bounds(descendant, "outer")).toEqual({ + x: 8, + y: 0, + width: 4, + height: 2, + }); + expect(bounds(descendant, "inner")).toEqual({ + x: 8, + y: 0, + width: 1, + height: 1, + }); + + let growMain = term.render([ + ttbRoot(12, 4), + open("a", { layout: { width: fit(), height: fixed(1) } }), + text("A"), + close(), + open("b", { + layout: { width: fit(), height: grow(), alignSelf: "flex-end" }, + }), + text("B"), + close(), + close(), + ]); + + expect(bounds(growMain, "b")).toEqual({ x: 11, y: 1, width: 1, height: 3 }); + }); + + it("treats root and floating alignSelf as no-ops", async () => { + let rootA = await createTerm({ width: 12, height: 3 }); + let rootB = await createTerm({ width: 12, height: 3 }); + + let withoutRootAlign = [ + open("root", { layout: { width: fixed(2), height: fixed(1) } }), + text("R"), + close(), + ]; + let withRootAlign = [ + open("root", { + layout: { width: fixed(2), height: fixed(1), alignSelf: "flex-end" }, + }), + text("R"), + close(), + ]; + + let noAlign = rootA.render(withoutRootAlign, { mode: "line" }); + let align = rootB.render(withRootAlign, { mode: "line" }); + expect(bounds(align, "root")).toEqual(bounds(noAlign, "root")); + expect(decode(align.output)).toBe(decode(noAlign.output)); + + let floatA = await createTerm({ width: 12, height: 6 }); + let floatB = await createTerm({ width: 12, height: 6 }); + let floating = (alignSelf = false): Op[] => [ + ttbRoot(12, 6), + open("float", { + layout: { + width: fixed(2), + height: fixed(1), + ...(alignSelf ? { alignSelf: "flex-end" as const } : {}), + }, + floating: { x: 2, y: 1, attachTo: "root" }, + }), + text("F"), + close(), + close(), + ]; + + let floatingNoAlign = floatA.render(floating(false), { mode: "line" }); + let floatingAlign = floatB.render(floating(true), { mode: "line" }); + expect(bounds(floatingAlign, "float")).toEqual( + bounds(floatingNoAlign, "float"), + ); + expect(decode(floatingAlign.output)).toBe(decode(floatingNoAlign.output)); + expect(validate(floating(true))).toBe(true); + }); + + it("keeps configured main-axis gaps and child order", async () => { + let term = await createTerm({ width: 12, height: 6 }); + let ttb = term.render([ + ttbRoot(12, 6, { gap: 1 }), + open("a", { layout: { width: fit(), height: fixed(1) } }), + text("A"), + close(), + open("b", { + layout: { width: fit(), height: fixed(1), alignSelf: "flex-end" }, + }), + text("B"), + close(), + open("c", { layout: { width: fit(), height: fixed(1) } }), + text("C"), + close(), + close(), + ]); + + expect(bounds(ttb, "a").y).toBe(0); + expect(bounds(ttb, "b").y).toBe(2); + expect(bounds(ttb, "c").y).toBe(4); + expect(bounds(ttb, "b").x).toBe(11); + + let ltr = term.render([ + ltrRoot(6, 3, { gap: 1 }), + open("a", { layout: { width: fixed(1), height: fit() } }), + text("A"), + close(), + open("b", { + layout: { width: fixed(1), height: fit(), alignSelf: "flex-end" }, + }), + text("B"), + close(), + open("c", { layout: { width: fixed(1), height: fit() } }), + text("C"), + close(), + close(), + ]); + + expect(bounds(ltr, "a").x).toBe(0); + expect(bounds(ltr, "b").x).toBe(2); + expect(bounds(ltr, "c").x).toBe(4); + expect(bounds(ltr, "b").y).toBe(2); + }); + + it("renders direct ops and snapshots equivalently for stretch and alignSelf", async () => { + let stretchOps = [ + ttbRoot(12, 3), + open("child", { layout: { width: stretch(), height: fixed(1) } }), + text("S"), + close(), + close(), + ]; + + let directStretch = await createTerm({ width: 12, height: 3 }); + let snapStretch = await createTerm({ width: 12, height: 3 }); + expect( + decode( + snapStretch.render([snapshot(stretchOps)], { mode: "line" }).output, + ), + ) + .toBe(decode(directStretch.render(stretchOps, { mode: "line" }).output)); + + let child = [ + open("child", { + layout: { width: fit(), height: fixed(1), alignSelf: "flex-end" }, + }), + text("B"), + close(), + ]; + let wrap = (content: Op[]) => [ttbRoot(12, 3), ...content, close()]; + + let directAlign = await createTerm({ width: 12, height: 3 }); + let snapAlign = await createTerm({ width: 12, height: 3 }); + let direct = directAlign.render(wrap(child), { mode: "line" }); + let snapped = snapAlign.render(wrap([snapshot(child)]), { mode: "line" }); + + expect(bounds(snapped, "child")).toEqual(bounds(direct, "child")); + expect(bounds(snapped, "child").x).toBe(11); + expect(decode(snapped.output)).toBe(decode(direct.output)); + }); + + it("does not expose synthetic wrapper ids through pointer events", async () => { + let term = await createTerm({ width: 12, height: 4 }); + let result = term.render([ + ttbRoot(12, 4), + open("b", { + layout: { width: fit(), height: fixed(1), alignSelf: "flex-end" }, + }), + text("B"), + close(), + close(), + ], { pointer: { x: 11, y: 0, down: false } }); + + let allowed = new Set(["Clay__RootContainer", "root", "b"]); + for (let event of result.events) { + expect(allowed.has(event.id)).toBe(true); + } + expect(result.events).toContainEqual({ type: "pointerenter", id: "b" }); + }); + + it("does not emit terminal state management sequences for new layouts", async () => { + let term = await createTerm({ width: 12, height: 3 }); + let result = term.render([ + ttbRoot(12, 3), + open("b", { + layout: { width: stretch(), height: fixed(1), alignSelf: "center" }, + }), + text("B"), + close(), + close(), + ]); + + let output = decode(result.output); + expect(output).not.toContain("?1049"); + expect(output).not.toContain("?25"); + expect(output).not.toContain("?1000"); + expect(output).not.toContain("?1002"); + expect(output).not.toContain("?1003"); + expect(output).not.toContain("?1006"); + }); +}); diff --git a/validate.ts b/validate.ts index f41c0d7..d4f4983 100644 --- a/validate.ts +++ b/validate.ts @@ -38,7 +38,11 @@ const Fixed = Type.Object({ value: Type.Number(), }); -const SizingAxis = Type.Union([Fit, Grow, Percent, Fixed]); +const Stretch = Type.Object({ + type: Type.Literal("stretch"), +}); + +const SizingAxis = Type.Union([Fit, Grow, Percent, Fixed, Stretch]); /* ── Sub-objects ──────────────────────────────────────────────────── */ @@ -49,6 +53,17 @@ const Padding = Type.Object({ bottom: Type.Optional(u8), }); +const AlignSelf = Type.Union([ + Type.Literal("auto"), + Type.Literal("normal"), + Type.Literal("stretch"), + Type.Literal("center"), + Type.Literal("start"), + Type.Literal("end"), + Type.Literal("flex-start"), + Type.Literal("flex-end"), +]); + const Layout = Type.Object({ width: Type.Optional(SizingAxis), height: Type.Optional(SizingAxis), @@ -71,6 +86,7 @@ const Layout = Type.Object({ Type.Literal("bottom"), ]), ), + alignSelf: Type.Optional(AlignSelf), }); const CornerRadius = Type.Object({