@cel-tui/core 0.6.2 → 0.7.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 +2 -2
- package/src/cel.ts +191 -67
- package/src/cell-buffer.ts +7 -2
- package/src/emitter.ts +1 -2
- package/src/hit-test.ts +30 -15
- package/src/index.ts +15 -16
- package/src/keys.ts +41 -11
- package/src/layout.ts +88 -40
- package/src/paint.ts +26 -15
- package/src/primitives/text-input.ts +7 -3
- package/src/scroll.ts +10 -6
- package/src/text-edit.ts +99 -41
- package/src/text-layout.ts +65 -8
- package/src/width.ts +9 -4
package/src/index.ts
CHANGED
|
@@ -29,25 +29,24 @@
|
|
|
29
29
|
|
|
30
30
|
export type {
|
|
31
31
|
Color,
|
|
32
|
-
|
|
33
|
-
Theme,
|
|
34
|
-
StyleProps,
|
|
35
|
-
SizeValue,
|
|
32
|
+
ContainerNode,
|
|
36
33
|
ContainerProps,
|
|
37
|
-
|
|
34
|
+
Node,
|
|
35
|
+
SizeValue,
|
|
36
|
+
StyleProps,
|
|
37
|
+
TextInputNode,
|
|
38
38
|
TextInputProps,
|
|
39
39
|
TextNode,
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
40
|
+
TextProps,
|
|
41
|
+
Theme,
|
|
42
|
+
ThemeValue,
|
|
43
43
|
} from "@cel-tui/types";
|
|
44
|
-
|
|
45
|
-
export { VStack, HStack } from "./primitives/stacks.js";
|
|
46
|
-
export { Text } from "./primitives/text.js";
|
|
47
|
-
export { TextInput } from "./primitives/text-input.js";
|
|
48
44
|
export { cel } from "./cel.js";
|
|
45
|
+
export { type Cell, CellBuffer, EMPTY_CELL } from "./cell-buffer.js";
|
|
46
|
+
export { defaultTheme, emitBuffer } from "./emitter.js";
|
|
49
47
|
export { measureContentHeight } from "./layout.js";
|
|
50
|
-
export {
|
|
51
|
-
export {
|
|
52
|
-
export {
|
|
53
|
-
export {
|
|
48
|
+
export { HStack, VStack } from "./primitives/stacks.js";
|
|
49
|
+
export { Text } from "./primitives/text.js";
|
|
50
|
+
export { TextInput } from "./primitives/text-input.js";
|
|
51
|
+
export { MockTerminal, ProcessTerminal, type Terminal } from "./terminal.js";
|
|
52
|
+
export { extractAnsiCode, visibleWidth } from "./width.js";
|
package/src/keys.ts
CHANGED
|
@@ -106,7 +106,7 @@ function decodeModifiers(param: number): string {
|
|
|
106
106
|
if (bits & 4) parts.push("ctrl");
|
|
107
107
|
if (bits & 2) parts.push("alt");
|
|
108
108
|
if (bits & 1) parts.push("shift");
|
|
109
|
-
return parts.length > 0 ? parts.join("+")
|
|
109
|
+
return parts.length > 0 ? `${parts.join("+")}+` : "";
|
|
110
110
|
}
|
|
111
111
|
|
|
112
112
|
/** Build a key string from a modifier prefix and base key name. */
|
|
@@ -125,6 +125,13 @@ const CSI_LETTER_RE = /^(?:1;(\d+))?([A-H])$/;
|
|
|
125
125
|
/** Match CSI tilde format: ESC [ <number> ; <modifiers> ~ */
|
|
126
126
|
const CSI_TILDE_RE = /^(\d+)(?:;(\d+))?~$/;
|
|
127
127
|
|
|
128
|
+
function parseDecimal(value: string | undefined, description: string): number {
|
|
129
|
+
if (value === undefined) {
|
|
130
|
+
throw new Error(`Missing ${description}`);
|
|
131
|
+
}
|
|
132
|
+
return parseInt(value, 10);
|
|
133
|
+
}
|
|
134
|
+
|
|
128
135
|
function parseCsiSequence(seq: string): KeyInput {
|
|
129
136
|
// Legacy Shift+Tab (CSI Z) — sent by tmux and some terminals
|
|
130
137
|
if (seq === "Z") return { key: "shift+tab" };
|
|
@@ -132,8 +139,8 @@ function parseCsiSequence(seq: string): KeyInput {
|
|
|
132
139
|
// CSI u format: codepoint [; modifiers] u
|
|
133
140
|
let match = CSI_U_RE.exec(seq);
|
|
134
141
|
if (match) {
|
|
135
|
-
const codepoint =
|
|
136
|
-
const modParam = match[2] ?
|
|
142
|
+
const codepoint = parseDecimal(match[1], "CSI-u codepoint");
|
|
143
|
+
const modParam = match[2] ? parseDecimal(match[2], "CSI-u modifier") : 0;
|
|
137
144
|
|
|
138
145
|
const name = CODEPOINT_NAMES[codepoint];
|
|
139
146
|
if (name) {
|
|
@@ -151,8 +158,13 @@ function parseCsiSequence(seq: string): KeyInput {
|
|
|
151
158
|
// CSI letter format: [1 ; modifiers] <letter>
|
|
152
159
|
match = CSI_LETTER_RE.exec(seq);
|
|
153
160
|
if (match) {
|
|
154
|
-
const modParam = match[1]
|
|
155
|
-
|
|
161
|
+
const modParam = match[1]
|
|
162
|
+
? parseDecimal(match[1], "CSI-letter modifier")
|
|
163
|
+
: 0;
|
|
164
|
+
const letter = match[2];
|
|
165
|
+
if (letter === undefined) {
|
|
166
|
+
throw new Error("Missing CSI-letter key");
|
|
167
|
+
}
|
|
156
168
|
const name = LETTER_NAMES[letter];
|
|
157
169
|
if (name) return { key: withModifiers(modParam, name) };
|
|
158
170
|
}
|
|
@@ -160,8 +172,10 @@ function parseCsiSequence(seq: string): KeyInput {
|
|
|
160
172
|
// CSI tilde format: number [; modifiers] ~
|
|
161
173
|
match = CSI_TILDE_RE.exec(seq);
|
|
162
174
|
if (match) {
|
|
163
|
-
const num =
|
|
164
|
-
const modParam = match[2]
|
|
175
|
+
const num = parseDecimal(match[1], "CSI-tilde code");
|
|
176
|
+
const modParam = match[2]
|
|
177
|
+
? parseDecimal(match[2], "CSI-tilde modifier")
|
|
178
|
+
: 0;
|
|
165
179
|
const name = TILDE_NAMES[num];
|
|
166
180
|
if (name) return { key: withModifiers(modParam, name) };
|
|
167
181
|
}
|
|
@@ -240,7 +254,10 @@ export function decodeKeyEvents(data: string): KeyInput[] {
|
|
|
240
254
|
continue;
|
|
241
255
|
}
|
|
242
256
|
|
|
243
|
-
const char = data[index]
|
|
257
|
+
const char = data[index];
|
|
258
|
+
if (char === undefined) {
|
|
259
|
+
break;
|
|
260
|
+
}
|
|
244
261
|
if (char === "\x1b") {
|
|
245
262
|
const next = readCodePoint(data, index + 1);
|
|
246
263
|
if (next && next.value !== "[") {
|
|
@@ -291,7 +308,10 @@ export function normalizeKey(key: string): string {
|
|
|
291
308
|
const parts = key.toLowerCase().split("+");
|
|
292
309
|
if (parts.length <= 1) return key.toLowerCase();
|
|
293
310
|
|
|
294
|
-
const base = parts
|
|
311
|
+
const base = parts.at(-1);
|
|
312
|
+
if (!base) {
|
|
313
|
+
return key.toLowerCase();
|
|
314
|
+
}
|
|
295
315
|
const mods = parts.slice(0, -1);
|
|
296
316
|
|
|
297
317
|
const ordered: string[] = [];
|
|
@@ -306,8 +326,10 @@ export function normalizeKey(key: string): string {
|
|
|
306
326
|
* Check whether a semantic key represents TextInput editing/navigation.
|
|
307
327
|
*
|
|
308
328
|
* Single-character semantic keys represent insertable text, while named keys
|
|
309
|
-
* like `"enter"` and `"left"` represent editing/navigation actions.
|
|
310
|
-
* combos (`ctrl+s`, `alt+x`) are NOT editing keys and should bubble
|
|
329
|
+
* like `"enter"` and `"left"` represent editing/navigation actions. Most
|
|
330
|
+
* modifier combos (`ctrl+s`, `alt+x`) are NOT editing keys and should bubble,
|
|
331
|
+
* but TextInput consumes a small set of readline-style shortcuts for cursor
|
|
332
|
+
* movement and word deletion.
|
|
311
333
|
*/
|
|
312
334
|
export function isEditingKey(key: string): boolean {
|
|
313
335
|
if (key.length === 1) return true;
|
|
@@ -326,6 +348,14 @@ export function isEditingKey(key: string): boolean {
|
|
|
326
348
|
"space",
|
|
327
349
|
"plus",
|
|
328
350
|
"shift+enter",
|
|
351
|
+
"ctrl+a",
|
|
352
|
+
"ctrl+e",
|
|
353
|
+
"alt+b",
|
|
354
|
+
"alt+f",
|
|
355
|
+
"ctrl+left",
|
|
356
|
+
"ctrl+right",
|
|
357
|
+
"ctrl+w",
|
|
358
|
+
"alt+d",
|
|
329
359
|
]);
|
|
330
360
|
|
|
331
361
|
return editingKeys.has(key);
|
package/src/layout.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { ContainerProps, Node, SizeValue } from "@cel-tui/types";
|
|
2
2
|
import { layoutText } from "./text-layout.js";
|
|
3
3
|
import { visibleWidth } from "./width.js";
|
|
4
4
|
|
|
@@ -33,7 +33,13 @@ function resolveSizeValue(
|
|
|
33
33
|
if (value === undefined) return undefined;
|
|
34
34
|
if (typeof value === "number") return value;
|
|
35
35
|
const match = value.match(/^(\d+(?:\.\d+)?)%$/);
|
|
36
|
-
if (match)
|
|
36
|
+
if (match) {
|
|
37
|
+
const percentage = match[1];
|
|
38
|
+
if (percentage === undefined) {
|
|
39
|
+
throw new Error(`Invalid percentage size: ${value}`);
|
|
40
|
+
}
|
|
41
|
+
return Math.floor((parentSize * parseFloat(percentage)) / 100);
|
|
42
|
+
}
|
|
37
43
|
return undefined;
|
|
38
44
|
}
|
|
39
45
|
|
|
@@ -46,6 +52,25 @@ function getProps(node: Node): ContainerProps | null {
|
|
|
46
52
|
return node.props;
|
|
47
53
|
}
|
|
48
54
|
|
|
55
|
+
function requiredAt<T>(
|
|
56
|
+
items: readonly T[],
|
|
57
|
+
index: number,
|
|
58
|
+
description: string,
|
|
59
|
+
): T {
|
|
60
|
+
const item = items[index];
|
|
61
|
+
if (item === undefined) {
|
|
62
|
+
throw new Error(`Missing ${description} at index ${index}`);
|
|
63
|
+
}
|
|
64
|
+
return item;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function getMainAxisGap(
|
|
68
|
+
gap: number,
|
|
69
|
+
justify: ContainerProps["justifyContent"] | undefined,
|
|
70
|
+
): number {
|
|
71
|
+
return justify === "space-between" ? 0 : gap;
|
|
72
|
+
}
|
|
73
|
+
|
|
49
74
|
// --- Intrinsic size computation ---
|
|
50
75
|
|
|
51
76
|
/**
|
|
@@ -93,6 +118,7 @@ function intrinsicMainSize(
|
|
|
93
118
|
// If it's the cross axis, take the max of children on that axis.
|
|
94
119
|
const props = node.props;
|
|
95
120
|
const gap = props.gap ?? 0;
|
|
121
|
+
const mainAxisGap = getMainAxisGap(gap, props.justifyContent);
|
|
96
122
|
const containerIsVertical = node.type === "vstack";
|
|
97
123
|
const axisMatchesMain = isVertical === containerIsVertical;
|
|
98
124
|
|
|
@@ -108,7 +134,7 @@ function intrinsicMainSize(
|
|
|
108
134
|
// Sum children along the main axis + gaps
|
|
109
135
|
let total = 0;
|
|
110
136
|
for (let i = 0; i < node.children.length; i++) {
|
|
111
|
-
const child = node.children
|
|
137
|
+
const child = requiredAt(node.children, i, "child node");
|
|
112
138
|
const cProps = getProps(child);
|
|
113
139
|
|
|
114
140
|
let childMain: number;
|
|
@@ -131,7 +157,7 @@ function intrinsicMainSize(
|
|
|
131
157
|
childMain = clamp(childMain, minMain, maxMain);
|
|
132
158
|
}
|
|
133
159
|
total += childMain;
|
|
134
|
-
if (i < node.children.length - 1) total +=
|
|
160
|
+
if (i < node.children.length - 1) total += mainAxisGap;
|
|
135
161
|
}
|
|
136
162
|
return total + padMain;
|
|
137
163
|
}
|
|
@@ -167,12 +193,13 @@ function intrinsicMainSize(
|
|
|
167
193
|
}
|
|
168
194
|
wrapHeights.push(h);
|
|
169
195
|
}
|
|
170
|
-
const wrapRows = assignWrapRows(wrapWidths, innerCross,
|
|
196
|
+
const wrapRows = assignWrapRows(wrapWidths, innerCross, mainAxisGap);
|
|
171
197
|
let total = 0;
|
|
172
198
|
for (let ri = 0; ri < wrapRows.length; ri++) {
|
|
173
199
|
let maxH = 0;
|
|
174
|
-
for (const idx of wrapRows
|
|
175
|
-
|
|
200
|
+
for (const idx of requiredAt(wrapRows, ri, "wrap row")) {
|
|
201
|
+
const height = requiredAt(wrapHeights, idx, "wrap height");
|
|
202
|
+
if (height > maxH) maxH = height;
|
|
176
203
|
}
|
|
177
204
|
total += maxH;
|
|
178
205
|
if (ri < wrapRows.length - 1) total += gap;
|
|
@@ -230,7 +257,7 @@ function assignWrapRows(
|
|
|
230
257
|
let rowWidth = 0;
|
|
231
258
|
|
|
232
259
|
for (let i = 0; i < widths.length; i++) {
|
|
233
|
-
const w = widths
|
|
260
|
+
const w = requiredAt(widths, i, "wrap width");
|
|
234
261
|
if (currentRow.length === 0) {
|
|
235
262
|
currentRow.push(i);
|
|
236
263
|
rowWidth = w;
|
|
@@ -262,7 +289,8 @@ function largestRemainder(fractions: number[], total: number): number[] {
|
|
|
262
289
|
|
|
263
290
|
for (const { i } of indices) {
|
|
264
291
|
if (remainder <= 0) break;
|
|
265
|
-
floored
|
|
292
|
+
const value = requiredAt(floored, i, "rounded size");
|
|
293
|
+
floored[i] = value + 1;
|
|
266
294
|
remainder--;
|
|
267
295
|
}
|
|
268
296
|
|
|
@@ -339,6 +367,7 @@ function layoutWrapHStack(
|
|
|
339
367
|
const gap = props.gap ?? 0;
|
|
340
368
|
const align = props.alignItems ?? "stretch";
|
|
341
369
|
const justify = props.justifyContent ?? "start";
|
|
370
|
+
const itemGap = getMainAxisGap(gap, justify);
|
|
342
371
|
|
|
343
372
|
// Padding
|
|
344
373
|
const padX = props.padding?.x ?? 0;
|
|
@@ -384,25 +413,26 @@ function layoutWrapHStack(
|
|
|
384
413
|
}
|
|
385
414
|
|
|
386
415
|
// Phase 2: Assign children to rows
|
|
387
|
-
const rows = assignWrapRows(baseWidths, innerW,
|
|
416
|
+
const rows = assignWrapRows(baseWidths, innerW, itemGap);
|
|
388
417
|
|
|
389
418
|
// Phase 3: Layout each row independently
|
|
390
419
|
const layoutChildren: LayoutNode[] = [];
|
|
391
420
|
let rowY = 0;
|
|
392
421
|
|
|
393
422
|
for (let ri = 0; ri < rows.length; ri++) {
|
|
394
|
-
const rowIdx = rows
|
|
395
|
-
const rowGapTotal =
|
|
423
|
+
const rowIdx = requiredAt(rows, ri, "wrap row");
|
|
424
|
+
const rowGapTotal = itemGap * (rowIdx.length - 1);
|
|
396
425
|
const rowAvail = innerW - rowGapTotal;
|
|
397
426
|
|
|
398
427
|
// Compute fixed and flex totals for this row
|
|
399
428
|
let fixedMain = 0;
|
|
400
429
|
let totalFlex = 0;
|
|
401
430
|
for (const idx of rowIdx) {
|
|
402
|
-
|
|
403
|
-
|
|
431
|
+
const flexValue = requiredAt(flexValues, idx, "flex value");
|
|
432
|
+
if (flexValue > 0) {
|
|
433
|
+
totalFlex += flexValue;
|
|
404
434
|
} else {
|
|
405
|
-
fixedMain += baseWidths
|
|
435
|
+
fixedMain += requiredAt(baseWidths, idx, "base width");
|
|
406
436
|
}
|
|
407
437
|
}
|
|
408
438
|
|
|
@@ -413,17 +443,25 @@ function layoutWrapHStack(
|
|
|
413
443
|
if (totalFlex > 0) {
|
|
414
444
|
const flexPositions: number[] = [];
|
|
415
445
|
for (let ci = 0; ci < rowIdx.length; ci++) {
|
|
416
|
-
|
|
446
|
+
const rowIndex = requiredAt(rowIdx, ci, "wrap row index");
|
|
447
|
+
if (requiredAt(flexValues, rowIndex, "flex value") > 0) {
|
|
448
|
+
flexPositions.push(ci);
|
|
449
|
+
}
|
|
417
450
|
}
|
|
418
|
-
const rawSizes = flexPositions.map(
|
|
419
|
-
|
|
420
|
-
|
|
451
|
+
const rawSizes = flexPositions.map((ci) => {
|
|
452
|
+
const rowIndex = requiredAt(rowIdx, ci, "wrap row index");
|
|
453
|
+
return (
|
|
454
|
+
(requiredAt(flexValues, rowIndex, "flex value") / totalFlex) *
|
|
455
|
+
flexSpace
|
|
456
|
+
);
|
|
457
|
+
});
|
|
421
458
|
const rounded = largestRemainder(rawSizes, flexSpace);
|
|
422
459
|
|
|
423
460
|
for (let fi = 0; fi < flexPositions.length; fi++) {
|
|
424
|
-
const ci = flexPositions
|
|
425
|
-
let size = rounded
|
|
426
|
-
const
|
|
461
|
+
const ci = requiredAt(flexPositions, fi, "flex position");
|
|
462
|
+
let size = requiredAt(rounded, fi, "rounded flex size");
|
|
463
|
+
const rowIndex = requiredAt(rowIdx, ci, "wrap row index");
|
|
464
|
+
const cProps = getProps(requiredAt(children, rowIndex, "child node"));
|
|
427
465
|
if (cProps) {
|
|
428
466
|
size = clamp(size, cProps.minWidth ?? 0, cProps.maxWidth ?? Infinity);
|
|
429
467
|
}
|
|
@@ -434,20 +472,22 @@ function layoutWrapHStack(
|
|
|
434
472
|
// Non-flex children keep their base width
|
|
435
473
|
for (let ci = 0; ci < rowIdx.length; ci++) {
|
|
436
474
|
if (childWidths[ci] === undefined) {
|
|
437
|
-
|
|
475
|
+
const rowIndex = requiredAt(rowIdx, ci, "wrap row index");
|
|
476
|
+
childWidths[ci] = requiredAt(baseWidths, rowIndex, "base width");
|
|
438
477
|
}
|
|
439
478
|
}
|
|
440
479
|
|
|
441
480
|
// Row height = max cross size of children in this row
|
|
442
481
|
let rowHeight = 0;
|
|
443
482
|
for (const idx of rowIdx) {
|
|
444
|
-
|
|
483
|
+
const crossSize = requiredAt(crossSizes, idx, "cross size");
|
|
484
|
+
if (crossSize > rowHeight) rowHeight = crossSize;
|
|
445
485
|
}
|
|
446
486
|
|
|
447
487
|
// justifyContent: compute main-axis starting offset
|
|
448
488
|
let totalUsedWidth = rowGapTotal;
|
|
449
489
|
for (let ci = 0; ci < rowIdx.length; ci++) {
|
|
450
|
-
totalUsedWidth += childWidths
|
|
490
|
+
totalUsedWidth += requiredAt(childWidths, ci, "child width");
|
|
451
491
|
}
|
|
452
492
|
const remainingMain = Math.max(0, innerW - totalUsedWidth);
|
|
453
493
|
|
|
@@ -470,17 +510,17 @@ function layoutWrapHStack(
|
|
|
470
510
|
// Position children in this row
|
|
471
511
|
let xOffset = mainStart;
|
|
472
512
|
for (let ci = 0; ci < rowIdx.length; ci++) {
|
|
473
|
-
const idx = rowIdx
|
|
474
|
-
const childW = childWidths
|
|
513
|
+
const idx = requiredAt(rowIdx, ci, "wrap row index");
|
|
514
|
+
const childW = requiredAt(childWidths, ci, "child width");
|
|
475
515
|
|
|
476
516
|
// Cross-axis sizing: stretch fills row height, others keep their size
|
|
477
517
|
let childH: number;
|
|
478
518
|
if (align === "stretch") {
|
|
479
|
-
const cProps = getProps(children
|
|
519
|
+
const cProps = getProps(requiredAt(children, idx, "child node"));
|
|
480
520
|
const explicitH = resolveSizeValue(cProps?.height, innerH);
|
|
481
521
|
childH = explicitH ?? rowHeight;
|
|
482
522
|
} else {
|
|
483
|
-
childH = crossSizes
|
|
523
|
+
childH = requiredAt(crossSizes, idx, "cross size");
|
|
484
524
|
}
|
|
485
525
|
|
|
486
526
|
// Cross-axis alignment within the row
|
|
@@ -495,14 +535,20 @@ function layoutWrapHStack(
|
|
|
495
535
|
const childY = innerY + rowY + crossOffset;
|
|
496
536
|
|
|
497
537
|
layoutChildren.push(
|
|
498
|
-
layoutNode(
|
|
538
|
+
layoutNode(
|
|
539
|
+
requiredAt(children, idx, "child node"),
|
|
540
|
+
childX,
|
|
541
|
+
childY,
|
|
542
|
+
childW,
|
|
543
|
+
childH,
|
|
544
|
+
),
|
|
499
545
|
);
|
|
500
546
|
|
|
501
547
|
xOffset += childW;
|
|
502
548
|
if (ci < rowIdx.length - 1) {
|
|
503
|
-
xOffset +=
|
|
549
|
+
xOffset += itemGap;
|
|
504
550
|
if (betweenGaps) {
|
|
505
|
-
xOffset += betweenGaps
|
|
551
|
+
xOffset += requiredAt(betweenGaps, ci, "justified gap");
|
|
506
552
|
}
|
|
507
553
|
}
|
|
508
554
|
}
|
|
@@ -572,7 +618,9 @@ function layoutNode(
|
|
|
572
618
|
|
|
573
619
|
// Gap
|
|
574
620
|
const gap = props.gap ?? 0;
|
|
575
|
-
const
|
|
621
|
+
const justify = props.justifyContent ?? "start";
|
|
622
|
+
const mainAxisGap = getMainAxisGap(gap, justify);
|
|
623
|
+
const totalGap = mainAxisGap * (children.length - 1);
|
|
576
624
|
const mainAvail = (isVertical ? innerH : innerW) - totalGap;
|
|
577
625
|
|
|
578
626
|
// --- Measure phase: compute each child's main-axis and cross-axis size ---
|
|
@@ -675,8 +723,9 @@ function layoutNode(
|
|
|
675
723
|
const rounded = largestRemainder(rawSizes, flexSpace);
|
|
676
724
|
|
|
677
725
|
for (let i = 0; i < flexInfos.length; i++) {
|
|
678
|
-
let size = rounded
|
|
679
|
-
const
|
|
726
|
+
let size = requiredAt(rounded, i, "rounded flex size");
|
|
727
|
+
const flexInfo = requiredAt(flexInfos, i, "flex child info");
|
|
728
|
+
const cProps = getProps(flexInfo.node);
|
|
680
729
|
if (cProps) {
|
|
681
730
|
const minMain = isVertical
|
|
682
731
|
? (cProps.minHeight ?? 0)
|
|
@@ -686,7 +735,7 @@ function layoutNode(
|
|
|
686
735
|
: (cProps.maxWidth ?? Infinity);
|
|
687
736
|
size = clamp(size, minMain, maxMain);
|
|
688
737
|
}
|
|
689
|
-
|
|
738
|
+
flexInfo.mainSize = size;
|
|
690
739
|
}
|
|
691
740
|
}
|
|
692
741
|
|
|
@@ -700,7 +749,6 @@ function layoutNode(
|
|
|
700
749
|
const remainingMain = Math.max(0, mainInner - totalContent);
|
|
701
750
|
|
|
702
751
|
// justifyContent: compute main-axis starting offset and per-gap extra space
|
|
703
|
-
const justify = props.justifyContent ?? "start";
|
|
704
752
|
let mainStart = 0;
|
|
705
753
|
let betweenGaps: number[] | null = null;
|
|
706
754
|
|
|
@@ -722,7 +770,7 @@ function layoutNode(
|
|
|
722
770
|
let mainOffset = mainStart;
|
|
723
771
|
|
|
724
772
|
for (let i = 0; i < infos.length; i++) {
|
|
725
|
-
const info = infos
|
|
773
|
+
const info = requiredAt(infos, i, "child layout info");
|
|
726
774
|
|
|
727
775
|
// Cross-axis alignment
|
|
728
776
|
let crossOffset = 0;
|
|
@@ -742,9 +790,9 @@ function layoutNode(
|
|
|
742
790
|
|
|
743
791
|
mainOffset += info.mainSize;
|
|
744
792
|
if (i < infos.length - 1) {
|
|
745
|
-
mainOffset +=
|
|
793
|
+
mainOffset += mainAxisGap;
|
|
746
794
|
if (betweenGaps) {
|
|
747
|
-
mainOffset += betweenGaps
|
|
795
|
+
mainOffset += requiredAt(betweenGaps, i, "justified gap");
|
|
748
796
|
}
|
|
749
797
|
}
|
|
750
798
|
}
|
package/src/paint.ts
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
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";
|
|
2
|
+
import type { Cell, CellBuffer } from "./cell-buffer.js";
|
|
4
3
|
import type { LayoutNode, Rect } from "./layout.js";
|
|
5
4
|
import { getMaxScrollOffset } from "./scroll.js";
|
|
6
5
|
import { layoutText } from "./text-layout.js";
|
|
@@ -233,23 +232,30 @@ function paintScrollbar(
|
|
|
233
232
|
): void {
|
|
234
233
|
const { rect, children } = ln;
|
|
235
234
|
const isVertical = ln.node.type === "vstack";
|
|
235
|
+
const props =
|
|
236
|
+
ln.node.type === "vstack" || ln.node.type === "hstack"
|
|
237
|
+
? ln.node.props
|
|
238
|
+
: null;
|
|
239
|
+
if (!props) return;
|
|
236
240
|
|
|
237
241
|
if (isVertical) {
|
|
238
|
-
// Compute total
|
|
242
|
+
// Compute total scrollable height from children plus bottom padding.
|
|
243
|
+
// Child positions already include top padding, so add only the trailing pad.
|
|
239
244
|
let contentHeight = 0;
|
|
240
245
|
for (const child of children) {
|
|
241
246
|
const childBottom = child.rect.y + child.rect.height - rect.y;
|
|
242
247
|
if (childBottom > contentHeight) contentHeight = childBottom;
|
|
243
248
|
}
|
|
249
|
+
const scrollHeight = contentHeight + (props.padding?.y ?? 0);
|
|
244
250
|
const viewportH = rect.height;
|
|
245
|
-
if (
|
|
251
|
+
if (scrollHeight <= viewportH) return; // no scrollbar needed
|
|
246
252
|
|
|
247
253
|
// Thumb size and position
|
|
248
254
|
const thumbSize = Math.max(
|
|
249
255
|
1,
|
|
250
|
-
Math.round((viewportH /
|
|
256
|
+
Math.round((viewportH / scrollHeight) * viewportH),
|
|
251
257
|
);
|
|
252
|
-
const maxOffset =
|
|
258
|
+
const maxOffset = scrollHeight - viewportH;
|
|
253
259
|
const thumbPos =
|
|
254
260
|
maxOffset > 0
|
|
255
261
|
? Math.round((scrollOffset / maxOffset) * (viewportH - thumbSize))
|
|
@@ -277,14 +283,15 @@ function paintScrollbar(
|
|
|
277
283
|
const childRight = child.rect.x + child.rect.width - rect.x;
|
|
278
284
|
if (childRight > contentWidth) contentWidth = childRight;
|
|
279
285
|
}
|
|
286
|
+
const scrollWidth = contentWidth + (props.padding?.x ?? 0);
|
|
280
287
|
const viewportW = rect.width;
|
|
281
|
-
if (
|
|
288
|
+
if (scrollWidth <= viewportW) return;
|
|
282
289
|
|
|
283
290
|
const thumbSize = Math.max(
|
|
284
291
|
1,
|
|
285
|
-
Math.round((viewportW /
|
|
292
|
+
Math.round((viewportW / scrollWidth) * viewportW),
|
|
286
293
|
);
|
|
287
|
-
const maxOffset =
|
|
294
|
+
const maxOffset = scrollWidth - viewportW;
|
|
288
295
|
const thumbPos =
|
|
289
296
|
maxOffset > 0
|
|
290
297
|
? Math.round((scrollOffset / maxOffset) * (viewportW - thumbSize))
|
|
@@ -414,8 +421,11 @@ function paintText(
|
|
|
414
421
|
|
|
415
422
|
// Paint lines, clipped to rect (grapheme-aware)
|
|
416
423
|
for (let row = 0; row < textLayout.lineCount && row < h; row++) {
|
|
417
|
-
const line = textLayout.lines[row]
|
|
418
|
-
|
|
424
|
+
const line = textLayout.lines[row];
|
|
425
|
+
if (!line) {
|
|
426
|
+
break;
|
|
427
|
+
}
|
|
428
|
+
paintLineGraphemes(line.text, x, y + row, w, clipRect, props, buf);
|
|
419
429
|
}
|
|
420
430
|
}
|
|
421
431
|
|
|
@@ -494,8 +504,11 @@ function paintTextInput(
|
|
|
494
504
|
for (let row = 0; row < ch; row++) {
|
|
495
505
|
const lineIdx = scrollOffset + row;
|
|
496
506
|
if (lineIdx >= textLayout.lineCount) break;
|
|
497
|
-
const line = textLayout.lines[lineIdx]
|
|
498
|
-
|
|
507
|
+
const line = textLayout.lines[lineIdx];
|
|
508
|
+
if (!line) {
|
|
509
|
+
break;
|
|
510
|
+
}
|
|
511
|
+
paintLineGraphemes(line.text, cx, cy + row, cw, clipRect, props, buf);
|
|
499
512
|
}
|
|
500
513
|
|
|
501
514
|
// Paint cursor if focused — invert colors at cursor position so it's
|
|
@@ -536,8 +549,6 @@ function paintTextInput(
|
|
|
536
549
|
|
|
537
550
|
// --- Framework-managed state ---
|
|
538
551
|
|
|
539
|
-
import type { ContainerProps } from "@cel-tui/types";
|
|
540
|
-
|
|
541
552
|
/**
|
|
542
553
|
* TextInput state is keyed on the `onChange` function reference, which is
|
|
543
554
|
* a stable identity across re-renders (the app provides the same closure).
|
|
@@ -12,9 +12,13 @@ import type { TextInputNode, TextInputProps } from "@cel-tui/types";
|
|
|
12
12
|
* responds to mouse wheel automatically.
|
|
13
13
|
*
|
|
14
14
|
* TextInput is always focusable. When focused, it consumes insertable text
|
|
15
|
-
* plus editing/navigation keys (arrows, backspace, delete, Enter, Tab)
|
|
16
|
-
*
|
|
17
|
-
*
|
|
15
|
+
* plus editing/navigation keys (arrows, backspace, delete, Enter, Tab),
|
|
16
|
+
* along with a small set of readline-style shortcuts: `ctrl+a` / `ctrl+e`,
|
|
17
|
+
* `alt+b` / `alt+f`, `ctrl+left` / `ctrl+right`, `ctrl+w`, and `alt+d`.
|
|
18
|
+
* Word movement and deletion use whitespace-delimited boundaries, and
|
|
19
|
+
* `up` / `down` follow visual wrapped lines. Other modifier combos (e.g.,
|
|
20
|
+
* `ctrl+s`) and non-insertable control keys bubble up to ancestor
|
|
21
|
+
* `onKeyPress` handlers.
|
|
18
22
|
*
|
|
19
23
|
* Use `onKeyPress` to intercept keys before editing. The handler receives a
|
|
20
24
|
* normalized semantic key string; inserted text preserves the original
|
package/src/scroll.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { ContainerProps } from "@cel-tui/types";
|
|
1
2
|
import type { LayoutNode } from "./layout.js";
|
|
2
3
|
import { layoutText } from "./text-layout.js";
|
|
3
4
|
|
|
@@ -5,8 +6,11 @@ function isVerticalScrollTarget(target: LayoutNode): boolean {
|
|
|
5
6
|
return target.node.type === "vstack" || target.node.type === "textinput";
|
|
6
7
|
}
|
|
7
8
|
|
|
8
|
-
function getScrollTargetProps(target: LayoutNode):
|
|
9
|
-
|
|
9
|
+
function getScrollTargetProps(target: LayoutNode): ContainerProps {
|
|
10
|
+
if (target.node.type === "text") {
|
|
11
|
+
throw new Error("Text nodes cannot be scroll targets");
|
|
12
|
+
}
|
|
13
|
+
return target.node.props;
|
|
10
14
|
}
|
|
11
15
|
|
|
12
16
|
function getScrollViewportMainAxisSize(target: LayoutNode): number {
|
|
@@ -26,7 +30,7 @@ function getScrollViewportMainAxisSize(target: LayoutNode): number {
|
|
|
26
30
|
*/
|
|
27
31
|
export function getScrollStep(target: LayoutNode): number {
|
|
28
32
|
const rawStep = getScrollTargetProps(target).scrollStep;
|
|
29
|
-
if (Number.isFinite(rawStep) && rawStep > 0) {
|
|
33
|
+
if (typeof rawStep === "number" && Number.isFinite(rawStep) && rawStep > 0) {
|
|
30
34
|
return Math.max(1, Math.floor(rawStep));
|
|
31
35
|
}
|
|
32
36
|
|
|
@@ -55,9 +59,9 @@ export function getMaxScrollOffset(target: LayoutNode): number {
|
|
|
55
59
|
}
|
|
56
60
|
|
|
57
61
|
const isVertical = target.node.type === "vstack";
|
|
58
|
-
const props = target
|
|
59
|
-
const padX =
|
|
60
|
-
const padY =
|
|
62
|
+
const props = getScrollTargetProps(target);
|
|
63
|
+
const padX = props.padding?.x ?? 0;
|
|
64
|
+
const padY = props.padding?.y ?? 0;
|
|
61
65
|
|
|
62
66
|
if (isVertical) {
|
|
63
67
|
let contentHeight = 0;
|