@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.
@@ -0,0 +1,196 @@
1
+ import type { Color } from "@cel-tui/types";
2
+
3
+ /**
4
+ * A single terminal cell with character content and styling.
5
+ *
6
+ * Each cell represents one column in the terminal grid.
7
+ * Wide characters (CJK, emoji) occupy two cells — the first cell
8
+ * holds the character, the second is a continuation marker.
9
+ */
10
+ export interface Cell {
11
+ /** The grapheme cluster displayed in this cell. */
12
+ char: string;
13
+ /** Foreground color, or null for terminal default. */
14
+ fgColor: Color | null;
15
+ /** Background color, or null for terminal default. */
16
+ bgColor: Color | null;
17
+ /** Bold weight. */
18
+ bold: boolean;
19
+ /** Italic style. */
20
+ italic: boolean;
21
+ /** Underline decoration. */
22
+ underline: boolean;
23
+ }
24
+
25
+ /**
26
+ * The default empty cell — a space with no styling.
27
+ * Used for cleared/uninitialized cells and transparency detection.
28
+ */
29
+ export const EMPTY_CELL: Readonly<Cell> = {
30
+ char: " ",
31
+ fgColor: null,
32
+ bgColor: null,
33
+ bold: false,
34
+ italic: false,
35
+ underline: false,
36
+ };
37
+
38
+ function cellsEqual(a: Cell, b: Cell): boolean {
39
+ return (
40
+ a.char === b.char &&
41
+ a.fgColor === b.fgColor &&
42
+ a.bgColor === b.bgColor &&
43
+ a.bold === b.bold &&
44
+ a.italic === b.italic &&
45
+ a.underline === b.underline
46
+ );
47
+ }
48
+
49
+ /**
50
+ * A 2D grid of styled terminal cells.
51
+ *
52
+ * The cell buffer is the core rendering target. The layout engine
53
+ * computes rects, painting writes styled cells into those rects,
54
+ * and the diff algorithm compares the current buffer against the
55
+ * previous one to produce minimal terminal updates.
56
+ *
57
+ * Empty cells (matching {@link EMPTY_CELL}) are considered transparent
58
+ * for layer compositing — higher layers overwrite lower layers only
59
+ * where they have non-empty content.
60
+ */
61
+ export class CellBuffer {
62
+ private cells: Cell[];
63
+ private _width: number;
64
+ private _height: number;
65
+
66
+ /**
67
+ * Create a new cell buffer filled with empty cells.
68
+ *
69
+ * @param width - Buffer width in columns.
70
+ * @param height - Buffer height in rows.
71
+ */
72
+ constructor(width: number, height: number) {
73
+ this._width = width;
74
+ this._height = height;
75
+ this.cells = new Array(width * height);
76
+ this.clearCells(0, this.cells.length);
77
+ }
78
+
79
+ /** Buffer width in columns. */
80
+ get width(): number {
81
+ return this._width;
82
+ }
83
+
84
+ /** Buffer height in rows. */
85
+ get height(): number {
86
+ return this._height;
87
+ }
88
+
89
+ /**
90
+ * Get the cell at `(x, y)`.
91
+ * Returns {@link EMPTY_CELL} for out-of-bounds coordinates.
92
+ */
93
+ get(x: number, y: number): Cell {
94
+ if (x < 0 || x >= this._width || y < 0 || y >= this._height) {
95
+ return EMPTY_CELL;
96
+ }
97
+ return this.cells[y * this._width + x]!;
98
+ }
99
+
100
+ /**
101
+ * Set the cell at `(x, y)`.
102
+ * Out-of-bounds writes are silently ignored.
103
+ */
104
+ set(x: number, y: number, cell: Cell): void {
105
+ if (x < 0 || x >= this._width || y < 0 || y >= this._height) return;
106
+ this.cells[y * this._width + x] = cell;
107
+ }
108
+
109
+ /**
110
+ * Check if the cell at `(x, y)` is empty (transparent).
111
+ * A cell is empty if it matches {@link EMPTY_CELL} exactly.
112
+ */
113
+ isEmpty(x: number, y: number): boolean {
114
+ return cellsEqual(this.get(x, y), EMPTY_CELL);
115
+ }
116
+
117
+ /** Reset all cells to {@link EMPTY_CELL}. */
118
+ clear(): void {
119
+ this.clearCells(0, this.cells.length);
120
+ }
121
+
122
+ /**
123
+ * Fill a rectangular region with a cell value.
124
+ * Coordinates are clipped to buffer bounds.
125
+ *
126
+ * @param x - Left column (inclusive).
127
+ * @param y - Top row (inclusive).
128
+ * @param w - Width in columns.
129
+ * @param h - Height in rows.
130
+ * @param cell - Cell value to fill with.
131
+ */
132
+ fill(x: number, y: number, w: number, h: number, cell: Cell): void {
133
+ const x0 = Math.max(0, x);
134
+ const y0 = Math.max(0, y);
135
+ const x1 = Math.min(this._width, x + w);
136
+ const y1 = Math.min(this._height, y + h);
137
+ for (let row = y0; row < y1; row++) {
138
+ for (let col = x0; col < x1; col++) {
139
+ this.cells[row * this._width + col] = cell;
140
+ }
141
+ }
142
+ }
143
+
144
+ /**
145
+ * Resize the buffer. Existing content within the new bounds is preserved.
146
+ * New cells are initialized to {@link EMPTY_CELL}.
147
+ *
148
+ * @param width - New width in columns.
149
+ * @param height - New height in rows.
150
+ */
151
+ resize(width: number, height: number): void {
152
+ const newCells = new Array<Cell>(width * height);
153
+ // Fill with empty
154
+ for (let i = 0; i < newCells.length; i++) {
155
+ newCells[i] = { ...EMPTY_CELL };
156
+ }
157
+ // Copy existing content
158
+ const copyW = Math.min(this._width, width);
159
+ const copyH = Math.min(this._height, height);
160
+ for (let y = 0; y < copyH; y++) {
161
+ for (let x = 0; x < copyW; x++) {
162
+ newCells[y * width + x] = this.cells[y * this._width + x]!;
163
+ }
164
+ }
165
+ this.cells = newCells;
166
+ this._width = width;
167
+ this._height = height;
168
+ }
169
+
170
+ /**
171
+ * Compare this buffer against another and return positions that differ.
172
+ * Used for differential rendering — only changed cells need terminal updates.
173
+ *
174
+ * @param other - The buffer to compare against.
175
+ * @returns Array of `{ x, y }` positions where cells differ.
176
+ */
177
+ diff(other: CellBuffer): { x: number; y: number }[] {
178
+ const changes: { x: number; y: number }[] = [];
179
+ const w = Math.min(this._width, other._width);
180
+ const h = Math.min(this._height, other._height);
181
+ for (let y = 0; y < h; y++) {
182
+ for (let x = 0; x < w; x++) {
183
+ if (!cellsEqual(this.get(x, y), other.get(x, y))) {
184
+ changes.push({ x, y });
185
+ }
186
+ }
187
+ }
188
+ return changes;
189
+ }
190
+
191
+ private clearCells(start: number, end: number): void {
192
+ for (let i = start; i < end; i++) {
193
+ this.cells[i] = { ...EMPTY_CELL };
194
+ }
195
+ }
196
+ }
package/src/emitter.ts ADDED
@@ -0,0 +1,192 @@
1
+ import type { Color } from "@cel-tui/types";
2
+ import type { Cell } from "./cell-buffer.js";
3
+ import { CellBuffer, EMPTY_CELL } from "./cell-buffer.js";
4
+
5
+ // --- Color mapping ---
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
+ };
25
+
26
+ const BG_CODES: Record<Color, number> = {
27
+ black: 40,
28
+ red: 41,
29
+ green: 42,
30
+ yellow: 43,
31
+ blue: 44,
32
+ magenta: 45,
33
+ cyan: 46,
34
+ white: 47,
35
+ brightBlack: 100,
36
+ brightRed: 101,
37
+ brightGreen: 102,
38
+ brightYellow: 103,
39
+ brightBlue: 104,
40
+ brightMagenta: 105,
41
+ brightCyan: 106,
42
+ brightWhite: 107,
43
+ };
44
+
45
+ // --- SGR generation ---
46
+
47
+ function sgrForCell(cell: Cell): string {
48
+ const codes: number[] = [];
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(FG_CODES[cell.fgColor]);
53
+ if (cell.bgColor) codes.push(BG_CODES[cell.bgColor]);
54
+ if (codes.length === 0) return "";
55
+ return `\x1b[${codes.join(";")}m`;
56
+ }
57
+
58
+ function styleEquals(a: Cell, b: Cell): boolean {
59
+ return (
60
+ a.fgColor === b.fgColor &&
61
+ a.bgColor === b.bgColor &&
62
+ a.bold === b.bold &&
63
+ a.italic === b.italic &&
64
+ a.underline === b.underline
65
+ );
66
+ }
67
+
68
+ function hasStyle(cell: Cell): boolean {
69
+ return (
70
+ cell.bold ||
71
+ cell.italic ||
72
+ cell.underline ||
73
+ cell.fgColor !== null ||
74
+ cell.bgColor !== null
75
+ );
76
+ }
77
+
78
+ // --- Synchronized output markers ---
79
+
80
+ const SYNC_START = "\x1b[?2026h";
81
+ const SYNC_END = "\x1b[?2026l";
82
+ const CURSOR_HOME = "\x1b[H";
83
+ const RESET = "\x1b[0m";
84
+
85
+ /**
86
+ * Emit a full cell buffer as an ANSI string for terminal output.
87
+ *
88
+ * Generates cursor positioning, SGR styling codes, and character content
89
+ * for every cell. Output is wrapped in synchronized output markers
90
+ * (CSI 2026) for flicker-free rendering.
91
+ *
92
+ * Optimizes by batching consecutive cells with the same style and
93
+ * only emitting SGR codes when the style changes.
94
+ *
95
+ * @param buf - The cell buffer to render.
96
+ * @returns A complete ANSI string ready to write to the terminal.
97
+ */
98
+ export function emitBuffer(buf: CellBuffer): string {
99
+ let out = SYNC_START + CURSOR_HOME;
100
+
101
+ let lastStyle: Cell | null = null;
102
+
103
+ for (let y = 0; y < buf.height; y++) {
104
+ if (y > 0) out += "\r\n";
105
+
106
+ for (let x = 0; x < buf.width; x++) {
107
+ const cell = buf.get(x, y);
108
+
109
+ // Emit style change if needed
110
+ if (lastStyle === null || !styleEquals(cell, lastStyle)) {
111
+ // Reset before applying new style
112
+ if (lastStyle !== null && hasStyle(lastStyle)) {
113
+ out += RESET;
114
+ }
115
+ const sgr = sgrForCell(cell);
116
+ if (sgr) out += sgr;
117
+ lastStyle = cell;
118
+ }
119
+
120
+ out += cell.char;
121
+ }
122
+ }
123
+
124
+ // Final reset if any style was active
125
+ if (lastStyle !== null && hasStyle(lastStyle)) {
126
+ out += RESET;
127
+ }
128
+
129
+ out += SYNC_END;
130
+ return out;
131
+ }
132
+
133
+ /**
134
+ * Emit only the cells that differ between two buffers.
135
+ *
136
+ * Uses cursor positioning (CSI row;col H) to jump to changed cells,
137
+ * batching consecutive changes on the same row into a single run.
138
+ * Output is wrapped in synchronized output markers for flicker-free updates.
139
+ *
140
+ * @param prev - The previous buffer.
141
+ * @param next - The new buffer.
142
+ * @returns An ANSI string with only the changed cells.
143
+ */
144
+ export function emitDiff(prev: CellBuffer, next: CellBuffer): string {
145
+ let out = SYNC_START;
146
+
147
+ const changes = prev.diff(next);
148
+ if (changes.length === 0) {
149
+ return out + SYNC_END;
150
+ }
151
+
152
+ let lastStyle: Cell | null = null;
153
+ let lastX = -1;
154
+ let lastY = -1;
155
+
156
+ for (const { x, y } of changes) {
157
+ const cell = next.get(x, y);
158
+
159
+ // Position cursor if not consecutive
160
+ if (y !== lastY || x !== lastX + 1) {
161
+ // Reset style before repositioning
162
+ if (lastStyle !== null && hasStyle(lastStyle)) {
163
+ out += RESET;
164
+ lastStyle = null;
165
+ }
166
+ // CSI row;col H (1-indexed)
167
+ out += `\x1b[${y + 1};${x + 1}H`;
168
+ }
169
+
170
+ // Emit style change if needed
171
+ if (lastStyle === null || !styleEquals(cell, lastStyle)) {
172
+ if (lastStyle !== null && hasStyle(lastStyle)) {
173
+ out += RESET;
174
+ }
175
+ const sgr = sgrForCell(cell);
176
+ if (sgr) out += sgr;
177
+ lastStyle = cell;
178
+ }
179
+
180
+ out += cell.char;
181
+ lastX = x;
182
+ lastY = y;
183
+ }
184
+
185
+ // Final reset
186
+ if (lastStyle !== null && hasStyle(lastStyle)) {
187
+ out += RESET;
188
+ }
189
+
190
+ out += SYNC_END;
191
+ return out;
192
+ }
@@ -0,0 +1,146 @@
1
+ import type { ContainerProps } from "@cel-tui/types";
2
+ import type { LayoutNode, Rect } from "./layout.js";
3
+
4
+ /**
5
+ * Test if a point is inside a rect.
6
+ */
7
+ function pointInRect(x: number, y: number, rect: Rect): boolean {
8
+ return (
9
+ x >= rect.x &&
10
+ x < rect.x + rect.width &&
11
+ y >= rect.y &&
12
+ y < rect.y + rect.height
13
+ );
14
+ }
15
+
16
+ /**
17
+ * Find the path from root to the deepest node at `(x, y)`.
18
+ *
19
+ * Returns an array of {@link LayoutNode}s from root (index 0) to the
20
+ * deepest hit node (last index). Returns empty array if the point is
21
+ * outside the root.
22
+ *
23
+ * @param root - Root of the layout tree.
24
+ * @param x - Column position.
25
+ * @param y - Row position.
26
+ * @returns Path from root to deepest node at the position.
27
+ */
28
+ export function hitTest(root: LayoutNode, x: number, y: number): LayoutNode[] {
29
+ if (!pointInRect(x, y, root.rect)) return [];
30
+
31
+ const path: LayoutNode[] = [root];
32
+
33
+ // Recurse into children (depth-first, last child wins for overlaps)
34
+ let current = root;
35
+ let found = true;
36
+
37
+ while (found) {
38
+ found = false;
39
+ for (let i = current.children.length - 1; i >= 0; i--) {
40
+ const child = current.children[i]!;
41
+ if (pointInRect(x, y, child.rect)) {
42
+ path.push(child);
43
+ current = child;
44
+ found = true;
45
+ break;
46
+ }
47
+ }
48
+ }
49
+
50
+ return path;
51
+ }
52
+
53
+ // --- Handler lookups ---
54
+
55
+ function getProps(ln: LayoutNode): ContainerProps | null {
56
+ const node = ln.node;
57
+ if (node.type === "text") return null;
58
+ return node.props;
59
+ }
60
+
61
+ /**
62
+ * Find the nearest ancestor with an `onClick` handler (innermost wins).
63
+ * Walks the path from deepest to root.
64
+ *
65
+ * @param path - Hit test path (root to deepest).
66
+ * @returns The handler and its layout node, or null.
67
+ */
68
+ export function findClickHandler(
69
+ path: LayoutNode[],
70
+ ): { layoutNode: LayoutNode; handler: () => void } | null {
71
+ for (let i = path.length - 1; i >= 0; i--) {
72
+ const props = getProps(path[i]!);
73
+ if (props?.onClick) {
74
+ return { layoutNode: path[i]!, handler: props.onClick };
75
+ }
76
+ }
77
+ return null;
78
+ }
79
+
80
+ /**
81
+ * Find the nearest scrollable ancestor (innermost wins).
82
+ * Matches containers with `overflow: "scroll"` or TextInput nodes.
83
+ *
84
+ * @param path - Hit test path (root to deepest).
85
+ * @returns The scrollable layout node, or null.
86
+ */
87
+ export function findScrollTarget(path: LayoutNode[]): LayoutNode | null {
88
+ for (let i = path.length - 1; i >= 0; i--) {
89
+ const node = path[i]!.node;
90
+ if (node.type === "textinput") return path[i]!;
91
+ const props = getProps(path[i]!);
92
+ if (props?.overflow === "scroll") return path[i]!;
93
+ }
94
+ return null;
95
+ }
96
+
97
+ /**
98
+ * Find the nearest ancestor with an `onKeyPress` handler.
99
+ * Walks from deepest to root (bubbling).
100
+ *
101
+ * @param path - Path from root to current node.
102
+ * @returns The handler and its layout node, or null.
103
+ */
104
+ export function findKeyPressHandler(
105
+ path: LayoutNode[],
106
+ ): { layoutNode: LayoutNode; handler: (key: string) => void } | null {
107
+ for (let i = path.length - 1; i >= 0; i--) {
108
+ const props = getProps(path[i]!);
109
+ if (props?.onKeyPress) {
110
+ return { layoutNode: path[i]!, handler: props.onKeyPress };
111
+ }
112
+ }
113
+ return null;
114
+ }
115
+
116
+ /**
117
+ * Collect all focusable nodes in document order (depth-first traversal).
118
+ *
119
+ * A node is focusable if:
120
+ * - It's a TextInput (always focusable), OR
121
+ * - It's a container with `onClick` and `focusable` is not `false`
122
+ *
123
+ * @param root - Root of the layout tree.
124
+ * @returns Focusable nodes in document order.
125
+ */
126
+ export function collectFocusable(root: LayoutNode): LayoutNode[] {
127
+ const result: LayoutNode[] = [];
128
+ collectFocusableRecursive(root, result);
129
+ return result;
130
+ }
131
+
132
+ function collectFocusableRecursive(ln: LayoutNode, result: LayoutNode[]): void {
133
+ const node = ln.node;
134
+
135
+ if (node.type === "textinput") {
136
+ result.push(ln);
137
+ } else if (node.type === "vstack" || node.type === "hstack") {
138
+ if (node.props.onClick && node.props.focusable !== false) {
139
+ result.push(ln);
140
+ }
141
+ }
142
+
143
+ for (const child of ln.children) {
144
+ collectFocusableRecursive(child, result);
145
+ }
146
+ }
package/src/index.ts ADDED
@@ -0,0 +1,49 @@
1
+ /**
2
+ * @module @cel-tui/core
3
+ *
4
+ * Core framework package. Provides the four primitives ({@link VStack},
5
+ * {@link HStack}, {@link Text}, {@link TextInput}) and the framework
6
+ * entrypoint ({@link cel}).
7
+ *
8
+ * All types are re-exported from `@cel-tui/types`.
9
+ *
10
+ * @example
11
+ * ```ts
12
+ * import { cel, VStack, HStack, Text, TextInput, ProcessTerminal } from "@cel-tui/core";
13
+ *
14
+ * let name = "";
15
+ *
16
+ * cel.init(new ProcessTerminal());
17
+ * cel.viewport(() =>
18
+ * VStack({ height: "100%" }, [
19
+ * Text("What is your name?", { bold: true }),
20
+ * TextInput({
21
+ * value: name,
22
+ * onChange: (v) => { name = v; cel.render(); },
23
+ * }),
24
+ * ])
25
+ * );
26
+ * ```
27
+ */
28
+
29
+ export type {
30
+ Color,
31
+ StyleProps,
32
+ SizeValue,
33
+ ContainerProps,
34
+ TextProps,
35
+ TextInputProps,
36
+ TextNode,
37
+ TextInputNode,
38
+ ContainerNode,
39
+ Node,
40
+ } from "@cel-tui/types";
41
+
42
+ export { VStack, HStack } from "./primitives/stacks.js";
43
+ export { Text } from "./primitives/text.js";
44
+ export { TextInput } from "./primitives/text-input.js";
45
+ export { cel } from "./cel.js";
46
+ export { CellBuffer, EMPTY_CELL, type Cell } from "./cell-buffer.js";
47
+ export { emitBuffer } from "./emitter.js";
48
+ export { visibleWidth, extractAnsiCode } from "./width.js";
49
+ export { type Terminal, ProcessTerminal, MockTerminal } from "./terminal.js";