@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/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
+ }