@zakmandhro/bunti 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.
- package/LICENSE +21 -0
- package/README.md +104 -0
- package/package.json +54 -0
- package/src/colors.ts +255 -0
- package/src/components/Button.ts +104 -0
- package/src/components/Card.ts +53 -0
- package/src/components/Header.ts +65 -0
- package/src/components/Input.ts +124 -0
- package/src/components/index.ts +4 -0
- package/src/data/glyphs.ts +30 -0
- package/src/detect.ts +60 -0
- package/src/dsl.ts +661 -0
- package/src/icons.ts +186 -0
- package/src/index.ts +72 -0
- package/src/layout.ts +639 -0
- package/src/render.ts +302 -0
- package/src/state.ts +148 -0
- package/src/utils.ts +165 -0
package/src/render.ts
ADDED
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bunti Functional Rendering & Diffing
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { ANSI, type ScreenState } from './state';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Diffs back and front buffers surgically.
|
|
9
|
+
*/
|
|
10
|
+
export function flush(state: ScreenState) {
|
|
11
|
+
const writer = Bun.stdout.writer();
|
|
12
|
+
let renderString = '';
|
|
13
|
+
let lastFg: any = state.lastFg;
|
|
14
|
+
let lastBg: any = state.lastBg;
|
|
15
|
+
let lastBold: boolean = state.lastBold ?? false;
|
|
16
|
+
|
|
17
|
+
const width = state.width;
|
|
18
|
+
const height = state.height;
|
|
19
|
+
|
|
20
|
+
for (let y = 0; y < height; y++) {
|
|
21
|
+
let firstDirty = -1;
|
|
22
|
+
let lastDirty = -1;
|
|
23
|
+
const rowOffset = y * width;
|
|
24
|
+
|
|
25
|
+
for (let x = 0; x < width; x++) {
|
|
26
|
+
const idx = rowOffset + x;
|
|
27
|
+
const b = state.backBuffer[idx];
|
|
28
|
+
const f = state.frontBuffer[idx];
|
|
29
|
+
if (
|
|
30
|
+
b.char !== f.char ||
|
|
31
|
+
b.fg !== f.fg ||
|
|
32
|
+
b.bg !== f.bg ||
|
|
33
|
+
!!b.bold !== !!f.bold
|
|
34
|
+
) {
|
|
35
|
+
if (firstDirty === -1) firstDirty = x;
|
|
36
|
+
lastDirty = x;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (firstDirty !== -1) {
|
|
41
|
+
renderString += `\x1b[${y + 1};${firstDirty + 1}H`;
|
|
42
|
+
|
|
43
|
+
for (let x = firstDirty; x <= lastDirty; x++) {
|
|
44
|
+
const idx = rowOffset + x;
|
|
45
|
+
const cell = state.backBuffer[idx];
|
|
46
|
+
const front = state.frontBuffer[idx];
|
|
47
|
+
|
|
48
|
+
if (cell.char === '') {
|
|
49
|
+
front.char = '';
|
|
50
|
+
front.fg = cell.fg;
|
|
51
|
+
front.bg = cell.bg;
|
|
52
|
+
front.fgCode = cell.fgCode;
|
|
53
|
+
front.bgCode = cell.bgCode;
|
|
54
|
+
front.bold = cell.bold;
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const fg = cell.fgCode;
|
|
59
|
+
const bg = cell.bgCode;
|
|
60
|
+
const bold = !!cell.bold;
|
|
61
|
+
|
|
62
|
+
if (bold !== lastBold) {
|
|
63
|
+
renderString += bold ? '\x1b[1m' : '\x1b[22m';
|
|
64
|
+
lastBold = bold;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (fg !== lastFg || bg !== lastBg) {
|
|
68
|
+
if (fg === undefined && bg === undefined) {
|
|
69
|
+
renderString += '\x1b[0m';
|
|
70
|
+
if (lastBold) {
|
|
71
|
+
renderString += '\x1b[1m';
|
|
72
|
+
}
|
|
73
|
+
} else {
|
|
74
|
+
// Foreground
|
|
75
|
+
if (fg !== lastFg) {
|
|
76
|
+
if (fg === undefined) {
|
|
77
|
+
renderString += '\x1b[39m';
|
|
78
|
+
} else {
|
|
79
|
+
const fgStr = String(fg);
|
|
80
|
+
if (fgStr.startsWith('2;')) {
|
|
81
|
+
renderString += `\x1b[38;${fgStr}m`;
|
|
82
|
+
} else {
|
|
83
|
+
renderString += `\x1b[38;5;${fgStr}m`;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
// Background
|
|
88
|
+
if (bg !== lastBg) {
|
|
89
|
+
if (bg === undefined) {
|
|
90
|
+
renderString += '\x1b[49m';
|
|
91
|
+
} else {
|
|
92
|
+
const bgStr = String(bg);
|
|
93
|
+
if (bgStr.startsWith('2;')) {
|
|
94
|
+
renderString += `\x1b[48;${bgStr}m`;
|
|
95
|
+
} else {
|
|
96
|
+
renderString += `\x1b[48;5;${bgStr}m`;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
lastFg = fg;
|
|
102
|
+
lastBg = bg;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
renderString += cell.char;
|
|
106
|
+
front.char = cell.char;
|
|
107
|
+
front.fg = cell.fg;
|
|
108
|
+
front.bg = cell.bg;
|
|
109
|
+
front.fgCode = cell.fgCode;
|
|
110
|
+
front.bgCode = cell.bgCode;
|
|
111
|
+
front.bold = cell.bold;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (renderString) {
|
|
117
|
+
writer.write(
|
|
118
|
+
ANSI.syncStart + ANSI.hideCursor + renderString + ANSI.syncEnd,
|
|
119
|
+
);
|
|
120
|
+
writer.flush();
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
state.lastFg = lastFg;
|
|
124
|
+
state.lastBg = lastBg;
|
|
125
|
+
state.lastBold = lastBold;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Starts the render loop for a given screen state.
|
|
130
|
+
*/
|
|
131
|
+
export function loop(
|
|
132
|
+
state: ScreenState,
|
|
133
|
+
renderCallback: (s: ScreenState) => void,
|
|
134
|
+
): Promise<void> {
|
|
135
|
+
const options = state.options;
|
|
136
|
+
|
|
137
|
+
if (options.alternateBuffer) process.stdout.write(ANSI.alternateBuffer);
|
|
138
|
+
if (options.hideCursor) process.stdout.write(ANSI.hideCursor);
|
|
139
|
+
|
|
140
|
+
return new Promise((resolve) => {
|
|
141
|
+
let stopped = false;
|
|
142
|
+
let interval: ReturnType<typeof setInterval> | null = null;
|
|
143
|
+
|
|
144
|
+
const setupInput = () => {
|
|
145
|
+
let cmd = '';
|
|
146
|
+
if (options.mouse) cmd += ANSI.mouseEnable;
|
|
147
|
+
if (options.focus) cmd += ANSI.focusEnable;
|
|
148
|
+
if (cmd) process.stdout.write(cmd);
|
|
149
|
+
|
|
150
|
+
if (process.stdin.isTTY) {
|
|
151
|
+
process.stdin.setRawMode(true);
|
|
152
|
+
process.stdin.resume();
|
|
153
|
+
process.stdin.on('data', inputHandler);
|
|
154
|
+
}
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
const inputHandler = (data: unknown) => handleInput(state, data, stop);
|
|
158
|
+
|
|
159
|
+
const stop = () => {
|
|
160
|
+
if (stopped) return;
|
|
161
|
+
stopped = true;
|
|
162
|
+
|
|
163
|
+
if (interval) clearInterval(interval);
|
|
164
|
+
|
|
165
|
+
// 1. Restore Terminal Modes
|
|
166
|
+
if (process.stdin.isTTY) {
|
|
167
|
+
process.stdin.setRawMode(false);
|
|
168
|
+
process.stdin.pause();
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// 2. Remove Listeners
|
|
172
|
+
process.stdin.removeListener('data', inputHandler);
|
|
173
|
+
process.removeListener('SIGWINCH', resizeHandler);
|
|
174
|
+
process.removeListener('SIGINT', stop);
|
|
175
|
+
process.removeListener('SIGTERM', stop);
|
|
176
|
+
|
|
177
|
+
// 3. Emit Restorative ANSI Sequence
|
|
178
|
+
let cmd = '';
|
|
179
|
+
if (options.mouse) cmd += ANSI.mouseDisable;
|
|
180
|
+
if (options.focus) cmd += ANSI.focusDisable;
|
|
181
|
+
if (options.alternateBuffer) cmd += ANSI.mainBuffer;
|
|
182
|
+
if (options.hideCursor) cmd += ANSI.showCursor;
|
|
183
|
+
cmd += ANSI.reset;
|
|
184
|
+
|
|
185
|
+
process.stdout.write(cmd);
|
|
186
|
+
resolve();
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
const resizeHandler = () => {
|
|
190
|
+
resizeScreen(state);
|
|
191
|
+
tick();
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
let ticking = false;
|
|
195
|
+
let tickAgain = false;
|
|
196
|
+
|
|
197
|
+
const tick = () => {
|
|
198
|
+
if (ticking) {
|
|
199
|
+
tickAgain = true;
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
ticking = true;
|
|
204
|
+
try {
|
|
205
|
+
do {
|
|
206
|
+
tickAgain = false;
|
|
207
|
+
renderCallback(state);
|
|
208
|
+
flush(state);
|
|
209
|
+
state.lastKey = undefined;
|
|
210
|
+
} while (tickAgain);
|
|
211
|
+
} catch (err) {
|
|
212
|
+
console.error(err);
|
|
213
|
+
} finally {
|
|
214
|
+
ticking = false;
|
|
215
|
+
}
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
const requestTick = () => {
|
|
219
|
+
if (ticking) {
|
|
220
|
+
tickAgain = true;
|
|
221
|
+
} else {
|
|
222
|
+
tick();
|
|
223
|
+
}
|
|
224
|
+
};
|
|
225
|
+
|
|
226
|
+
const restartLoop = () => {
|
|
227
|
+
if (interval) clearInterval(interval);
|
|
228
|
+
const fps = state.hasFocus ? options.fps || 60 : 5;
|
|
229
|
+
interval = setInterval(tick, 1000 / fps);
|
|
230
|
+
requestTick();
|
|
231
|
+
};
|
|
232
|
+
|
|
233
|
+
if (options.mouse || options.focus || options.keyboard) setupInput();
|
|
234
|
+
|
|
235
|
+
process.on('SIGWINCH', resizeHandler);
|
|
236
|
+
process.on('SIGINT', stop);
|
|
237
|
+
process.on('SIGTERM', stop);
|
|
238
|
+
|
|
239
|
+
(state as { restartLoop?: () => void }).restartLoop = restartLoop;
|
|
240
|
+
(state as { requestTick?: () => void }).requestTick = requestTick;
|
|
241
|
+
(state as { requestStop?: () => void }).requestStop = stop;
|
|
242
|
+
restartLoop();
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function handleInput(state: ScreenState, data: any, stop: () => void) {
|
|
247
|
+
const input = data.toString();
|
|
248
|
+
|
|
249
|
+
// 1. Focus Tracking
|
|
250
|
+
if (input === '\x1b[I') {
|
|
251
|
+
state.hasFocus = true;
|
|
252
|
+
if ((state as any).restartLoop) (state as any).restartLoop();
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
if (input === '\x1b[O') {
|
|
256
|
+
state.hasFocus = false;
|
|
257
|
+
if ((state as any).restartLoop) (state as any).restartLoop();
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// 2. Mouse Tracking (SGR protocol)
|
|
262
|
+
const match = input.match(/\x1b\[<(\d+);(\d+);(\d+)([Mm])/);
|
|
263
|
+
if (match) {
|
|
264
|
+
state.mouseButton = parseInt(match[1], 10);
|
|
265
|
+
state.mouseX = parseInt(match[2], 10) - 1;
|
|
266
|
+
state.mouseY = parseInt(match[3], 10) - 1;
|
|
267
|
+
state.isMouseDown = match[4] === 'M';
|
|
268
|
+
if (match[4] === 'M') {
|
|
269
|
+
if (state.mouseButton === 64) state.lastKey = 'wheel_up';
|
|
270
|
+
else if (state.mouseButton === 65) state.lastKey = 'wheel_down';
|
|
271
|
+
else if (state.mouseButton === 0) state.lastKey = 'click';
|
|
272
|
+
if ((state as { requestTick?: () => void }).requestTick) {
|
|
273
|
+
(state as { requestTick?: () => void }).requestTick!();
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// 3. Signal Interception
|
|
280
|
+
if (input === '\u0003') {
|
|
281
|
+
stop(); // Ctrl+C
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// 4. Key Normalization
|
|
286
|
+
let key = input;
|
|
287
|
+
if (input === '\x7f' || input === '\x08') key = 'backspace';
|
|
288
|
+
else if (input === '\r' || input === '\n') key = 'enter';
|
|
289
|
+
else if (input === '\t') key = 'tab';
|
|
290
|
+
else if (input === '\x1b[A') key = 'up';
|
|
291
|
+
else if (input === '\x1b[B') key = 'down';
|
|
292
|
+
else if (input === '\x1b[C') key = 'right';
|
|
293
|
+
else if (input === '\x1b[D') key = 'left';
|
|
294
|
+
else if (input === '\x1b') key = 'escape';
|
|
295
|
+
|
|
296
|
+
state.lastKey = key;
|
|
297
|
+
if ((state as { requestTick?: () => void }).requestTick) {
|
|
298
|
+
(state as { requestTick?: () => void }).requestTick!();
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
import { resizeScreen } from './state';
|
package/src/state.ts
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bunti Functional Screen State & Initialization
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
export interface RGB {
|
|
6
|
+
r: number;
|
|
7
|
+
g: number;
|
|
8
|
+
b: number;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface Cell {
|
|
12
|
+
char: string;
|
|
13
|
+
fg?: string | number | RGB;
|
|
14
|
+
bg?: string | number | RGB;
|
|
15
|
+
bold?: boolean;
|
|
16
|
+
fgCode?: string | number;
|
|
17
|
+
bgCode?: string | number;
|
|
18
|
+
raw?: boolean; // Bypasses automatic emoji-to-NF replacement
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface ScreenState {
|
|
22
|
+
width: number;
|
|
23
|
+
height: number;
|
|
24
|
+
frontBuffer: Cell[];
|
|
25
|
+
backBuffer: Cell[];
|
|
26
|
+
mouseX: number;
|
|
27
|
+
mouseY: number;
|
|
28
|
+
mouseButton: number;
|
|
29
|
+
isMouseDown: boolean;
|
|
30
|
+
hasFocus: boolean;
|
|
31
|
+
lastKey?: string;
|
|
32
|
+
focusedId?: string;
|
|
33
|
+
focusableIds: string[];
|
|
34
|
+
componentState: Map<string, any>;
|
|
35
|
+
startTime: number;
|
|
36
|
+
lastFg?: any;
|
|
37
|
+
lastBg?: any;
|
|
38
|
+
lastBold?: boolean;
|
|
39
|
+
id?: string;
|
|
40
|
+
options: ScreenOptions;
|
|
41
|
+
requestStop?: () => void;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface ScreenOptions {
|
|
45
|
+
fps?: number;
|
|
46
|
+
mouse?: boolean;
|
|
47
|
+
focus?: boolean;
|
|
48
|
+
keyboard?: boolean;
|
|
49
|
+
alternateBuffer?: boolean;
|
|
50
|
+
hideCursor?: boolean;
|
|
51
|
+
nerdFont?: boolean;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Creates a fresh ScreenState based on the terminal dimensions and options.
|
|
56
|
+
*/
|
|
57
|
+
export function createScreenState(options: ScreenOptions = {}): ScreenState {
|
|
58
|
+
const width = process.stdout.columns || 80;
|
|
59
|
+
const height = process.stdout.rows || 24;
|
|
60
|
+
const size = width * height;
|
|
61
|
+
|
|
62
|
+
const state: ScreenState = {
|
|
63
|
+
width,
|
|
64
|
+
height,
|
|
65
|
+
frontBuffer: Array.from({ length: size }, () => ({
|
|
66
|
+
char: '',
|
|
67
|
+
fg: undefined,
|
|
68
|
+
bg: undefined,
|
|
69
|
+
fgCode: undefined,
|
|
70
|
+
bgCode: undefined,
|
|
71
|
+
})),
|
|
72
|
+
backBuffer: Array.from({ length: size }, () => ({
|
|
73
|
+
char: ' ',
|
|
74
|
+
fg: undefined,
|
|
75
|
+
bg: undefined,
|
|
76
|
+
fgCode: undefined,
|
|
77
|
+
bgCode: undefined,
|
|
78
|
+
})),
|
|
79
|
+
mouseX: 0,
|
|
80
|
+
mouseY: 0,
|
|
81
|
+
mouseButton: 0,
|
|
82
|
+
isMouseDown: false,
|
|
83
|
+
hasFocus: true,
|
|
84
|
+
focusableIds: [],
|
|
85
|
+
componentState: new Map(),
|
|
86
|
+
startTime: Date.now(),
|
|
87
|
+
options,
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
return state;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Resizes the front/back buffers to match the new terminal size.
|
|
95
|
+
*/
|
|
96
|
+
export function resizeScreen(state: ScreenState) {
|
|
97
|
+
const width = process.stdout.columns || 80;
|
|
98
|
+
const height = process.stdout.rows || 24;
|
|
99
|
+
const size = width * height;
|
|
100
|
+
|
|
101
|
+
state.width = width;
|
|
102
|
+
state.height = height;
|
|
103
|
+
state.frontBuffer = Array.from({ length: size }, () => ({
|
|
104
|
+
char: '',
|
|
105
|
+
fg: undefined,
|
|
106
|
+
bg: undefined,
|
|
107
|
+
fgCode: undefined,
|
|
108
|
+
bgCode: undefined,
|
|
109
|
+
}));
|
|
110
|
+
state.backBuffer = Array.from({ length: size }, () => ({
|
|
111
|
+
char: ' ',
|
|
112
|
+
fg: undefined,
|
|
113
|
+
bg: undefined,
|
|
114
|
+
fgCode: undefined,
|
|
115
|
+
bgCode: undefined,
|
|
116
|
+
}));
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Clears the back buffer to the base wallpaper or a given cell style.
|
|
121
|
+
*/
|
|
122
|
+
export function clearBackBuffer(state: ScreenState) {
|
|
123
|
+
for (let i = 0; i < state.backBuffer.length; i++) {
|
|
124
|
+
const cell = state.backBuffer[i];
|
|
125
|
+
cell.char = ' ';
|
|
126
|
+
cell.fg = undefined;
|
|
127
|
+
cell.bg = undefined;
|
|
128
|
+
cell.fgCode = undefined;
|
|
129
|
+
cell.bgCode = undefined;
|
|
130
|
+
cell.bold = false;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export const ANSI = {
|
|
135
|
+
reset: '\x1b[0m',
|
|
136
|
+
clear: '\x1b[2J',
|
|
137
|
+
home: '\x1b[H',
|
|
138
|
+
alternateBuffer: '\x1b[?1049h',
|
|
139
|
+
mainBuffer: '\x1b[?1049l',
|
|
140
|
+
hideCursor: '\x1b[?25l',
|
|
141
|
+
showCursor: '\x1b[?25h',
|
|
142
|
+
mouseEnable: '\x1b[?1003h\x1b[?1006h',
|
|
143
|
+
mouseDisable: '\x1b[?1003l\x1b[?1006l',
|
|
144
|
+
focusEnable: '\x1b[?1004h',
|
|
145
|
+
focusDisable: '\x1b[?1004l',
|
|
146
|
+
syncStart: '\x1b[?2026h',
|
|
147
|
+
syncEnd: '\x1b[?2026l',
|
|
148
|
+
};
|
package/src/utils.ts
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bunti Utility Suite - Bun-Native High Performance Edition
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Strips ANSI escape sequences from a string to allow accurate width measurement.
|
|
7
|
+
*/
|
|
8
|
+
export function stripAnsi(str: string): string {
|
|
9
|
+
if (!str) return '';
|
|
10
|
+
return str.replace(/\x1B\[[0-9;]*[a-zA-Z]/g, '');
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Calculates the visible width of a string using Bun's SIMD-optimized API.
|
|
15
|
+
*/
|
|
16
|
+
export function visibleWidth(str: string): number {
|
|
17
|
+
if (!str) return 0;
|
|
18
|
+
const lines = str.split('\n');
|
|
19
|
+
if (lines.length > 1) {
|
|
20
|
+
return Math.max(...lines.map(visibleWidth));
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
return Bun.stringWidth(stripAnsi(str));
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Returns the width of a single character or grapheme.
|
|
28
|
+
*/
|
|
29
|
+
export function charWidth(char: string): number {
|
|
30
|
+
const clean = stripAnsi(char);
|
|
31
|
+
if (clean.length === 0) return 0;
|
|
32
|
+
return Bun.stringWidth(clean);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Truncates a string to a visible width while preserving ANSI codes.
|
|
37
|
+
* GUARANTEE: Never slices an ANSI escape sequence in half.
|
|
38
|
+
*/
|
|
39
|
+
export function truncate(str: string, width: number, tail = '…'): string {
|
|
40
|
+
const visible = visibleWidth(str);
|
|
41
|
+
if (visible <= width) return str;
|
|
42
|
+
|
|
43
|
+
const targetWidth = width - visibleWidth(tail);
|
|
44
|
+
if (targetWidth <= 0) return '';
|
|
45
|
+
|
|
46
|
+
let currentWidth = 0;
|
|
47
|
+
let out = '';
|
|
48
|
+
|
|
49
|
+
// Custom parsing loop to isolate ANSI sequences from text
|
|
50
|
+
const regex = /\x1B\[[0-9;]*[a-zA-Z]/g;
|
|
51
|
+
let lastIndex = 0;
|
|
52
|
+
let match: RegExpExecArray | null = null;
|
|
53
|
+
|
|
54
|
+
while ((match = regex.exec(str)) !== null) {
|
|
55
|
+
// 1. Process text before the ANSI code
|
|
56
|
+
const textSegment = str.substring(lastIndex, match.index);
|
|
57
|
+
if (textSegment.length > 0) {
|
|
58
|
+
const segmenter = new Intl.Segmenter('en', { granularity: 'grapheme' });
|
|
59
|
+
for (const { segment } of segmenter.segment(textSegment)) {
|
|
60
|
+
const w = charWidth(segment);
|
|
61
|
+
if (currentWidth + w > targetWidth) {
|
|
62
|
+
return `${out}\x1b[0m${tail}`; // Return immediately upon hitting limit
|
|
63
|
+
}
|
|
64
|
+
out += segment;
|
|
65
|
+
currentWidth += w;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// 2. Append the ANSI code itself (zero width)
|
|
70
|
+
out += match[0];
|
|
71
|
+
lastIndex = regex.lastIndex;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// 3. Process remaining text after the last ANSI code
|
|
75
|
+
const remainingText = str.substring(lastIndex);
|
|
76
|
+
if (remainingText.length > 0) {
|
|
77
|
+
const segmenter = new Intl.Segmenter('en', { granularity: 'grapheme' });
|
|
78
|
+
for (const { segment } of segmenter.segment(remainingText)) {
|
|
79
|
+
const w = charWidth(segment);
|
|
80
|
+
if (currentWidth + w > targetWidth) {
|
|
81
|
+
return `${out}\x1b[0m${tail}`;
|
|
82
|
+
}
|
|
83
|
+
out += segment;
|
|
84
|
+
currentWidth += w;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return `${out}\x1b[0m`;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Wraps text to a specific visible width, preserving ANSI codes.
|
|
93
|
+
* Implements word-based wrapping with fallback to char-breaking for long tokens.
|
|
94
|
+
*/
|
|
95
|
+
export function wrapText(str: string, width: number): string[] {
|
|
96
|
+
if (width <= 0) return [str];
|
|
97
|
+
const result: string[] = [];
|
|
98
|
+
const rawLines = str.split('\n');
|
|
99
|
+
|
|
100
|
+
for (const rawLine of rawLines) {
|
|
101
|
+
if (visibleWidth(rawLine) <= width) {
|
|
102
|
+
result.push(rawLine);
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Split into tokens: words and whitespaces
|
|
107
|
+
const tokens = rawLine.split(/(\s+)/).filter((t) => t.length > 0);
|
|
108
|
+
let currentLine = '';
|
|
109
|
+
let currentWidth = 0;
|
|
110
|
+
|
|
111
|
+
for (const token of tokens) {
|
|
112
|
+
const tokenWidth = visibleWidth(token);
|
|
113
|
+
const isWhitespace = /^\s+$/.test(stripAnsi(token));
|
|
114
|
+
|
|
115
|
+
// If token itself is too wide, we MUST break it by character
|
|
116
|
+
if (tokenWidth > width) {
|
|
117
|
+
// Flush current line if it exists
|
|
118
|
+
if (currentLine) {
|
|
119
|
+
result.push(`${currentLine}\x1b[0m`);
|
|
120
|
+
currentLine = '';
|
|
121
|
+
currentWidth = 0;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Segmented break of the long token
|
|
125
|
+
const segmenter = new Intl.Segmenter('en', { granularity: 'grapheme' });
|
|
126
|
+
for (const { segment } of segmenter.segment(token)) {
|
|
127
|
+
const sw = charWidth(segment);
|
|
128
|
+
if (currentWidth + sw > width) {
|
|
129
|
+
result.push(`${currentLine}\x1b[0m`);
|
|
130
|
+
currentLine = segment;
|
|
131
|
+
currentWidth = sw;
|
|
132
|
+
} else {
|
|
133
|
+
currentLine += segment;
|
|
134
|
+
currentWidth += sw;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Normal word wrap
|
|
141
|
+
if (currentWidth + tokenWidth > width) {
|
|
142
|
+
if (isWhitespace) {
|
|
143
|
+
// Swallow leading whitespace on new lines
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
result.push(`${currentLine.trimEnd()}\x1b[0m`);
|
|
147
|
+
currentLine = token;
|
|
148
|
+
currentWidth = tokenWidth;
|
|
149
|
+
} else {
|
|
150
|
+
currentLine += token;
|
|
151
|
+
currentWidth += tokenWidth;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
if (currentLine) result.push(currentLine.trimEnd());
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
return result;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Identity function.
|
|
162
|
+
*/
|
|
163
|
+
export function replaceEmojis(str: string): string {
|
|
164
|
+
return str;
|
|
165
|
+
}
|