@cel-tui/core 0.1.0 → 0.2.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 +3 -2
- package/src/cel.ts +250 -36
- package/src/emitter.ts +84 -49
- package/src/hit-test.ts +65 -12
- package/src/index.ts +3 -1
- package/src/keys.ts +181 -74
- package/src/layout.ts +325 -5
- package/src/paint.ts +155 -52
- package/src/primitives/stacks.ts +1 -1
- package/src/primitives/text-input.ts +1 -1
- package/src/primitives/text.ts +1 -1
- package/src/terminal.ts +12 -1
- package/src/text-edit.ts +52 -44
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cel-tui/core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Core framework engine for cel-tui — primitives, layout, rendering, input",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"module": "src/index.ts",
|
|
@@ -14,7 +14,8 @@
|
|
|
14
14
|
},
|
|
15
15
|
"files": [
|
|
16
16
|
"src/**/*.ts",
|
|
17
|
-
"!src/**/*.test.ts"
|
|
17
|
+
"!src/**/*.test.ts",
|
|
18
|
+
"!src/test-helpers.ts"
|
|
18
19
|
],
|
|
19
20
|
"license": "MIT",
|
|
20
21
|
"repository": {
|
package/src/cel.ts
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
|
-
import type { Node } from "@cel-tui/types";
|
|
1
|
+
import type { Node, Theme } from "@cel-tui/types";
|
|
2
2
|
import { CellBuffer } from "./cell-buffer.js";
|
|
3
|
-
import { emitBuffer, emitDiff } from "./emitter.js";
|
|
3
|
+
import { emitBuffer, emitDiff, defaultTheme } from "./emitter.js";
|
|
4
4
|
import {
|
|
5
5
|
hitTest,
|
|
6
6
|
findClickHandler,
|
|
7
7
|
findScrollTarget,
|
|
8
|
-
|
|
8
|
+
collectKeyPressHandlers,
|
|
9
9
|
collectFocusable,
|
|
10
10
|
} from "./hit-test.js";
|
|
11
11
|
import { parseKey, isEditingKey, normalizeKey } from "./keys.js";
|
|
@@ -16,8 +16,7 @@ import {
|
|
|
16
16
|
setTextInputCursor,
|
|
17
17
|
getTextInputScroll,
|
|
18
18
|
setTextInputScroll,
|
|
19
|
-
|
|
20
|
-
setContainerScroll,
|
|
19
|
+
getTextInputCursorScreenPos,
|
|
21
20
|
} from "./paint.js";
|
|
22
21
|
import {
|
|
23
22
|
insertChar,
|
|
@@ -32,6 +31,7 @@ import { visibleWidth } from "./width.js";
|
|
|
32
31
|
type RenderFn = () => Node | Node[];
|
|
33
32
|
|
|
34
33
|
let terminal: Terminal | null = null;
|
|
34
|
+
let activeTheme: Theme = defaultTheme;
|
|
35
35
|
let renderFn: RenderFn | null = null;
|
|
36
36
|
let renderScheduled = false;
|
|
37
37
|
let prevBuffer: CellBuffer | null = null;
|
|
@@ -49,6 +49,17 @@ let frameworkFocusIndex = -1;
|
|
|
49
49
|
/** The node whose props were stamped with `focused: true` during the last paint. */
|
|
50
50
|
let stampedNode: LayoutNode | null = null;
|
|
51
51
|
|
|
52
|
+
/**
|
|
53
|
+
* Uncontrolled scroll offsets, keyed by structural tree path.
|
|
54
|
+
* The path is a string like "0/2/1" representing the DFS child indices
|
|
55
|
+
* from the layer root to the scrollable container. This survives re-renders
|
|
56
|
+
* because the structural position is the same even with new props objects.
|
|
57
|
+
*/
|
|
58
|
+
const uncontrolledScrollOffsets = new Map<string, number>();
|
|
59
|
+
|
|
60
|
+
/** Nodes whose props were stamped with scrollOffset during the last paint. */
|
|
61
|
+
let stampedScrollNodes: { node: Node; key: string }[] = [];
|
|
62
|
+
|
|
52
63
|
function doRender(): void {
|
|
53
64
|
renderScheduled = false;
|
|
54
65
|
if (!renderFn || !terminal) return;
|
|
@@ -81,8 +92,9 @@ function doRender(): void {
|
|
|
81
92
|
currentLayouts.push(layoutTree);
|
|
82
93
|
}
|
|
83
94
|
|
|
84
|
-
// Stamp uncontrolled focus before painting
|
|
95
|
+
// Stamp uncontrolled focus and scroll before painting
|
|
85
96
|
stampUncontrolledFocus();
|
|
97
|
+
stampUncontrolledScroll();
|
|
86
98
|
|
|
87
99
|
// Paint each layer into the buffer
|
|
88
100
|
for (const layoutTree of currentLayouts) {
|
|
@@ -91,15 +103,79 @@ function doRender(): void {
|
|
|
91
103
|
|
|
92
104
|
// Unstamp after paint so input handlers see clean props
|
|
93
105
|
unstampUncontrolledFocus();
|
|
106
|
+
unstampUncontrolledScroll();
|
|
94
107
|
|
|
95
108
|
// Emit to terminal — differential when possible
|
|
96
109
|
if (prevBuffer) {
|
|
97
|
-
const output = emitDiff(prevBuffer, currentBuffer);
|
|
110
|
+
const output = emitDiff(prevBuffer, currentBuffer, activeTheme);
|
|
98
111
|
if (output.length > 0) terminal.write(output);
|
|
99
112
|
} else {
|
|
100
|
-
const output = emitBuffer(currentBuffer);
|
|
113
|
+
const output = emitBuffer(currentBuffer, activeTheme);
|
|
101
114
|
terminal.write(output);
|
|
102
115
|
}
|
|
116
|
+
|
|
117
|
+
// Position the native terminal cursor at the focused TextInput's cursor.
|
|
118
|
+
// This gives us a blinking cursor for free (terminal-managed blink).
|
|
119
|
+
positionTerminalCursor();
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* After each render, show the native terminal cursor at the focused
|
|
124
|
+
* TextInput's cursor position (gives blinking for free). When no
|
|
125
|
+
* TextInput is focused, hide the cursor.
|
|
126
|
+
*/
|
|
127
|
+
function positionTerminalCursor(): void {
|
|
128
|
+
if (!terminal) return;
|
|
129
|
+
|
|
130
|
+
// Find focused TextInput in the layout tree (check stamped state during
|
|
131
|
+
// render — we need to check controlled focus in the current layouts)
|
|
132
|
+
const focusedTI = findFocusedTextInputLayout();
|
|
133
|
+
if (focusedTI) {
|
|
134
|
+
const props = focusedTI.node
|
|
135
|
+
.props as import("@cel-tui/types").TextInputProps;
|
|
136
|
+
const pos = getTextInputCursorScreenPos(props, focusedTI.rect);
|
|
137
|
+
if (pos) {
|
|
138
|
+
// CUP: move cursor to (row, col) — 1-indexed
|
|
139
|
+
terminal.write(`\x1b[${pos.y + 1};${pos.x + 1}H`);
|
|
140
|
+
terminal.showCursor();
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
terminal.hideCursor();
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Find the focused TextInput in the current layout tree.
|
|
149
|
+
* Checks controlled focus (props.focused) and uncontrolled
|
|
150
|
+
* (framework-tracked index). Returns the LayoutNode or null.
|
|
151
|
+
*/
|
|
152
|
+
function findFocusedTextInputLayout(): LayoutNode | null {
|
|
153
|
+
// Check controlled focus: scan all layers for TextInput with focused: true
|
|
154
|
+
for (let i = currentLayouts.length - 1; i >= 0; i--) {
|
|
155
|
+
const found = findFocusedTIInTree(currentLayouts[i]!);
|
|
156
|
+
if (found) return found;
|
|
157
|
+
}
|
|
158
|
+
// Check uncontrolled focus
|
|
159
|
+
if (frameworkFocusIndex >= 0) {
|
|
160
|
+
const topLayer = currentLayouts[currentLayouts.length - 1];
|
|
161
|
+
if (topLayer) {
|
|
162
|
+
const focusables = collectFocusable(topLayer);
|
|
163
|
+
if (frameworkFocusIndex < focusables.length) {
|
|
164
|
+
const target = focusables[frameworkFocusIndex]!;
|
|
165
|
+
if (target.node.type === "textinput") return target;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
return null;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function findFocusedTIInTree(ln: LayoutNode): LayoutNode | null {
|
|
173
|
+
if (ln.node.type === "textinput" && ln.node.props.focused) return ln;
|
|
174
|
+
for (const child of ln.children) {
|
|
175
|
+
const found = findFocusedTIInTree(child);
|
|
176
|
+
if (found) return found;
|
|
177
|
+
}
|
|
178
|
+
return null;
|
|
103
179
|
}
|
|
104
180
|
|
|
105
181
|
// --- Input handling ---
|
|
@@ -252,6 +328,75 @@ function unstampUncontrolledFocus(): void {
|
|
|
252
328
|
stampedNode = null;
|
|
253
329
|
}
|
|
254
330
|
|
|
331
|
+
/**
|
|
332
|
+
* Compute the structural tree path from a layer root to a target LayoutNode.
|
|
333
|
+
* Returns a string like "0/2/1" or null if target is not in the tree.
|
|
334
|
+
*/
|
|
335
|
+
function computeTreePath(root: LayoutNode, target: LayoutNode): string | null {
|
|
336
|
+
if (root === target) return "";
|
|
337
|
+
for (let i = 0; i < root.children.length; i++) {
|
|
338
|
+
const sub = computeTreePath(root.children[i]!, target);
|
|
339
|
+
if (sub !== null) return sub === "" ? String(i) : `${i}/${sub}`;
|
|
340
|
+
}
|
|
341
|
+
return null;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* Get the full path key for a scrollable node, prefixed by layer index.
|
|
346
|
+
*/
|
|
347
|
+
function getScrollPathKey(target: LayoutNode): string | null {
|
|
348
|
+
for (let i = 0; i < currentLayouts.length; i++) {
|
|
349
|
+
const path = computeTreePath(currentLayouts[i]!, target);
|
|
350
|
+
if (path !== null) return `L${i}:${path}`;
|
|
351
|
+
}
|
|
352
|
+
return null;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* Stamp `scrollOffset` on uncontrolled scrollable containers' props
|
|
357
|
+
* before painting, so paint reads the correct offset.
|
|
358
|
+
*/
|
|
359
|
+
function stampUncontrolledScroll(): void {
|
|
360
|
+
stampedScrollNodes = [];
|
|
361
|
+
if (uncontrolledScrollOffsets.size === 0) return;
|
|
362
|
+
for (let i = 0; i < currentLayouts.length; i++) {
|
|
363
|
+
walkAndStampScroll(currentLayouts[i]!, `L${i}:`);
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
function walkAndStampScroll(ln: LayoutNode, pathKey: string): void {
|
|
368
|
+
const node = ln.node;
|
|
369
|
+
if (node.type === "vstack" || node.type === "hstack") {
|
|
370
|
+
if (
|
|
371
|
+
node.props.overflow === "scroll" &&
|
|
372
|
+
node.props.scrollOffset === undefined &&
|
|
373
|
+
!node.props.onScroll
|
|
374
|
+
) {
|
|
375
|
+
const offset = uncontrolledScrollOffsets.get(pathKey);
|
|
376
|
+
if (offset !== undefined && offset !== 0) {
|
|
377
|
+
(node.props as any).scrollOffset = offset;
|
|
378
|
+
stampedScrollNodes.push({ node, key: pathKey });
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
for (let i = 0; i < ln.children.length; i++) {
|
|
383
|
+
// Keys match getScrollPathKey format: "L0:" for root, "L0:2" for child 2, "L0:2/1" for grandchild
|
|
384
|
+
const childKey = pathKey.endsWith(":")
|
|
385
|
+
? `${pathKey}${i}`
|
|
386
|
+
: `${pathKey}/${i}`;
|
|
387
|
+
walkAndStampScroll(ln.children[i]!, childKey);
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
function unstampUncontrolledScroll(): void {
|
|
392
|
+
for (const { node } of stampedScrollNodes) {
|
|
393
|
+
if (node.type === "vstack" || node.type === "hstack") {
|
|
394
|
+
delete (node.props as any).scrollOffset;
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
stampedScrollNodes = [];
|
|
398
|
+
}
|
|
399
|
+
|
|
255
400
|
/**
|
|
256
401
|
* Blur the currently focused element and focus a new one.
|
|
257
402
|
* Manages both controlled (via onFocus/onBlur callbacks) and
|
|
@@ -328,6 +473,9 @@ function getMaxScrollOffset(target: LayoutNode): number {
|
|
|
328
473
|
}
|
|
329
474
|
|
|
330
475
|
const isVertical = target.node.type === "vstack";
|
|
476
|
+
const props = target.node.type !== "text" ? target.node.props : null;
|
|
477
|
+
const padX = (props as any)?.padding?.x ?? 0;
|
|
478
|
+
const padY = (props as any)?.padding?.y ?? 0;
|
|
331
479
|
|
|
332
480
|
if (isVertical) {
|
|
333
481
|
let contentHeight = 0;
|
|
@@ -335,22 +483,42 @@ function getMaxScrollOffset(target: LayoutNode): number {
|
|
|
335
483
|
const childBottom = child.rect.y + child.rect.height - rect.y;
|
|
336
484
|
if (childBottom > contentHeight) contentHeight = childBottom;
|
|
337
485
|
}
|
|
338
|
-
|
|
486
|
+
// Viewport is the inner height (minus bottom padding)
|
|
487
|
+
// Content starts at padY, so contentHeight includes top padding offset
|
|
488
|
+
return Math.max(0, contentHeight + padY - rect.height);
|
|
339
489
|
} else {
|
|
340
490
|
let contentWidth = 0;
|
|
341
491
|
for (const child of children) {
|
|
342
492
|
const childRight = child.rect.x + child.rect.width - rect.x;
|
|
343
493
|
if (childRight > contentWidth) contentWidth = childRight;
|
|
344
494
|
}
|
|
345
|
-
return Math.max(0, contentWidth - rect.width);
|
|
495
|
+
return Math.max(0, contentWidth + padX - rect.width);
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
/**
|
|
500
|
+
* Resolve the current scroll offset for a layout node.
|
|
501
|
+
* Checks controlled (props.scrollOffset), then uncontrolled (path-based map).
|
|
502
|
+
*/
|
|
503
|
+
function resolveScrollOffset(ln: import("./layout.js").LayoutNode): number {
|
|
504
|
+
const node = ln.node;
|
|
505
|
+
if (node.type === "text") return 0;
|
|
506
|
+
const props = node.props;
|
|
507
|
+
if ((props as any).scrollOffset !== undefined)
|
|
508
|
+
return (props as any).scrollOffset;
|
|
509
|
+
// Check uncontrolled map
|
|
510
|
+
const pathKey = getScrollPathKey(ln);
|
|
511
|
+
if (pathKey !== null) {
|
|
512
|
+
return uncontrolledScrollOffsets.get(pathKey) ?? 0;
|
|
346
513
|
}
|
|
514
|
+
return 0;
|
|
347
515
|
}
|
|
348
516
|
|
|
349
517
|
function handleMouseEvent(event: MouseEvent): void {
|
|
350
518
|
// Hit test on topmost layer first
|
|
351
519
|
for (let i = currentLayouts.length - 1; i >= 0; i--) {
|
|
352
520
|
const layoutRoot = currentLayouts[i]!;
|
|
353
|
-
const path = hitTest(layoutRoot, event.x, event.y);
|
|
521
|
+
const path = hitTest(layoutRoot, event.x, event.y, resolveScrollOffset);
|
|
354
522
|
if (path.length === 0) continue;
|
|
355
523
|
|
|
356
524
|
if (event.type === "click") {
|
|
@@ -383,7 +551,7 @@ function handleMouseEvent(event: MouseEvent): void {
|
|
|
383
551
|
cel.render();
|
|
384
552
|
} else {
|
|
385
553
|
const props = target.node.type !== "text" ? target.node.props : null;
|
|
386
|
-
if (props &&
|
|
554
|
+
if (props && (props as any).overflow === "scroll") {
|
|
387
555
|
if (props.onScroll) {
|
|
388
556
|
// Controlled scroll: notify app.
|
|
389
557
|
// Use batch accumulator if available (multiple events in one chunk),
|
|
@@ -397,12 +565,18 @@ function handleMouseEvent(event: MouseEvent): void {
|
|
|
397
565
|
Math.min(maxOffset, baseOffset + delta),
|
|
398
566
|
);
|
|
399
567
|
batchScrollOffsets?.set(props, newOffset);
|
|
400
|
-
props.onScroll(newOffset);
|
|
568
|
+
props.onScroll(newOffset, maxOffset);
|
|
401
569
|
} else {
|
|
402
|
-
// Uncontrolled scroll: framework manages state
|
|
403
|
-
const
|
|
404
|
-
|
|
405
|
-
|
|
570
|
+
// Uncontrolled scroll: framework manages state via path key
|
|
571
|
+
const pathKey = getScrollPathKey(target);
|
|
572
|
+
if (pathKey !== null) {
|
|
573
|
+
const current = uncontrolledScrollOffsets.get(pathKey) ?? 0;
|
|
574
|
+
const clamped = Math.max(
|
|
575
|
+
0,
|
|
576
|
+
Math.min(maxOffset, current + delta),
|
|
577
|
+
);
|
|
578
|
+
uncontrolledScrollOffsets.set(pathKey, clamped);
|
|
579
|
+
}
|
|
406
580
|
}
|
|
407
581
|
cel.render();
|
|
408
582
|
}
|
|
@@ -420,12 +594,11 @@ function findClickFocusTarget(path: LayoutNode[]): LayoutNode | null {
|
|
|
420
594
|
for (let i = path.length - 1; i >= 0; i--) {
|
|
421
595
|
const node = path[i]!.node;
|
|
422
596
|
if (node.type === "textinput") return path[i]!;
|
|
423
|
-
if (
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
return path[i]!;
|
|
597
|
+
if (node.type === "vstack" || node.type === "hstack") {
|
|
598
|
+
const isFocusable =
|
|
599
|
+
node.props.focusable === true ||
|
|
600
|
+
(node.props.onClick != null && node.props.focusable !== false);
|
|
601
|
+
if (isFocusable) return path[i]!;
|
|
429
602
|
}
|
|
430
603
|
}
|
|
431
604
|
return null;
|
|
@@ -539,11 +712,20 @@ function handleKeyEvent(key: string, rawData?: string): void {
|
|
|
539
712
|
case "down":
|
|
540
713
|
case "home":
|
|
541
714
|
case "end":
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
715
|
+
{
|
|
716
|
+
const tiPadX =
|
|
717
|
+
(focusedInput.node as import("@cel-tui/types").TextInputNode)
|
|
718
|
+
.props.padding?.x ?? 0;
|
|
719
|
+
const contentWidth = Math.max(
|
|
720
|
+
0,
|
|
721
|
+
focusedInput.rect.width - tiPadX * 2,
|
|
722
|
+
);
|
|
723
|
+
newState = moveCursor(
|
|
724
|
+
editState,
|
|
725
|
+
key as "left" | "right" | "up" | "down" | "home" | "end",
|
|
726
|
+
contentWidth,
|
|
727
|
+
);
|
|
728
|
+
}
|
|
547
729
|
break;
|
|
548
730
|
case "enter":
|
|
549
731
|
newState = insertChar(editState, "\n");
|
|
@@ -551,6 +733,12 @@ function handleKeyEvent(key: string, rawData?: string): void {
|
|
|
551
733
|
case "tab":
|
|
552
734
|
newState = insertChar(editState, "\t");
|
|
553
735
|
break;
|
|
736
|
+
case "space":
|
|
737
|
+
newState = insertChar(editState, " ");
|
|
738
|
+
break;
|
|
739
|
+
case "plus":
|
|
740
|
+
newState = insertChar(editState, "+");
|
|
741
|
+
break;
|
|
554
742
|
default:
|
|
555
743
|
// Single printable character — use raw data to preserve case
|
|
556
744
|
if (key.length === 1 && rawData && rawData.length === 1) {
|
|
@@ -578,10 +766,21 @@ function handleKeyEvent(key: string, rawData?: string): void {
|
|
|
578
766
|
for (let i = currentLayouts.length - 1; i >= 0; i--) {
|
|
579
767
|
const path = findPathTo(currentLayouts[i]!, focused);
|
|
580
768
|
if (path) {
|
|
581
|
-
const
|
|
582
|
-
if (
|
|
583
|
-
|
|
584
|
-
|
|
769
|
+
const handlers = collectKeyPressHandlers(path);
|
|
770
|
+
if (handlers.length > 0) {
|
|
771
|
+
let consumed = false;
|
|
772
|
+
for (const h of handlers) {
|
|
773
|
+
const result = h.handler(key);
|
|
774
|
+
if (result !== false) {
|
|
775
|
+
consumed = true;
|
|
776
|
+
break;
|
|
777
|
+
}
|
|
778
|
+
// result === false → key not consumed, keep bubbling
|
|
779
|
+
}
|
|
780
|
+
// Always return — the key was offered to every handler in the
|
|
781
|
+
// focused element's path (including root). Even if all returned
|
|
782
|
+
// false, we don't retry via the unfocused fallback path.
|
|
783
|
+
if (consumed) cel.render();
|
|
585
784
|
return;
|
|
586
785
|
}
|
|
587
786
|
}
|
|
@@ -592,9 +791,12 @@ function handleKeyEvent(key: string, rawData?: string): void {
|
|
|
592
791
|
for (let i = currentLayouts.length - 1; i >= 0; i--) {
|
|
593
792
|
const layoutRoot = currentLayouts[i]!;
|
|
594
793
|
const path = [layoutRoot];
|
|
595
|
-
const
|
|
596
|
-
if (
|
|
597
|
-
|
|
794
|
+
const handlers = collectKeyPressHandlers(path);
|
|
795
|
+
if (handlers.length > 0) {
|
|
796
|
+
for (const h of handlers) {
|
|
797
|
+
const result = h.handler(key);
|
|
798
|
+
if (result !== false) break;
|
|
799
|
+
}
|
|
598
800
|
cel.render();
|
|
599
801
|
return;
|
|
600
802
|
}
|
|
@@ -663,10 +865,16 @@ export const cel = {
|
|
|
663
865
|
* Initialize the framework with a terminal implementation.
|
|
664
866
|
* Must be called before {@link cel.viewport}.
|
|
665
867
|
*
|
|
868
|
+
* Enables the Kitty keyboard protocol (level 1) via the terminal,
|
|
869
|
+
* enters raw mode, and starts mouse tracking.
|
|
870
|
+
*
|
|
666
871
|
* @param term - Terminal to render to (ProcessTerminal or MockTerminal).
|
|
872
|
+
* @param options - Optional configuration.
|
|
873
|
+
* @param options.theme - Color theme mapping. Defaults to the ANSI 16 theme.
|
|
667
874
|
*/
|
|
668
|
-
init(term: Terminal): void {
|
|
875
|
+
init(term: Terminal, options?: { theme?: Theme }): void {
|
|
669
876
|
terminal = term;
|
|
877
|
+
activeTheme = options?.theme ?? defaultTheme;
|
|
670
878
|
terminal.start(handleInput, () => cel.render());
|
|
671
879
|
},
|
|
672
880
|
|
|
@@ -695,6 +903,9 @@ export const cel = {
|
|
|
695
903
|
|
|
696
904
|
/**
|
|
697
905
|
* Stop the framework and restore terminal state.
|
|
906
|
+
*
|
|
907
|
+
* Pops the Kitty keyboard protocol mode, disables mouse tracking,
|
|
908
|
+
* and restores the terminal to its previous state.
|
|
698
909
|
*/
|
|
699
910
|
stop(): void {
|
|
700
911
|
terminal?.stop();
|
|
@@ -707,6 +918,9 @@ export const cel = {
|
|
|
707
918
|
lastFocusedIndex = -1;
|
|
708
919
|
frameworkFocusIndex = -1;
|
|
709
920
|
stampedNode = null;
|
|
921
|
+
uncontrolledScrollOffsets.clear();
|
|
922
|
+
stampedScrollNodes = [];
|
|
923
|
+
activeTheme = defaultTheme;
|
|
710
924
|
},
|
|
711
925
|
|
|
712
926
|
/** @internal */
|
package/src/emitter.ts
CHANGED
|
@@ -1,56 +1,76 @@
|
|
|
1
|
-
import type { Color } from "@cel-tui/types";
|
|
1
|
+
import type { Color, Theme, ThemeValue } from "@cel-tui/types";
|
|
2
2
|
import type { Cell } from "./cell-buffer.js";
|
|
3
3
|
import { CellBuffer, EMPTY_CELL } from "./cell-buffer.js";
|
|
4
4
|
|
|
5
|
-
// ---
|
|
6
|
-
|
|
7
|
-
const FG_CODES: Record<Color, number> = {
|
|
8
|
-
black: 30,
|
|
9
|
-
red: 31,
|
|
10
|
-
green: 32,
|
|
11
|
-
yellow: 33,
|
|
12
|
-
blue: 34,
|
|
13
|
-
magenta: 35,
|
|
14
|
-
cyan: 36,
|
|
15
|
-
white: 37,
|
|
16
|
-
brightBlack: 90,
|
|
17
|
-
brightRed: 91,
|
|
18
|
-
brightGreen: 92,
|
|
19
|
-
brightYellow: 93,
|
|
20
|
-
brightBlue: 94,
|
|
21
|
-
brightMagenta: 95,
|
|
22
|
-
brightCyan: 96,
|
|
23
|
-
brightWhite: 97,
|
|
24
|
-
};
|
|
5
|
+
// --- Default ANSI 16 theme ---
|
|
25
6
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
7
|
+
/**
|
|
8
|
+
* The default theme — maps each color slot to its matching ANSI palette
|
|
9
|
+
* index. With this theme, colors inherit the terminal's configured
|
|
10
|
+
* color scheme automatically.
|
|
11
|
+
*/
|
|
12
|
+
export const defaultTheme: Theme = {
|
|
13
|
+
color00: 0,
|
|
14
|
+
color01: 1,
|
|
15
|
+
color02: 2,
|
|
16
|
+
color03: 3,
|
|
17
|
+
color04: 4,
|
|
18
|
+
color05: 5,
|
|
19
|
+
color06: 6,
|
|
20
|
+
color07: 7,
|
|
21
|
+
color08: 8,
|
|
22
|
+
color09: 9,
|
|
23
|
+
color10: 10,
|
|
24
|
+
color11: 11,
|
|
25
|
+
color12: 12,
|
|
26
|
+
color13: 13,
|
|
27
|
+
color14: 14,
|
|
28
|
+
color15: 15,
|
|
43
29
|
};
|
|
44
30
|
|
|
31
|
+
// --- Color resolution ---
|
|
32
|
+
|
|
33
|
+
/** Parse a hex color string "#rrggbb" into [r, g, b]. */
|
|
34
|
+
function parseHex(hex: string): [number, number, number] {
|
|
35
|
+
const r = parseInt(hex.slice(1, 3), 16);
|
|
36
|
+
const g = parseInt(hex.slice(3, 5), 16);
|
|
37
|
+
const b = parseInt(hex.slice(5, 7), 16);
|
|
38
|
+
return [r, g, b];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Resolve a color slot to an SGR code fragment for foreground. */
|
|
42
|
+
function fgSgr(color: Color, theme: Theme): string {
|
|
43
|
+
const val: ThemeValue = theme[color];
|
|
44
|
+
if (typeof val === "number") {
|
|
45
|
+
// ANSI palette index 0-15
|
|
46
|
+
return String(val < 8 ? 30 + val : 82 + val);
|
|
47
|
+
}
|
|
48
|
+
// Hex true color
|
|
49
|
+
const [r, g, b] = parseHex(val);
|
|
50
|
+
return `38;2;${r};${g};${b}`;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Resolve a color slot to an SGR code fragment for background. */
|
|
54
|
+
function bgSgr(color: Color, theme: Theme): string {
|
|
55
|
+
const val: ThemeValue = theme[color];
|
|
56
|
+
if (typeof val === "number") {
|
|
57
|
+
// ANSI palette index 0-15
|
|
58
|
+
return String(val < 8 ? 40 + val : 92 + val);
|
|
59
|
+
}
|
|
60
|
+
// Hex true color
|
|
61
|
+
const [r, g, b] = parseHex(val);
|
|
62
|
+
return `48;2;${r};${g};${b}`;
|
|
63
|
+
}
|
|
64
|
+
|
|
45
65
|
// --- SGR generation ---
|
|
46
66
|
|
|
47
|
-
function sgrForCell(cell: Cell): string {
|
|
48
|
-
const codes:
|
|
49
|
-
if (cell.bold) codes.push(1);
|
|
50
|
-
if (cell.italic) codes.push(3);
|
|
51
|
-
if (cell.underline) codes.push(4);
|
|
52
|
-
if (cell.fgColor) codes.push(
|
|
53
|
-
if (cell.bgColor) codes.push(
|
|
67
|
+
function sgrForCell(cell: Cell, theme: Theme): string {
|
|
68
|
+
const codes: string[] = [];
|
|
69
|
+
if (cell.bold) codes.push("1");
|
|
70
|
+
if (cell.italic) codes.push("3");
|
|
71
|
+
if (cell.underline) codes.push("4");
|
|
72
|
+
if (cell.fgColor) codes.push(fgSgr(cell.fgColor, theme));
|
|
73
|
+
if (cell.bgColor) codes.push(bgSgr(cell.bgColor, theme));
|
|
54
74
|
if (codes.length === 0) return "";
|
|
55
75
|
return `\x1b[${codes.join(";")}m`;
|
|
56
76
|
}
|
|
@@ -93,9 +113,13 @@ const RESET = "\x1b[0m";
|
|
|
93
113
|
* only emitting SGR codes when the style changes.
|
|
94
114
|
*
|
|
95
115
|
* @param buf - The cell buffer to render.
|
|
116
|
+
* @param theme - Color theme mapping. Defaults to the ANSI 16 theme.
|
|
96
117
|
* @returns A complete ANSI string ready to write to the terminal.
|
|
97
118
|
*/
|
|
98
|
-
export function emitBuffer(
|
|
119
|
+
export function emitBuffer(
|
|
120
|
+
buf: CellBuffer,
|
|
121
|
+
theme: Theme = defaultTheme,
|
|
122
|
+
): string {
|
|
99
123
|
let out = SYNC_START + CURSOR_HOME;
|
|
100
124
|
|
|
101
125
|
let lastStyle: Cell | null = null;
|
|
@@ -106,13 +130,16 @@ export function emitBuffer(buf: CellBuffer): string {
|
|
|
106
130
|
for (let x = 0; x < buf.width; x++) {
|
|
107
131
|
const cell = buf.get(x, y);
|
|
108
132
|
|
|
133
|
+
// Skip continuation cells (trailing half of wide characters)
|
|
134
|
+
if (cell.char === "") continue;
|
|
135
|
+
|
|
109
136
|
// Emit style change if needed
|
|
110
137
|
if (lastStyle === null || !styleEquals(cell, lastStyle)) {
|
|
111
138
|
// Reset before applying new style
|
|
112
139
|
if (lastStyle !== null && hasStyle(lastStyle)) {
|
|
113
140
|
out += RESET;
|
|
114
141
|
}
|
|
115
|
-
const sgr = sgrForCell(cell);
|
|
142
|
+
const sgr = sgrForCell(cell, theme);
|
|
116
143
|
if (sgr) out += sgr;
|
|
117
144
|
lastStyle = cell;
|
|
118
145
|
}
|
|
@@ -139,9 +166,14 @@ export function emitBuffer(buf: CellBuffer): string {
|
|
|
139
166
|
*
|
|
140
167
|
* @param prev - The previous buffer.
|
|
141
168
|
* @param next - The new buffer.
|
|
169
|
+
* @param theme - Color theme mapping. Defaults to the ANSI 16 theme.
|
|
142
170
|
* @returns An ANSI string with only the changed cells.
|
|
143
171
|
*/
|
|
144
|
-
export function emitDiff(
|
|
172
|
+
export function emitDiff(
|
|
173
|
+
prev: CellBuffer,
|
|
174
|
+
next: CellBuffer,
|
|
175
|
+
theme: Theme = defaultTheme,
|
|
176
|
+
): string {
|
|
145
177
|
let out = SYNC_START;
|
|
146
178
|
|
|
147
179
|
const changes = prev.diff(next);
|
|
@@ -156,6 +188,9 @@ export function emitDiff(prev: CellBuffer, next: CellBuffer): string {
|
|
|
156
188
|
for (const { x, y } of changes) {
|
|
157
189
|
const cell = next.get(x, y);
|
|
158
190
|
|
|
191
|
+
// Skip continuation cells (trailing half of wide characters)
|
|
192
|
+
if (cell.char === "") continue;
|
|
193
|
+
|
|
159
194
|
// Position cursor if not consecutive
|
|
160
195
|
if (y !== lastY || x !== lastX + 1) {
|
|
161
196
|
// Reset style before repositioning
|
|
@@ -172,7 +207,7 @@ export function emitDiff(prev: CellBuffer, next: CellBuffer): string {
|
|
|
172
207
|
if (lastStyle !== null && hasStyle(lastStyle)) {
|
|
173
208
|
out += RESET;
|
|
174
209
|
}
|
|
175
|
-
const sgr = sgrForCell(cell);
|
|
210
|
+
const sgr = sgrForCell(cell, theme);
|
|
176
211
|
if (sgr) out += sgr;
|
|
177
212
|
lastStyle = cell;
|
|
178
213
|
}
|