[READ-ONLY] Mirror of https://github.com/bombshell-dev/tty. Platform independent 2D layout engine for terminal applications based on Clay
5

Configure Feed

Select the types of activity you want to include in your feed.

spike: initial spike for extended flex layout

Ryan Rauh (Jun 15, 2026, 7:05 AM EDT) e18aeb19 be7e746d

+792 -12
+40 -2
ops.ts
··· 12 12 const PROP_CLIP = 0x10; 13 13 const PROP_FLOATING = 0x20; 14 14 15 + const ALIGN_SELF: Record<AlignSelf, number> = { 16 + auto: 0, 17 + normal: 1, 18 + stretch: 2, 19 + center: 3, 20 + start: 4, 21 + end: 5, 22 + "flex-start": 6, 23 + "flex-end": 7, 24 + }; 25 + 15 26 const encoder = new TextEncoder(); 16 27 17 28 function packAxis(view: DataView, offset: number, axis: SizingAxis): number { ··· 49 60 view.setFloat32(o, 0, true); 50 61 o += 4; 51 62 break; 63 + case "stretch": 64 + // Public stretch() maps to Clay's existing GROW primitive. The 65 + // normative guarantee is cross-axis fill; main-axis behavior remains 66 + // grow-like per the flex-layout-controls spec. 67 + view.setUint32(o, 1, true); 68 + o += 4; 69 + view.setFloat32(o, 0, true); 70 + o += 4; 71 + view.setFloat32(o, 0, true); 72 + o += 4; 73 + break; 52 74 } 53 75 return o; 54 76 } ··· 141 163 : 0; 142 164 143 165 view.setUint32(o, alignX | (alignY << 8), true); 166 + o += 4; 167 + 168 + view.setUint32(o, ALIGN_SELF[l.alignSelf ?? "auto"], true); 144 169 o += 4; 145 170 } 146 171 ··· 278 303 | { type: "fit"; min?: number; max?: number } 279 304 | { type: "grow"; min?: number; max?: number } 280 305 | { type: "percent"; value: number } 281 - | { type: "fixed"; value: number }; 306 + | { type: "fixed"; value: number } 307 + | { type: "stretch" }; 282 308 283 309 export const fit = (min = 0, max = 0): SizingAxis => ({ 284 310 type: "fit", ··· 295 321 value, 296 322 }); 297 323 export const fixed = (value: number): SizingAxis => ({ type: "fixed", value }); 324 + export const stretch = (): SizingAxis => ({ type: "stretch" }); 325 + 326 + export type AlignSelf = 327 + | "auto" 328 + | "normal" 329 + | "stretch" 330 + | "center" 331 + | "start" 332 + | "end" 333 + | "flex-start" 334 + | "flex-end"; 298 335 299 336 export interface CloseElement { 300 337 directive: typeof OP_CLOSE_ELEMENT; ··· 311 348 direction?: "ltr" | "ttb"; 312 349 alignX?: "left" | "center" | "right"; 313 350 alignY?: "top" | "center" | "bottom"; 351 + alignSelf?: AlignSelf; 314 352 }; 315 353 bg?: number; 316 354 cornerRadius?: { tl?: number; tr?: number; bl?: number; br?: number }; ··· 450 488 n += 4; // opcode 451 489 n += 4 + Math.ceil(encoder.encode(op.id).length / 4) * 4; // id string 452 490 n += 4; // mask 453 - if (op.layout) n += 6 * 4 + 4 + 4 + 4; // 2 axes (3 words each) + pad + gap + align 491 + if (op.layout) n += 6 * 4 + 4 + 4 + 4 + 4; // 2 axes (3 words each) + pad + gap + align + alignSelf 454 492 if (op.bg !== undefined) n += 4; 455 493 if (op.cornerRadius) n += 4; 456 494 if (op.border) n += 12;
+160 -9
src/clayterm.c
··· 34 34 #define PROP_CLIP 0x10 35 35 #define PROP_FLOATING 0x20 36 36 37 + #define ALIGN_SELF_AUTO 0 38 + #define ALIGN_SELF_NORMAL 1 39 + #define ALIGN_SELF_STRETCH 2 40 + #define ALIGN_SELF_CENTER 3 41 + #define ALIGN_SELF_START 4 42 + #define ALIGN_SELF_END 5 43 + #define ALIGN_SELF_FLEX_START 6 44 + #define ALIGN_SELF_FLEX_END 7 45 + 46 + #define USER_ELEMENT_STACK_MAX 4096 47 + 37 48 /* ── Instance state ───────────────────────────────────────────────── */ 38 49 39 50 #define MAX_ERRORS 32 ··· 52 63 Clay_ErrorData errors[MAX_ERRORS]; 53 64 int error_count; 54 65 }; 66 + 67 + typedef struct UserElementFrame { 68 + Clay_LayoutDirection direction; 69 + int close_align_self_wrapper; 70 + } UserElementFrame; 55 71 56 72 /* Memory layout inside the arena provided by the host: 57 73 * [Clayterm struct] [front cells] [back cells] [output buffer] ··· 396 412 return axis; 397 413 } 398 414 415 + static Clay_SizingAxis sizing_grow(float min, float max) { 416 + Clay_SizingAxis axis = {0}; 417 + axis.type = CLAY__SIZING_TYPE_GROW; 418 + axis.size.minMax.min = min; 419 + axis.size.minMax.max = max; 420 + return axis; 421 + } 422 + 423 + static Clay_SizingAxis grow_like_axis(Clay_SizingAxis axis) { 424 + if (axis.type == CLAY__SIZING_TYPE_FIT || 425 + axis.type == CLAY__SIZING_TYPE_GROW) { 426 + return sizing_grow(axis.size.minMax.min, axis.size.minMax.max); 427 + } 428 + return sizing_grow(0, 0); 429 + } 430 + 431 + static int axis_is_definite(Clay_SizingAxis axis) { 432 + return axis.type == CLAY__SIZING_TYPE_FIXED || 433 + axis.type == CLAY__SIZING_TYPE_PERCENT; 434 + } 435 + 436 + static int should_move_main_axis_to_wrapper(Clay_SizingAxis axis) { 437 + return axis.type == CLAY__SIZING_TYPE_FIXED || 438 + axis.type == CLAY__SIZING_TYPE_PERCENT || 439 + axis.type == CLAY__SIZING_TYPE_GROW; 440 + } 441 + 442 + static Clay_LayoutAlignmentX align_self_x(uint32_t align_self) { 443 + switch (align_self) { 444 + case ALIGN_SELF_CENTER: 445 + return CLAY_ALIGN_X_CENTER; 446 + case ALIGN_SELF_END: 447 + case ALIGN_SELF_FLEX_END: 448 + return CLAY_ALIGN_X_RIGHT; 449 + default: 450 + return CLAY_ALIGN_X_LEFT; 451 + } 452 + } 453 + 454 + static Clay_LayoutAlignmentY align_self_y(uint32_t align_self) { 455 + switch (align_self) { 456 + case ALIGN_SELF_CENTER: 457 + return CLAY_ALIGN_Y_CENTER; 458 + case ALIGN_SELF_END: 459 + case ALIGN_SELF_FLEX_END: 460 + return CLAY_ALIGN_Y_BOTTOM; 461 + default: 462 + return CLAY_ALIGN_Y_TOP; 463 + } 464 + } 465 + 466 + static void stretch_align_self_cross_axis(Clay_ElementDeclaration *decl, 467 + Clay_LayoutDirection parent_dir, 468 + uint32_t align_self) { 469 + if (align_self != ALIGN_SELF_STRETCH && align_self != ALIGN_SELF_NORMAL) 470 + return; 471 + 472 + Clay_SizingAxis *cross = parent_dir == CLAY_TOP_TO_BOTTOM 473 + ? &decl->layout.sizing.width 474 + : &decl->layout.sizing.height; 475 + if (!axis_is_definite(*cross)) { 476 + *cross = grow_like_axis(*cross); 477 + } 478 + } 479 + 480 + static void 481 + move_main_axis_to_align_self_wrapper(Clay_ElementDeclaration *decl, 482 + Clay_LayoutDirection parent_dir) { 483 + Clay_SizingAxis *main = parent_dir == CLAY_TOP_TO_BOTTOM 484 + ? &decl->layout.sizing.height 485 + : &decl->layout.sizing.width; 486 + if (should_move_main_axis_to_wrapper(*main)) { 487 + *main = sizing_grow(0, 0); 488 + } 489 + } 490 + 491 + static void open_align_self_wrapper(Clay_LayoutDirection parent_dir, 492 + uint32_t align_self, 493 + Clay_ElementDeclaration child_decl) { 494 + Clay_ElementDeclaration wrapper = {0}; 495 + wrapper.layout.layoutDirection = parent_dir; 496 + 497 + if (parent_dir == CLAY_TOP_TO_BOTTOM) { 498 + wrapper.layout.sizing.width = sizing_grow(0, 0); 499 + wrapper.layout.sizing.height = child_decl.layout.sizing.height; 500 + wrapper.layout.childAlignment.x = align_self_x(align_self); 501 + wrapper.layout.childAlignment.y = CLAY_ALIGN_Y_TOP; 502 + } else { 503 + wrapper.layout.sizing.width = child_decl.layout.sizing.width; 504 + wrapper.layout.sizing.height = sizing_grow(0, 0); 505 + wrapper.layout.childAlignment.x = CLAY_ALIGN_X_LEFT; 506 + wrapper.layout.childAlignment.y = align_self_y(align_self); 507 + } 508 + 509 + Clay__OpenElement(); 510 + Clay__ConfigureOpenElement(wrapper); 511 + } 512 + 399 513 /* ── Public API ───────────────────────────────────────────────────── */ 400 514 401 515 static int align64(int n) { return (n + 63) & ~63; } ··· 474 588 475 589 void reduce(struct Clayterm *ct, uint32_t *buf, int len, int mode, int row) { 476 590 int i = 0; 591 + UserElementFrame user_stack[USER_ELEMENT_STACK_MAX]; 592 + int user_depth = 0; 477 593 ct->error_count = 0; 478 594 479 595 Clay_BeginLayout(); ··· 489 605 char *id_chars = (char *)&buf[i]; 490 606 i += id_words; 491 607 492 - if (id_len > 0) { 493 - Clay_String str = {.length = (int32_t)id_len, .chars = id_chars}; 494 - Clay_ElementId eid = Clay__HashString(str, 0); 495 - Clay__OpenElementWithId(eid); 496 - } else { 497 - Clay__OpenElement(); 498 - } 499 - 500 608 /* read property mask */ 501 609 uint32_t mask = rd(buf, len, &i); 502 610 Clay_ElementDeclaration decl = {0}; 611 + uint32_t align_self = ALIGN_SELF_AUTO; 503 612 504 613 if (mask & PROP_LAYOUT) { 505 614 decl.layout.sizing.width = decode_axis(buf, len, &i); ··· 518 627 uint32_t al = rd(buf, len, &i); 519 628 decl.layout.childAlignment.x = al & 0xff; 520 629 decl.layout.childAlignment.y = (al >> 8) & 0xff; 630 + 631 + align_self = rd(buf, len, &i); 521 632 } 522 633 523 634 if (mask & PROP_BG_COLOR) { ··· 568 679 decl.floating.zIndex = (int16_t)(fd >> 8); 569 680 } 570 681 682 + int close_align_self_wrapper = 0; 683 + int has_normal_flow_parent = user_depth > 0; 684 + int is_floating = (mask & PROP_FLOATING) != 0; 685 + Clay_LayoutDirection parent_dir = 686 + has_normal_flow_parent ? user_stack[user_depth - 1].direction 687 + : CLAY_LEFT_TO_RIGHT; 688 + 689 + if (has_normal_flow_parent && !is_floating && 690 + align_self != ALIGN_SELF_AUTO) { 691 + stretch_align_self_cross_axis(&decl, parent_dir, align_self); 692 + open_align_self_wrapper(parent_dir, align_self, decl); 693 + move_main_axis_to_align_self_wrapper(&decl, parent_dir); 694 + close_align_self_wrapper = 1; 695 + } 696 + 697 + if (id_len > 0) { 698 + Clay_String str = {.length = (int32_t)id_len, .chars = id_chars}; 699 + Clay_ElementId eid = Clay__HashString(str, 0); 700 + Clay__OpenElementWithId(eid); 701 + } else { 702 + Clay__OpenElement(); 703 + } 704 + 571 705 Clay__ConfigureOpenElement(decl); 706 + 707 + if (user_depth < USER_ELEMENT_STACK_MAX) { 708 + user_stack[user_depth++] = (UserElementFrame){ 709 + .direction = decl.layout.layoutDirection, 710 + .close_align_self_wrapper = close_align_self_wrapper, 711 + }; 712 + } 572 713 break; 573 714 } 574 715 ··· 596 737 break; 597 738 } 598 739 599 - case OP_CLOSE_ELEMENT: 740 + case OP_CLOSE_ELEMENT: { 741 + int close_align_self_wrapper = 0; 742 + if (user_depth > 0) { 743 + close_align_self_wrapper = 744 + user_stack[user_depth - 1].close_align_self_wrapper; 745 + user_depth--; 746 + } 600 747 Clay__CloseElement(); 748 + if (close_align_self_wrapper) { 749 + Clay__CloseElement(); 750 + } 601 751 break; 752 + } 602 753 603 754 default: 604 755 break;
+575
test/flex-layout-controls.test.ts
··· 1 + import { describe, expect, it } from "./suite.ts"; 2 + import * as mod from "../mod.ts"; 3 + import { 4 + close, 5 + fit, 6 + fixed, 7 + grow, 8 + type Op, 9 + open, 10 + pack, 11 + percent, 12 + snapshot, 13 + stretch, 14 + text, 15 + } from "../ops.ts"; 16 + import { createTerm, type RenderResult } from "../term.ts"; 17 + import { validate } from "../validate.ts"; 18 + 19 + const decode = (bytes: Uint8Array) => new TextDecoder().decode(bytes); 20 + 21 + function bounds(result: RenderResult, id: string) { 22 + let info = result.info.get(id); 23 + expect(info).toBeDefined(); 24 + return info!.bounds; 25 + } 26 + 27 + function ttbRoot(width: number, height: number, extraLayout = {}) { 28 + return open("root", { 29 + layout: { 30 + width: fixed(width), 31 + height: fixed(height), 32 + direction: "ttb" as const, 33 + ...extraLayout, 34 + }, 35 + }); 36 + } 37 + 38 + function ltrRoot(width: number, height: number, extraLayout = {}) { 39 + return open("root", { 40 + layout: { 41 + width: fixed(width), 42 + height: fixed(height), 43 + direction: "ltr" as const, 44 + ...extraLayout, 45 + }, 46 + }); 47 + } 48 + 49 + describe("flex layout controls", () => { 50 + it("exports stretch() as a plain sizing object and does not export #80 helpers", () => { 51 + expect(stretch()).toEqual({ type: "stretch" }); 52 + expect(mod.stretch()).toEqual({ type: "stretch" }); 53 + expect(Object.getPrototypeOf(stretch())).toBe(Object.prototype); 54 + 55 + let exported = mod as unknown as Record<string, unknown>; 56 + expect("basis" in exported).toBe(false); 57 + expect("flexBasis" in exported).toBe(false); 58 + expect("shrinkWeight" in exported).toBe(false); 59 + expect("flexShrink" in exported).toBe(false); 60 + }); 61 + 62 + it("validates supported alignSelf values and stretch axes", () => { 63 + let values = [ 64 + "auto", 65 + "normal", 66 + "stretch", 67 + "center", 68 + "start", 69 + "end", 70 + "flex-start", 71 + "flex-end", 72 + ] as const; 73 + 74 + for (let alignSelf of values) { 75 + expect(validate([ 76 + open("root"), 77 + open("child", { layout: { alignSelf } }), 78 + close(), 79 + close(), 80 + ])).toBe(true); 81 + } 82 + 83 + expect(validate([ 84 + open("root", { layout: { width: stretch(), height: stretch() } }), 85 + close(), 86 + ])).toBe(true); 87 + }); 88 + 89 + it("rejects invalid alignSelf values and invalid sizing discriminators", () => { 90 + expect(validate([ 91 + { directive: 0x02, id: "x", layout: { alignSelf: "baseline" } }, 92 + { directive: 0x04 }, 93 + ])).toBe(false); 94 + 95 + expect(validate([ 96 + { directive: 0x02, id: "x", layout: { alignSelf: 1 } }, 97 + { directive: 0x04 }, 98 + ])).toBe(false); 99 + 100 + expect(validate([ 101 + { directive: 0x02, id: "x", layout: { width: { type: "stretched" } } }, 102 + { directive: 0x04 }, 103 + ])).toBe(false); 104 + 105 + for (let alignSelf of ["self-start", "safe center", "unsafe center"]) { 106 + expect(validate([ 107 + { directive: 0x02, id: "x", layout: { alignSelf } }, 108 + { directive: 0x04 }, 109 + ])).toBe(false); 110 + } 111 + }); 112 + 113 + it("packs stretch axes and snapshots ops containing stretch and alignSelf", () => { 114 + let widthStretch = [ 115 + open("root", { layout: { width: stretch(), height: fixed(1) } }), 116 + close(), 117 + ]; 118 + let heightStretch = [ 119 + open("root", { layout: { width: fixed(1), height: stretch() } }), 120 + close(), 121 + ]; 122 + let alignSelf = [ 123 + ttbRoot(12, 1), 124 + open("child", { layout: { width: fit(), alignSelf: "flex-end" } }), 125 + text("B"), 126 + close(), 127 + close(), 128 + ]; 129 + 130 + expect(() => pack(widthStretch, new ArrayBuffer(512), 0, 512)).not 131 + .toThrow(); 132 + expect(() => pack(heightStretch, new ArrayBuffer(512), 0, 512)).not 133 + .toThrow(); 134 + expect(() => snapshot(widthStretch)).not.toThrow(); 135 + expect(() => snapshot(heightStretch)).not.toThrow(); 136 + expect(() => snapshot(alignSelf)).not.toThrow(); 137 + }); 138 + 139 + it("aligns one ttb child to cross-end without moving siblings", async () => { 140 + let term = await createTerm({ width: 12, height: 4 }); 141 + let result = term.render([ 142 + ttbRoot(12, 4), 143 + open("a", { layout: { width: fit(), height: fixed(1) } }), 144 + text("A"), 145 + close(), 146 + open("b", { 147 + layout: { width: fit(), height: fixed(1), alignSelf: "flex-end" }, 148 + }), 149 + text("B"), 150 + close(), 151 + close(), 152 + ]); 153 + 154 + expect(bounds(result, "a")).toEqual({ x: 0, y: 0, width: 1, height: 1 }); 155 + expect(bounds(result, "b")).toEqual({ x: 11, y: 1, width: 1, height: 1 }); 156 + expect(result.info.get("")).toBeUndefined(); 157 + }); 158 + 159 + it("aligns one ltr child to cross-end", async () => { 160 + let term = await createTerm({ width: 3, height: 5 }); 161 + let result = term.render([ 162 + ltrRoot(3, 5), 163 + open("b", { 164 + layout: { width: fixed(1), height: fit(), alignSelf: "flex-end" }, 165 + }), 166 + text("B"), 167 + close(), 168 + close(), 169 + ]); 170 + 171 + expect(bounds(result, "b")).toEqual({ x: 0, y: 4, width: 1, height: 1 }); 172 + }); 173 + 174 + it("maps start and flex-start to cross-start in both directions", async () => { 175 + let term = await createTerm({ width: 12, height: 6 }); 176 + 177 + for (let alignSelf of ["start", "flex-start"] as const) { 178 + let ttb = term.render([ 179 + ttbRoot(12, 3, { alignX: "right" as const }), 180 + open("child", { 181 + layout: { width: fit(), height: fixed(1), alignSelf }, 182 + }), 183 + text("C"), 184 + close(), 185 + close(), 186 + ]); 187 + expect(bounds(ttb, "child").x).toBe(0); 188 + 189 + let ltr = term.render([ 190 + ltrRoot(3, 6, { alignY: "bottom" as const }), 191 + open("child", { 192 + layout: { width: fixed(1), height: fit(), alignSelf }, 193 + }), 194 + text("C"), 195 + close(), 196 + close(), 197 + ]); 198 + expect(bounds(ltr, "child").y).toBe(0); 199 + } 200 + }); 201 + 202 + it("centers alignSelf children within the parent content extent", async () => { 203 + let term = await createTerm({ width: 12, height: 6 }); 204 + 205 + let ttb = term.render([ 206 + ttbRoot(12, 3), 207 + open("child", { 208 + layout: { width: fixed(2), height: fixed(1), alignSelf: "center" }, 209 + }), 210 + close(), 211 + close(), 212 + ]); 213 + expect(bounds(ttb, "child").x).toBe(5); 214 + 215 + let ltr = term.render([ 216 + ltrRoot(3, 6), 217 + open("child", { 218 + layout: { width: fixed(1), height: fixed(2), alignSelf: "center" }, 219 + }), 220 + close(), 221 + close(), 222 + ]); 223 + expect(bounds(ltr, "child").y).toBe(2); 224 + }); 225 + 226 + it("omitted alignSelf and auto follow parent cross-axis alignment", async () => { 227 + let withoutAuto = await createTerm({ width: 12, height: 2 }); 228 + let withAuto = await createTerm({ width: 12, height: 2 }); 229 + 230 + let omittedOps = [ 231 + ttbRoot(12, 2, { alignX: "right" as const }), 232 + open("child", { layout: { width: fit(), height: fixed(1) } }), 233 + text("B"), 234 + close(), 235 + close(), 236 + ]; 237 + let autoOps = [ 238 + ttbRoot(12, 2, { alignX: "right" as const }), 239 + open("child", { 240 + layout: { width: fit(), height: fixed(1), alignSelf: "auto" }, 241 + }), 242 + text("B"), 243 + close(), 244 + close(), 245 + ]; 246 + 247 + let omitted = withoutAuto.render(omittedOps, { mode: "line" }); 248 + let auto = withAuto.render(autoOps, { mode: "line" }); 249 + 250 + expect(bounds(omitted, "child")).toEqual(bounds(auto, "child")); 251 + expect(bounds(auto, "child").x).toBe(11); 252 + expect(decode(auto.output)).toBe(decode(omitted.output)); 253 + }); 254 + 255 + it("stretches auto-like cross sizes for stretch and normal alignSelf", async () => { 256 + let term = await createTerm({ width: 12, height: 4 }); 257 + let result = term.render([ 258 + ttbRoot(12, 4), 259 + open("omitted", { layout: { height: fixed(1), alignSelf: "stretch" } }), 260 + text("O"), 261 + close(), 262 + open("fit", { 263 + layout: { width: fit(), height: fixed(1), alignSelf: "stretch" }, 264 + }), 265 + text("F"), 266 + close(), 267 + open("normal", { 268 + layout: { width: fit(), height: fixed(1), alignSelf: "normal" }, 269 + }), 270 + text("N"), 271 + close(), 272 + close(), 273 + ]); 274 + 275 + expect(bounds(result, "omitted").width).toBe(12); 276 + expect(bounds(result, "fit").width).toBe(12); 277 + expect(bounds(result, "normal").width).toBe(12); 278 + }); 279 + 280 + it("preserves definite cross sizes under stretch alignSelf", async () => { 281 + let term = await createTerm({ width: 12, height: 3 }); 282 + let result = term.render([ 283 + ttbRoot(12, 3, { alignX: "right" as const }), 284 + open("fixed", { 285 + layout: { width: fixed(4), height: fixed(1), alignSelf: "stretch" }, 286 + }), 287 + text("F"), 288 + close(), 289 + open("percent", { 290 + layout: { width: percent(0.5), height: fixed(1), alignSelf: "stretch" }, 291 + }), 292 + text("P"), 293 + close(), 294 + close(), 295 + ]); 296 + 297 + expect(bounds(result, "fixed")).toEqual({ 298 + x: 0, 299 + y: 0, 300 + width: 4, 301 + height: 1, 302 + }); 303 + expect(bounds(result, "percent")).toEqual({ 304 + x: 0, 305 + y: 1, 306 + width: 6, 307 + height: 1, 308 + }); 309 + }); 310 + 311 + it("uses parent padding as the cross-axis content extent", async () => { 312 + let term = await createTerm({ width: 12, height: 6 }); 313 + 314 + let alignStretch = term.render([ 315 + ttbRoot(12, 3, { padding: { left: 1, right: 1 } }), 316 + open("child", { layout: { height: fixed(1), alignSelf: "stretch" } }), 317 + text("S"), 318 + close(), 319 + close(), 320 + ]); 321 + expect(bounds(alignStretch, "child")).toEqual({ 322 + x: 1, 323 + y: 0, 324 + width: 10, 325 + height: 1, 326 + }); 327 + 328 + let stretchWidth = term.render([ 329 + ttbRoot(12, 3, { padding: { left: 1, right: 1 } }), 330 + open("child", { layout: { width: stretch(), height: fixed(1) } }), 331 + text("S"), 332 + close(), 333 + close(), 334 + ]); 335 + expect(bounds(stretchWidth, "child")).toEqual({ 336 + x: 1, 337 + y: 0, 338 + width: 10, 339 + height: 1, 340 + }); 341 + 342 + let stretchHeight = term.render([ 343 + ltrRoot(3, 6, { padding: { top: 1, bottom: 1 } }), 344 + open("child", { layout: { width: fixed(1), height: stretch() } }), 345 + text("S"), 346 + close(), 347 + close(), 348 + ]); 349 + expect(bounds(stretchHeight, "child")).toEqual({ 350 + x: 0, 351 + y: 1, 352 + width: 1, 353 + height: 4, 354 + }); 355 + }); 356 + 357 + it("preserves descendant alignment and main-axis grow sizing", async () => { 358 + let term = await createTerm({ width: 12, height: 4 }); 359 + let descendant = term.render([ 360 + ttbRoot(12, 4), 361 + open("outer", { 362 + layout: { 363 + width: fixed(4), 364 + height: fixed(2), 365 + direction: "ttb", 366 + alignX: "left", 367 + alignSelf: "flex-end", 368 + }, 369 + }), 370 + open("inner", { layout: { width: fit(), height: fixed(1) } }), 371 + text("I"), 372 + close(), 373 + close(), 374 + close(), 375 + ]); 376 + 377 + expect(bounds(descendant, "outer")).toEqual({ 378 + x: 8, 379 + y: 0, 380 + width: 4, 381 + height: 2, 382 + }); 383 + expect(bounds(descendant, "inner")).toEqual({ 384 + x: 8, 385 + y: 0, 386 + width: 1, 387 + height: 1, 388 + }); 389 + 390 + let growMain = term.render([ 391 + ttbRoot(12, 4), 392 + open("a", { layout: { width: fit(), height: fixed(1) } }), 393 + text("A"), 394 + close(), 395 + open("b", { 396 + layout: { width: fit(), height: grow(), alignSelf: "flex-end" }, 397 + }), 398 + text("B"), 399 + close(), 400 + close(), 401 + ]); 402 + 403 + expect(bounds(growMain, "b")).toEqual({ x: 11, y: 1, width: 1, height: 3 }); 404 + }); 405 + 406 + it("treats root and floating alignSelf as no-ops", async () => { 407 + let rootA = await createTerm({ width: 12, height: 3 }); 408 + let rootB = await createTerm({ width: 12, height: 3 }); 409 + 410 + let withoutRootAlign = [ 411 + open("root", { layout: { width: fixed(2), height: fixed(1) } }), 412 + text("R"), 413 + close(), 414 + ]; 415 + let withRootAlign = [ 416 + open("root", { 417 + layout: { width: fixed(2), height: fixed(1), alignSelf: "flex-end" }, 418 + }), 419 + text("R"), 420 + close(), 421 + ]; 422 + 423 + let noAlign = rootA.render(withoutRootAlign, { mode: "line" }); 424 + let align = rootB.render(withRootAlign, { mode: "line" }); 425 + expect(bounds(align, "root")).toEqual(bounds(noAlign, "root")); 426 + expect(decode(align.output)).toBe(decode(noAlign.output)); 427 + 428 + let floatA = await createTerm({ width: 12, height: 6 }); 429 + let floatB = await createTerm({ width: 12, height: 6 }); 430 + let floating = (alignSelf = false): Op[] => [ 431 + ttbRoot(12, 6), 432 + open("float", { 433 + layout: { 434 + width: fixed(2), 435 + height: fixed(1), 436 + ...(alignSelf ? { alignSelf: "flex-end" as const } : {}), 437 + }, 438 + floating: { x: 2, y: 1, attachTo: "root" }, 439 + }), 440 + text("F"), 441 + close(), 442 + close(), 443 + ]; 444 + 445 + let floatingNoAlign = floatA.render(floating(false), { mode: "line" }); 446 + let floatingAlign = floatB.render(floating(true), { mode: "line" }); 447 + expect(bounds(floatingAlign, "float")).toEqual( 448 + bounds(floatingNoAlign, "float"), 449 + ); 450 + expect(decode(floatingAlign.output)).toBe(decode(floatingNoAlign.output)); 451 + expect(validate(floating(true))).toBe(true); 452 + }); 453 + 454 + it("keeps configured main-axis gaps and child order", async () => { 455 + let term = await createTerm({ width: 12, height: 6 }); 456 + let ttb = term.render([ 457 + ttbRoot(12, 6, { gap: 1 }), 458 + open("a", { layout: { width: fit(), height: fixed(1) } }), 459 + text("A"), 460 + close(), 461 + open("b", { 462 + layout: { width: fit(), height: fixed(1), alignSelf: "flex-end" }, 463 + }), 464 + text("B"), 465 + close(), 466 + open("c", { layout: { width: fit(), height: fixed(1) } }), 467 + text("C"), 468 + close(), 469 + close(), 470 + ]); 471 + 472 + expect(bounds(ttb, "a").y).toBe(0); 473 + expect(bounds(ttb, "b").y).toBe(2); 474 + expect(bounds(ttb, "c").y).toBe(4); 475 + expect(bounds(ttb, "b").x).toBe(11); 476 + 477 + let ltr = term.render([ 478 + ltrRoot(6, 3, { gap: 1 }), 479 + open("a", { layout: { width: fixed(1), height: fit() } }), 480 + text("A"), 481 + close(), 482 + open("b", { 483 + layout: { width: fixed(1), height: fit(), alignSelf: "flex-end" }, 484 + }), 485 + text("B"), 486 + close(), 487 + open("c", { layout: { width: fixed(1), height: fit() } }), 488 + text("C"), 489 + close(), 490 + close(), 491 + ]); 492 + 493 + expect(bounds(ltr, "a").x).toBe(0); 494 + expect(bounds(ltr, "b").x).toBe(2); 495 + expect(bounds(ltr, "c").x).toBe(4); 496 + expect(bounds(ltr, "b").y).toBe(2); 497 + }); 498 + 499 + it("renders direct ops and snapshots equivalently for stretch and alignSelf", async () => { 500 + let stretchOps = [ 501 + ttbRoot(12, 3), 502 + open("child", { layout: { width: stretch(), height: fixed(1) } }), 503 + text("S"), 504 + close(), 505 + close(), 506 + ]; 507 + 508 + let directStretch = await createTerm({ width: 12, height: 3 }); 509 + let snapStretch = await createTerm({ width: 12, height: 3 }); 510 + expect( 511 + decode( 512 + snapStretch.render([snapshot(stretchOps)], { mode: "line" }).output, 513 + ), 514 + ) 515 + .toBe(decode(directStretch.render(stretchOps, { mode: "line" }).output)); 516 + 517 + let child = [ 518 + open("child", { 519 + layout: { width: fit(), height: fixed(1), alignSelf: "flex-end" }, 520 + }), 521 + text("B"), 522 + close(), 523 + ]; 524 + let wrap = (content: Op[]) => [ttbRoot(12, 3), ...content, close()]; 525 + 526 + let directAlign = await createTerm({ width: 12, height: 3 }); 527 + let snapAlign = await createTerm({ width: 12, height: 3 }); 528 + let direct = directAlign.render(wrap(child), { mode: "line" }); 529 + let snapped = snapAlign.render(wrap([snapshot(child)]), { mode: "line" }); 530 + 531 + expect(bounds(snapped, "child")).toEqual(bounds(direct, "child")); 532 + expect(bounds(snapped, "child").x).toBe(11); 533 + expect(decode(snapped.output)).toBe(decode(direct.output)); 534 + }); 535 + 536 + it("does not expose synthetic wrapper ids through pointer events", async () => { 537 + let term = await createTerm({ width: 12, height: 4 }); 538 + let result = term.render([ 539 + ttbRoot(12, 4), 540 + open("b", { 541 + layout: { width: fit(), height: fixed(1), alignSelf: "flex-end" }, 542 + }), 543 + text("B"), 544 + close(), 545 + close(), 546 + ], { pointer: { x: 11, y: 0, down: false } }); 547 + 548 + let allowed = new Set(["Clay__RootContainer", "root", "b"]); 549 + for (let event of result.events) { 550 + expect(allowed.has(event.id)).toBe(true); 551 + } 552 + expect(result.events).toContainEqual({ type: "pointerenter", id: "b" }); 553 + }); 554 + 555 + it("does not emit terminal state management sequences for new layouts", async () => { 556 + let term = await createTerm({ width: 12, height: 3 }); 557 + let result = term.render([ 558 + ttbRoot(12, 3), 559 + open("b", { 560 + layout: { width: stretch(), height: fixed(1), alignSelf: "center" }, 561 + }), 562 + text("B"), 563 + close(), 564 + close(), 565 + ]); 566 + 567 + let output = decode(result.output); 568 + expect(output).not.toContain("?1049"); 569 + expect(output).not.toContain("?25"); 570 + expect(output).not.toContain("?1000"); 571 + expect(output).not.toContain("?1002"); 572 + expect(output).not.toContain("?1003"); 573 + expect(output).not.toContain("?1006"); 574 + }); 575 + });
+17 -1
validate.ts
··· 38 38 value: Type.Number(), 39 39 }); 40 40 41 - const SizingAxis = Type.Union([Fit, Grow, Percent, Fixed]); 41 + const Stretch = Type.Object({ 42 + type: Type.Literal("stretch"), 43 + }); 44 + 45 + const SizingAxis = Type.Union([Fit, Grow, Percent, Fixed, Stretch]); 42 46 43 47 /* ── Sub-objects ──────────────────────────────────────────────────── */ 44 48 ··· 49 53 bottom: Type.Optional(u8), 50 54 }); 51 55 56 + const AlignSelf = Type.Union([ 57 + Type.Literal("auto"), 58 + Type.Literal("normal"), 59 + Type.Literal("stretch"), 60 + Type.Literal("center"), 61 + Type.Literal("start"), 62 + Type.Literal("end"), 63 + Type.Literal("flex-start"), 64 + Type.Literal("flex-end"), 65 + ]); 66 + 52 67 const Layout = Type.Object({ 53 68 width: Type.Optional(SizingAxis), 54 69 height: Type.Optional(SizingAxis), ··· 71 86 Type.Literal("bottom"), 72 87 ]), 73 88 ), 89 + alignSelf: Type.Optional(AlignSelf), 74 90 }); 75 91 76 92 const CornerRadius = Type.Object({