@bindtty/renderer-terminal 0.1.0-alpha.1

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/README.md ADDED
@@ -0,0 +1,32 @@
1
+ # @bindtty/renderer-terminal
2
+
3
+ Terminal renderer package for BindTTY.
4
+
5
+ Renders `LayoutNode` trees into ANSI terminal output:
6
+
7
+ ```
8
+ LayoutNode → Frame → cell diff → ANSI patch string
9
+ ```
10
+
11
+ ## Features
12
+
13
+ - `createTerminalRenderer()` — stateful renderer with previous frame cache
14
+ - `renderer.render(layoutTree, options)` — paint → diff → ANSI
15
+ - `renderer.reset()` — clear previous frame (for resize/clear screen)
16
+ - Default focused inverse style with `focusStyle: "none"` opt-out
17
+ - Cell-level diff for minimal ANSI output
18
+ - Wide-cell frame model for CJK, common emoji, and combining marks
19
+ - Placeholder cells for wide grapheme continuation columns
20
+
21
+ ## Usage
22
+
23
+ ```ts
24
+ import { createTerminalRenderer } from "@bindtty/renderer-terminal";
25
+
26
+ const renderer = createTerminalRenderer();
27
+ const ansi = renderer.render(layoutTree, {
28
+ viewport: { width: 80, height: 24 },
29
+ isFocused: (mounted) => interaction.isFocused(mounted)
30
+ });
31
+ stdout.write(ansi);
32
+ ```
package/dist/ansi.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ import type { FramePatch } from "./types.js";
2
+ export declare function encodeAnsiPatch(patch: FramePatch): string;
package/dist/ansi.js ADDED
@@ -0,0 +1,90 @@
1
+ const RESET = "\x1b[0m";
2
+ const foregroundColors = new Map([
3
+ ["black", 30],
4
+ ["red", 31],
5
+ ["green", 32],
6
+ ["yellow", 33],
7
+ ["blue", 34],
8
+ ["magenta", 35],
9
+ ["cyan", 36],
10
+ ["white", 37],
11
+ ["gray", 90],
12
+ ["brightBlack", 90],
13
+ ["brightRed", 91],
14
+ ["brightGreen", 92],
15
+ ["brightYellow", 93],
16
+ ["brightBlue", 94],
17
+ ["brightMagenta", 95],
18
+ ["brightCyan", 96],
19
+ ["brightWhite", 97]
20
+ ]);
21
+ const backgroundColors = new Map([
22
+ ["black", 40],
23
+ ["red", 41],
24
+ ["green", 42],
25
+ ["yellow", 43],
26
+ ["blue", 44],
27
+ ["magenta", 45],
28
+ ["cyan", 46],
29
+ ["white", 47],
30
+ ["gray", 100],
31
+ ["brightBlack", 100],
32
+ ["brightRed", 101],
33
+ ["brightGreen", 102],
34
+ ["brightYellow", 103],
35
+ ["brightBlue", 104],
36
+ ["brightMagenta", 105],
37
+ ["brightCyan", 106],
38
+ ["brightWhite", 107]
39
+ ]);
40
+ export function encodeAnsiPatch(patch) {
41
+ if (patch.changes.length === 0) {
42
+ return "";
43
+ }
44
+ let output = "";
45
+ for (const change of [...patch.changes].sort((left, right) => left.y - right.y || left.x - right.x)) {
46
+ if (change.cell.width === 0) {
47
+ continue;
48
+ }
49
+ output += moveCursor(change.x, change.y);
50
+ output += RESET;
51
+ output += encodeStyle(change.cell.style);
52
+ output += change.cell.char;
53
+ }
54
+ return output.length === 0 ? "" : output + RESET;
55
+ }
56
+ function moveCursor(x, y) {
57
+ return `\x1b[${y + 1};${x + 1}H`;
58
+ }
59
+ function encodeStyle(style) {
60
+ const codes = [];
61
+ if (style.bold === true) {
62
+ codes.push(1);
63
+ }
64
+ if (style.dim === true) {
65
+ codes.push(2);
66
+ }
67
+ if (style.italic === true) {
68
+ codes.push(3);
69
+ }
70
+ if (style.underline === true) {
71
+ codes.push(4);
72
+ }
73
+ if (style.inverse === true) {
74
+ codes.push(7);
75
+ }
76
+ if (style.foreground !== undefined) {
77
+ codes.push(readColorCode(foregroundColors, style.foreground, "foreground"));
78
+ }
79
+ if (style.background !== undefined) {
80
+ codes.push(readColorCode(backgroundColors, style.background, "background"));
81
+ }
82
+ return codes.length === 0 ? "" : `\x1b[${codes.join(";")}m`;
83
+ }
84
+ function readColorCode(colors, color, kind) {
85
+ const code = colors.get(color);
86
+ if (code === undefined) {
87
+ throw new Error(`Unsupported ${kind} color: ${color}`);
88
+ }
89
+ return code;
90
+ }
package/dist/diff.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ import type { Frame, FramePatch } from "./types.js";
2
+ export declare function diffFrames(previous: Frame | null, next: Frame): FramePatch;
package/dist/diff.js ADDED
@@ -0,0 +1,140 @@
1
+ export function diffFrames(previous, next) {
2
+ if (previous === null ||
3
+ previous.width !== next.width ||
4
+ previous.height !== next.height) {
5
+ return createFullFramePatch(next);
6
+ }
7
+ const dirtyCells = new Set();
8
+ for (let y = 0; y < next.height; y += 1) {
9
+ for (let x = 0; x < next.width; x += 1) {
10
+ const index = y * next.width + x;
11
+ const previousCell = previous.cells[index];
12
+ const nextCell = next.cells[index];
13
+ if (nextCell && (!previousCell || !cellsEqual(previousCell, nextCell))) {
14
+ markChangedCell(dirtyCells, previous, next, x, y);
15
+ }
16
+ }
17
+ }
18
+ const dirtyCoordinates = Array.from(dirtyCells)
19
+ .map(parseCellKey)
20
+ .sort((left, right) => left.y - right.y || left.x - right.x);
21
+ if (!hasVisibleChange(dirtyCoordinates, previous, next)) {
22
+ return {
23
+ width: next.width,
24
+ height: next.height,
25
+ changes: []
26
+ };
27
+ }
28
+ const changes = dirtyCoordinates
29
+ .flatMap(({ x, y }) => {
30
+ const cell = next.cells[y * next.width + x];
31
+ return cell
32
+ ? [{
33
+ x,
34
+ y,
35
+ cell: cloneCell(cell)
36
+ }]
37
+ : [];
38
+ });
39
+ return {
40
+ width: next.width,
41
+ height: next.height,
42
+ changes
43
+ };
44
+ }
45
+ function hasVisibleChange(coordinates, previous, next) {
46
+ return coordinates.some(({ x, y }) => {
47
+ const index = y * next.width + x;
48
+ const nextCell = next.cells[index];
49
+ const previousCell = previous.cells[index];
50
+ return (nextCell !== undefined &&
51
+ normalizeWidth(nextCell) !== 0 &&
52
+ (!previousCell || !cellsEqual(previousCell, nextCell)));
53
+ });
54
+ }
55
+ function createFullFramePatch(frame) {
56
+ const changes = [];
57
+ for (let y = 0; y < frame.height; y += 1) {
58
+ for (let x = 0; x < frame.width; x += 1) {
59
+ const cell = frame.cells[y * frame.width + x];
60
+ if (cell) {
61
+ changes.push({
62
+ x,
63
+ y,
64
+ cell: cloneCell(cell)
65
+ });
66
+ }
67
+ }
68
+ }
69
+ return {
70
+ width: frame.width,
71
+ height: frame.height,
72
+ changes
73
+ };
74
+ }
75
+ function markChangedCell(dirtyCells, previous, next, x, y) {
76
+ mark(dirtyCells, next, x, y);
77
+ markWideRange(dirtyCells, previous, x, y);
78
+ markWideRange(dirtyCells, next, x, y);
79
+ }
80
+ function markWideRange(dirtyCells, frame, x, y) {
81
+ const cell = frame.cells[y * frame.width + x];
82
+ if (!cell) {
83
+ return;
84
+ }
85
+ if (cell.width === 2) {
86
+ mark(dirtyCells, frame, x, y);
87
+ mark(dirtyCells, frame, x + 1, y);
88
+ return;
89
+ }
90
+ if (cell.width === 0) {
91
+ mark(dirtyCells, frame, x - 1, y);
92
+ mark(dirtyCells, frame, x, y);
93
+ }
94
+ }
95
+ function mark(dirtyCells, frame, x, y) {
96
+ if (Number.isInteger(x) &&
97
+ Number.isInteger(y) &&
98
+ x >= 0 &&
99
+ y >= 0 &&
100
+ x < frame.width &&
101
+ y < frame.height) {
102
+ dirtyCells.add(`${y}:${x}`);
103
+ }
104
+ }
105
+ function parseCellKey(key) {
106
+ const [y, x] = key.split(":").map(Number);
107
+ return {
108
+ x: x ?? 0,
109
+ y: y ?? 0
110
+ };
111
+ }
112
+ function cellsEqual(left, right) {
113
+ return (left.char === right.char &&
114
+ normalizeWidth(left) === normalizeWidth(right) &&
115
+ stylesEqual(left.style, right.style));
116
+ }
117
+ function stylesEqual(left, right) {
118
+ return (left.foreground === right.foreground &&
119
+ left.background === right.background &&
120
+ normalizeBoolean(left.bold) === normalizeBoolean(right.bold) &&
121
+ normalizeBoolean(left.dim) === normalizeBoolean(right.dim) &&
122
+ normalizeBoolean(left.italic) === normalizeBoolean(right.italic) &&
123
+ normalizeBoolean(left.underline) === normalizeBoolean(right.underline) &&
124
+ normalizeBoolean(left.inverse) === normalizeBoolean(right.inverse));
125
+ }
126
+ function normalizeBoolean(value) {
127
+ return value === true;
128
+ }
129
+ function cloneCell(cell) {
130
+ return {
131
+ char: cell.char,
132
+ style: { ...cell.style },
133
+ width: normalizeWidth(cell)
134
+ };
135
+ }
136
+ function normalizeWidth(cell) {
137
+ return cell.width === 0 || cell.width === 1 || cell.width === 2
138
+ ? cell.width
139
+ : 1;
140
+ }
@@ -0,0 +1,13 @@
1
+ import type { Cell, CellStyle, Frame } from "./types.js";
2
+ export declare function createFrame(width: number, height: number): Frame;
3
+ export declare function getCell(frame: Frame, x: number, y: number): Cell | undefined;
4
+ export declare function setCell(frame: Frame, x: number, y: number, cell: Cell): boolean;
5
+ export declare function transformCellStyle(frame: Frame, x: number, y: number, transform: (style: CellStyle) => CellStyle): boolean;
6
+ export declare function writeText(frame: Frame, x: number, y: number, text: string, style?: CellStyle): number;
7
+ export declare function frameToLines(frame: Frame): string[];
8
+ export declare function frameToDebugLines(frame: Frame): string[];
9
+ export declare function createBlankCell(style?: CellStyle): Cell;
10
+ export declare function createTextCell(text: string, width: 1 | 2, style?: CellStyle): Cell;
11
+ export declare function createPlaceholderCell(style?: CellStyle): Cell;
12
+ export declare function isPlaceholderCell(cell: Cell): boolean;
13
+ export declare function isWideLeadingCell(cell: Cell): boolean;
package/dist/frame.js ADDED
@@ -0,0 +1,227 @@
1
+ import { segmentText } from "@bindtty/text";
2
+ export function createFrame(width, height) {
3
+ const normalizedWidth = toNonNegativeInteger(width);
4
+ const normalizedHeight = toNonNegativeInteger(height);
5
+ const cellCount = normalizedWidth * normalizedHeight;
6
+ const cells = [];
7
+ for (let index = 0; index < cellCount; index += 1) {
8
+ cells.push(createBlankCell());
9
+ }
10
+ return {
11
+ width: normalizedWidth,
12
+ height: normalizedHeight,
13
+ cells
14
+ };
15
+ }
16
+ export function getCell(frame, x, y) {
17
+ if (!isInsideFrame(frame, x, y)) {
18
+ return undefined;
19
+ }
20
+ return frame.cells[getCellIndex(frame, x, y)];
21
+ }
22
+ export function setCell(frame, x, y, cell) {
23
+ if (!isInsideFrame(frame, x, y)) {
24
+ return false;
25
+ }
26
+ const clonedCell = cloneCell(cell);
27
+ const width = clonedCell.width ?? 1;
28
+ if (width === 0) {
29
+ throw new Error("Invalid placeholder cell: use wide text writes to create placeholders");
30
+ }
31
+ if (width > 1 && x + width > frame.width) {
32
+ throw new Error("Invalid wide cell: cell width exceeds frame bounds");
33
+ }
34
+ clearCellsForWrite(frame, x, y, width);
35
+ setCellRaw(frame, x, y, clonedCell);
36
+ for (let offset = 1; offset < width; offset += 1) {
37
+ setCellRaw(frame, x + offset, y, createPlaceholderCell(clonedCell.style));
38
+ }
39
+ return true;
40
+ }
41
+ export function transformCellStyle(frame, x, y, transform) {
42
+ const cell = getCell(frame, x, y);
43
+ if (!cell) {
44
+ return false;
45
+ }
46
+ setCellRaw(frame, x, y, {
47
+ char: cell.char,
48
+ style: transform(cell.style),
49
+ width: cell.width
50
+ });
51
+ return true;
52
+ }
53
+ export function writeText(frame, x, y, text, style = {}) {
54
+ const firstLine = text.split("\n", 1)[0] ?? "";
55
+ let written = 0;
56
+ let cursorX = x;
57
+ for (const segment of segmentText(firstLine)) {
58
+ if (segment.width <= 0) {
59
+ continue;
60
+ }
61
+ if (canDrawWholeSegment(frame, cursorX, y, segment.width)) {
62
+ clearCellsForWrite(frame, cursorX, y, segment.width);
63
+ setCellRaw(frame, cursorX, y, {
64
+ char: segment.text,
65
+ style,
66
+ width: segment.width
67
+ });
68
+ for (let offset = 1; offset < segment.width; offset += 1) {
69
+ setCellRaw(frame, cursorX + offset, y, createPlaceholderCell(style));
70
+ }
71
+ written += segment.width;
72
+ }
73
+ cursorX += segment.width;
74
+ }
75
+ return written;
76
+ }
77
+ export function frameToLines(frame) {
78
+ const lines = [];
79
+ for (let y = 0; y < frame.height; y += 1) {
80
+ let line = "";
81
+ for (let x = 0; x < frame.width; x += 1) {
82
+ const cell = getCell(frame, x, y);
83
+ line += cell?.width === 0 ? "" : cell?.char ?? " ";
84
+ }
85
+ lines.push(line);
86
+ }
87
+ return lines;
88
+ }
89
+ export function frameToDebugLines(frame) {
90
+ const lines = [];
91
+ for (let y = 0; y < frame.height; y += 1) {
92
+ let line = "";
93
+ for (let x = 0; x < frame.width; x += 1) {
94
+ const cell = getCell(frame, x, y);
95
+ line += cell?.width === 0 ? "·" : cell?.char ?? " ";
96
+ }
97
+ lines.push(line);
98
+ }
99
+ return lines;
100
+ }
101
+ export function createBlankCell(style = {}) {
102
+ return {
103
+ char: " ",
104
+ style: cloneStyle(style),
105
+ width: 1
106
+ };
107
+ }
108
+ export function createTextCell(text, width, style = {}) {
109
+ assertValidCell(text, width);
110
+ return {
111
+ char: text,
112
+ style: cloneStyle(style),
113
+ width
114
+ };
115
+ }
116
+ export function createPlaceholderCell(style = {}) {
117
+ return {
118
+ char: "",
119
+ style: cloneStyle(style),
120
+ width: 0
121
+ };
122
+ }
123
+ export function isPlaceholderCell(cell) {
124
+ return cell.width === 0;
125
+ }
126
+ export function isWideLeadingCell(cell) {
127
+ return cell.width === 2;
128
+ }
129
+ function cloneCell(cell) {
130
+ const width = normalizeWidth(cell);
131
+ const char = width === 0 ? cell.char : normalizeChar(cell.char);
132
+ assertValidCell(char, width);
133
+ return {
134
+ char: width === 0 ? "" : char,
135
+ style: cloneStyle(cell.style),
136
+ width
137
+ };
138
+ }
139
+ function cloneStyle(style) {
140
+ return { ...style };
141
+ }
142
+ function setCellRaw(frame, x, y, cell) {
143
+ if (!isInsideFrame(frame, x, y)) {
144
+ return false;
145
+ }
146
+ const clonedCell = cloneCell(cell);
147
+ const width = clonedCell.width ?? 1;
148
+ if (width > 1 && x + width > frame.width) {
149
+ throw new Error("Invalid wide cell: cell width exceeds frame bounds");
150
+ }
151
+ frame.cells[getCellIndex(frame, x, y)] = clonedCell;
152
+ return true;
153
+ }
154
+ function normalizeChar(char) {
155
+ return char.length > 0 ? char : " ";
156
+ }
157
+ function normalizeWidth(cell) {
158
+ if (cell.width === 0 || cell.width === 1 || cell.width === 2) {
159
+ return cell.width;
160
+ }
161
+ return 1;
162
+ }
163
+ function assertValidCell(char, width) {
164
+ if (width === 0) {
165
+ if (char !== "") {
166
+ throw new Error("Invalid placeholder cell: char must be empty");
167
+ }
168
+ return;
169
+ }
170
+ const segments = segmentText(char);
171
+ if (segments.length !== 1) {
172
+ throw new Error("Invalid text cell: char must be a single grapheme");
173
+ }
174
+ const [segment] = segments;
175
+ if (!segment || segment.width !== width) {
176
+ throw new Error(`Invalid text cell: char display width ${segment?.width ?? 0} does not match cell width ${width}`);
177
+ }
178
+ }
179
+ function canDrawWholeSegment(frame, x, y, width) {
180
+ return (Number.isInteger(x) &&
181
+ Number.isInteger(y) &&
182
+ y >= 0 &&
183
+ y < frame.height &&
184
+ x >= 0 &&
185
+ x + width <= frame.width);
186
+ }
187
+ function clearCellsForWrite(frame, x, y, width) {
188
+ for (let col = x; col < x + width; col += 1) {
189
+ clearWideCellAt(frame, col, y);
190
+ }
191
+ }
192
+ function clearWideCellAt(frame, x, y) {
193
+ const cell = getCell(frame, x, y);
194
+ if (!cell) {
195
+ return;
196
+ }
197
+ if (cell.width === 2) {
198
+ setCellRaw(frame, x, y, createBlankCell());
199
+ setCellRaw(frame, x + 1, y, createBlankCell());
200
+ return;
201
+ }
202
+ if (cell.width === 0) {
203
+ const leadingX = findWideLeadingCell(frame, x, y);
204
+ if (leadingX !== null) {
205
+ setCellRaw(frame, leadingX, y, createBlankCell());
206
+ setCellRaw(frame, leadingX + 1, y, createBlankCell());
207
+ }
208
+ }
209
+ }
210
+ function findWideLeadingCell(frame, x, y) {
211
+ const previous = getCell(frame, x - 1, y);
212
+ return previous?.width === 2 ? x - 1 : null;
213
+ }
214
+ function getCellIndex(frame, x, y) {
215
+ return y * frame.width + x;
216
+ }
217
+ function isInsideFrame(frame, x, y) {
218
+ return (Number.isInteger(x) &&
219
+ Number.isInteger(y) &&
220
+ x >= 0 &&
221
+ y >= 0 &&
222
+ x < frame.width &&
223
+ y < frame.height);
224
+ }
225
+ function toNonNegativeInteger(value) {
226
+ return Number.isFinite(value) ? Math.max(0, Math.floor(value)) : 0;
227
+ }
@@ -0,0 +1,7 @@
1
+ export { createBlankCell, createFrame, createPlaceholderCell, createTextCell, frameToDebugLines, frameToLines, getCell, isPlaceholderCell, isWideLeadingCell, setCell, writeText } from "./frame.js";
2
+ export { encodeAnsiPatch } from "./ansi.js";
3
+ export { diffFrames } from "./diff.js";
4
+ export { paintLayout } from "./paint.js";
5
+ export { createTerminalRenderer } from "./renderer.js";
6
+ export type { Cell, CellChange, CellStyle, Frame, FramePatch, RenderOptions, TerminalRenderer } from "./types.js";
7
+ export type { PaintOptions } from "./paint.js";
package/dist/index.js ADDED
@@ -0,0 +1,5 @@
1
+ export { createBlankCell, createFrame, createPlaceholderCell, createTextCell, frameToDebugLines, frameToLines, getCell, isPlaceholderCell, isWideLeadingCell, setCell, writeText } from "./frame.js";
2
+ export { encodeAnsiPatch } from "./ansi.js";
3
+ export { diffFrames } from "./diff.js";
4
+ export { paintLayout } from "./paint.js";
5
+ export { createTerminalRenderer } from "./renderer.js";
@@ -0,0 +1,7 @@
1
+ import type { LayoutNode, LayoutViewport } from "@bindtty/layout";
2
+ import type { Frame } from "./types.js";
3
+ export interface PaintOptions {
4
+ viewport: LayoutViewport;
5
+ isFocused?: (mounted: LayoutNode["mounted"]) => boolean;
6
+ }
7
+ export declare function paintLayout(root: LayoutNode | null, options: PaintOptions): Frame;
package/dist/paint.js ADDED
@@ -0,0 +1,240 @@
1
+ import { layoutText, readTextWrapMode, segmentText } from "@bindtty/text";
2
+ import { createFrame, getCell, setCell, transformCellStyle } from "./frame.js";
3
+ import { readPaintStyle, toBorderCellStyle, toCellStyle } from "./style.js";
4
+ const BORDER = {
5
+ topLeft: "┌",
6
+ topRight: "┐",
7
+ bottomLeft: "└",
8
+ bottomRight: "┘",
9
+ horizontal: "─",
10
+ vertical: "│"
11
+ };
12
+ export function paintLayout(root, options) {
13
+ const frame = createFrame(options.viewport.width, options.viewport.height);
14
+ if (root) {
15
+ paintNode(frame, root, options, {
16
+ clip: {
17
+ x: 0,
18
+ y: 0,
19
+ width: options.viewport.width,
20
+ height: options.viewport.height
21
+ },
22
+ offsetX: 0,
23
+ offsetY: 0
24
+ });
25
+ }
26
+ return frame;
27
+ }
28
+ function paintNode(frame, node, options, context) {
29
+ const mounted = node.mounted;
30
+ const childContext = createChildContext(node, context);
31
+ if (mounted.kind !== "element") {
32
+ paintChildren(frame, node, options, childContext);
33
+ return;
34
+ }
35
+ switch (mounted.tag) {
36
+ case "screen":
37
+ case "vstack":
38
+ case "hstack":
39
+ paintChildren(frame, node, options, childContext);
40
+ paintFocusedState(frame, node, options, context);
41
+ return;
42
+ case "box":
43
+ paintBox(frame, node, mounted, context);
44
+ paintChildren(frame, node, options, childContext);
45
+ paintFocusedState(frame, node, options, context);
46
+ return;
47
+ case "text":
48
+ paintText(frame, node, mounted, context);
49
+ paintFocusedState(frame, node, options, context);
50
+ return;
51
+ case "spacer":
52
+ paintFocusedState(frame, node, options, context);
53
+ return;
54
+ case "button":
55
+ case "input":
56
+ throw new Error(`Unsupported paint element: ${mounted.tag}`);
57
+ }
58
+ }
59
+ function paintChildren(frame, node, options, context) {
60
+ for (const child of node.children) {
61
+ paintNode(frame, child, options, context);
62
+ }
63
+ }
64
+ function paintText(frame, node, mounted, context) {
65
+ if (node.rect.width <= 0 || node.rect.height <= 0) {
66
+ return;
67
+ }
68
+ const value = mounted.props.value;
69
+ const text = value === null || value === undefined ? "" : String(value);
70
+ const wrap = readTextWrapMode(mounted.props.wrap);
71
+ const textLayout = layoutText(text, {
72
+ width: node.rect.width,
73
+ wrap
74
+ });
75
+ const lines = textLayout.lines.slice(0, node.rect.height);
76
+ const style = toCellStyle(readPaintStyle(mounted.props));
77
+ for (let row = 0; row < lines.length; row += 1) {
78
+ writeTextClipped(frame, node.rect.x + context.offsetX, node.rect.y + row + context.offsetY, lines[row] ?? "", node.rect.width, style, context);
79
+ }
80
+ }
81
+ function paintBox(frame, node, mounted, context) {
82
+ const style = readPaintStyle(mounted.props);
83
+ if (style.background !== undefined) {
84
+ fillRect(frame, offsetRect(node.rect, context), {
85
+ background: style.background
86
+ }, context);
87
+ }
88
+ if (shouldPaintBorder(style.border)) {
89
+ paintBorder(frame, offsetRect(node.rect, context), toBorderCellStyle(style), context);
90
+ }
91
+ }
92
+ function fillRect(frame, rect, style, context) {
93
+ for (let y = rect.y; y < rect.y + rect.height; y += 1) {
94
+ for (let x = rect.x; x < rect.x + rect.width; x += 1) {
95
+ setCellClipped(frame, x, y, {
96
+ char: " ",
97
+ style,
98
+ width: 1
99
+ }, context);
100
+ }
101
+ }
102
+ }
103
+ function paintBorder(frame, rect, style, context) {
104
+ const { x, y, width, height } = rect;
105
+ if (width <= 0 || height <= 0) {
106
+ return;
107
+ }
108
+ if (width === 1) {
109
+ for (let row = y; row < y + height; row += 1) {
110
+ paintChar(frame, x, row, BORDER.vertical, style, context);
111
+ }
112
+ return;
113
+ }
114
+ if (height === 1) {
115
+ for (let col = x; col < x + width; col += 1) {
116
+ paintChar(frame, col, y, BORDER.horizontal, style, context);
117
+ }
118
+ return;
119
+ }
120
+ paintChar(frame, x, y, BORDER.topLeft, style, context);
121
+ paintChar(frame, x + width - 1, y, BORDER.topRight, style, context);
122
+ paintChar(frame, x, y + height - 1, BORDER.bottomLeft, style, context);
123
+ paintChar(frame, x + width - 1, y + height - 1, BORDER.bottomRight, style, context);
124
+ for (let col = x + 1; col < x + width - 1; col += 1) {
125
+ paintChar(frame, col, y, BORDER.horizontal, style, context);
126
+ paintChar(frame, col, y + height - 1, BORDER.horizontal, style, context);
127
+ }
128
+ for (let row = y + 1; row < y + height - 1; row += 1) {
129
+ paintChar(frame, x, row, BORDER.vertical, style, context);
130
+ paintChar(frame, x + width - 1, row, BORDER.vertical, style, context);
131
+ }
132
+ }
133
+ function paintChar(frame, x, y, char, style, context) {
134
+ setCellClipped(frame, x, y, {
135
+ char,
136
+ style,
137
+ width: 1
138
+ }, context);
139
+ }
140
+ function shouldPaintBorder(border) {
141
+ if (typeof border === "number") {
142
+ return border > 0;
143
+ }
144
+ return border === true;
145
+ }
146
+ function paintFocusedState(frame, node, options, context) {
147
+ if (options.isFocused?.(node.mounted) !== true) {
148
+ return;
149
+ }
150
+ if (node.mounted.kind === "element" &&
151
+ readPaintStyle(node.mounted.props).focusStyle === "none") {
152
+ return;
153
+ }
154
+ const { x, y, width, height } = offsetRect(node.rect, context);
155
+ for (let row = y; row < y + height; row += 1) {
156
+ for (let col = x; col < x + width; col += 1) {
157
+ if (!isInsideRect(context.clip, col, row)) {
158
+ continue;
159
+ }
160
+ const cell = getCell(frame, col, row);
161
+ if (cell) {
162
+ transformCellStyle(frame, col, row, (style) => ({
163
+ ...style,
164
+ inverse: true
165
+ }));
166
+ }
167
+ }
168
+ }
169
+ }
170
+ function writeTextClipped(frame, x, y, text, maxWidth, style, context) {
171
+ let written = 0;
172
+ let cursorX = x;
173
+ let usedWidth = 0;
174
+ for (const segment of segmentText(text)) {
175
+ if (segment.width <= 0) {
176
+ continue;
177
+ }
178
+ if (usedWidth + segment.width > maxWidth) {
179
+ break;
180
+ }
181
+ if (canDrawWholeSegment(cursorX, y, segment.width, context.clip)) {
182
+ setCellClipped(frame, cursorX, y, {
183
+ char: segment.text,
184
+ style,
185
+ width: segment.width
186
+ }, context);
187
+ written += segment.width;
188
+ }
189
+ cursorX += segment.width;
190
+ usedWidth += segment.width;
191
+ }
192
+ return written;
193
+ }
194
+ function canDrawWholeSegment(x, y, width, clip) {
195
+ return (y >= clip.y &&
196
+ y < clip.y + clip.height &&
197
+ x >= clip.x &&
198
+ x + width <= clip.x + clip.width);
199
+ }
200
+ function setCellClipped(frame, x, y, cell, context) {
201
+ if (!isInsideRect(context.clip, x, y)) {
202
+ return false;
203
+ }
204
+ return setCell(frame, x, y, cell);
205
+ }
206
+ function createChildContext(node, context) {
207
+ const nodeClip = node.clip ? offsetRect(node.clip, context) : context.clip;
208
+ const clip = intersectRects(context.clip, nodeClip);
209
+ return {
210
+ clip,
211
+ offsetX: context.offsetX - (node.scrollOffset?.x ?? 0),
212
+ offsetY: context.offsetY - (node.scrollOffset?.y ?? 0)
213
+ };
214
+ }
215
+ function offsetRect(rect, context) {
216
+ return {
217
+ x: rect.x + context.offsetX,
218
+ y: rect.y + context.offsetY,
219
+ width: rect.width,
220
+ height: rect.height
221
+ };
222
+ }
223
+ function intersectRects(first, second) {
224
+ const x = Math.max(first.x, second.x);
225
+ const y = Math.max(first.y, second.y);
226
+ const right = Math.min(first.x + first.width, second.x + second.width);
227
+ const bottom = Math.min(first.y + first.height, second.y + second.height);
228
+ return {
229
+ x,
230
+ y,
231
+ width: Math.max(0, right - x),
232
+ height: Math.max(0, bottom - y)
233
+ };
234
+ }
235
+ function isInsideRect(rect, x, y) {
236
+ return (x >= rect.x &&
237
+ y >= rect.y &&
238
+ x < rect.x + rect.width &&
239
+ y < rect.y + rect.height);
240
+ }
@@ -0,0 +1,2 @@
1
+ import type { TerminalRenderer } from "./types.js";
2
+ export declare function createTerminalRenderer(): TerminalRenderer;
@@ -0,0 +1,18 @@
1
+ import { encodeAnsiPatch } from "./ansi.js";
2
+ import { diffFrames } from "./diff.js";
3
+ import { paintLayout } from "./paint.js";
4
+ export function createTerminalRenderer() {
5
+ let previousFrame = null;
6
+ return {
7
+ render(root, options) {
8
+ const nextFrame = paintLayout(root, options);
9
+ const patch = diffFrames(previousFrame, nextFrame);
10
+ const ansi = encodeAnsiPatch(patch);
11
+ previousFrame = nextFrame;
12
+ return ansi;
13
+ },
14
+ reset() {
15
+ previousFrame = null;
16
+ }
17
+ };
18
+ }
@@ -0,0 +1,9 @@
1
+ import type { CellStyle } from "./types.js";
2
+ export interface PaintStyle extends CellStyle {
3
+ border?: boolean | number;
4
+ borderColor?: string;
5
+ focusStyle?: "inverse" | "none";
6
+ }
7
+ export declare function readPaintStyle(props: Record<string, unknown>): PaintStyle;
8
+ export declare function toCellStyle(style: PaintStyle): CellStyle;
9
+ export declare function toBorderCellStyle(style: PaintStyle): CellStyle;
package/dist/style.js ADDED
@@ -0,0 +1,87 @@
1
+ const kebabStyleAliases = new Map([
2
+ ["border-color", "borderColor"]
3
+ ]);
4
+ export function readPaintStyle(props) {
5
+ const normalized = normalizePaintProps(props);
6
+ const style = {};
7
+ const color = readString(normalized, "color");
8
+ const foreground = readString(normalized, "foreground");
9
+ if (color !== undefined && foreground !== undefined) {
10
+ throw new Error("Duplicate paint prop: foreground / color");
11
+ }
12
+ if (color !== undefined) {
13
+ style.foreground = color;
14
+ }
15
+ if (foreground !== undefined) {
16
+ style.foreground = foreground;
17
+ }
18
+ setStringStyle(style, normalized, "background");
19
+ setStringStyle(style, normalized, "borderColor");
20
+ setBooleanStyle(style, normalized, "bold");
21
+ setBooleanStyle(style, normalized, "dim");
22
+ setBooleanStyle(style, normalized, "italic");
23
+ setBooleanStyle(style, normalized, "underline");
24
+ setBooleanStyle(style, normalized, "inverse");
25
+ const focusStyle = normalized.focusStyle;
26
+ if (focusStyle === "inverse" || focusStyle === "none") {
27
+ style.focusStyle = focusStyle;
28
+ }
29
+ const border = normalized.border;
30
+ if (typeof border === "boolean" || typeof border === "number") {
31
+ style.border = border;
32
+ }
33
+ return style;
34
+ }
35
+ export function toCellStyle(style) {
36
+ const cellStyle = {};
37
+ setDefinedString(cellStyle, "foreground", style.foreground);
38
+ setDefinedString(cellStyle, "background", style.background);
39
+ setTrueBoolean(cellStyle, "bold", style.bold);
40
+ setTrueBoolean(cellStyle, "dim", style.dim);
41
+ setTrueBoolean(cellStyle, "italic", style.italic);
42
+ setTrueBoolean(cellStyle, "underline", style.underline);
43
+ setTrueBoolean(cellStyle, "inverse", style.inverse);
44
+ return cellStyle;
45
+ }
46
+ export function toBorderCellStyle(style) {
47
+ return {
48
+ ...toCellStyle(style),
49
+ ...(style.borderColor === undefined ? {} : { foreground: style.borderColor })
50
+ };
51
+ }
52
+ function normalizePaintProps(props) {
53
+ const normalized = {};
54
+ for (const [name, value] of Object.entries(props)) {
55
+ const canonicalName = kebabStyleAliases.get(name) ?? name;
56
+ if (canonicalName in normalized && canonicalName !== name) {
57
+ throw new Error(`Duplicate paint prop: ${canonicalName} / ${name}`);
58
+ }
59
+ normalized[canonicalName] = value;
60
+ }
61
+ return normalized;
62
+ }
63
+ function readString(props, name) {
64
+ const value = props[name];
65
+ return typeof value === "string" ? value : undefined;
66
+ }
67
+ function setStringStyle(style, props, name) {
68
+ const value = readString(props, name);
69
+ if (value !== undefined) {
70
+ style[name] = value;
71
+ }
72
+ }
73
+ function setBooleanStyle(style, props, name) {
74
+ if (props[name] === true) {
75
+ style[name] = true;
76
+ }
77
+ }
78
+ function setDefinedString(style, name, value) {
79
+ if (value !== undefined) {
80
+ style[name] = value;
81
+ }
82
+ }
83
+ function setTrueBoolean(style, name, value) {
84
+ if (value === true) {
85
+ style[name] = true;
86
+ }
87
+ }
@@ -0,0 +1,40 @@
1
+ export interface Frame {
2
+ width: number;
3
+ height: number;
4
+ cells: Cell[];
5
+ }
6
+ export interface Cell {
7
+ char: string;
8
+ style: CellStyle;
9
+ width?: 0 | 1 | 2;
10
+ }
11
+ export interface CellStyle {
12
+ foreground?: string;
13
+ background?: string;
14
+ bold?: boolean;
15
+ dim?: boolean;
16
+ italic?: boolean;
17
+ underline?: boolean;
18
+ inverse?: boolean;
19
+ }
20
+ export interface FramePatch {
21
+ width: number;
22
+ height: number;
23
+ changes: CellChange[];
24
+ }
25
+ export interface CellChange {
26
+ x: number;
27
+ y: number;
28
+ cell: Cell;
29
+ }
30
+ export interface RenderOptions {
31
+ viewport: {
32
+ width: number;
33
+ height: number;
34
+ };
35
+ isFocused?: (mounted: import("@bindtty/vnode").MountedNode) => boolean;
36
+ }
37
+ export interface TerminalRenderer {
38
+ render(root: import("@bindtty/layout").LayoutNode | null, options: RenderOptions): string;
39
+ reset(): void;
40
+ }
package/dist/types.js ADDED
@@ -0,0 +1 @@
1
+ export {};
package/package.json ADDED
@@ -0,0 +1,49 @@
1
+ {
2
+ "name": "@bindtty/renderer-terminal",
3
+ "version": "0.1.0-alpha.1",
4
+ "type": "module",
5
+ "description": "Terminal renderer package for BindTTY.",
6
+ "main": "./dist/index.js",
7
+ "types": "./dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./dist/index.d.ts",
11
+ "import": "./dist/index.js"
12
+ }
13
+ },
14
+ "files": [
15
+ "dist",
16
+ "README.md"
17
+ ],
18
+ "scripts": {
19
+ "build": "tsc -p tsconfig.json",
20
+ "prepublishOnly": "npm run build",
21
+ "test": "npm run build --workspace @bindtty/text && npm run build --workspace @bindtty/layout && npm run build && tsc -p test/tsconfig.json && node --test test/dist/*.test.js"
22
+ },
23
+ "dependencies": {
24
+ "@bindtty/layout": "0.1.0-alpha.1",
25
+ "@bindtty/text": "0.1.0-alpha.1"
26
+ },
27
+ "devDependencies": {
28
+ "@bindtty/signal": "0.1.0-alpha.1",
29
+ "@bindtty/vnode": "0.1.0-alpha.1",
30
+ "@types/node": "^22.0.0",
31
+ "typescript": "^5.5.0"
32
+ },
33
+ "engines": {
34
+ "node": ">=18"
35
+ },
36
+ "publishConfig": {
37
+ "access": "public"
38
+ },
39
+ "license": "MIT",
40
+ "repository": {
41
+ "type": "git",
42
+ "url": "git+https://github.com/lithdoo/BindTTY.git",
43
+ "directory": "packages/renderer-terminal"
44
+ },
45
+ "bugs": {
46
+ "url": "https://github.com/lithdoo/BindTTY/issues"
47
+ },
48
+ "homepage": "https://github.com/lithdoo/BindTTY/tree/main/packages/renderer-terminal#readme"
49
+ }