@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/paint.ts
ADDED
|
@@ -0,0 +1,646 @@
|
|
|
1
|
+
import type { Color, StyleProps, TextInputProps } from "@cel-tui/types";
|
|
2
|
+
import type { Cell } from "./cell-buffer.js";
|
|
3
|
+
import { CellBuffer } from "./cell-buffer.js";
|
|
4
|
+
import type { LayoutNode, Rect } from "./layout.js";
|
|
5
|
+
import { visibleWidth } from "./width.js";
|
|
6
|
+
|
|
7
|
+
const segmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" });
|
|
8
|
+
|
|
9
|
+
/** Empty inherited style — no values set. */
|
|
10
|
+
const EMPTY_STYLE: StyleProps = {};
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Paint a laid-out tree into a cell buffer.
|
|
14
|
+
*
|
|
15
|
+
* Walks the {@link LayoutNode} tree and writes styled cells into the
|
|
16
|
+
* buffer within each node's computed rect. Content is clipped at
|
|
17
|
+
* rect boundaries. Container styles are inherited by descendants.
|
|
18
|
+
*
|
|
19
|
+
* @param root - The root of the laid-out tree (from {@link layout}).
|
|
20
|
+
* @param buf - Target cell buffer.
|
|
21
|
+
*/
|
|
22
|
+
export function paint(root: LayoutNode, buf: CellBuffer): void {
|
|
23
|
+
paintLayoutNode(root, buf, root.rect, EMPTY_STYLE);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Intersect two rectangles. Returns a rect with zero area if they don't overlap.
|
|
28
|
+
*/
|
|
29
|
+
function intersectRect(a: Rect, b: Rect): Rect {
|
|
30
|
+
const x = Math.max(a.x, b.x);
|
|
31
|
+
const y = Math.max(a.y, b.y);
|
|
32
|
+
const right = Math.min(a.x + a.width, b.x + b.width);
|
|
33
|
+
const bottom = Math.min(a.y + a.height, b.y + b.height);
|
|
34
|
+
return {
|
|
35
|
+
x,
|
|
36
|
+
y,
|
|
37
|
+
width: Math.max(0, right - x),
|
|
38
|
+
height: Math.max(0, bottom - y),
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Resolve the effective style for a container, accounting for focusStyle.
|
|
44
|
+
* When the container is focused and has focusStyle, those values override
|
|
45
|
+
* the normal style props.
|
|
46
|
+
*/
|
|
47
|
+
function resolveContainerStyle(
|
|
48
|
+
props: { focused?: boolean; focusStyle?: StyleProps } & StyleProps,
|
|
49
|
+
): StyleProps {
|
|
50
|
+
const isFocused = props.focused === true;
|
|
51
|
+
if (!isFocused || !props.focusStyle) {
|
|
52
|
+
return props;
|
|
53
|
+
}
|
|
54
|
+
return {
|
|
55
|
+
bold: props.focusStyle.bold ?? props.bold,
|
|
56
|
+
italic: props.focusStyle.italic ?? props.italic,
|
|
57
|
+
underline: props.focusStyle.underline ?? props.underline,
|
|
58
|
+
fgColor: props.focusStyle.fgColor ?? props.fgColor,
|
|
59
|
+
bgColor: props.focusStyle.bgColor ?? props.bgColor,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Merge a container's resolved style into the inherited style.
|
|
65
|
+
* Only values explicitly set on the container override inherited ones.
|
|
66
|
+
*/
|
|
67
|
+
function mergeInherited(
|
|
68
|
+
inherited: StyleProps,
|
|
69
|
+
resolved: StyleProps,
|
|
70
|
+
): StyleProps {
|
|
71
|
+
return {
|
|
72
|
+
bold: resolved.bold ?? inherited.bold,
|
|
73
|
+
italic: resolved.italic ?? inherited.italic,
|
|
74
|
+
underline: resolved.underline ?? inherited.underline,
|
|
75
|
+
fgColor: resolved.fgColor ?? inherited.fgColor,
|
|
76
|
+
bgColor: resolved.bgColor ?? inherited.bgColor,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Fill a rectangle with opaque background cells, respecting the clip rect.
|
|
82
|
+
* Writes space characters with the given bgColor, making the container
|
|
83
|
+
* fully opaque. This ensures upper layers properly occlude lower layers.
|
|
84
|
+
*/
|
|
85
|
+
function fillBackground(
|
|
86
|
+
rect: Rect,
|
|
87
|
+
clipRect: Rect,
|
|
88
|
+
bgColor: Color,
|
|
89
|
+
buf: CellBuffer,
|
|
90
|
+
): void {
|
|
91
|
+
const fill = intersectRect(rect, clipRect);
|
|
92
|
+
if (fill.width <= 0 || fill.height <= 0) return;
|
|
93
|
+
for (let y = fill.y; y < fill.y + fill.height; y++) {
|
|
94
|
+
for (let x = fill.x; x < fill.x + fill.width; x++) {
|
|
95
|
+
buf.set(x, y, {
|
|
96
|
+
char: " ",
|
|
97
|
+
fgColor: null,
|
|
98
|
+
bgColor,
|
|
99
|
+
bold: false,
|
|
100
|
+
italic: false,
|
|
101
|
+
underline: false,
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function paintLayoutNode(
|
|
108
|
+
ln: LayoutNode,
|
|
109
|
+
buf: CellBuffer,
|
|
110
|
+
clipRect: Rect,
|
|
111
|
+
inherited: StyleProps,
|
|
112
|
+
): void {
|
|
113
|
+
const { node, rect } = ln;
|
|
114
|
+
|
|
115
|
+
// Clip this node's rect against the parent clip rect
|
|
116
|
+
const clipped = intersectRect(rect, clipRect);
|
|
117
|
+
if (clipped.width <= 0 || clipped.height <= 0) return;
|
|
118
|
+
|
|
119
|
+
// Compute inherited style for this subtree
|
|
120
|
+
let childInherited = inherited;
|
|
121
|
+
|
|
122
|
+
switch (node.type) {
|
|
123
|
+
case "text": {
|
|
124
|
+
// Resolve text styles: own props override inherited
|
|
125
|
+
const effective = {
|
|
126
|
+
fgColor: node.props.fgColor ?? inherited.fgColor,
|
|
127
|
+
bgColor: node.props.bgColor ?? inherited.bgColor,
|
|
128
|
+
bold: node.props.bold ?? inherited.bold,
|
|
129
|
+
italic: node.props.italic ?? inherited.italic,
|
|
130
|
+
underline: node.props.underline ?? inherited.underline,
|
|
131
|
+
};
|
|
132
|
+
paintText(
|
|
133
|
+
node.content,
|
|
134
|
+
{ ...node.props, ...effective },
|
|
135
|
+
rect,
|
|
136
|
+
clipped,
|
|
137
|
+
buf,
|
|
138
|
+
);
|
|
139
|
+
break;
|
|
140
|
+
}
|
|
141
|
+
case "textinput": {
|
|
142
|
+
// TextInput: resolve own styles with inheritance
|
|
143
|
+
const tiResolved = resolveContainerStyle(node.props);
|
|
144
|
+
const tiEffective = mergeInherited(inherited, tiResolved);
|
|
145
|
+
const tiProps = {
|
|
146
|
+
...node.props,
|
|
147
|
+
fgColor: node.props.fgColor ?? tiEffective.fgColor,
|
|
148
|
+
bgColor: node.props.bgColor ?? tiEffective.bgColor,
|
|
149
|
+
bold: node.props.bold ?? tiEffective.bold,
|
|
150
|
+
italic: node.props.italic ?? tiEffective.italic,
|
|
151
|
+
underline: node.props.underline ?? tiEffective.underline,
|
|
152
|
+
};
|
|
153
|
+
paintTextInput(tiProps, rect, clipped, buf);
|
|
154
|
+
break;
|
|
155
|
+
}
|
|
156
|
+
case "vstack":
|
|
157
|
+
case "hstack": {
|
|
158
|
+
// Resolve container style (applies focusStyle if focused)
|
|
159
|
+
const resolved = resolveContainerStyle(node.props);
|
|
160
|
+
childInherited = mergeInherited(inherited, resolved);
|
|
161
|
+
|
|
162
|
+
// Fill container background
|
|
163
|
+
const effectiveBg = childInherited.bgColor;
|
|
164
|
+
if (effectiveBg) {
|
|
165
|
+
fillBackground(rect, clipped, effectiveBg, buf);
|
|
166
|
+
}
|
|
167
|
+
break;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Determine scroll offset for scrollable containers
|
|
172
|
+
const isContainer = node.type === "vstack" || node.type === "hstack";
|
|
173
|
+
const containerProps = isContainer ? node.props : null;
|
|
174
|
+
const isScrollable = containerProps?.overflow === "scroll";
|
|
175
|
+
const scrollOffset = isScrollable
|
|
176
|
+
? (containerProps.scrollOffset ?? getContainerScroll(containerProps))
|
|
177
|
+
: 0;
|
|
178
|
+
const isVertical = node.type === "vstack";
|
|
179
|
+
|
|
180
|
+
// Recurse into children, using this node's clipped rect as the clip for children
|
|
181
|
+
for (const child of ln.children) {
|
|
182
|
+
if (isScrollable && scrollOffset !== 0) {
|
|
183
|
+
// Paint child with shifted position
|
|
184
|
+
const shifted = shiftLayoutNode(child, isVertical, -scrollOffset);
|
|
185
|
+
paintLayoutNode(shifted, buf, clipped, childInherited);
|
|
186
|
+
} else {
|
|
187
|
+
paintLayoutNode(child, buf, clipped, childInherited);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// Paint scrollbar if enabled
|
|
192
|
+
if (isScrollable && containerProps.scrollbar) {
|
|
193
|
+
paintScrollbar(ln, scrollOffset, buf, clipped);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Create a copy of a layout node (and all descendants) with positions
|
|
199
|
+
* shifted along the given axis.
|
|
200
|
+
*/
|
|
201
|
+
function shiftLayoutNode(
|
|
202
|
+
ln: LayoutNode,
|
|
203
|
+
isVertical: boolean,
|
|
204
|
+
offset: number,
|
|
205
|
+
): LayoutNode {
|
|
206
|
+
const newRect: Rect = {
|
|
207
|
+
x: ln.rect.x + (isVertical ? 0 : offset),
|
|
208
|
+
y: ln.rect.y + (isVertical ? offset : 0),
|
|
209
|
+
width: ln.rect.width,
|
|
210
|
+
height: ln.rect.height,
|
|
211
|
+
};
|
|
212
|
+
return {
|
|
213
|
+
node: ln.node,
|
|
214
|
+
rect: newRect,
|
|
215
|
+
children: ln.children.map((c) => shiftLayoutNode(c, isVertical, offset)),
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Paint a scrollbar indicator for a scrollable container.
|
|
221
|
+
*/
|
|
222
|
+
function paintScrollbar(
|
|
223
|
+
ln: LayoutNode,
|
|
224
|
+
scrollOffset: number,
|
|
225
|
+
buf: CellBuffer,
|
|
226
|
+
clipRect: Rect,
|
|
227
|
+
): void {
|
|
228
|
+
const { rect, children } = ln;
|
|
229
|
+
const isVertical = ln.node.type === "vstack";
|
|
230
|
+
|
|
231
|
+
if (isVertical) {
|
|
232
|
+
// Compute total content height from children
|
|
233
|
+
let contentHeight = 0;
|
|
234
|
+
for (const child of children) {
|
|
235
|
+
const childBottom = child.rect.y + child.rect.height - rect.y;
|
|
236
|
+
if (childBottom > contentHeight) contentHeight = childBottom;
|
|
237
|
+
}
|
|
238
|
+
const viewportH = rect.height;
|
|
239
|
+
if (contentHeight <= viewportH) return; // no scrollbar needed
|
|
240
|
+
|
|
241
|
+
// Thumb size and position
|
|
242
|
+
const thumbSize = Math.max(
|
|
243
|
+
1,
|
|
244
|
+
Math.round((viewportH / contentHeight) * viewportH),
|
|
245
|
+
);
|
|
246
|
+
const maxOffset = contentHeight - viewportH;
|
|
247
|
+
const thumbPos =
|
|
248
|
+
maxOffset > 0
|
|
249
|
+
? Math.round((scrollOffset / maxOffset) * (viewportH - thumbSize))
|
|
250
|
+
: 0;
|
|
251
|
+
|
|
252
|
+
const barX = rect.x + rect.width - 1;
|
|
253
|
+
for (let row = 0; row < viewportH; row++) {
|
|
254
|
+
const absY = rect.y + row;
|
|
255
|
+
if (absY < clipRect.y || absY >= clipRect.y + clipRect.height) continue;
|
|
256
|
+
if (barX < clipRect.x || barX >= clipRect.x + clipRect.width) continue;
|
|
257
|
+
const isThumb = row >= thumbPos && row < thumbPos + thumbSize;
|
|
258
|
+
buf.set(barX, absY, {
|
|
259
|
+
char: isThumb ? "┃" : "│",
|
|
260
|
+
fgColor: isThumb ? "white" : "brightBlack",
|
|
261
|
+
bgColor: null,
|
|
262
|
+
bold: false,
|
|
263
|
+
italic: false,
|
|
264
|
+
underline: false,
|
|
265
|
+
});
|
|
266
|
+
}
|
|
267
|
+
} else {
|
|
268
|
+
// Horizontal scrollbar
|
|
269
|
+
let contentWidth = 0;
|
|
270
|
+
for (const child of children) {
|
|
271
|
+
const childRight = child.rect.x + child.rect.width - rect.x;
|
|
272
|
+
if (childRight > contentWidth) contentWidth = childRight;
|
|
273
|
+
}
|
|
274
|
+
const viewportW = rect.width;
|
|
275
|
+
if (contentWidth <= viewportW) return;
|
|
276
|
+
|
|
277
|
+
const thumbSize = Math.max(
|
|
278
|
+
1,
|
|
279
|
+
Math.round((viewportW / contentWidth) * viewportW),
|
|
280
|
+
);
|
|
281
|
+
const maxOffset = contentWidth - viewportW;
|
|
282
|
+
const thumbPos =
|
|
283
|
+
maxOffset > 0
|
|
284
|
+
? Math.round((scrollOffset / maxOffset) * (viewportW - thumbSize))
|
|
285
|
+
: 0;
|
|
286
|
+
|
|
287
|
+
const barY = rect.y + rect.height - 1;
|
|
288
|
+
for (let col = 0; col < viewportW; col++) {
|
|
289
|
+
const absX = rect.x + col;
|
|
290
|
+
if (absX < clipRect.x || absX >= clipRect.x + clipRect.width) continue;
|
|
291
|
+
if (barY < clipRect.y || barY >= clipRect.y + clipRect.height) continue;
|
|
292
|
+
const isThumb = col >= thumbPos && col < thumbPos + thumbSize;
|
|
293
|
+
buf.set(absX, barY, {
|
|
294
|
+
char: isThumb ? "━" : "─",
|
|
295
|
+
fgColor: isThumb ? "white" : "brightBlack",
|
|
296
|
+
bgColor: null,
|
|
297
|
+
bold: false,
|
|
298
|
+
italic: false,
|
|
299
|
+
underline: false,
|
|
300
|
+
});
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function makeCell(
|
|
306
|
+
char: string,
|
|
307
|
+
props: {
|
|
308
|
+
fgColor?: Color;
|
|
309
|
+
bgColor?: Color;
|
|
310
|
+
bold?: boolean;
|
|
311
|
+
italic?: boolean;
|
|
312
|
+
underline?: boolean;
|
|
313
|
+
},
|
|
314
|
+
): Cell {
|
|
315
|
+
return {
|
|
316
|
+
char,
|
|
317
|
+
fgColor: props.fgColor ?? null,
|
|
318
|
+
bgColor: props.bgColor ?? null,
|
|
319
|
+
bold: props.bold ?? false,
|
|
320
|
+
italic: props.italic ?? false,
|
|
321
|
+
underline: props.underline ?? false,
|
|
322
|
+
};
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* Paint a single line of text into the buffer using grapheme segmentation.
|
|
327
|
+
* Correctly handles wide characters (CJK, emoji) by advancing the column
|
|
328
|
+
* by the grapheme's visible width.
|
|
329
|
+
*/
|
|
330
|
+
/**
|
|
331
|
+
* Paint a single line of text into the buffer using grapheme segmentation.
|
|
332
|
+
* Correctly handles wide characters (CJK, emoji) by advancing the column
|
|
333
|
+
* by the grapheme's visible width. Respects the clip rect.
|
|
334
|
+
*/
|
|
335
|
+
function paintLineGraphemes(
|
|
336
|
+
line: string,
|
|
337
|
+
x: number,
|
|
338
|
+
y: number,
|
|
339
|
+
maxWidth: number,
|
|
340
|
+
clipRect: Rect,
|
|
341
|
+
props: {
|
|
342
|
+
fgColor?: Color;
|
|
343
|
+
bgColor?: Color;
|
|
344
|
+
bold?: boolean;
|
|
345
|
+
italic?: boolean;
|
|
346
|
+
underline?: boolean;
|
|
347
|
+
},
|
|
348
|
+
buf: CellBuffer,
|
|
349
|
+
): void {
|
|
350
|
+
if (y < clipRect.y || y >= clipRect.y + clipRect.height) return;
|
|
351
|
+
const clipLeft = clipRect.x;
|
|
352
|
+
const clipRight = clipRect.x + clipRect.width;
|
|
353
|
+
|
|
354
|
+
let col = 0;
|
|
355
|
+
for (const { segment } of segmenter.segment(line)) {
|
|
356
|
+
const gw = visibleWidth(segment);
|
|
357
|
+
if (gw === 0) continue;
|
|
358
|
+
if (col + gw > maxWidth) break; // clip: grapheme doesn't fit in rect
|
|
359
|
+
const absX = x + col;
|
|
360
|
+
if (absX >= clipRight) break; // past clip right edge
|
|
361
|
+
if (absX + gw > clipLeft) {
|
|
362
|
+
// At least partially visible in clip rect
|
|
363
|
+
buf.set(absX, y, makeCell(segment, props));
|
|
364
|
+
}
|
|
365
|
+
col += gw;
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
function paintText(
|
|
370
|
+
content: string,
|
|
371
|
+
props: {
|
|
372
|
+
repeat?: number | "fill";
|
|
373
|
+
wrap?: "none" | "word";
|
|
374
|
+
fgColor?: Color;
|
|
375
|
+
bgColor?: Color;
|
|
376
|
+
bold?: boolean;
|
|
377
|
+
italic?: boolean;
|
|
378
|
+
underline?: boolean;
|
|
379
|
+
},
|
|
380
|
+
rect: Rect,
|
|
381
|
+
clipRect: Rect,
|
|
382
|
+
buf: CellBuffer,
|
|
383
|
+
): void {
|
|
384
|
+
const { x, y, width: w, height: h } = rect;
|
|
385
|
+
if (w <= 0 || h <= 0) return;
|
|
386
|
+
|
|
387
|
+
// Resolve repeat
|
|
388
|
+
let text = content;
|
|
389
|
+
if (props.repeat === "fill" && content.length > 0) {
|
|
390
|
+
const contentW = visibleWidth(content);
|
|
391
|
+
if (contentW > 0) {
|
|
392
|
+
text = content.repeat(Math.ceil(w / contentW));
|
|
393
|
+
}
|
|
394
|
+
} else if (typeof props.repeat === "number" && props.repeat > 0) {
|
|
395
|
+
text = content.repeat(props.repeat);
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
// Split into lines
|
|
399
|
+
const rawLines = text.split("\n");
|
|
400
|
+
|
|
401
|
+
// Word-wrap if enabled
|
|
402
|
+
const lines: string[] = [];
|
|
403
|
+
if (props.wrap === "word") {
|
|
404
|
+
for (const rawLine of rawLines) {
|
|
405
|
+
if (visibleWidth(rawLine) <= w) {
|
|
406
|
+
lines.push(rawLine);
|
|
407
|
+
} else {
|
|
408
|
+
wrapLine(rawLine, w, lines);
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
} else {
|
|
412
|
+
lines.push(...rawLines);
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
// Paint lines, clipped to rect (grapheme-aware)
|
|
416
|
+
for (let row = 0; row < lines.length && row < h; row++) {
|
|
417
|
+
const line = lines[row]!;
|
|
418
|
+
paintLineGraphemes(line, x, y + row, w, clipRect, props, buf);
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
function paintTextInput(
|
|
423
|
+
props: TextInputProps,
|
|
424
|
+
rect: Rect,
|
|
425
|
+
clipRect: Rect,
|
|
426
|
+
buf: CellBuffer,
|
|
427
|
+
): void {
|
|
428
|
+
const { x, y, width: w, height: h } = rect;
|
|
429
|
+
if (w <= 0 || h <= 0) return;
|
|
430
|
+
|
|
431
|
+
const value = props.value;
|
|
432
|
+
const showPlaceholder = value.length === 0 && props.placeholder;
|
|
433
|
+
|
|
434
|
+
if (showPlaceholder && props.placeholder) {
|
|
435
|
+
// Paint placeholder text
|
|
436
|
+
paintText(
|
|
437
|
+
props.placeholder.content,
|
|
438
|
+
props.placeholder.props,
|
|
439
|
+
rect,
|
|
440
|
+
clipRect,
|
|
441
|
+
buf,
|
|
442
|
+
);
|
|
443
|
+
return;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
// Word-wrap value (always on for TextInput)
|
|
447
|
+
const lines: string[] = [];
|
|
448
|
+
for (const rawLine of value.split("\n")) {
|
|
449
|
+
if (visibleWidth(rawLine) <= w) {
|
|
450
|
+
lines.push(rawLine);
|
|
451
|
+
} else {
|
|
452
|
+
wrapLine(rawLine, w, lines);
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
// Framework-managed scroll: auto-scroll to keep cursor visible
|
|
457
|
+
let scrollOffset = getTextInputScroll(props);
|
|
458
|
+
|
|
459
|
+
if (props.focused) {
|
|
460
|
+
const cursorOffset = getTextInputCursor(props);
|
|
461
|
+
const cursorPos = offsetToWrappedPos(value, cursorOffset, w);
|
|
462
|
+
// Scroll down if cursor is below viewport
|
|
463
|
+
if (cursorPos.line >= scrollOffset + h) {
|
|
464
|
+
scrollOffset = cursorPos.line - h + 1;
|
|
465
|
+
}
|
|
466
|
+
// Scroll up if cursor is above viewport
|
|
467
|
+
if (cursorPos.line < scrollOffset) {
|
|
468
|
+
scrollOffset = cursorPos.line;
|
|
469
|
+
}
|
|
470
|
+
setTextInputScroll(props, scrollOffset);
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
// Paint visible lines (grapheme-aware)
|
|
474
|
+
for (let row = 0; row < h; row++) {
|
|
475
|
+
const lineIdx = scrollOffset + row;
|
|
476
|
+
if (lineIdx >= lines.length) break;
|
|
477
|
+
const line = lines[lineIdx]!;
|
|
478
|
+
paintLineGraphemes(line, x, y + row, w, clipRect, props, buf);
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
// Paint cursor if focused
|
|
482
|
+
if (props.focused) {
|
|
483
|
+
const cursorOffset = getTextInputCursor(props);
|
|
484
|
+
const pos = offsetToWrappedPos(value, cursorOffset, w);
|
|
485
|
+
const screenRow = pos.line - scrollOffset;
|
|
486
|
+
if (screenRow >= 0 && screenRow < h && pos.col < w) {
|
|
487
|
+
const existing = buf.get(x + pos.col, y + screenRow);
|
|
488
|
+
// Invert colors for cursor visibility
|
|
489
|
+
buf.set(x + pos.col, y + screenRow, {
|
|
490
|
+
char: existing.char === " " && !existing.bgColor ? " " : existing.char,
|
|
491
|
+
fgColor: existing.bgColor ?? "black",
|
|
492
|
+
bgColor: existing.fgColor ?? "white",
|
|
493
|
+
bold: existing.bold,
|
|
494
|
+
italic: existing.italic,
|
|
495
|
+
underline: existing.underline,
|
|
496
|
+
});
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
/**
|
|
502
|
+
* Map a cursor offset in the raw value to a (line, col) position
|
|
503
|
+
* in the word-wrapped output.
|
|
504
|
+
*/
|
|
505
|
+
function offsetToWrappedPos(
|
|
506
|
+
value: string,
|
|
507
|
+
cursor: number,
|
|
508
|
+
width: number,
|
|
509
|
+
): { line: number; col: number } {
|
|
510
|
+
const rawLines = value.split("\n");
|
|
511
|
+
let offset = 0;
|
|
512
|
+
let wrappedLine = 0;
|
|
513
|
+
|
|
514
|
+
for (const rawLine of rawLines) {
|
|
515
|
+
if (cursor <= offset + rawLine.length) {
|
|
516
|
+
// Cursor is in this raw line
|
|
517
|
+
const colInRaw = cursor - offset;
|
|
518
|
+
if (width <= 0) return { line: wrappedLine, col: colInRaw };
|
|
519
|
+
// Compute visible width of text before cursor
|
|
520
|
+
const textBeforeCursor = rawLine.slice(0, colInRaw);
|
|
521
|
+
const vw = visibleWidth(textBeforeCursor);
|
|
522
|
+
const extraLines = Math.floor(vw / width);
|
|
523
|
+
return { line: wrappedLine + extraLines, col: vw % width };
|
|
524
|
+
}
|
|
525
|
+
// Count wrapped lines for this raw line
|
|
526
|
+
const lineVW = visibleWidth(rawLine);
|
|
527
|
+
if (lineVW <= width || width <= 0) {
|
|
528
|
+
wrappedLine += 1;
|
|
529
|
+
} else {
|
|
530
|
+
wrappedLine += Math.ceil(lineVW / width);
|
|
531
|
+
}
|
|
532
|
+
offset += rawLine.length + 1; // +1 for \n
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
return { line: wrappedLine, col: 0 };
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
// --- Framework-managed state ---
|
|
539
|
+
|
|
540
|
+
import type { ContainerProps } from "@cel-tui/types";
|
|
541
|
+
|
|
542
|
+
const containerScrolls = new WeakMap<ContainerProps, number>();
|
|
543
|
+
|
|
544
|
+
/** Get the scroll offset for an uncontrolled scrollable container. */
|
|
545
|
+
export function getContainerScroll(props: ContainerProps): number {
|
|
546
|
+
return containerScrolls.get(props) ?? 0;
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
/** Set the scroll offset for an uncontrolled scrollable container. */
|
|
550
|
+
export function setContainerScroll(
|
|
551
|
+
props: ContainerProps,
|
|
552
|
+
scroll: number,
|
|
553
|
+
): void {
|
|
554
|
+
containerScrolls.set(props, scroll);
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
/**
|
|
558
|
+
* TextInput state is keyed on the `onChange` function reference, which is
|
|
559
|
+
* a stable identity across re-renders (the app provides the same closure).
|
|
560
|
+
* This avoids losing cursor/scroll position when props objects are recreated
|
|
561
|
+
* each frame.
|
|
562
|
+
*/
|
|
563
|
+
type OnChangeFn = (value: string) => void;
|
|
564
|
+
const textInputCursors = new WeakMap<OnChangeFn, number>();
|
|
565
|
+
const textInputScrolls = new WeakMap<OnChangeFn, number>();
|
|
566
|
+
|
|
567
|
+
/** Get the cursor offset for a TextInput (framework-managed). */
|
|
568
|
+
export function getTextInputCursor(props: TextInputProps): number {
|
|
569
|
+
return textInputCursors.get(props.onChange) ?? props.value.length;
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
/** Set the cursor offset for a TextInput. */
|
|
573
|
+
export function setTextInputCursor(
|
|
574
|
+
props: TextInputProps,
|
|
575
|
+
cursor: number,
|
|
576
|
+
): void {
|
|
577
|
+
textInputCursors.set(props.onChange, cursor);
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
/** Get the scroll offset for a TextInput (framework-managed). */
|
|
581
|
+
export function getTextInputScroll(props: TextInputProps): number {
|
|
582
|
+
return textInputScrolls.get(props.onChange) ?? 0;
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
/** Set the scroll offset for a TextInput. */
|
|
586
|
+
export function setTextInputScroll(
|
|
587
|
+
props: TextInputProps,
|
|
588
|
+
scroll: number,
|
|
589
|
+
): void {
|
|
590
|
+
textInputScrolls.set(props.onChange, scroll);
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
/**
|
|
594
|
+
* Simple word-wrap: break a line into multiple lines at word boundaries.
|
|
595
|
+
*/
|
|
596
|
+
function wrapLine(line: string, width: number, out: string[]): void {
|
|
597
|
+
if (width <= 0) return;
|
|
598
|
+
|
|
599
|
+
let current = "";
|
|
600
|
+
let currentW = 0;
|
|
601
|
+
const words = line.split(" ");
|
|
602
|
+
|
|
603
|
+
for (const word of words) {
|
|
604
|
+
const wordW = visibleWidth(word);
|
|
605
|
+
if (currentW === 0) {
|
|
606
|
+
current = word;
|
|
607
|
+
currentW = wordW;
|
|
608
|
+
} else if (currentW + 1 + wordW <= width) {
|
|
609
|
+
current += " " + word;
|
|
610
|
+
currentW += 1 + wordW;
|
|
611
|
+
} else {
|
|
612
|
+
out.push(current);
|
|
613
|
+
current = word;
|
|
614
|
+
currentW = wordW;
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
// Handle words longer than width (break by grapheme)
|
|
618
|
+
while (currentW > width) {
|
|
619
|
+
let taken = "";
|
|
620
|
+
let takenW = 0;
|
|
621
|
+
let rest = "";
|
|
622
|
+
let inRest = false;
|
|
623
|
+
for (const { segment } of segmenter.segment(current)) {
|
|
624
|
+
if (inRest) {
|
|
625
|
+
rest += segment;
|
|
626
|
+
continue;
|
|
627
|
+
}
|
|
628
|
+
const gw = visibleWidth(segment);
|
|
629
|
+
if (takenW + gw > width) {
|
|
630
|
+
rest += segment;
|
|
631
|
+
inRest = true;
|
|
632
|
+
} else {
|
|
633
|
+
taken += segment;
|
|
634
|
+
takenW += gw;
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
out.push(taken);
|
|
638
|
+
current = rest;
|
|
639
|
+
currentW = visibleWidth(rest);
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
if (current.length > 0) {
|
|
644
|
+
out.push(current);
|
|
645
|
+
}
|
|
646
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { ContainerNode, ContainerProps, Node } from "@cel-tui/types";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Create a vertical stack container — children laid out top to bottom.
|
|
5
|
+
*
|
|
6
|
+
* Equivalent to CSS `flex-direction: column`. Main axis is vertical,
|
|
7
|
+
* cross axis is horizontal.
|
|
8
|
+
*
|
|
9
|
+
* @param props - Layout, sizing, scrolling, focus, and interaction props.
|
|
10
|
+
* @param children - Ordered child nodes.
|
|
11
|
+
* @returns A container node for the UI tree.
|
|
12
|
+
*
|
|
13
|
+
* @example
|
|
14
|
+
* VStack({ flex: 1, gap: 1 }, [
|
|
15
|
+
* Text("Hello"),
|
|
16
|
+
* Text("World"),
|
|
17
|
+
* ])
|
|
18
|
+
*/
|
|
19
|
+
export function VStack(props: ContainerProps, children: Node[]): ContainerNode {
|
|
20
|
+
return { type: "vstack", props, children };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Create a horizontal stack container — children laid out left to right.
|
|
25
|
+
*
|
|
26
|
+
* Equivalent to CSS `flex-direction: row`. Main axis is horizontal,
|
|
27
|
+
* cross axis is vertical.
|
|
28
|
+
*
|
|
29
|
+
* @param props - Layout, sizing, scrolling, focus, and interaction props.
|
|
30
|
+
* @param children - Ordered child nodes.
|
|
31
|
+
* @returns A container node for the UI tree.
|
|
32
|
+
*
|
|
33
|
+
* @example
|
|
34
|
+
* HStack({ height: 1, gap: 1 }, [
|
|
35
|
+
* Text("Name", { bold: true }),
|
|
36
|
+
* VStack({ flex: 1 }, []),
|
|
37
|
+
* Text("value", { fgColor: "brightBlack" }),
|
|
38
|
+
* ])
|
|
39
|
+
*/
|
|
40
|
+
export function HStack(props: ContainerProps, children: Node[]): ContainerNode {
|
|
41
|
+
return { type: "hstack", props, children };
|
|
42
|
+
}
|