@cel-tui/core 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/layout.ts CHANGED
@@ -86,20 +86,23 @@ function intrinsicMainSize(
86
86
  }
87
87
 
88
88
  if (node.type === "textinput") {
89
+ const tiPadX = (node.props.padding?.x ?? 0) * 2;
90
+ const tiPadY = (node.props.padding?.y ?? 0) * 2;
89
91
  if (isVertical) {
90
92
  const val = node.props.value || "";
91
- if (val.length === 0) return 1;
93
+ const innerCrossForTI = Math.max(1, crossSize - tiPadX);
94
+ if (val.length === 0) return 1 + tiPadY;
92
95
  const lines = val.split("\n");
93
96
  let total = 0;
94
97
  for (const line of lines) {
95
98
  total += Math.max(
96
99
  1,
97
- Math.ceil(visibleWidth(line) / Math.max(1, crossSize)),
100
+ Math.ceil(visibleWidth(line) / Math.max(1, innerCrossForTI)),
98
101
  );
99
102
  }
100
- return total;
103
+ return total + tiPadY;
101
104
  }
102
- return 0;
105
+ return 0 + tiPadX;
103
106
  }
104
107
 
105
108
  // Container: compute intrinsic size along the requested axis.
@@ -141,6 +144,47 @@ function intrinsicMainSize(
141
144
  return total + padMain;
142
145
  }
143
146
 
147
+ // Special case: wrapping HStack computing intrinsic height.
148
+ // Instead of max-of-children, simulate the row layout and sum row heights.
149
+ if (
150
+ node.type === "hstack" &&
151
+ (node.props as ContainerProps).flexWrap === "wrap"
152
+ ) {
153
+ const wrapWidths: number[] = [];
154
+ const wrapHeights: number[] = [];
155
+ for (const child of node.children) {
156
+ const cProps = getProps(child);
157
+ const flex = cProps?.flex ?? 0;
158
+ let w: number;
159
+ if (flex > 0) {
160
+ w = cProps?.minWidth ?? 0;
161
+ } else {
162
+ w =
163
+ resolveSizeValue(cProps?.width, innerCross) ??
164
+ intrinsicMainSize(child, false, innerCross);
165
+ if (cProps) {
166
+ w = clamp(w, cProps.minWidth ?? 0, cProps.maxWidth ?? Infinity);
167
+ }
168
+ }
169
+ wrapWidths.push(w);
170
+ const h =
171
+ resolveSizeValue(cProps?.height, 0) ??
172
+ intrinsicMainSize(child, true, innerCross);
173
+ wrapHeights.push(h);
174
+ }
175
+ const wrapRows = assignWrapRows(wrapWidths, innerCross, gap);
176
+ let total = 0;
177
+ for (let ri = 0; ri < wrapRows.length; ri++) {
178
+ let maxH = 0;
179
+ for (const idx of wrapRows[ri]!) {
180
+ if (wrapHeights[idx]! > maxH) maxH = wrapHeights[idx]!;
181
+ }
182
+ total += maxH;
183
+ if (ri < wrapRows.length - 1) total += gap;
184
+ }
185
+ return total + padMain;
186
+ }
187
+
144
188
  // Cross axis: max of children on the requested axis
145
189
  let maxSize = 0;
146
190
  for (const child of node.children) {
@@ -156,11 +200,61 @@ function intrinsicMainSize(
156
200
  resolveSizeValue(cProps?.width, 0) ??
157
201
  intrinsicMainSize(child, false, innerCross);
158
202
  }
203
+ // Apply child's cross-axis constraints (e.g. maxHeight on a TextInput
204
+ // inside an HStack) so the container's intrinsic size respects them.
205
+ if (cProps) {
206
+ const minCross = isVertical
207
+ ? (cProps.minHeight ?? 0)
208
+ : (cProps.minWidth ?? 0);
209
+ const maxCross = isVertical
210
+ ? (cProps.maxHeight ?? Infinity)
211
+ : (cProps.maxWidth ?? Infinity);
212
+ childSize = clamp(childSize, minCross, maxCross);
213
+ }
159
214
  if (childSize > maxSize) maxSize = childSize;
160
215
  }
161
216
  return maxSize + padMain;
162
217
  }
163
218
 
219
+ // --- Wrap row assignment ---
220
+
221
+ /**
222
+ * Assign children to rows for a wrapping HStack.
223
+ * Children are placed left-to-right; when adding the next child (plus gap)
224
+ * would exceed availWidth, a new row begins. A child wider than the
225
+ * container still gets its own row.
226
+ */
227
+ function assignWrapRows(
228
+ widths: number[],
229
+ availWidth: number,
230
+ gap: number,
231
+ ): number[][] {
232
+ if (widths.length === 0) return [];
233
+ const rows: number[][] = [];
234
+ let currentRow: number[] = [];
235
+ let rowWidth = 0;
236
+
237
+ for (let i = 0; i < widths.length; i++) {
238
+ const w = widths[i]!;
239
+ if (currentRow.length === 0) {
240
+ currentRow.push(i);
241
+ rowWidth = w;
242
+ } else {
243
+ const needed = rowWidth + gap + w;
244
+ if (needed > availWidth) {
245
+ rows.push(currentRow);
246
+ currentRow = [i];
247
+ rowWidth = w;
248
+ } else {
249
+ currentRow.push(i);
250
+ rowWidth = needed;
251
+ }
252
+ }
253
+ }
254
+ if (currentRow.length > 0) rows.push(currentRow);
255
+ return rows;
256
+ }
257
+
164
258
  // --- Largest remainder rounding ---
165
259
 
166
260
  function largestRemainder(fractions: number[], total: number): number[] {
@@ -202,6 +296,207 @@ export function layout(
202
296
  return layoutNode(root, 0, 0, rootW, rootH);
203
297
  }
204
298
 
299
+ /**
300
+ * Layout a wrapping HStack. Children are assigned to rows based on their
301
+ * base widths, then each row is laid out as an independent flex context.
302
+ */
303
+ function layoutWrapHStack(
304
+ node: Node,
305
+ rect: Rect,
306
+ props: ContainerProps,
307
+ children: Node[],
308
+ x: number,
309
+ y: number,
310
+ width: number,
311
+ height: number,
312
+ ): LayoutNode {
313
+ const gap = props.gap ?? 0;
314
+ const align = props.alignItems ?? "stretch";
315
+ const justify = props.justifyContent ?? "start";
316
+
317
+ // Padding
318
+ const padX = props.padding?.x ?? 0;
319
+ const padY = props.padding?.y ?? 0;
320
+ const innerX = x + padX;
321
+ const innerY = y + padY;
322
+ const innerW = Math.max(0, width - padX * 2);
323
+ const innerH = Math.max(0, height - padY * 2);
324
+
325
+ // Phase 1: Measure each child's base width and cross (height) size
326
+ const baseWidths: number[] = [];
327
+ const crossSizes: number[] = [];
328
+ const flexValues: number[] = [];
329
+
330
+ for (const child of children) {
331
+ const cProps = getProps(child);
332
+ const flex = cProps?.flex ?? 0;
333
+ flexValues.push(flex);
334
+
335
+ let baseW: number;
336
+ if (flex > 0) {
337
+ // Flex children use minWidth for row assignment (like CSS flex-basis)
338
+ baseW = cProps?.minWidth ?? 0;
339
+ } else {
340
+ baseW =
341
+ resolveSizeValue(cProps?.width, innerW) ??
342
+ intrinsicMainSize(child, false, innerH);
343
+ if (cProps) {
344
+ baseW = clamp(baseW, cProps.minWidth ?? 0, cProps.maxWidth ?? Infinity);
345
+ }
346
+ }
347
+ baseWidths.push(baseW);
348
+
349
+ // Always compute real cross size (explicit or intrinsic) for row height
350
+ let cross =
351
+ resolveSizeValue(cProps?.height, innerH) ??
352
+ intrinsicMainSize(child, true, innerW);
353
+ // Apply cross-axis constraints (e.g. maxHeight)
354
+ if (cProps) {
355
+ cross = clamp(cross, cProps.minHeight ?? 0, cProps.maxHeight ?? Infinity);
356
+ }
357
+ crossSizes.push(cross);
358
+ }
359
+
360
+ // Phase 2: Assign children to rows
361
+ const rows = assignWrapRows(baseWidths, innerW, gap);
362
+
363
+ // Phase 3: Layout each row independently
364
+ const layoutChildren: LayoutNode[] = [];
365
+ let rowY = 0;
366
+
367
+ for (let ri = 0; ri < rows.length; ri++) {
368
+ const rowIdx = rows[ri]!;
369
+ const rowGapTotal = gap * (rowIdx.length - 1);
370
+ const rowAvail = innerW - rowGapTotal;
371
+
372
+ // Compute fixed and flex totals for this row
373
+ let fixedMain = 0;
374
+ let totalFlex = 0;
375
+ for (const idx of rowIdx) {
376
+ if (flexValues[idx]! > 0) {
377
+ totalFlex += flexValues[idx]!;
378
+ } else {
379
+ fixedMain += baseWidths[idx]!;
380
+ }
381
+ }
382
+
383
+ // Distribute flex space within this row
384
+ const flexSpace = Math.max(0, rowAvail - fixedMain);
385
+ const childWidths: number[] = new Array(rowIdx.length);
386
+
387
+ if (totalFlex > 0) {
388
+ const flexPositions: number[] = [];
389
+ for (let ci = 0; ci < rowIdx.length; ci++) {
390
+ if (flexValues[rowIdx[ci]!]! > 0) flexPositions.push(ci);
391
+ }
392
+ const rawSizes = flexPositions.map(
393
+ (ci) => (flexValues[rowIdx[ci]!]! / totalFlex) * flexSpace,
394
+ );
395
+ const rounded = largestRemainder(rawSizes, flexSpace);
396
+
397
+ for (let fi = 0; fi < flexPositions.length; fi++) {
398
+ const ci = flexPositions[fi]!;
399
+ let size = rounded[fi]!;
400
+ const cProps = getProps(children[rowIdx[ci]!]!);
401
+ if (cProps) {
402
+ size = clamp(size, cProps.minWidth ?? 0, cProps.maxWidth ?? Infinity);
403
+ }
404
+ childWidths[ci] = size;
405
+ }
406
+ }
407
+
408
+ // Non-flex children keep their base width
409
+ for (let ci = 0; ci < rowIdx.length; ci++) {
410
+ if (childWidths[ci] === undefined) {
411
+ childWidths[ci] = baseWidths[rowIdx[ci]!]!;
412
+ }
413
+ }
414
+
415
+ // Row height = max cross size of children in this row
416
+ let rowHeight = 0;
417
+ for (const idx of rowIdx) {
418
+ if (crossSizes[idx]! > rowHeight) rowHeight = crossSizes[idx]!;
419
+ }
420
+
421
+ // justifyContent: compute main-axis starting offset
422
+ let totalUsedWidth = rowGapTotal;
423
+ for (let ci = 0; ci < rowIdx.length; ci++) {
424
+ totalUsedWidth += childWidths[ci]!;
425
+ }
426
+ const remainingMain = Math.max(0, innerW - totalUsedWidth);
427
+
428
+ let mainStart = 0;
429
+ let betweenGaps: number[] | null = null;
430
+
431
+ if (justify === "end") {
432
+ mainStart = remainingMain;
433
+ } else if (justify === "center") {
434
+ mainStart = Math.floor(remainingMain / 2);
435
+ } else if (justify === "space-between" && rowIdx.length > 1) {
436
+ const gapCount = rowIdx.length - 1;
437
+ const rawGaps = Array.from(
438
+ { length: gapCount },
439
+ () => remainingMain / gapCount,
440
+ );
441
+ betweenGaps = largestRemainder(rawGaps, remainingMain);
442
+ }
443
+
444
+ // Position children in this row
445
+ let xOffset = mainStart;
446
+ for (let ci = 0; ci < rowIdx.length; ci++) {
447
+ const idx = rowIdx[ci]!;
448
+ const childW = childWidths[ci]!;
449
+
450
+ // Cross-axis sizing: stretch fills row height, others keep their size
451
+ let childH: number;
452
+ if (align === "stretch") {
453
+ const cProps = getProps(children[idx]!);
454
+ const explicitH = resolveSizeValue(cProps?.height, innerH);
455
+ childH = explicitH ?? rowHeight;
456
+ } else {
457
+ childH = crossSizes[idx]!;
458
+ }
459
+
460
+ // Cross-axis alignment within the row
461
+ let crossOffset = 0;
462
+ if (align === "center") {
463
+ crossOffset = Math.floor((rowHeight - childH) / 2);
464
+ } else if (align === "end") {
465
+ crossOffset = rowHeight - childH;
466
+ }
467
+
468
+ const childX = innerX + xOffset;
469
+ const childY = innerY + rowY + crossOffset;
470
+
471
+ layoutChildren.push(
472
+ layoutNode(children[idx]!, childX, childY, childW, childH),
473
+ );
474
+
475
+ xOffset += childW;
476
+ if (ci < rowIdx.length - 1) {
477
+ xOffset += gap;
478
+ if (betweenGaps) {
479
+ xOffset += betweenGaps[ci]!;
480
+ }
481
+ }
482
+ }
483
+
484
+ rowY += rowHeight;
485
+ if (ri < rows.length - 1) {
486
+ rowY += gap;
487
+ }
488
+ }
489
+
490
+ // Intrinsic height: if no explicit height, shrink to fit all rows
491
+ const hasExplicitHeight =
492
+ props.height !== undefined || props.flex !== undefined;
493
+ if (!hasExplicitHeight) {
494
+ rect.height = rowY + padY * 2;
495
+ }
496
+
497
+ return { node, rect, children: layoutChildren };
498
+ }
499
+
205
500
  /**
206
501
  * Layout a node within the given available space.
207
502
  * Resolves the node's own explicit size (if any) against available space,
@@ -236,6 +531,11 @@ function layoutNode(
236
531
  return { node, rect, children: [] };
237
532
  }
238
533
 
534
+ // Wrapping HStack: separate layout path
535
+ if (node.type === "hstack" && props.flexWrap === "wrap") {
536
+ return layoutWrapHStack(node, rect, props, children, x, y, width, height);
537
+ }
538
+
239
539
  // Padding
240
540
  const padX = props.padding?.x ?? 0;
241
541
  const padY = props.padding?.y ?? 0;
@@ -281,6 +581,16 @@ function layoutNode(
281
581
  resolveSizeValue(cProps?.height, innerH) ??
282
582
  (useIntrinsicCross ? intrinsicMainSize(child, true, innerW) : innerH);
283
583
  }
584
+ // Apply cross-axis constraints
585
+ if (cProps) {
586
+ const minCross = isVertical
587
+ ? (cProps.minWidth ?? 0)
588
+ : (cProps.minHeight ?? 0);
589
+ const maxCross = isVertical
590
+ ? (cProps.maxWidth ?? Infinity)
591
+ : (cProps.maxHeight ?? Infinity);
592
+ cross = clamp(cross, minCross, maxCross);
593
+ }
284
594
  infos.push({ node: child, mainSize: 0, crossSize: cross, flex });
285
595
  } else {
286
596
  // Main-axis: explicit → percentage → intrinsic
@@ -304,7 +614,7 @@ function layoutNode(
304
614
  (useIntrinsicCross ? intrinsicMainSize(child, true, innerW) : innerH);
305
615
  }
306
616
 
307
- // Apply constraints
617
+ // Apply main-axis constraints
308
618
  if (cProps) {
309
619
  const minMain = isVertical
310
620
  ? (cProps.minHeight ?? 0)
@@ -314,6 +624,16 @@ function layoutNode(
314
624
  : (cProps.maxWidth ?? Infinity);
315
625
  main = clamp(main, minMain, maxMain);
316
626
  }
627
+ // Apply cross-axis constraints
628
+ if (cProps) {
629
+ const minCross = isVertical
630
+ ? (cProps.minWidth ?? 0)
631
+ : (cProps.minHeight ?? 0);
632
+ const maxCross = isVertical
633
+ ? (cProps.maxWidth ?? Infinity)
634
+ : (cProps.maxHeight ?? Infinity);
635
+ cross = clamp(cross, minCross, maxCross);
636
+ }
317
637
 
318
638
  fixedMain += main;
319
639
  infos.push({ node: child, mainSize: main, crossSize: cross, flex });