@vertz/tui 0.2.3

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,111 @@
1
+ /** Style attributes for a single terminal cell. */
2
+ interface CellStyle {
3
+ color?: string;
4
+ bgColor?: string;
5
+ bold?: boolean;
6
+ dim?: boolean;
7
+ italic?: boolean;
8
+ underline?: boolean;
9
+ strikethrough?: boolean;
10
+ }
11
+ /** Computed layout box — the result of layout computation. */
12
+ interface LayoutBox {
13
+ x: number;
14
+ y: number;
15
+ width: number;
16
+ height: number;
17
+ }
18
+ /** Border style options. */
19
+ type BorderStyle = "single" | "double" | "round" | "bold" | "none";
20
+ /** Layout properties for a node. */
21
+ interface LayoutProps {
22
+ direction: "row" | "column";
23
+ padding: number;
24
+ paddingX: number;
25
+ paddingY: number;
26
+ gap: number;
27
+ width: number | "full" | undefined;
28
+ height: number | undefined;
29
+ grow: number;
30
+ align: "start" | "center" | "end";
31
+ justify: "start" | "center" | "end" | "between";
32
+ border: BorderStyle;
33
+ }
34
+ /** A persistent TUI element (like <Box> or <Text>). Built once, mutated by effects. */
35
+ interface TuiElement {
36
+ _tuiElement: true;
37
+ tag: string;
38
+ props: Record<string, unknown>;
39
+ style: CellStyle;
40
+ layoutProps: LayoutProps;
41
+ children: TuiChild[];
42
+ parent: TuiElement | null;
43
+ dirty: boolean;
44
+ /** Computed layout box. */
45
+ box: LayoutBox;
46
+ }
47
+ /** A persistent text node. Updated in-place by reactive effects. */
48
+ interface TuiTextNode {
49
+ _tuiText: true;
50
+ text: string;
51
+ style: CellStyle;
52
+ dirty: boolean;
53
+ box: LayoutBox;
54
+ }
55
+ /** A conditional node that swaps between branches. */
56
+ interface TuiConditionalNode {
57
+ _tuiConditional: true;
58
+ current: TuiElement | TuiTextNode | null;
59
+ dirty: boolean;
60
+ }
61
+ /** A list node that manages keyed items. */
62
+ interface TuiListNode {
63
+ _tuiList: true;
64
+ items: TuiElement[];
65
+ dirty: boolean;
66
+ }
67
+ /** Any child in a persistent TUI tree. */
68
+ type TuiChild = TuiElement | TuiTextNode | TuiConditionalNode | TuiListNode;
69
+ /** Any renderable TUI content. */
70
+ type TuiNode = TuiElement2 | TuiTextNode2 | TuiConditionalNode | TuiListNode | null | undefined | false | TuiNode[];
71
+ /** A TUI element (like <Box> or <Text>). */
72
+ interface TuiElement2 {
73
+ _tuiElement: true;
74
+ tag: string;
75
+ props: Record<string, unknown>;
76
+ style: CellStyle;
77
+ layoutProps: LayoutProps;
78
+ children: TuiNode[];
79
+ /** Computed layout box. */
80
+ box: LayoutBox;
81
+ /** Component function, if this is a component element. */
82
+ component?: (props: Record<string, unknown>) => TuiNode;
83
+ }
84
+ /** A text node (raw string or number). */
85
+ interface TuiTextNode2 {
86
+ _tuiText: true;
87
+ text: string;
88
+ style: CellStyle;
89
+ box: LayoutBox;
90
+ }
91
+ /** Color type for Text components. */
92
+ type Color = "black" | "red" | "green" | "yellow" | "blue" | "magenta" | "cyan" | "white" | "gray" | "redBright" | "greenBright" | "yellowBright" | "blueBright" | "magentaBright" | "cyanBright" | "whiteBright" | `#${string}`;
93
+ /** Tag type: string intrinsic or component function. */
94
+ type Tag = string | ((props: Record<string, unknown>) => TuiNode);
95
+ /** JSX factory function. */
96
+ declare function jsx(tag: Tag, props: Record<string, unknown>): TuiNode;
97
+ /** JSX factory for elements with multiple static children. */
98
+ declare const jsxs: typeof jsx;
99
+ /** JSX dev factory. */
100
+ declare const jsxDEV: typeof jsx;
101
+ /** Fragment: returns children as-is. */
102
+ declare function Fragment(props: {
103
+ children?: TuiNode;
104
+ }): TuiNode;
105
+ declare namespace JSX {
106
+ type Element = TuiNode;
107
+ interface IntrinsicElements {
108
+ [tag: string]: Record<string, unknown>;
109
+ }
110
+ }
111
+ export { jsxs, jsxDEV, jsx, JSX, Fragment, Color };
@@ -0,0 +1,134 @@
1
+ import {
2
+ defaultLayoutProps
3
+ } from "../shared/chunk-5x9fb9b2.js";
4
+
5
+ // src/jsx-runtime/index.ts
6
+ function jsx(tag, props) {
7
+ if (typeof tag === "function") {
8
+ return tag(props);
9
+ }
10
+ const { children, ...rest } = props;
11
+ const element = createElement(tag, rest);
12
+ element.children = normalizeChildren(children);
13
+ return element;
14
+ }
15
+ var jsxs = jsx;
16
+ var jsxDEV = jsx;
17
+ function Fragment(props) {
18
+ return props.children ?? null;
19
+ }
20
+ function createElement(tag, props) {
21
+ const layoutProps = defaultLayoutProps();
22
+ const style = {};
23
+ for (const [key, value] of Object.entries(props)) {
24
+ switch (key) {
25
+ case "direction":
26
+ if (value === "row" || value === "column")
27
+ layoutProps.direction = value;
28
+ break;
29
+ case "padding":
30
+ if (typeof value === "number")
31
+ layoutProps.padding = value;
32
+ break;
33
+ case "paddingX":
34
+ if (typeof value === "number")
35
+ layoutProps.paddingX = value;
36
+ break;
37
+ case "paddingY":
38
+ if (typeof value === "number")
39
+ layoutProps.paddingY = value;
40
+ break;
41
+ case "gap":
42
+ if (typeof value === "number")
43
+ layoutProps.gap = value;
44
+ break;
45
+ case "width":
46
+ if (typeof value === "number" || value === "full")
47
+ layoutProps.width = value;
48
+ break;
49
+ case "height":
50
+ if (typeof value === "number")
51
+ layoutProps.height = value;
52
+ break;
53
+ case "grow":
54
+ if (typeof value === "number")
55
+ layoutProps.grow = value;
56
+ break;
57
+ case "align":
58
+ if (value === "start" || value === "center" || value === "end")
59
+ layoutProps.align = value;
60
+ break;
61
+ case "justify":
62
+ if (value === "start" || value === "center" || value === "end" || value === "between") {
63
+ layoutProps.justify = value;
64
+ }
65
+ break;
66
+ case "border":
67
+ if (value === "single" || value === "double" || value === "round" || value === "bold" || value === "none") {
68
+ layoutProps.border = value;
69
+ }
70
+ break;
71
+ case "color":
72
+ if (typeof value === "string")
73
+ style.color = value;
74
+ break;
75
+ case "bgColor":
76
+ case "borderColor":
77
+ if (typeof value === "string")
78
+ style.bgColor = value;
79
+ break;
80
+ case "bold":
81
+ if (value === true)
82
+ style.bold = true;
83
+ break;
84
+ case "dim":
85
+ if (value === true)
86
+ style.dim = true;
87
+ break;
88
+ case "italic":
89
+ if (value === true)
90
+ style.italic = true;
91
+ break;
92
+ case "underline":
93
+ if (value === true)
94
+ style.underline = true;
95
+ break;
96
+ case "strikethrough":
97
+ if (value === true)
98
+ style.strikethrough = true;
99
+ break;
100
+ }
101
+ }
102
+ return {
103
+ _tuiElement: true,
104
+ tag,
105
+ props,
106
+ style,
107
+ layoutProps,
108
+ children: [],
109
+ box: { x: 0, y: 0, width: 0, height: 0 }
110
+ };
111
+ }
112
+ function normalizeChildren(children) {
113
+ if (children == null || children === false || children === true)
114
+ return [];
115
+ if (Array.isArray(children)) {
116
+ return children.flatMap((c) => normalizeChildren(c));
117
+ }
118
+ if (typeof children === "string" || typeof children === "number") {
119
+ const textNode = {
120
+ _tuiText: true,
121
+ text: String(children),
122
+ style: {},
123
+ box: { x: 0, y: 0, width: 0, height: 0 }
124
+ };
125
+ return [textNode];
126
+ }
127
+ return [children];
128
+ }
129
+ export {
130
+ jsxs,
131
+ jsxDEV,
132
+ jsx,
133
+ Fragment
134
+ };
@@ -0,0 +1,52 @@
1
+ // src/layout/types.ts
2
+ function defaultLayoutProps() {
3
+ return {
4
+ direction: "column",
5
+ padding: 0,
6
+ paddingX: 0,
7
+ paddingY: 0,
8
+ gap: 0,
9
+ width: undefined,
10
+ height: undefined,
11
+ grow: 0,
12
+ align: "start",
13
+ justify: "start",
14
+ border: "none"
15
+ };
16
+ }
17
+ var BORDER_CHARS = {
18
+ single: {
19
+ topLeft: "┌",
20
+ topRight: "┐",
21
+ bottomLeft: "└",
22
+ bottomRight: "┘",
23
+ horizontal: "─",
24
+ vertical: "│"
25
+ },
26
+ double: {
27
+ topLeft: "╔",
28
+ topRight: "╗",
29
+ bottomLeft: "╚",
30
+ bottomRight: "╝",
31
+ horizontal: "═",
32
+ vertical: "║"
33
+ },
34
+ round: {
35
+ topLeft: "╭",
36
+ topRight: "╮",
37
+ bottomLeft: "╰",
38
+ bottomRight: "╯",
39
+ horizontal: "─",
40
+ vertical: "│"
41
+ },
42
+ bold: {
43
+ topLeft: "┏",
44
+ topRight: "┓",
45
+ bottomLeft: "┗",
46
+ bottomRight: "┛",
47
+ horizontal: "━",
48
+ vertical: "┃"
49
+ }
50
+ };
51
+
52
+ export { defaultLayoutProps, BORDER_CHARS };
@@ -0,0 +1,137 @@
1
+ // src/buffer/cell.ts
2
+ function emptyCell() {
3
+ return { char: " ", width: 1, style: {} };
4
+ }
5
+ function stylesEqual(a, b) {
6
+ return a.color === b.color && a.bgColor === b.bgColor && a.bold === b.bold && a.dim === b.dim && a.italic === b.italic && a.underline === b.underline && a.strikethrough === b.strikethrough;
7
+ }
8
+ function cellsEqual(a, b) {
9
+ return a.char === b.char && a.width === b.width && stylesEqual(a.style, b.style);
10
+ }
11
+
12
+ // src/buffer/terminal-buffer.ts
13
+ class TerminalBuffer {
14
+ width;
15
+ height;
16
+ _cells;
17
+ constructor(width, height) {
18
+ this.width = width;
19
+ this.height = height;
20
+ this._cells = TerminalBuffer._createGrid(width, height);
21
+ }
22
+ static _createGrid(width, height) {
23
+ const grid = [];
24
+ for (let r = 0;r < height; r++) {
25
+ const row = [];
26
+ for (let c = 0;c < width; c++) {
27
+ row.push(emptyCell());
28
+ }
29
+ grid.push(row);
30
+ }
31
+ return grid;
32
+ }
33
+ get(row, col) {
34
+ return this._cells[row]?.[col];
35
+ }
36
+ set(row, col, char, style) {
37
+ const cellRow = this._cells[row];
38
+ if (!cellRow || col < 0 || col >= this.width)
39
+ return;
40
+ cellRow[col] = { char, width: 1, style };
41
+ }
42
+ writeString(row, col, text, style) {
43
+ for (let i = 0;i < text.length; i++) {
44
+ const c = col + i;
45
+ if (c >= this.width)
46
+ break;
47
+ const ch = text[i];
48
+ if (ch !== undefined)
49
+ this.set(row, c, ch, style);
50
+ }
51
+ }
52
+ clear() {
53
+ this._cells = TerminalBuffer._createGrid(this.width, this.height);
54
+ }
55
+ diff(previous) {
56
+ const regions = [];
57
+ const maxRows = Math.min(this.height, previous.height);
58
+ const maxCols = Math.min(this.width, previous.width);
59
+ for (let r = 0;r < maxRows; r++) {
60
+ let regionStart = -1;
61
+ let regionCells = [];
62
+ for (let c = 0;c < maxCols; c++) {
63
+ const curr = this._cells[r]?.[c];
64
+ const prev = previous._cells[r]?.[c];
65
+ if (!curr || !prev)
66
+ continue;
67
+ if (!cellsEqual(curr, prev)) {
68
+ if (regionStart === -1) {
69
+ regionStart = c;
70
+ regionCells = [];
71
+ }
72
+ regionCells.push(curr);
73
+ } else if (regionStart !== -1) {
74
+ regions.push({ row: r, col: regionStart, cells: regionCells });
75
+ regionStart = -1;
76
+ }
77
+ }
78
+ if (regionStart !== -1) {
79
+ regions.push({ row: r, col: regionStart, cells: regionCells });
80
+ }
81
+ }
82
+ return regions;
83
+ }
84
+ clone() {
85
+ const copy = new TerminalBuffer(this.width, this.height);
86
+ for (let r = 0;r < this.height; r++) {
87
+ for (let c = 0;c < this.width; c++) {
88
+ const cell = this._cells[r]?.[c];
89
+ if (cell)
90
+ copy.set(r, c, cell.char, { ...cell.style });
91
+ }
92
+ }
93
+ return copy;
94
+ }
95
+ getRowText(row) {
96
+ const cellRow = this._cells[row];
97
+ if (!cellRow)
98
+ return "";
99
+ return cellRow.map((c) => c.char).join("");
100
+ }
101
+ getText() {
102
+ const lines = [];
103
+ for (let r = 0;r < this.height; r++) {
104
+ lines.push(this.getRowText(r));
105
+ }
106
+ return lines.join(`
107
+ `);
108
+ }
109
+ }
110
+
111
+ // src/test/test-adapter.ts
112
+ class TestAdapter {
113
+ columns;
114
+ rows;
115
+ buffer;
116
+ rawOutput = [];
117
+ constructor(columns = 80, rows = 24) {
118
+ this.columns = columns;
119
+ this.rows = rows;
120
+ this.buffer = new TerminalBuffer(columns, rows);
121
+ }
122
+ write(data) {
123
+ this.rawOutput.push(data);
124
+ }
125
+ textAt(row) {
126
+ return this.buffer.getRowText(row);
127
+ }
128
+ text() {
129
+ return this.buffer.getText();
130
+ }
131
+ reset() {
132
+ this.rawOutput = [];
133
+ this.buffer = new TerminalBuffer(this.columns, this.rows);
134
+ }
135
+ }
136
+
137
+ export { TerminalBuffer, TestAdapter };
@@ -0,0 +1,109 @@
1
+ /** Style attributes for a single terminal cell. */
2
+ interface CellStyle {
3
+ color?: string;
4
+ bgColor?: string;
5
+ bold?: boolean;
6
+ dim?: boolean;
7
+ italic?: boolean;
8
+ underline?: boolean;
9
+ strikethrough?: boolean;
10
+ }
11
+ /** A single terminal cell: one character with style. */
12
+ interface Cell {
13
+ char: string;
14
+ width: number;
15
+ style: CellStyle;
16
+ }
17
+ /** A region of cells that changed between two buffers. */
18
+ interface DirtyRegion {
19
+ row: number;
20
+ col: number;
21
+ cells: Cell[];
22
+ }
23
+ /**
24
+ * 2D grid of cells representing the terminal screen.
25
+ * Double-buffered: write to current, diff against previous.
26
+ */
27
+ declare class TerminalBuffer {
28
+ readonly width: number;
29
+ readonly height: number;
30
+ private _cells;
31
+ constructor(width: number, height: number);
32
+ private static _createGrid;
33
+ /** Get a cell at the given position. Returns undefined if out of bounds. */
34
+ get(row: number, col: number): Cell | undefined;
35
+ /** Set a cell at the given position. No-op if out of bounds. */
36
+ set(row: number, col: number, char: string, style: CellStyle): void;
37
+ /** Write a string starting at the given position. */
38
+ writeString(row: number, col: number, text: string, style: CellStyle): void;
39
+ /** Fill the entire buffer with empty cells. */
40
+ clear(): void;
41
+ /**
42
+ * Diff this buffer against another, returning regions that differ.
43
+ * Each dirty region is a contiguous run of changed cells on one row.
44
+ */
45
+ diff(previous: TerminalBuffer): DirtyRegion[];
46
+ /** Create a deep copy of this buffer. */
47
+ clone(): TerminalBuffer;
48
+ /** Get the text content of a specific row (no styling). */
49
+ getRowText(row: number): string;
50
+ /** Get all text content (no styling), rows joined by newlines. */
51
+ getText(): string;
52
+ }
53
+ /**
54
+ * Output adapter interface. Abstracts stdout for testing.
55
+ */
56
+ interface OutputAdapter {
57
+ /** Write a string to the output. */
58
+ write(data: string): void;
59
+ /** Get terminal width in columns. */
60
+ readonly columns: number;
61
+ /** Get terminal height in rows. */
62
+ readonly rows: number;
63
+ }
64
+ /**
65
+ * Test output adapter that captures rendered output for assertions.
66
+ * Instead of writing to stdout, maintains a TerminalBuffer that can be inspected.
67
+ */
68
+ declare class TestAdapter implements OutputAdapter {
69
+ readonly columns: number;
70
+ readonly rows: number;
71
+ /** The current terminal buffer (public for test inspection). */
72
+ buffer: TerminalBuffer;
73
+ /** Raw ANSI output captured from write calls. */
74
+ rawOutput: string[];
75
+ constructor(columns?: number, rows?: number);
76
+ write(data: string): void;
77
+ /** Get the text content of a specific row. */
78
+ textAt(row: number): string;
79
+ /** Get all text content, rows joined by newlines. */
80
+ text(): string;
81
+ /** Reset captured output. */
82
+ reset(): void;
83
+ }
84
+ /** Parsed key event from terminal stdin. */
85
+ interface KeyEvent {
86
+ /** Key name: 'a', 'return', 'up', 'down', 'tab', 'escape', 'space', etc. */
87
+ name: string;
88
+ /** Printable character or empty string. */
89
+ char: string;
90
+ ctrl: boolean;
91
+ shift: boolean;
92
+ meta: boolean;
93
+ }
94
+ type KeyListener = (key: KeyEvent) => void;
95
+ /**
96
+ * Test stdin that allows programmatic key injection for testing.
97
+ */
98
+ declare class TestStdin {
99
+ private _listeners;
100
+ /** Register a key listener. Returns a cleanup function. */
101
+ onKey(listener: KeyListener): () => void;
102
+ /** Simulate a key press. */
103
+ pressKey(name: string, options?: Partial<Omit<KeyEvent, "name">>): void;
104
+ /** Simulate typing a string character by character. */
105
+ type(text: string): void;
106
+ /** Remove all listeners. */
107
+ dispose(): void;
108
+ }
109
+ export { TestStdin, TestAdapter };
@@ -0,0 +1,40 @@
1
+ import {
2
+ TestAdapter
3
+ } from "../shared/chunk-6y95cgnx.js";
4
+ // src/test/test-stdin.ts
5
+ class TestStdin {
6
+ _listeners = [];
7
+ onKey(listener) {
8
+ this._listeners.push(listener);
9
+ return () => {
10
+ const idx = this._listeners.indexOf(listener);
11
+ if (idx !== -1)
12
+ this._listeners.splice(idx, 1);
13
+ };
14
+ }
15
+ pressKey(name, options) {
16
+ const event = {
17
+ name,
18
+ char: options?.char ?? (name.length === 1 ? name : ""),
19
+ ctrl: options?.ctrl ?? false,
20
+ shift: options?.shift ?? false,
21
+ meta: options?.meta ?? false
22
+ };
23
+ const snapshot = [...this._listeners];
24
+ for (const listener of snapshot) {
25
+ listener(event);
26
+ }
27
+ }
28
+ type(text) {
29
+ for (const char of text) {
30
+ this.pressKey(char, { char });
31
+ }
32
+ }
33
+ dispose() {
34
+ this._listeners = [];
35
+ }
36
+ }
37
+ export {
38
+ TestStdin,
39
+ TestAdapter
40
+ };
package/package.json ADDED
@@ -0,0 +1,61 @@
1
+ {
2
+ "name": "@vertz/tui",
3
+ "version": "0.2.3",
4
+ "type": "module",
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "https://github.com/vertz-dev/vertz.git",
9
+ "directory": "packages/tui"
10
+ },
11
+ "main": "dist/index.js",
12
+ "types": "dist/index.d.ts",
13
+ "exports": {
14
+ ".": {
15
+ "types": "./dist/index.d.ts",
16
+ "import": "./dist/index.js"
17
+ },
18
+ "./jsx-runtime": {
19
+ "types": "./dist/jsx-runtime/index.d.ts",
20
+ "import": "./dist/jsx-runtime/index.js"
21
+ },
22
+ "./jsx-dev-runtime": {
23
+ "types": "./dist/jsx-runtime/index.d.ts",
24
+ "import": "./dist/jsx-runtime/index.js"
25
+ },
26
+ "./internals": {
27
+ "types": "./dist/internals.d.ts",
28
+ "import": "./dist/internals.js"
29
+ },
30
+ "./test": {
31
+ "types": "./dist/test/index.d.ts",
32
+ "import": "./dist/test/index.js"
33
+ }
34
+ },
35
+ "files": [
36
+ "dist"
37
+ ],
38
+ "scripts": {
39
+ "build": "bunup",
40
+ "typecheck": "tsc --noEmit",
41
+ "test": "vitest run",
42
+ "lint": "biome check src/"
43
+ },
44
+ "dependencies": {
45
+ "@vertz/ui": "0.2.2"
46
+ },
47
+ "devDependencies": {
48
+ "@types/node": "^25.3.1",
49
+ "@vitest/coverage-v8": "^4.0.18",
50
+ "bunup": "^0.16.23",
51
+ "typescript": "^5.9.3",
52
+ "vitest": "^4.0.18"
53
+ },
54
+ "engines": {
55
+ "node": ">=22"
56
+ },
57
+ "publishConfig": {
58
+ "access": "public",
59
+ "provenance": true
60
+ }
61
+ }