@cel-tui/core 0.1.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/package.json +42 -0
- package/src/cel.ts +716 -0
- package/src/cell-buffer.ts +196 -0
- package/src/emitter.ts +192 -0
- package/src/hit-test.ts +146 -0
- package/src/index.ts +49 -0
- package/src/keys.ts +147 -0
- package/src/layout.ts +422 -0
- package/src/paint.ts +646 -0
- package/src/primitives/stacks.ts +42 -0
- package/src/primitives/text-input.ts +42 -0
- package/src/primitives/text.ts +29 -0
- package/src/terminal.ts +194 -0
- package/src/text-edit.ts +164 -0
- package/src/width.ts +174 -0
package/src/keys.ts
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parse raw terminal input data into a normalized key string.
|
|
3
|
+
*
|
|
4
|
+
* Handles:
|
|
5
|
+
* - Printable ASCII characters
|
|
6
|
+
* - Control characters (Ctrl+A through Ctrl+Z)
|
|
7
|
+
* - Special keys (Enter, Escape, Tab, Backspace, Delete)
|
|
8
|
+
* - Arrow keys, Home, End, Page Up/Down
|
|
9
|
+
* - CSI sequences
|
|
10
|
+
*
|
|
11
|
+
* @param data - Raw terminal input string.
|
|
12
|
+
* @returns Normalized key string (e.g., `"ctrl+s"`, `"escape"`, `"up"`).
|
|
13
|
+
*/
|
|
14
|
+
export function parseKey(data: string): string {
|
|
15
|
+
// CSI sequences: ESC [ ...
|
|
16
|
+
if (data.startsWith("\x1b[")) {
|
|
17
|
+
return parseCsiSequence(data);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// Single escape
|
|
21
|
+
if (data === "\x1b") return "escape";
|
|
22
|
+
|
|
23
|
+
// Control characters
|
|
24
|
+
if (data.length === 1) {
|
|
25
|
+
const code = data.charCodeAt(0);
|
|
26
|
+
|
|
27
|
+
// Ctrl+A (0x01) through Ctrl+Z (0x1a), excluding special ones
|
|
28
|
+
if (code >= 1 && code <= 26) {
|
|
29
|
+
const letter = String.fromCharCode(code + 96); // 1→a, 2→b, etc.
|
|
30
|
+
// Special cases
|
|
31
|
+
if (code === 9) return "tab"; // Ctrl+I = Tab
|
|
32
|
+
if (code === 13) return "enter"; // Ctrl+M = Enter
|
|
33
|
+
return `ctrl+${letter}`;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Backspace
|
|
37
|
+
if (code === 127) return "backspace";
|
|
38
|
+
|
|
39
|
+
// Printable ASCII
|
|
40
|
+
if (code >= 32 && code <= 126) return data.toLowerCase();
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Multi-byte (e.g., UTF-8) — return as-is lowercase
|
|
44
|
+
return data.toLowerCase();
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function parseCsiSequence(data: string): string {
|
|
48
|
+
const seq = data.slice(2); // Remove ESC [
|
|
49
|
+
|
|
50
|
+
switch (seq) {
|
|
51
|
+
case "A":
|
|
52
|
+
return "up";
|
|
53
|
+
case "B":
|
|
54
|
+
return "down";
|
|
55
|
+
case "C":
|
|
56
|
+
return "right";
|
|
57
|
+
case "D":
|
|
58
|
+
return "left";
|
|
59
|
+
case "H":
|
|
60
|
+
return "home";
|
|
61
|
+
case "F":
|
|
62
|
+
return "end";
|
|
63
|
+
case "Z":
|
|
64
|
+
return "shift+tab";
|
|
65
|
+
case "3~":
|
|
66
|
+
return "delete";
|
|
67
|
+
case "5~":
|
|
68
|
+
return "pageup";
|
|
69
|
+
case "6~":
|
|
70
|
+
return "pagedown";
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Function keys
|
|
74
|
+
const fnMatch = seq.match(/^(\d+)~$/);
|
|
75
|
+
if (fnMatch) {
|
|
76
|
+
const num = parseInt(fnMatch[1]!, 10);
|
|
77
|
+
const fnMap: Record<number, string> = {
|
|
78
|
+
11: "f1",
|
|
79
|
+
12: "f2",
|
|
80
|
+
13: "f3",
|
|
81
|
+
14: "f4",
|
|
82
|
+
15: "f5",
|
|
83
|
+
17: "f6",
|
|
84
|
+
18: "f7",
|
|
85
|
+
19: "f8",
|
|
86
|
+
20: "f9",
|
|
87
|
+
21: "f10",
|
|
88
|
+
23: "f11",
|
|
89
|
+
24: "f12",
|
|
90
|
+
};
|
|
91
|
+
if (fnMap[num]) return fnMap[num]!;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Unknown CSI sequence
|
|
95
|
+
return `unknown:${data}`;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Normalize a key string to canonical format.
|
|
100
|
+
*
|
|
101
|
+
* Lowercases everything and reorders modifiers to the canonical
|
|
102
|
+
* order: `ctrl+alt+shift+<key>`.
|
|
103
|
+
*
|
|
104
|
+
* @param key - Key string to normalize.
|
|
105
|
+
* @returns Normalized key string.
|
|
106
|
+
*/
|
|
107
|
+
export function normalizeKey(key: string): string {
|
|
108
|
+
const parts = key.toLowerCase().split("+");
|
|
109
|
+
if (parts.length <= 1) return key.toLowerCase();
|
|
110
|
+
|
|
111
|
+
const base = parts[parts.length - 1]!;
|
|
112
|
+
const mods = parts.slice(0, -1);
|
|
113
|
+
|
|
114
|
+
// Canonical order: ctrl, alt, shift
|
|
115
|
+
const ordered: string[] = [];
|
|
116
|
+
if (mods.includes("ctrl")) ordered.push("ctrl");
|
|
117
|
+
if (mods.includes("alt")) ordered.push("alt");
|
|
118
|
+
if (mods.includes("shift")) ordered.push("shift");
|
|
119
|
+
|
|
120
|
+
return [...ordered, base].join("+");
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Check if a parsed key is a text-editing key that TextInput should consume.
|
|
125
|
+
* Modifier combos (ctrl+s, alt+x) are NOT editing keys and should bubble.
|
|
126
|
+
*/
|
|
127
|
+
export function isEditingKey(key: string): boolean {
|
|
128
|
+
// Single printable characters
|
|
129
|
+
if (key.length === 1) return true;
|
|
130
|
+
|
|
131
|
+
// Navigation and editing keys consumed by TextInput
|
|
132
|
+
const editingKeys = new Set([
|
|
133
|
+
"enter",
|
|
134
|
+
"backspace",
|
|
135
|
+
"delete",
|
|
136
|
+
"tab",
|
|
137
|
+
"up",
|
|
138
|
+
"down",
|
|
139
|
+
"left",
|
|
140
|
+
"right",
|
|
141
|
+
"home",
|
|
142
|
+
"end",
|
|
143
|
+
"space",
|
|
144
|
+
]);
|
|
145
|
+
|
|
146
|
+
return editingKeys.has(key);
|
|
147
|
+
}
|
package/src/layout.ts
ADDED
|
@@ -0,0 +1,422 @@
|
|
|
1
|
+
import type { Node, ContainerProps, SizeValue } from "@cel-tui/types";
|
|
2
|
+
import { visibleWidth } from "./width.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* A rectangle in absolute screen coordinates.
|
|
6
|
+
*/
|
|
7
|
+
export interface Rect {
|
|
8
|
+
x: number;
|
|
9
|
+
y: number;
|
|
10
|
+
width: number;
|
|
11
|
+
height: number;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* A node in the layout tree with computed position and size.
|
|
16
|
+
*/
|
|
17
|
+
export interface LayoutNode {
|
|
18
|
+
/** The original UI node. */
|
|
19
|
+
node: Node;
|
|
20
|
+
/** Computed absolute screen rect. */
|
|
21
|
+
rect: Rect;
|
|
22
|
+
/** Laid-out children (empty for leaf nodes). */
|
|
23
|
+
children: LayoutNode[];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// --- Helpers ---
|
|
27
|
+
|
|
28
|
+
function resolveSizeValue(
|
|
29
|
+
value: SizeValue | undefined,
|
|
30
|
+
parentSize: number,
|
|
31
|
+
): number | undefined {
|
|
32
|
+
if (value === undefined) return undefined;
|
|
33
|
+
if (typeof value === "number") return value;
|
|
34
|
+
const match = value.match(/^(\d+(?:\.\d+)?)%$/);
|
|
35
|
+
if (match) return Math.floor((parentSize * parseFloat(match[1]!)) / 100);
|
|
36
|
+
return undefined;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function clamp(value: number, min: number, max: number): number {
|
|
40
|
+
return Math.max(min, Math.min(max, value));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function getProps(node: Node): ContainerProps | null {
|
|
44
|
+
if (node.type === "text") return null;
|
|
45
|
+
return node.props;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// --- Intrinsic size computation ---
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Compute intrinsic main-axis size for a node (before layout).
|
|
52
|
+
* Used by the parent to determine how much space to allocate.
|
|
53
|
+
*/
|
|
54
|
+
function intrinsicMainSize(
|
|
55
|
+
node: Node,
|
|
56
|
+
isVertical: boolean,
|
|
57
|
+
crossSize: number,
|
|
58
|
+
): number {
|
|
59
|
+
if (node.type === "text") {
|
|
60
|
+
if (isVertical) {
|
|
61
|
+
// Height = number of lines
|
|
62
|
+
if (node.content.length === 0) return 1;
|
|
63
|
+
const lines = node.content.split("\n");
|
|
64
|
+
if (node.props.wrap === "word") {
|
|
65
|
+
let total = 0;
|
|
66
|
+
for (const line of lines) {
|
|
67
|
+
total += Math.max(
|
|
68
|
+
1,
|
|
69
|
+
Math.ceil(visibleWidth(line) / Math.max(1, crossSize)),
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
return total;
|
|
73
|
+
}
|
|
74
|
+
return lines.length;
|
|
75
|
+
}
|
|
76
|
+
// Width (intrinsic)
|
|
77
|
+
if (node.props.repeat === "fill") return 0;
|
|
78
|
+
const lines = node.content.split("\n");
|
|
79
|
+
let maxW = 0;
|
|
80
|
+
for (const line of lines) {
|
|
81
|
+
const w = visibleWidth(line);
|
|
82
|
+
if (w > maxW) maxW = w;
|
|
83
|
+
}
|
|
84
|
+
if (typeof node.props.repeat === "number") maxW *= node.props.repeat;
|
|
85
|
+
return maxW;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (node.type === "textinput") {
|
|
89
|
+
if (isVertical) {
|
|
90
|
+
const val = node.props.value || "";
|
|
91
|
+
if (val.length === 0) return 1;
|
|
92
|
+
const lines = val.split("\n");
|
|
93
|
+
let total = 0;
|
|
94
|
+
for (const line of lines) {
|
|
95
|
+
total += Math.max(
|
|
96
|
+
1,
|
|
97
|
+
Math.ceil(visibleWidth(line) / Math.max(1, crossSize)),
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
return total;
|
|
101
|
+
}
|
|
102
|
+
return 0;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Container: compute intrinsic size along the requested axis.
|
|
106
|
+
// If the requested axis matches the container's main axis, sum children + gaps.
|
|
107
|
+
// If it's the cross axis, take the max of children on that axis.
|
|
108
|
+
const props = node.props;
|
|
109
|
+
const gap = props.gap ?? 0;
|
|
110
|
+
const containerIsVertical = node.type === "vstack";
|
|
111
|
+
const axisMatchesMain = isVertical === containerIsVertical;
|
|
112
|
+
|
|
113
|
+
const padMain = isVertical
|
|
114
|
+
? (props.padding?.y ?? 0) * 2
|
|
115
|
+
: (props.padding?.x ?? 0) * 2;
|
|
116
|
+
const padCross = isVertical
|
|
117
|
+
? (props.padding?.x ?? 0) * 2
|
|
118
|
+
: (props.padding?.y ?? 0) * 2;
|
|
119
|
+
const innerCross = Math.max(0, crossSize - padCross);
|
|
120
|
+
|
|
121
|
+
if (axisMatchesMain) {
|
|
122
|
+
// Sum children along the main axis + gaps
|
|
123
|
+
let total = 0;
|
|
124
|
+
for (let i = 0; i < node.children.length; i++) {
|
|
125
|
+
const child = node.children[i]!;
|
|
126
|
+
const cProps = getProps(child);
|
|
127
|
+
|
|
128
|
+
let childMain: number;
|
|
129
|
+
if (isVertical) {
|
|
130
|
+
childMain =
|
|
131
|
+
resolveSizeValue(cProps?.height, 0) ??
|
|
132
|
+
intrinsicMainSize(child, true, innerCross);
|
|
133
|
+
} else {
|
|
134
|
+
childMain =
|
|
135
|
+
resolveSizeValue(cProps?.width, 0) ??
|
|
136
|
+
intrinsicMainSize(child, false, innerCross);
|
|
137
|
+
}
|
|
138
|
+
total += childMain;
|
|
139
|
+
if (i < node.children.length - 1) total += gap;
|
|
140
|
+
}
|
|
141
|
+
return total + padMain;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Cross axis: max of children on the requested axis
|
|
145
|
+
let maxSize = 0;
|
|
146
|
+
for (const child of node.children) {
|
|
147
|
+
const cProps = getProps(child);
|
|
148
|
+
|
|
149
|
+
let childSize: number;
|
|
150
|
+
if (isVertical) {
|
|
151
|
+
childSize =
|
|
152
|
+
resolveSizeValue(cProps?.height, 0) ??
|
|
153
|
+
intrinsicMainSize(child, true, innerCross);
|
|
154
|
+
} else {
|
|
155
|
+
childSize =
|
|
156
|
+
resolveSizeValue(cProps?.width, 0) ??
|
|
157
|
+
intrinsicMainSize(child, false, innerCross);
|
|
158
|
+
}
|
|
159
|
+
if (childSize > maxSize) maxSize = childSize;
|
|
160
|
+
}
|
|
161
|
+
return maxSize + padMain;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// --- Largest remainder rounding ---
|
|
165
|
+
|
|
166
|
+
function largestRemainder(fractions: number[], total: number): number[] {
|
|
167
|
+
const floored = fractions.map(Math.floor);
|
|
168
|
+
let remainder = total - floored.reduce((a, b) => a + b, 0);
|
|
169
|
+
|
|
170
|
+
const indices = fractions
|
|
171
|
+
.map((v, i) => ({ i, frac: v - Math.floor(v) }))
|
|
172
|
+
.sort((a, b) => b.frac - a.frac);
|
|
173
|
+
|
|
174
|
+
for (const { i } of indices) {
|
|
175
|
+
if (remainder <= 0) break;
|
|
176
|
+
floored[i]!++;
|
|
177
|
+
remainder--;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
return floored;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// --- Main layout ---
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Compute the layout for a UI tree.
|
|
187
|
+
*
|
|
188
|
+
* @param root - The root UI node.
|
|
189
|
+
* @param availWidth - Available width (typically terminal columns).
|
|
190
|
+
* @param availHeight - Available height (typically terminal rows).
|
|
191
|
+
* @returns Layout tree with computed rects.
|
|
192
|
+
*/
|
|
193
|
+
export function layout(
|
|
194
|
+
root: Node,
|
|
195
|
+
availWidth: number,
|
|
196
|
+
availHeight: number,
|
|
197
|
+
): LayoutNode {
|
|
198
|
+
// Resolve root's own size against the viewport
|
|
199
|
+
const rootProps = getProps(root);
|
|
200
|
+
const rootW = resolveSizeValue(rootProps?.width, availWidth) ?? availWidth;
|
|
201
|
+
const rootH = resolveSizeValue(rootProps?.height, availHeight) ?? availHeight;
|
|
202
|
+
return layoutNode(root, 0, 0, rootW, rootH);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Layout a node within the given available space.
|
|
207
|
+
* Resolves the node's own explicit size (if any) against available space,
|
|
208
|
+
* then lays out children within that.
|
|
209
|
+
*/
|
|
210
|
+
function layoutNode(
|
|
211
|
+
node: Node,
|
|
212
|
+
x: number,
|
|
213
|
+
y: number,
|
|
214
|
+
availWidth: number,
|
|
215
|
+
availHeight: number,
|
|
216
|
+
): LayoutNode {
|
|
217
|
+
// Resolve own dimensions: explicit size wins, otherwise fill available.
|
|
218
|
+
// Note: for children, the parent has already resolved sizing and passes
|
|
219
|
+
// the result as availWidth/availHeight. We only re-resolve for the root
|
|
220
|
+
// node (where available space = viewport, not pre-resolved).
|
|
221
|
+
const width = availWidth;
|
|
222
|
+
const height = availHeight;
|
|
223
|
+
const rect: Rect = { x, y, width, height };
|
|
224
|
+
|
|
225
|
+
// Leaf nodes
|
|
226
|
+
if (node.type === "text" || node.type === "textinput") {
|
|
227
|
+
return { node, rect, children: [] };
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// Container nodes
|
|
231
|
+
const props = node.props;
|
|
232
|
+
const isVertical = node.type === "vstack";
|
|
233
|
+
const children = node.children;
|
|
234
|
+
|
|
235
|
+
if (children.length === 0) {
|
|
236
|
+
return { node, rect, children: [] };
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// Padding
|
|
240
|
+
const padX = props.padding?.x ?? 0;
|
|
241
|
+
const padY = props.padding?.y ?? 0;
|
|
242
|
+
const innerX = x + padX;
|
|
243
|
+
const innerY = y + padY;
|
|
244
|
+
const innerW = Math.max(0, width - padX * 2);
|
|
245
|
+
const innerH = Math.max(0, height - padY * 2);
|
|
246
|
+
|
|
247
|
+
// Gap
|
|
248
|
+
const gap = props.gap ?? 0;
|
|
249
|
+
const totalGap = gap * (children.length - 1);
|
|
250
|
+
const mainAvail = (isVertical ? innerH : innerW) - totalGap;
|
|
251
|
+
|
|
252
|
+
// --- Measure phase: compute each child's main-axis and cross-axis size ---
|
|
253
|
+
type ChildInfo = {
|
|
254
|
+
node: Node;
|
|
255
|
+
mainSize: number;
|
|
256
|
+
crossSize: number;
|
|
257
|
+
flex: number;
|
|
258
|
+
};
|
|
259
|
+
const infos: ChildInfo[] = [];
|
|
260
|
+
let fixedMain = 0;
|
|
261
|
+
let totalFlex = 0;
|
|
262
|
+
const align = props.alignItems ?? "stretch";
|
|
263
|
+
const useIntrinsicCross = align !== "stretch";
|
|
264
|
+
|
|
265
|
+
for (const child of children) {
|
|
266
|
+
const cProps = getProps(child);
|
|
267
|
+
const flex = cProps?.flex ?? 0;
|
|
268
|
+
|
|
269
|
+
if (flex > 0) {
|
|
270
|
+
totalFlex += flex;
|
|
271
|
+
// Cross-axis: explicit size, or intrinsic if not stretch, or fill
|
|
272
|
+
let cross: number;
|
|
273
|
+
if (isVertical) {
|
|
274
|
+
cross =
|
|
275
|
+
resolveSizeValue(cProps?.width, innerW) ??
|
|
276
|
+
(useIntrinsicCross
|
|
277
|
+
? intrinsicMainSize(child, false, innerH)
|
|
278
|
+
: innerW);
|
|
279
|
+
} else {
|
|
280
|
+
cross =
|
|
281
|
+
resolveSizeValue(cProps?.height, innerH) ??
|
|
282
|
+
(useIntrinsicCross ? intrinsicMainSize(child, true, innerW) : innerH);
|
|
283
|
+
}
|
|
284
|
+
infos.push({ node: child, mainSize: 0, crossSize: cross, flex });
|
|
285
|
+
} else {
|
|
286
|
+
// Main-axis: explicit → percentage → intrinsic
|
|
287
|
+
let main: number;
|
|
288
|
+
let cross: number;
|
|
289
|
+
if (isVertical) {
|
|
290
|
+
main =
|
|
291
|
+
resolveSizeValue(cProps?.height, innerH) ??
|
|
292
|
+
intrinsicMainSize(child, true, innerW);
|
|
293
|
+
cross =
|
|
294
|
+
resolveSizeValue(cProps?.width, innerW) ??
|
|
295
|
+
(useIntrinsicCross
|
|
296
|
+
? intrinsicMainSize(child, false, innerH)
|
|
297
|
+
: innerW);
|
|
298
|
+
} else {
|
|
299
|
+
main =
|
|
300
|
+
resolveSizeValue(cProps?.width, innerW) ??
|
|
301
|
+
intrinsicMainSize(child, false, innerH);
|
|
302
|
+
cross =
|
|
303
|
+
resolveSizeValue(cProps?.height, innerH) ??
|
|
304
|
+
(useIntrinsicCross ? intrinsicMainSize(child, true, innerW) : innerH);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// Apply constraints
|
|
308
|
+
if (cProps) {
|
|
309
|
+
const minMain = isVertical
|
|
310
|
+
? (cProps.minHeight ?? 0)
|
|
311
|
+
: (cProps.minWidth ?? 0);
|
|
312
|
+
const maxMain = isVertical
|
|
313
|
+
? (cProps.maxHeight ?? Infinity)
|
|
314
|
+
: (cProps.maxWidth ?? Infinity);
|
|
315
|
+
main = clamp(main, minMain, maxMain);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
fixedMain += main;
|
|
319
|
+
infos.push({ node: child, mainSize: main, crossSize: cross, flex });
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// --- Flex distribution ---
|
|
324
|
+
const flexSpace = Math.max(0, mainAvail - fixedMain);
|
|
325
|
+
|
|
326
|
+
if (totalFlex > 0) {
|
|
327
|
+
const flexInfos = infos.filter((c) => c.flex > 0);
|
|
328
|
+
const rawSizes = flexInfos.map((c) => (c.flex / totalFlex) * flexSpace);
|
|
329
|
+
const rounded = largestRemainder(rawSizes, flexSpace);
|
|
330
|
+
|
|
331
|
+
for (let i = 0; i < flexInfos.length; i++) {
|
|
332
|
+
let size = rounded[i]!;
|
|
333
|
+
const cProps = getProps(flexInfos[i]!.node);
|
|
334
|
+
if (cProps) {
|
|
335
|
+
const minMain = isVertical
|
|
336
|
+
? (cProps.minHeight ?? 0)
|
|
337
|
+
: (cProps.minWidth ?? 0);
|
|
338
|
+
const maxMain = isVertical
|
|
339
|
+
? (cProps.maxHeight ?? Infinity)
|
|
340
|
+
: (cProps.maxWidth ?? Infinity);
|
|
341
|
+
size = clamp(size, minMain, maxMain);
|
|
342
|
+
}
|
|
343
|
+
flexInfos[i]!.mainSize = size;
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// --- Position phase ---
|
|
348
|
+
|
|
349
|
+
// Compute total main-axis content size (children + gaps)
|
|
350
|
+
const totalChildMain = infos.reduce((sum, c) => sum + c.mainSize, 0);
|
|
351
|
+
const totalContent = totalChildMain + totalGap;
|
|
352
|
+
const mainInner = isVertical ? innerH : innerW;
|
|
353
|
+
const crossInner = isVertical ? innerW : innerH;
|
|
354
|
+
const remainingMain = Math.max(0, mainInner - totalContent);
|
|
355
|
+
|
|
356
|
+
// justifyContent: compute main-axis starting offset and per-gap extra space
|
|
357
|
+
const justify = props.justifyContent ?? "start";
|
|
358
|
+
let mainStart = 0;
|
|
359
|
+
let betweenGaps: number[] | null = null;
|
|
360
|
+
|
|
361
|
+
if (justify === "end") {
|
|
362
|
+
mainStart = remainingMain;
|
|
363
|
+
} else if (justify === "center") {
|
|
364
|
+
mainStart = Math.floor(remainingMain / 2);
|
|
365
|
+
} else if (justify === "space-between" && infos.length > 1) {
|
|
366
|
+
// Distribute remaining space into gaps between children
|
|
367
|
+
const gapCount = infos.length - 1;
|
|
368
|
+
const rawGaps = Array.from(
|
|
369
|
+
{ length: gapCount },
|
|
370
|
+
() => remainingMain / gapCount,
|
|
371
|
+
);
|
|
372
|
+
betweenGaps = largestRemainder(rawGaps, remainingMain);
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
const layoutChildren: LayoutNode[] = [];
|
|
376
|
+
let mainOffset = mainStart;
|
|
377
|
+
|
|
378
|
+
for (let i = 0; i < infos.length; i++) {
|
|
379
|
+
const info = infos[i]!;
|
|
380
|
+
|
|
381
|
+
// Cross-axis alignment
|
|
382
|
+
let crossOffset = 0;
|
|
383
|
+
if (align === "center") {
|
|
384
|
+
crossOffset = Math.floor((crossInner - info.crossSize) / 2);
|
|
385
|
+
} else if (align === "end") {
|
|
386
|
+
crossOffset = crossInner - info.crossSize;
|
|
387
|
+
}
|
|
388
|
+
// "start" and "stretch" keep crossOffset = 0
|
|
389
|
+
|
|
390
|
+
const childX = isVertical ? innerX + crossOffset : innerX + mainOffset;
|
|
391
|
+
const childY = isVertical ? innerY + mainOffset : innerY + crossOffset;
|
|
392
|
+
const childW = isVertical ? info.crossSize : info.mainSize;
|
|
393
|
+
const childH = isVertical ? info.mainSize : info.crossSize;
|
|
394
|
+
|
|
395
|
+
layoutChildren.push(layoutNode(info.node, childX, childY, childW, childH));
|
|
396
|
+
|
|
397
|
+
mainOffset += info.mainSize;
|
|
398
|
+
if (i < infos.length - 1) {
|
|
399
|
+
mainOffset += gap;
|
|
400
|
+
if (betweenGaps) {
|
|
401
|
+
mainOffset += betweenGaps[i]!;
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
// --- Intrinsic container sizing ---
|
|
407
|
+
// If no explicit main-axis size, shrink to fit children
|
|
408
|
+
const hasExplicitMain = isVertical
|
|
409
|
+
? props.height !== undefined || props.flex !== undefined
|
|
410
|
+
: props.width !== undefined || props.flex !== undefined;
|
|
411
|
+
|
|
412
|
+
if (!hasExplicitMain) {
|
|
413
|
+
const contentMain = mainOffset + (isVertical ? padY * 2 : padX * 2);
|
|
414
|
+
if (isVertical) {
|
|
415
|
+
rect.height = contentMain;
|
|
416
|
+
} else {
|
|
417
|
+
rect.width = contentMain;
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
return { node, rect, children: layoutChildren };
|
|
422
|
+
}
|