@cel-tui/core 0.4.1 → 0.6.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 +165 -29
- package/src/index.ts +4 -2
- package/src/layout.ts +44 -1
- package/src/terminal.ts +11 -6
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cel-tui/core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"description": "Core framework engine for cel-tui — primitives, layout, rendering, input",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"module": "src/index.ts",
|
|
@@ -34,7 +34,7 @@
|
|
|
34
34
|
"layout"
|
|
35
35
|
],
|
|
36
36
|
"dependencies": {
|
|
37
|
-
"@cel-tui/types": "0.
|
|
37
|
+
"@cel-tui/types": "0.6.0",
|
|
38
38
|
"get-east-asian-width": "^1.5.0"
|
|
39
39
|
},
|
|
40
40
|
"peerDependencies": {
|
package/src/cel.ts
CHANGED
|
@@ -191,8 +191,19 @@ function findFocusedTIInTree(ln: LayoutNode): LayoutNode | null {
|
|
|
191
191
|
|
|
192
192
|
// --- Input handling ---
|
|
193
193
|
|
|
194
|
-
|
|
195
|
-
const
|
|
194
|
+
const BRACKETED_PASTE_START = "\x1b[200~";
|
|
195
|
+
const BRACKETED_PASTE_END = "\x1b[201~";
|
|
196
|
+
|
|
197
|
+
// Regex for a single SGR mouse event, anchored to the current parse position.
|
|
198
|
+
const SGR_MOUSE_RE = /^\x1b\[<(\d+);(\d+);(\d+)([Mm])/;
|
|
199
|
+
|
|
200
|
+
// Trailing keyboard data that ended with an incomplete CSI sequence.
|
|
201
|
+
let pendingKeyData = "";
|
|
202
|
+
|
|
203
|
+
// Bracketed paste state across stdin chunks.
|
|
204
|
+
let inBracketedPaste = false;
|
|
205
|
+
let bracketedPasteData = "";
|
|
206
|
+
let bracketedPasteSuffix = "";
|
|
196
207
|
|
|
197
208
|
// Tracks accumulated scroll offsets during a batch of mouse events.
|
|
198
209
|
// Controlled scroll reads scrollOffset from props, which doesn't update until
|
|
@@ -206,32 +217,61 @@ let batchScrollOffsets: Map<object, number> | null = null;
|
|
|
206
217
|
let batchTextInputEdits: Map<TextInputProps, EditState> | null = null;
|
|
207
218
|
|
|
208
219
|
function handleInput(data: string): void {
|
|
209
|
-
SGR_MOUSE_RE.lastIndex = 0;
|
|
210
220
|
batchTextInputEdits = new Map();
|
|
211
221
|
|
|
212
|
-
let lastIndex = 0;
|
|
213
|
-
|
|
214
222
|
try {
|
|
215
|
-
let
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
223
|
+
let remaining = data;
|
|
224
|
+
|
|
225
|
+
if (inBracketedPaste) {
|
|
226
|
+
remaining = consumeBracketedPasteData(remaining);
|
|
227
|
+
if (remaining.length === 0) return;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
let chunk = pendingKeyData + remaining;
|
|
231
|
+
pendingKeyData = "";
|
|
232
|
+
|
|
233
|
+
let keyChunkStart = 0;
|
|
234
|
+
let index = 0;
|
|
235
|
+
|
|
236
|
+
while (index < chunk.length) {
|
|
237
|
+
if (chunk.startsWith(BRACKETED_PASTE_START, index)) {
|
|
238
|
+
handleKeyChunk(chunk.slice(keyChunkStart, index));
|
|
239
|
+
|
|
240
|
+
index += BRACKETED_PASTE_START.length;
|
|
241
|
+
keyChunkStart = index;
|
|
242
|
+
inBracketedPaste = true;
|
|
243
|
+
|
|
244
|
+
const afterPaste = consumeBracketedPasteData(chunk.slice(index));
|
|
245
|
+
if (afterPaste.length === 0) return;
|
|
220
246
|
|
|
221
|
-
|
|
222
|
-
|
|
247
|
+
chunk = afterPaste;
|
|
248
|
+
index = 0;
|
|
249
|
+
keyChunkStart = 0;
|
|
250
|
+
continue;
|
|
223
251
|
}
|
|
224
252
|
|
|
225
|
-
const mouse =
|
|
226
|
-
if (mouse)
|
|
253
|
+
const mouse = readSgrMouseEvent(chunk, index);
|
|
254
|
+
if (mouse) {
|
|
255
|
+
handleKeyChunk(chunk.slice(keyChunkStart, index));
|
|
227
256
|
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
257
|
+
if (batchScrollOffsets === null) {
|
|
258
|
+
batchScrollOffsets = new Map();
|
|
259
|
+
}
|
|
260
|
+
if (mouse.event) handleMouseEvent(mouse.event);
|
|
261
|
+
|
|
262
|
+
index = mouse.nextIndex;
|
|
263
|
+
keyChunkStart = index;
|
|
264
|
+
continue;
|
|
265
|
+
}
|
|
231
266
|
|
|
232
|
-
|
|
233
|
-
handleKeyChunk(data.slice(lastIndex));
|
|
267
|
+
index++;
|
|
234
268
|
}
|
|
269
|
+
|
|
270
|
+
const { complete, pending } = splitIncompleteCsiSuffix(
|
|
271
|
+
chunk.slice(keyChunkStart),
|
|
272
|
+
);
|
|
273
|
+
handleKeyChunk(complete);
|
|
274
|
+
pendingKeyData = pending;
|
|
235
275
|
} finally {
|
|
236
276
|
batchScrollOffsets = null;
|
|
237
277
|
batchTextInputEdits = null;
|
|
@@ -245,12 +285,84 @@ function handleKeyChunk(data: string): void {
|
|
|
245
285
|
}
|
|
246
286
|
}
|
|
247
287
|
|
|
288
|
+
function consumeBracketedPasteData(data: string): string {
|
|
289
|
+
const chunk = bracketedPasteSuffix + data;
|
|
290
|
+
bracketedPasteSuffix = "";
|
|
291
|
+
|
|
292
|
+
const endIndex = chunk.indexOf(BRACKETED_PASTE_END);
|
|
293
|
+
if (endIndex === -1) {
|
|
294
|
+
const { complete, pending } = splitTrailingMarkerPrefix(
|
|
295
|
+
chunk,
|
|
296
|
+
BRACKETED_PASTE_END,
|
|
297
|
+
);
|
|
298
|
+
bracketedPasteData += complete;
|
|
299
|
+
bracketedPasteSuffix = pending;
|
|
300
|
+
return "";
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
bracketedPasteData += chunk.slice(0, endIndex);
|
|
304
|
+
const pastedText = bracketedPasteData;
|
|
305
|
+
|
|
306
|
+
inBracketedPaste = false;
|
|
307
|
+
bracketedPasteData = "";
|
|
308
|
+
bracketedPasteSuffix = "";
|
|
309
|
+
handleBracketedPaste(pastedText);
|
|
310
|
+
|
|
311
|
+
return chunk.slice(endIndex + BRACKETED_PASTE_END.length);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function splitTrailingMarkerPrefix(
|
|
315
|
+
data: string,
|
|
316
|
+
marker: string,
|
|
317
|
+
): { complete: string; pending: string } {
|
|
318
|
+
const maxPrefixLength = Math.min(data.length, marker.length - 1);
|
|
319
|
+
for (let length = maxPrefixLength; length > 0; length--) {
|
|
320
|
+
if (data.endsWith(marker.slice(0, length))) {
|
|
321
|
+
return {
|
|
322
|
+
complete: data.slice(0, data.length - length),
|
|
323
|
+
pending: data.slice(data.length - length),
|
|
324
|
+
};
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
return { complete: data, pending: "" };
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
function splitIncompleteCsiSuffix(data: string): {
|
|
331
|
+
complete: string;
|
|
332
|
+
pending: string;
|
|
333
|
+
} {
|
|
334
|
+
const csiStart = data.lastIndexOf("\x1b[");
|
|
335
|
+
if (csiStart === -1) return { complete: data, pending: "" };
|
|
336
|
+
|
|
337
|
+
const suffix = data.slice(csiStart);
|
|
338
|
+
for (let i = 2; i < suffix.length; i++) {
|
|
339
|
+
const code = suffix.charCodeAt(i);
|
|
340
|
+
if (code >= 0x40 && code <= 0x7e) {
|
|
341
|
+
return { complete: data, pending: "" };
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
return { complete: data.slice(0, csiStart), pending: suffix };
|
|
346
|
+
}
|
|
347
|
+
|
|
248
348
|
interface MouseEvent {
|
|
249
349
|
type: "click" | "scroll-up" | "scroll-down";
|
|
250
350
|
x: number;
|
|
251
351
|
y: number;
|
|
252
352
|
}
|
|
253
353
|
|
|
354
|
+
function readSgrMouseEvent(
|
|
355
|
+
data: string,
|
|
356
|
+
index: number,
|
|
357
|
+
): { event: MouseEvent | null; nextIndex: number } | null {
|
|
358
|
+
const match = SGR_MOUSE_RE.exec(data.slice(index));
|
|
359
|
+
if (!match) return null;
|
|
360
|
+
return {
|
|
361
|
+
event: parseSgrMatch(match),
|
|
362
|
+
nextIndex: index + match[0].length,
|
|
363
|
+
};
|
|
364
|
+
}
|
|
365
|
+
|
|
254
366
|
/**
|
|
255
367
|
* Parse a single SGR mouse event from a RegExp match.
|
|
256
368
|
* Returns a MouseEvent for scroll and click events, null for unhandled buttons.
|
|
@@ -610,6 +722,31 @@ function getTextInputEditState(props: TextInputProps): EditState {
|
|
|
610
722
|
};
|
|
611
723
|
}
|
|
612
724
|
|
|
725
|
+
function commitTextInputEdit(
|
|
726
|
+
props: TextInputProps,
|
|
727
|
+
previousState: EditState,
|
|
728
|
+
nextState: EditState,
|
|
729
|
+
): void {
|
|
730
|
+
batchTextInputEdits?.set(props, nextState);
|
|
731
|
+
setTextInputCursor(props, nextState.cursor);
|
|
732
|
+
if (nextState.value !== previousState.value) {
|
|
733
|
+
props.onChange(nextState.value);
|
|
734
|
+
}
|
|
735
|
+
cel.render();
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
function handleBracketedPaste(text: string): void {
|
|
739
|
+
if (text.length === 0) return;
|
|
740
|
+
|
|
741
|
+
const focusedInput = findFocusedTextInput();
|
|
742
|
+
if (!focusedInput) return;
|
|
743
|
+
|
|
744
|
+
const props = focusedInput.node.props as TextInputProps;
|
|
745
|
+
const editState = getTextInputEditState(props);
|
|
746
|
+
const nextState = insertChar(editState, text);
|
|
747
|
+
commitTextInputEdit(props, editState, nextState);
|
|
748
|
+
}
|
|
749
|
+
|
|
613
750
|
function handleKeyEvent(event: KeyInput): void {
|
|
614
751
|
const { key, text } = event;
|
|
615
752
|
|
|
@@ -750,12 +887,7 @@ function handleKeyEvent(event: KeyInput): void {
|
|
|
750
887
|
}
|
|
751
888
|
|
|
752
889
|
if (newState && newState !== editState) {
|
|
753
|
-
|
|
754
|
-
setTextInputCursor(props, newState.cursor);
|
|
755
|
-
if (newState.value !== editState.value) {
|
|
756
|
-
props.onChange(newState.value);
|
|
757
|
-
}
|
|
758
|
-
cel.render();
|
|
890
|
+
commitTextInputEdit(props, editState, newState);
|
|
759
891
|
return;
|
|
760
892
|
}
|
|
761
893
|
}
|
|
@@ -874,8 +1006,8 @@ export const cel = {
|
|
|
874
1006
|
* Initialize the framework with a terminal implementation.
|
|
875
1007
|
* Must be called before {@link cel.viewport}.
|
|
876
1008
|
*
|
|
877
|
-
* Enables the Kitty keyboard protocol (level 1)
|
|
878
|
-
* enters raw mode, and starts mouse tracking.
|
|
1009
|
+
* Enables the Kitty keyboard protocol (level 1) and bracketed paste mode via
|
|
1010
|
+
* the terminal, enters raw mode, and starts mouse tracking.
|
|
879
1011
|
*
|
|
880
1012
|
* @param term - Terminal to render to (ProcessTerminal or MockTerminal).
|
|
881
1013
|
* @param options - Optional configuration.
|
|
@@ -914,8 +1046,8 @@ export const cel = {
|
|
|
914
1046
|
/**
|
|
915
1047
|
* Stop the framework and restore terminal state.
|
|
916
1048
|
*
|
|
917
|
-
* Pops the Kitty keyboard protocol mode, disables mouse
|
|
918
|
-
* and restores the terminal to its previous state.
|
|
1049
|
+
* Pops the Kitty keyboard protocol mode, disables bracketed paste and mouse
|
|
1050
|
+
* tracking, and restores the terminal to its previous state.
|
|
919
1051
|
*/
|
|
920
1052
|
stop(): void {
|
|
921
1053
|
terminal?.stop();
|
|
@@ -930,6 +1062,10 @@ export const cel = {
|
|
|
930
1062
|
stampedNode = null;
|
|
931
1063
|
uncontrolledScrollOffsets.clear();
|
|
932
1064
|
stampedScrollNodes = [];
|
|
1065
|
+
pendingKeyData = "";
|
|
1066
|
+
inBracketedPaste = false;
|
|
1067
|
+
bracketedPasteData = "";
|
|
1068
|
+
bracketedPasteSuffix = "";
|
|
933
1069
|
lastTerminalCursor = { visible: false };
|
|
934
1070
|
activeTheme = defaultTheme;
|
|
935
1071
|
},
|
package/src/index.ts
CHANGED
|
@@ -2,8 +2,9 @@
|
|
|
2
2
|
* @module @cel-tui/core
|
|
3
3
|
*
|
|
4
4
|
* Core framework package. Provides the four primitives ({@link VStack},
|
|
5
|
-
* {@link HStack}, {@link Text}, {@link TextInput})
|
|
6
|
-
* entrypoint ({@link cel})
|
|
5
|
+
* {@link HStack}, {@link Text}, {@link TextInput}), the framework
|
|
6
|
+
* entrypoint ({@link cel}), and measurement helpers such as
|
|
7
|
+
* {@link measureContentHeight}.
|
|
7
8
|
*
|
|
8
9
|
* All types are re-exported from `@cel-tui/types`.
|
|
9
10
|
*
|
|
@@ -45,6 +46,7 @@ export { VStack, HStack } from "./primitives/stacks.js";
|
|
|
45
46
|
export { Text } from "./primitives/text.js";
|
|
46
47
|
export { TextInput } from "./primitives/text-input.js";
|
|
47
48
|
export { cel } from "./cel.js";
|
|
49
|
+
export { measureContentHeight } from "./layout.js";
|
|
48
50
|
export { CellBuffer, EMPTY_CELL, type Cell } from "./cell-buffer.js";
|
|
49
51
|
export { emitBuffer, defaultTheme } from "./emitter.js";
|
|
50
52
|
export { visibleWidth, extractAnsiCode } from "./width.js";
|
package/src/layout.ts
CHANGED
|
@@ -121,6 +121,15 @@ function intrinsicMainSize(
|
|
|
121
121
|
resolveSizeValue(cProps?.width, 0) ??
|
|
122
122
|
intrinsicMainSize(child, false, innerCross);
|
|
123
123
|
}
|
|
124
|
+
if (cProps) {
|
|
125
|
+
const minMain = isVertical
|
|
126
|
+
? (cProps.minHeight ?? 0)
|
|
127
|
+
: (cProps.minWidth ?? 0);
|
|
128
|
+
const maxMain = isVertical
|
|
129
|
+
? (cProps.maxHeight ?? Infinity)
|
|
130
|
+
: (cProps.maxWidth ?? Infinity);
|
|
131
|
+
childMain = clamp(childMain, minMain, maxMain);
|
|
132
|
+
}
|
|
124
133
|
total += childMain;
|
|
125
134
|
if (i < node.children.length - 1) total += gap;
|
|
126
135
|
}
|
|
@@ -150,9 +159,12 @@ function intrinsicMainSize(
|
|
|
150
159
|
}
|
|
151
160
|
}
|
|
152
161
|
wrapWidths.push(w);
|
|
153
|
-
|
|
162
|
+
let h =
|
|
154
163
|
resolveSizeValue(cProps?.height, 0) ??
|
|
155
164
|
intrinsicMainSize(child, true, innerCross);
|
|
165
|
+
if (cProps) {
|
|
166
|
+
h = clamp(h, cProps.minHeight ?? 0, cProps.maxHeight ?? Infinity);
|
|
167
|
+
}
|
|
156
168
|
wrapHeights.push(h);
|
|
157
169
|
}
|
|
158
170
|
const wrapRows = assignWrapRows(wrapWidths, innerCross, gap);
|
|
@@ -259,6 +271,37 @@ function largestRemainder(fractions: number[], total: number): number[] {
|
|
|
259
271
|
|
|
260
272
|
// --- Main layout ---
|
|
261
273
|
|
|
274
|
+
/**
|
|
275
|
+
* Measure a node tree's intrinsic content height at the provided width.
|
|
276
|
+
*
|
|
277
|
+
* This is a content-measurement helper, not a viewport/clipping helper.
|
|
278
|
+
* The caller-provided `width` is the authoritative wrapping width for the
|
|
279
|
+
* measured subtree. Measurement starts at the given node, ignores that
|
|
280
|
+
* node's own main-axis height constraints, and walks downward through its
|
|
281
|
+
* descendants. Descendant sizing rules still apply normally.
|
|
282
|
+
*
|
|
283
|
+
* Use this for intrinsically sized content such as scrollback/message
|
|
284
|
+
* history chunks. If a wrapper's visible height is controlled by `height`,
|
|
285
|
+
* `flex`, or percentage sizing, measure the content subtree inside that
|
|
286
|
+
* wrapper instead.
|
|
287
|
+
*
|
|
288
|
+
* @example
|
|
289
|
+
* ```ts
|
|
290
|
+
* const addedHeight = measureContentHeight(
|
|
291
|
+
* VStack({}, olderMessages.map(renderMessage)),
|
|
292
|
+
* { width: historyContentWidth },
|
|
293
|
+
* );
|
|
294
|
+
*
|
|
295
|
+
* scrollOffset += addedHeight;
|
|
296
|
+
* ```
|
|
297
|
+
*/
|
|
298
|
+
export function measureContentHeight(
|
|
299
|
+
node: Node,
|
|
300
|
+
options: { width: number },
|
|
301
|
+
): number {
|
|
302
|
+
return intrinsicMainSize(node, true, options.width);
|
|
303
|
+
}
|
|
304
|
+
|
|
262
305
|
/**
|
|
263
306
|
* Compute the layout for a UI tree.
|
|
264
307
|
*
|
package/src/terminal.ts
CHANGED
|
@@ -12,8 +12,8 @@ export interface Terminal {
|
|
|
12
12
|
/** Terminal height in rows. */
|
|
13
13
|
get rows(): number;
|
|
14
14
|
/**
|
|
15
|
-
* Enter raw mode, enable Kitty level 1 keyboard reporting, enable
|
|
16
|
-
* tracking, and hide the cursor.
|
|
15
|
+
* Enter raw mode, enable Kitty level 1 keyboard reporting, enable bracketed
|
|
16
|
+
* paste mode, enable mouse tracking, and hide the cursor.
|
|
17
17
|
*
|
|
18
18
|
* The framework prefers Kitty semantics but its parser also accepts mixed
|
|
19
19
|
* tmux/legacy keyboard encodings that may still arrive on stdin.
|
|
@@ -30,10 +30,11 @@ export interface Terminal {
|
|
|
30
30
|
/**
|
|
31
31
|
* Real terminal using process.stdin/stdout.
|
|
32
32
|
*
|
|
33
|
-
* Enables Kitty keyboard protocol level 1,
|
|
34
|
-
* The runtime prefers Kitty semantics for full
|
|
35
|
-
* parser remains compatible with mixed
|
|
36
|
-
* may still arrive on stdin. All modes are
|
|
33
|
+
* Enables Kitty keyboard protocol level 1, bracketed paste mode, SGR mouse
|
|
34
|
+
* tracking, and raw mode. The runtime prefers Kitty semantics for full
|
|
35
|
+
* modifier fidelity, while the parser remains compatible with mixed
|
|
36
|
+
* tmux/legacy keyboard encodings that may still arrive on stdin. All modes are
|
|
37
|
+
* restored on stop/crash.
|
|
37
38
|
*/
|
|
38
39
|
export class ProcessTerminal implements Terminal {
|
|
39
40
|
private wasRaw = false;
|
|
@@ -76,6 +77,8 @@ export class ProcessTerminal implements Terminal {
|
|
|
76
77
|
this.write("\x1b[?1049h");
|
|
77
78
|
// Enable Kitty keyboard protocol level 1 (disambiguate) with push flag
|
|
78
79
|
this.write("\x1b[>1u");
|
|
80
|
+
// Enable bracketed paste mode
|
|
81
|
+
this.write("\x1b[?2004h");
|
|
79
82
|
// Enable mouse tracking (normal mode) + SGR encoding
|
|
80
83
|
this.write("\x1b[?1000h\x1b[?1006h");
|
|
81
84
|
this.hideCursor();
|
|
@@ -110,6 +113,8 @@ export class ProcessTerminal implements Terminal {
|
|
|
110
113
|
|
|
111
114
|
// Disable mouse tracking + SGR encoding
|
|
112
115
|
this.write("\x1b[?1006l\x1b[?1000l");
|
|
116
|
+
// Disable bracketed paste mode
|
|
117
|
+
this.write("\x1b[?2004l");
|
|
113
118
|
// Pop Kitty keyboard protocol mode
|
|
114
119
|
this.write("\x1b[<u");
|
|
115
120
|
this.showCursor();
|