tinky 1.6.0 → 1.7.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/lib/core/cell-buffer.d.ts +7 -5
- package/lib/core/cell-buffer.js +68 -10
- package/lib/core/cell-log-update-run.d.ts +2 -1
- package/lib/core/cell-log-update-run.js +44 -36
- package/lib/core/cell-output.d.ts +7 -0
- package/lib/core/cell-output.js +126 -2
- package/lib/core/output.d.ts +3 -0
- package/lib/core/output.js +12 -0
- package/lib/core/render-border.d.ts +0 -10
- package/lib/core/render-border.js +78 -69
- package/lib/core/render.d.ts +1 -0
- package/lib/core/renderer.js +9 -6
- package/lib/core/tinky.d.ts +0 -1
- package/lib/core/tinky.js +4 -6
- package/lib/types/ansi.d.ts +5 -0
- package/lib/types/ansi.js +1 -0
- package/lib/utils/colorize.d.ts +5 -0
- package/lib/utils/colorize.js +42 -20
- package/lib/utils/render-background.js +4 -3
- package/lib/utils/squash-text-nodes.d.ts +5 -0
- package/lib/utils/squash-text-nodes.js +18 -2
- package/package.json +3 -1
|
@@ -4,11 +4,7 @@
|
|
|
4
4
|
* This stores terminal cells in compact parallel arrays and keeps ANSI styles
|
|
5
5
|
* as small numeric IDs via StyleRegistry.
|
|
6
6
|
*/
|
|
7
|
-
|
|
8
|
-
type: "ansi";
|
|
9
|
-
code: string;
|
|
10
|
-
endCode: string;
|
|
11
|
-
}
|
|
7
|
+
import { type AnsiCode } from "../types/ansi.js";
|
|
12
8
|
export interface CellBufferSize {
|
|
13
9
|
width: number;
|
|
14
10
|
height: number;
|
|
@@ -45,6 +41,11 @@ export declare class CellBuffer {
|
|
|
45
41
|
* Marks whether trailing spaces on a touched cell should be preserved.
|
|
46
42
|
*/
|
|
47
43
|
preserveTrailingSpace: Uint8Array;
|
|
44
|
+
/**
|
|
45
|
+
* Marks whether a row has been touched by any write operation.
|
|
46
|
+
* Used to optimize isRowEqual/diffing.
|
|
47
|
+
*/
|
|
48
|
+
rowTouched: Uint8Array;
|
|
48
49
|
constructor(size?: CellBufferSize, styleRegistry?: StyleRegistry);
|
|
49
50
|
resize(width: number, height: number): void;
|
|
50
51
|
clear(styleId?: number): void;
|
|
@@ -53,6 +54,7 @@ export declare class CellBuffer {
|
|
|
53
54
|
*/
|
|
54
55
|
index(x: number, y: number): number;
|
|
55
56
|
setCell(x: number, y: number, ch: string, charWidth: number, styleId: number, preserveTrailing?: boolean): void;
|
|
57
|
+
fill(x: number, y: number, length: number, char: string, charWidth: number, styleId: number): void;
|
|
56
58
|
isRowEqual(other: CellBuffer, row: number): boolean;
|
|
57
59
|
isEqual(other: CellBuffer): boolean;
|
|
58
60
|
/**
|
package/lib/core/cell-buffer.js
CHANGED
|
@@ -69,6 +69,11 @@ export class CellBuffer {
|
|
|
69
69
|
* Marks whether trailing spaces on a touched cell should be preserved.
|
|
70
70
|
*/
|
|
71
71
|
preserveTrailingSpace = new Uint8Array(0);
|
|
72
|
+
/**
|
|
73
|
+
* Marks whether a row has been touched by any write operation.
|
|
74
|
+
* Used to optimize isRowEqual/diffing.
|
|
75
|
+
*/
|
|
76
|
+
rowTouched = new Uint8Array(0);
|
|
72
77
|
constructor(size = { width: 0, height: 0 }, styleRegistry) {
|
|
73
78
|
this.styleRegistry = styleRegistry ?? new StyleRegistry();
|
|
74
79
|
this.resize(size.width, size.height);
|
|
@@ -88,6 +93,7 @@ export class CellBuffer {
|
|
|
88
93
|
this.styleIds = new Uint32Array(size);
|
|
89
94
|
this.touched = new Uint8Array(size);
|
|
90
95
|
this.preserveTrailingSpace = new Uint8Array(size);
|
|
96
|
+
this.rowTouched = new Uint8Array(nextHeight);
|
|
91
97
|
}
|
|
92
98
|
clear(styleId = 0) {
|
|
93
99
|
const size = this.width * this.height;
|
|
@@ -96,6 +102,13 @@ export class CellBuffer {
|
|
|
96
102
|
this.styleIds.fill(styleId, 0, size);
|
|
97
103
|
this.touched.fill(0, 0, size);
|
|
98
104
|
this.preserveTrailingSpace.fill(0, 0, size);
|
|
105
|
+
// Only rows with default style (0) are considered "untouched" for optimization
|
|
106
|
+
if (styleId === 0) {
|
|
107
|
+
this.rowTouched.fill(0);
|
|
108
|
+
}
|
|
109
|
+
else {
|
|
110
|
+
this.rowTouched.fill(1);
|
|
111
|
+
}
|
|
99
112
|
}
|
|
100
113
|
/**
|
|
101
114
|
* Converts `(x, y)` coordinates to a linear index in parallel arrays.
|
|
@@ -117,6 +130,7 @@ export class CellBuffer {
|
|
|
117
130
|
this.styleIds[cellIndex] = styleId;
|
|
118
131
|
this.touched[cellIndex] = 1;
|
|
119
132
|
this.preserveTrailingSpace[cellIndex] = preserveTrailing ? 1 : 0;
|
|
133
|
+
this.rowTouched[y] = 1;
|
|
120
134
|
if (width > 1) {
|
|
121
135
|
for (let i = 1; i < width; i++) {
|
|
122
136
|
const continuationIndex = cellIndex + i;
|
|
@@ -130,6 +144,33 @@ export class CellBuffer {
|
|
|
130
144
|
}
|
|
131
145
|
}
|
|
132
146
|
}
|
|
147
|
+
fill(x, y, length, char, charWidth, styleId) {
|
|
148
|
+
if (y < 0 || y >= this.height || length <= 0) {
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
const startX = Math.max(0, x);
|
|
152
|
+
const endX = Math.min(this.width, x + length);
|
|
153
|
+
const fillLength = endX - startX;
|
|
154
|
+
if (fillLength <= 0) {
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
const start = this.index(startX, y);
|
|
158
|
+
// Optimized path for single-width characters (common for background/borders)
|
|
159
|
+
if (charWidth === 1) {
|
|
160
|
+
const end = start + fillLength;
|
|
161
|
+
this.chars.fill(char, start, end);
|
|
162
|
+
this.widths.fill(1, start, end);
|
|
163
|
+
this.styleIds.fill(styleId, start, end);
|
|
164
|
+
this.touched.fill(1, start, end);
|
|
165
|
+
this.preserveTrailingSpace.fill(0, start, end);
|
|
166
|
+
this.rowTouched[y] = 1;
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
// Fallback for multi-width characters
|
|
170
|
+
for (let i = 0; i < fillLength; i += charWidth) {
|
|
171
|
+
this.setCell(startX + i, y, char, charWidth, styleId, false);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
133
174
|
isRowEqual(other, row) {
|
|
134
175
|
if (this.width !== other.width || this.height !== other.height) {
|
|
135
176
|
return false;
|
|
@@ -137,8 +178,20 @@ export class CellBuffer {
|
|
|
137
178
|
if (row < 0 || row >= this.height) {
|
|
138
179
|
return false;
|
|
139
180
|
}
|
|
181
|
+
// Optimization: if both rows are untouched (empty/default), they are equal
|
|
182
|
+
if (!this.rowTouched[row] && !other.rowTouched[row]) {
|
|
183
|
+
return true;
|
|
184
|
+
}
|
|
185
|
+
const thisRightEdge = this.getRowRightEdge(row);
|
|
186
|
+
const otherRightEdge = other.getRowRightEdge(row);
|
|
187
|
+
if (thisRightEdge !== otherRightEdge) {
|
|
188
|
+
return false;
|
|
189
|
+
}
|
|
190
|
+
if (thisRightEdge === 0) {
|
|
191
|
+
return true;
|
|
192
|
+
}
|
|
140
193
|
const start = row * this.width;
|
|
141
|
-
const end = start +
|
|
194
|
+
const end = start + thisRightEdge;
|
|
142
195
|
for (let index = start; index < end; index++) {
|
|
143
196
|
if (this.styleIds[index] !== other.styleIds[index]) {
|
|
144
197
|
return false;
|
|
@@ -149,12 +202,6 @@ export class CellBuffer {
|
|
|
149
202
|
if (this.chars[index] !== other.chars[index]) {
|
|
150
203
|
return false;
|
|
151
204
|
}
|
|
152
|
-
if (this.touched[index] !== other.touched[index]) {
|
|
153
|
-
return false;
|
|
154
|
-
}
|
|
155
|
-
if (this.preserveTrailingSpace[index] !== other.preserveTrailingSpace[index]) {
|
|
156
|
-
return false;
|
|
157
|
-
}
|
|
158
205
|
}
|
|
159
206
|
return true;
|
|
160
207
|
}
|
|
@@ -180,6 +227,9 @@ export class CellBuffer {
|
|
|
180
227
|
if (row < 0 || row >= this.height) {
|
|
181
228
|
return 0;
|
|
182
229
|
}
|
|
230
|
+
if (!this.rowTouched[row]) {
|
|
231
|
+
return 0;
|
|
232
|
+
}
|
|
183
233
|
const offset = row * width;
|
|
184
234
|
for (let x = width - 1; x >= 0; x--) {
|
|
185
235
|
const index = offset + x;
|
|
@@ -279,10 +329,18 @@ export class CellBuffer {
|
|
|
279
329
|
* Serializes the entire buffer into a multi-line ANSI string.
|
|
280
330
|
*/
|
|
281
331
|
toString() {
|
|
282
|
-
const
|
|
332
|
+
const out = [];
|
|
283
333
|
for (let row = 0; row < this.height; row++) {
|
|
284
|
-
|
|
334
|
+
if (row > 0) {
|
|
335
|
+
out.push("\n");
|
|
336
|
+
}
|
|
337
|
+
const rightEdge = this.getRowRightEdge(row);
|
|
338
|
+
if (rightEdge === 0) {
|
|
339
|
+
continue;
|
|
340
|
+
}
|
|
341
|
+
const { activeStyleId } = this.appendStyledRange(row, 0, rightEdge, out, 0, 0);
|
|
342
|
+
this.appendCloseActiveStyle(out, activeStyleId);
|
|
285
343
|
}
|
|
286
|
-
return
|
|
344
|
+
return out.join("");
|
|
287
345
|
}
|
|
288
346
|
}
|
|
@@ -4,7 +4,8 @@ import { type CellBuffer } from "./cell-buffer.js";
|
|
|
4
4
|
* Run-diff renderer for interactive terminal output.
|
|
5
5
|
*
|
|
6
6
|
* Compared with line-diff mode, this renderer diffs cell buffers and emits
|
|
7
|
-
* minimal cursor movement + text writes for changed runs.
|
|
7
|
+
* minimal cursor movement + text writes for changed runs. It entirely skips
|
|
8
|
+
* terminal writes when consecutive frames are identical.
|
|
8
9
|
*/
|
|
9
10
|
export interface CellLogUpdateRunOptions {
|
|
10
11
|
/** Enables diff updates. Set `false` to force full-frame redraws. */
|
|
@@ -348,29 +348,24 @@ const create = (stream, options = {}) => {
|
|
|
348
348
|
const prevCount = previousHeight + 1;
|
|
349
349
|
const nextCount = nextHeight + 1;
|
|
350
350
|
const visibleCount = nextHeight;
|
|
351
|
-
const
|
|
352
|
-
|
|
353
|
-
chunks.push(ansiEscapes.eraseLines(prevCount - nextCount + 1));
|
|
354
|
-
chunks.push(ansiEscapes.cursorUp(visibleCount));
|
|
355
|
-
}
|
|
356
|
-
else {
|
|
357
|
-
chunks.push(ansiEscapes.cursorUp(prevCount - 1));
|
|
358
|
-
}
|
|
351
|
+
const rowChunks = [];
|
|
352
|
+
let hasDiff = false;
|
|
359
353
|
const width = nextBuffer.width;
|
|
360
354
|
// Incremental path: process one row at a time and emit minimal writes.
|
|
361
355
|
for (let row = 0; row < visibleCount; row++) {
|
|
362
356
|
if (prevBuffer.isRowEqual(nextBuffer, row)) {
|
|
363
|
-
|
|
357
|
+
rowChunks.push(ansiEscapes.cursorNextLine);
|
|
364
358
|
continue;
|
|
365
359
|
}
|
|
366
|
-
|
|
360
|
+
hasDiff = true;
|
|
361
|
+
rowChunks.push(cursorToColumn(0));
|
|
367
362
|
const nextEnd = nextBuffer.getRowRightEdge(row);
|
|
368
363
|
const prevEnd = prevBuffer.getRowRightEdge(row);
|
|
369
364
|
const tailClear = prevEnd > nextEnd;
|
|
370
365
|
const scanEnd = tailClear ? nextEnd : Math.max(nextEnd, prevEnd);
|
|
371
366
|
if (scanEnd === 0 && tailClear) {
|
|
372
|
-
|
|
373
|
-
|
|
367
|
+
rowChunks.push(ansiEscapes.eraseEndLine);
|
|
368
|
+
rowChunks.push(ansiEscapes.cursorNextLine);
|
|
374
369
|
continue;
|
|
375
370
|
}
|
|
376
371
|
let segments = computeSegments(prevBuffer, nextBuffer, row, scanEnd);
|
|
@@ -378,20 +373,20 @@ const create = (stream, options = {}) => {
|
|
|
378
373
|
segments = mergeByGapThreshold(segments, mergeThreshold);
|
|
379
374
|
}
|
|
380
375
|
if (segments.length > maxSegmentsBeforeFullRow) {
|
|
381
|
-
const full = nextBuffer.appendStyledRange(row, 0, nextEnd,
|
|
376
|
+
const full = nextBuffer.appendStyledRange(row, 0, nextEnd, rowChunks, 0, 0);
|
|
382
377
|
let activeStyleId = full.activeStyleId;
|
|
383
|
-
activeStyleId = nextBuffer.appendCloseActiveStyle(
|
|
378
|
+
activeStyleId = nextBuffer.appendCloseActiveStyle(rowChunks, activeStyleId);
|
|
384
379
|
if (tailClear) {
|
|
385
|
-
|
|
380
|
+
rowChunks.push(ansiEscapes.eraseEndLine);
|
|
386
381
|
}
|
|
387
|
-
|
|
382
|
+
rowChunks.push(ansiEscapes.cursorNextLine);
|
|
388
383
|
continue;
|
|
389
384
|
}
|
|
390
385
|
if (segments.length === 0) {
|
|
391
386
|
if (tailClear) {
|
|
392
|
-
|
|
387
|
+
rowChunks.push(ansiEscapes.eraseEndLine);
|
|
393
388
|
}
|
|
394
|
-
|
|
389
|
+
rowChunks.push(ansiEscapes.cursorNextLine);
|
|
395
390
|
continue;
|
|
396
391
|
}
|
|
397
392
|
let cursorX = 0;
|
|
@@ -399,14 +394,14 @@ const create = (stream, options = {}) => {
|
|
|
399
394
|
if (mergeStrategy === "cost") {
|
|
400
395
|
const plan = planRowOpsWithCostModel(nextBuffer, styleLenCache, row, segments, nextEnd, tailClear, overwriteGapPenaltyBytes);
|
|
401
396
|
if (plan.useFullRow) {
|
|
402
|
-
const full = nextBuffer.appendStyledRange(row, 0, nextEnd,
|
|
397
|
+
const full = nextBuffer.appendStyledRange(row, 0, nextEnd, rowChunks, activeStyleId, cursorX);
|
|
403
398
|
cursorX = full.cursorX;
|
|
404
399
|
activeStyleId = full.activeStyleId;
|
|
405
|
-
activeStyleId = nextBuffer.appendCloseActiveStyle(
|
|
400
|
+
activeStyleId = nextBuffer.appendCloseActiveStyle(rowChunks, activeStyleId);
|
|
406
401
|
if (tailClear) {
|
|
407
|
-
|
|
402
|
+
rowChunks.push(ansiEscapes.eraseEndLine);
|
|
408
403
|
}
|
|
409
|
-
|
|
404
|
+
rowChunks.push(ansiEscapes.cursorNextLine);
|
|
410
405
|
continue;
|
|
411
406
|
}
|
|
412
407
|
for (const op of plan.ops) {
|
|
@@ -418,15 +413,15 @@ const create = (stream, options = {}) => {
|
|
|
418
413
|
}
|
|
419
414
|
if (delta > 0) {
|
|
420
415
|
if (op.method === "abs") {
|
|
421
|
-
|
|
416
|
+
rowChunks.push(cursorToColumn(toX));
|
|
422
417
|
}
|
|
423
418
|
else {
|
|
424
|
-
|
|
419
|
+
rowChunks.push(cursorForward(delta));
|
|
425
420
|
}
|
|
426
421
|
cursorX = toX;
|
|
427
422
|
}
|
|
428
423
|
else {
|
|
429
|
-
|
|
424
|
+
rowChunks.push(cursorToColumn(toX));
|
|
430
425
|
cursorX = toX;
|
|
431
426
|
}
|
|
432
427
|
continue;
|
|
@@ -442,14 +437,14 @@ const create = (stream, options = {}) => {
|
|
|
442
437
|
if (start !== cursorX) {
|
|
443
438
|
const move = chooseHorizontalMove(cursorX, start);
|
|
444
439
|
if (move.method === "abs") {
|
|
445
|
-
|
|
440
|
+
rowChunks.push(cursorToColumn(start));
|
|
446
441
|
}
|
|
447
442
|
else {
|
|
448
|
-
|
|
443
|
+
rowChunks.push(cursorForward(start - cursorX));
|
|
449
444
|
}
|
|
450
445
|
cursorX = start;
|
|
451
446
|
}
|
|
452
|
-
const result = nextBuffer.appendStyledRange(row, start, end,
|
|
447
|
+
const result = nextBuffer.appendStyledRange(row, start, end, rowChunks, activeStyleId, cursorX);
|
|
453
448
|
cursorX = result.cursorX;
|
|
454
449
|
activeStyleId = result.activeStyleId;
|
|
455
450
|
}
|
|
@@ -466,33 +461,46 @@ const create = (stream, options = {}) => {
|
|
|
466
461
|
}
|
|
467
462
|
const delta = segStart - cursorX;
|
|
468
463
|
if (delta > 0) {
|
|
469
|
-
|
|
464
|
+
rowChunks.push(cursorForward(delta));
|
|
470
465
|
cursorX = segStart;
|
|
471
466
|
}
|
|
472
467
|
else if (delta < 0) {
|
|
473
|
-
|
|
468
|
+
rowChunks.push(cursorToColumn(segStart));
|
|
474
469
|
cursorX = segStart;
|
|
475
470
|
}
|
|
476
|
-
const result = nextBuffer.appendStyledRange(row, segStart, segEnd,
|
|
471
|
+
const result = nextBuffer.appendStyledRange(row, segStart, segEnd, rowChunks, activeStyleId, cursorX);
|
|
477
472
|
cursorX = result.cursorX;
|
|
478
473
|
activeStyleId = result.activeStyleId;
|
|
479
474
|
}
|
|
480
475
|
}
|
|
481
|
-
activeStyleId = nextBuffer.appendCloseActiveStyle(
|
|
476
|
+
activeStyleId = nextBuffer.appendCloseActiveStyle(rowChunks, activeStyleId);
|
|
482
477
|
if (tailClear) {
|
|
483
478
|
if (cursorX !== nextEnd) {
|
|
484
479
|
const move = chooseHorizontalMove(cursorX, nextEnd);
|
|
485
480
|
if (move.method === "abs") {
|
|
486
|
-
|
|
481
|
+
rowChunks.push(cursorToColumn(nextEnd));
|
|
487
482
|
}
|
|
488
483
|
else {
|
|
489
|
-
|
|
484
|
+
rowChunks.push(cursorForward(Math.max(0, nextEnd - cursorX)));
|
|
490
485
|
}
|
|
491
486
|
}
|
|
492
|
-
|
|
487
|
+
rowChunks.push(ansiEscapes.eraseEndLine);
|
|
493
488
|
}
|
|
494
|
-
|
|
489
|
+
rowChunks.push(ansiEscapes.cursorNextLine);
|
|
490
|
+
}
|
|
491
|
+
if (!hasDiff && nextCount === prevCount) {
|
|
492
|
+
previousHeight = nextHeight;
|
|
493
|
+
return;
|
|
494
|
+
}
|
|
495
|
+
const chunks = [];
|
|
496
|
+
if (nextCount < prevCount) {
|
|
497
|
+
chunks.push(ansiEscapes.eraseLines(prevCount - nextCount + 1));
|
|
498
|
+
chunks.push(ansiEscapes.cursorUp(visibleCount));
|
|
499
|
+
}
|
|
500
|
+
else {
|
|
501
|
+
chunks.push(ansiEscapes.cursorUp(prevCount - 1));
|
|
495
502
|
}
|
|
503
|
+
chunks.push(...rowChunks);
|
|
496
504
|
stream.write(chunks.join(""));
|
|
497
505
|
previousHeight = nextHeight;
|
|
498
506
|
});
|
|
@@ -3,11 +3,18 @@
|
|
|
3
3
|
*/
|
|
4
4
|
import { type CellBuffer } from "./cell-buffer.js";
|
|
5
5
|
import { type Clip, type OutputLike, type OutputWriteOptions } from "./output.js";
|
|
6
|
+
import { type AnsiCode } from "../types/ansi.js";
|
|
6
7
|
export declare class CellOutput implements OutputLike {
|
|
7
8
|
private readonly clips;
|
|
9
|
+
private readonly styleIdByRef;
|
|
10
|
+
private lastStyleRef;
|
|
11
|
+
private lastStyleId;
|
|
8
12
|
readonly buffer: CellBuffer;
|
|
9
13
|
constructor(buffer: CellBuffer);
|
|
10
14
|
clip(clip: Clip): void;
|
|
11
15
|
unclip(): void;
|
|
16
|
+
fill(x: number, y: number, length: number, char: string, charWidth: number, styles: AnsiCode[]): void;
|
|
17
|
+
private resolveStyleId;
|
|
18
|
+
private writePlainLine;
|
|
12
19
|
write(x: number, y: number, text: string, options: OutputWriteOptions): void;
|
|
13
20
|
}
|
package/lib/core/cell-output.js
CHANGED
|
@@ -5,8 +5,38 @@ import sliceAnsi from "slice-ansi";
|
|
|
5
5
|
import stringWidth from "string-width";
|
|
6
6
|
import widestLine from "widest-line";
|
|
7
7
|
import { styledCharsFromTokens, tokenize } from "@alcalzone/ansi-tokenize";
|
|
8
|
+
const ESC = "\u001b";
|
|
9
|
+
const isPrintableAsciiText = (value) => {
|
|
10
|
+
for (let index = 0; index < value.length; index++) {
|
|
11
|
+
const code = value.charCodeAt(index);
|
|
12
|
+
if (code === 10) {
|
|
13
|
+
continue;
|
|
14
|
+
}
|
|
15
|
+
if (code < 0x20 || code > 0x7e) {
|
|
16
|
+
return false;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
return true;
|
|
20
|
+
};
|
|
21
|
+
const getCharWidth = (value, widthCache) => {
|
|
22
|
+
if (value.length === 1 &&
|
|
23
|
+
value.charCodeAt(0) >= 0x20 &&
|
|
24
|
+
value.charCodeAt(0) <= 0x7e) {
|
|
25
|
+
return 1;
|
|
26
|
+
}
|
|
27
|
+
const cached = widthCache.get(value);
|
|
28
|
+
if (cached !== undefined) {
|
|
29
|
+
return cached;
|
|
30
|
+
}
|
|
31
|
+
const width = Math.max(1, stringWidth(value));
|
|
32
|
+
widthCache.set(value, width);
|
|
33
|
+
return width;
|
|
34
|
+
};
|
|
8
35
|
export class CellOutput {
|
|
9
36
|
clips = [];
|
|
37
|
+
styleIdByRef = new WeakMap();
|
|
38
|
+
lastStyleRef;
|
|
39
|
+
lastStyleId = 0;
|
|
10
40
|
buffer;
|
|
11
41
|
constructor(buffer) {
|
|
12
42
|
this.buffer = buffer;
|
|
@@ -17,6 +47,74 @@ export class CellOutput {
|
|
|
17
47
|
unclip() {
|
|
18
48
|
this.clips.pop();
|
|
19
49
|
}
|
|
50
|
+
fill(x, y, length, char, charWidth, styles) {
|
|
51
|
+
if (length <= 0) {
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
const visualLength = length * charWidth;
|
|
55
|
+
let startX = x;
|
|
56
|
+
let endX = x + visualLength;
|
|
57
|
+
const row = y;
|
|
58
|
+
const clip = this.clips.at(-1);
|
|
59
|
+
if (clip) {
|
|
60
|
+
const clipY1 = clip.y1 ?? 0;
|
|
61
|
+
// Clipping logic: y2 is exclusive boundary
|
|
62
|
+
if (clip.y2 !== undefined && row >= clip.y2) {
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
if (row < clipY1) {
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
const clipX1 = clip.x1 ?? 0;
|
|
69
|
+
startX = Math.max(startX, clipX1);
|
|
70
|
+
if (clip.x2 !== undefined) {
|
|
71
|
+
endX = Math.min(endX, clip.x2);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
const fillVisualLength = endX - startX;
|
|
75
|
+
if (fillVisualLength <= 0) {
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
const styleId = this.buffer.styleRegistry.getId(styles);
|
|
79
|
+
this.buffer.fill(startX, row, fillVisualLength, char, charWidth, styleId);
|
|
80
|
+
}
|
|
81
|
+
resolveStyleId(styles) {
|
|
82
|
+
if (styles.length === 0) {
|
|
83
|
+
return 0;
|
|
84
|
+
}
|
|
85
|
+
if (styles === this.lastStyleRef) {
|
|
86
|
+
return this.lastStyleId;
|
|
87
|
+
}
|
|
88
|
+
const byRef = this.styleIdByRef.get(styles);
|
|
89
|
+
if (byRef !== undefined) {
|
|
90
|
+
this.lastStyleRef = styles;
|
|
91
|
+
this.lastStyleId = byRef;
|
|
92
|
+
return byRef;
|
|
93
|
+
}
|
|
94
|
+
const styleId = this.buffer.styleRegistry.getId(styles);
|
|
95
|
+
this.styleIdByRef.set(styles, styleId);
|
|
96
|
+
this.lastStyleRef = styles;
|
|
97
|
+
this.lastStyleId = styleId;
|
|
98
|
+
return styleId;
|
|
99
|
+
}
|
|
100
|
+
writePlainLine(line, writeX, row, preserveLineTrailingSpaces, widthCache) {
|
|
101
|
+
let offsetX = writeX;
|
|
102
|
+
for (const value of line) {
|
|
103
|
+
const charWidth = getCharWidth(value, widthCache);
|
|
104
|
+
if (offsetX < 0) {
|
|
105
|
+
offsetX += charWidth;
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
if (offsetX >= this.buffer.width) {
|
|
109
|
+
break;
|
|
110
|
+
}
|
|
111
|
+
if (offsetX + charWidth > this.buffer.width) {
|
|
112
|
+
break;
|
|
113
|
+
}
|
|
114
|
+
this.buffer.setCell(offsetX, row, value, charWidth, 0, preserveLineTrailingSpaces);
|
|
115
|
+
offsetX += charWidth;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
20
118
|
write(x, y, text, options) {
|
|
21
119
|
const { transformers } = options;
|
|
22
120
|
if (!text) {
|
|
@@ -28,7 +126,27 @@ export class CellOutput {
|
|
|
28
126
|
// Match Output.get() semantics: transformed lines may intentionally keep
|
|
29
127
|
// trailing spaces (for alignment or visual effects), so mark them.
|
|
30
128
|
const preserveLineTrailingSpaces = transformers.length > 0;
|
|
129
|
+
const widthCache = new Map();
|
|
31
130
|
const clip = this.clips.at(-1);
|
|
131
|
+
if (!clip &&
|
|
132
|
+
transformers.length === 0 &&
|
|
133
|
+
!text.includes(ESC) &&
|
|
134
|
+
isPrintableAsciiText(text)) {
|
|
135
|
+
let offsetY = 0;
|
|
136
|
+
for (const line of lines) {
|
|
137
|
+
const row = writeY + offsetY;
|
|
138
|
+
if (row < 0) {
|
|
139
|
+
offsetY++;
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
if (row >= this.buffer.height) {
|
|
143
|
+
break;
|
|
144
|
+
}
|
|
145
|
+
this.writePlainLine(line, writeX, row, preserveLineTrailingSpaces, widthCache);
|
|
146
|
+
offsetY++;
|
|
147
|
+
}
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
32
150
|
if (clip) {
|
|
33
151
|
const clipHorizontally = typeof clip.x1 === "number" && typeof clip.x2 === "number";
|
|
34
152
|
const clipVertically = typeof clip.y1 === "number" && typeof clip.y2 === "number";
|
|
@@ -83,10 +201,16 @@ export class CellOutput {
|
|
|
83
201
|
for (const transformer of transformers) {
|
|
84
202
|
transformedLine = transformer(transformedLine, lineIndex);
|
|
85
203
|
}
|
|
204
|
+
if (!transformedLine.includes(ESC) &&
|
|
205
|
+
isPrintableAsciiText(transformedLine)) {
|
|
206
|
+
this.writePlainLine(transformedLine, writeX, row, preserveLineTrailingSpaces, widthCache);
|
|
207
|
+
offsetY++;
|
|
208
|
+
continue;
|
|
209
|
+
}
|
|
86
210
|
const styledChars = styledCharsFromTokens(tokenize(transformedLine));
|
|
87
211
|
let offsetX = writeX;
|
|
88
212
|
for (const char of styledChars) {
|
|
89
|
-
const charWidth =
|
|
213
|
+
const charWidth = getCharWidth(char.value, widthCache);
|
|
90
214
|
if (offsetX < 0) {
|
|
91
215
|
offsetX += charWidth;
|
|
92
216
|
continue;
|
|
@@ -97,7 +221,7 @@ export class CellOutput {
|
|
|
97
221
|
if (offsetX + charWidth > this.buffer.width) {
|
|
98
222
|
break;
|
|
99
223
|
}
|
|
100
|
-
const styleId = this.
|
|
224
|
+
const styleId = this.resolveStyleId(char.styles);
|
|
101
225
|
this.buffer.setCell(offsetX, row, char.value, charWidth, styleId, preserveLineTrailingSpaces);
|
|
102
226
|
offsetX += charWidth;
|
|
103
227
|
}
|
package/lib/core/output.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { type OutputTransformer } from "./render-node-to-output.js";
|
|
2
2
|
import { type Dimension } from "../utils/dimension.js";
|
|
3
|
+
import { type AnsiCode } from "../types/ansi.js";
|
|
3
4
|
/**
|
|
4
5
|
* Options for writing text to an output target.
|
|
5
6
|
*/
|
|
@@ -27,6 +28,7 @@ export interface OutputLike {
|
|
|
27
28
|
write(x: number, y: number, text: string, options: OutputWriteOptions): void;
|
|
28
29
|
clip(clip: Clip): void;
|
|
29
30
|
unclip(): void;
|
|
31
|
+
fill(x: number, y: number, length: number, char: string, charWidth: number, styles: AnsiCode[]): void;
|
|
30
32
|
}
|
|
31
33
|
/**
|
|
32
34
|
* "Virtual" output class
|
|
@@ -65,6 +67,7 @@ export declare class Output implements OutputLike {
|
|
|
65
67
|
* Removes the last clipping area, restoring the previous one.
|
|
66
68
|
*/
|
|
67
69
|
unclip(): void;
|
|
70
|
+
fill(x: number, y: number, length: number, char: string, _charWidth: number, styles: AnsiCode[]): void;
|
|
68
71
|
/**
|
|
69
72
|
* Generates the final output string and its height.
|
|
70
73
|
*
|
package/lib/core/output.js
CHANGED
|
@@ -63,6 +63,18 @@ export class Output {
|
|
|
63
63
|
type: "unclip",
|
|
64
64
|
});
|
|
65
65
|
}
|
|
66
|
+
fill(x, y, length, char, _charWidth, styles) {
|
|
67
|
+
if (length <= 0) {
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
const prefix = styles.map((style) => style.code).join("");
|
|
71
|
+
const suffix = [...styles]
|
|
72
|
+
.reverse()
|
|
73
|
+
.map((style) => style.endCode)
|
|
74
|
+
.join("");
|
|
75
|
+
const text = prefix + char.repeat(length) + suffix;
|
|
76
|
+
this.write(x, y, text, { transformers: [] });
|
|
77
|
+
}
|
|
66
78
|
/**
|
|
67
79
|
* Generates the final output string and its height.
|
|
68
80
|
*
|
|
@@ -1,13 +1,3 @@
|
|
|
1
1
|
import { type DOMNode } from "./dom.js";
|
|
2
2
|
import { type OutputLike } from "./output.js";
|
|
3
|
-
/**
|
|
4
|
-
* Renders the border for a DOM node.
|
|
5
|
-
* Calculates border dimensions and draws border characters with specified
|
|
6
|
-
* styles.
|
|
7
|
-
*
|
|
8
|
-
* @param x - The x-coordinate (column) where the border starts.
|
|
9
|
-
* @param y - The y-coordinate (row) where the border starts.
|
|
10
|
-
* @param node - The DOM node for which to render the border.
|
|
11
|
-
* @param output - The output instance to write the border to.
|
|
12
|
-
*/
|
|
13
3
|
export declare const renderBorder: (x: number, y: number, node: DOMNode, output: OutputLike) => void;
|
|
@@ -1,82 +1,91 @@
|
|
|
1
1
|
import { boxStyles } from "./box-styles.js";
|
|
2
2
|
import ansis from "ansis";
|
|
3
|
-
import
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
3
|
+
import stringWidth from "string-width";
|
|
4
|
+
import { getStyle } from "../utils/colorize.js";
|
|
5
|
+
// Helper to resolve styles once
|
|
6
|
+
const resolveBorderStyles = (color, dim) => {
|
|
7
|
+
const styles = [];
|
|
8
|
+
if (dim) {
|
|
9
|
+
styles.push({
|
|
10
|
+
type: "ansi",
|
|
11
|
+
code: ansis.dim.open,
|
|
12
|
+
endCode: ansis.dim.close,
|
|
13
|
+
});
|
|
14
|
+
}
|
|
15
|
+
const colorStyle = getStyle(color, "foreground");
|
|
16
|
+
if (colorStyle) {
|
|
17
|
+
styles.push(colorStyle);
|
|
18
|
+
}
|
|
19
|
+
return styles;
|
|
20
|
+
};
|
|
14
21
|
export const renderBorder = (x, y, node, output) => {
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
const
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
const contentWidth = width - (showLeftBorder ? 1 : 0) - (showRightBorder ? 1 : 0);
|
|
35
|
-
let topBorder = showTopBorder
|
|
36
|
-
? colorize((showLeftBorder ? box.topLeft : "") +
|
|
37
|
-
box.top.repeat(contentWidth) +
|
|
38
|
-
(showRightBorder ? box.topRight : ""), topBorderColor, "foreground")
|
|
39
|
-
: undefined;
|
|
40
|
-
if (showTopBorder && dimTopBorderColor) {
|
|
41
|
-
topBorder = ansis.dim(topBorder);
|
|
42
|
-
}
|
|
43
|
-
let verticalBorderHeight = height;
|
|
44
|
-
if (showTopBorder) {
|
|
45
|
-
verticalBorderHeight -= 1;
|
|
46
|
-
}
|
|
47
|
-
if (showBottomBorder) {
|
|
48
|
-
verticalBorderHeight -= 1;
|
|
49
|
-
}
|
|
50
|
-
let leftBorder = (colorize(box.left, leftBorderColor, "foreground") + "\n").repeat(verticalBorderHeight);
|
|
51
|
-
if (dimLeftBorderColor) {
|
|
52
|
-
leftBorder = ansis.dim(leftBorder);
|
|
22
|
+
const { borderStyle } = node.style;
|
|
23
|
+
if (!borderStyle) {
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
const layout = node.taffyNode?.tree.getLayout(node.taffyNode.id);
|
|
27
|
+
const width = layout?.width ?? 0;
|
|
28
|
+
const height = layout?.height ?? 0;
|
|
29
|
+
const box = typeof borderStyle === "string" ? boxStyles[borderStyle] : borderStyle;
|
|
30
|
+
const { borderColor, borderTopColor, borderBottomColor, borderLeftColor, borderRightColor, borderDimColor, borderTopDimColor, borderBottomDimColor, borderLeftDimColor, borderRightDimColor, } = node.style;
|
|
31
|
+
const showTopBorder = node.style.borderTop !== false;
|
|
32
|
+
const showBottomBorder = node.style.borderBottom !== false;
|
|
33
|
+
const showLeftBorder = node.style.borderLeft !== false;
|
|
34
|
+
const showRightBorder = node.style.borderRight !== false;
|
|
35
|
+
const contentWidth = width - (showLeftBorder ? 1 : 0) - (showRightBorder ? 1 : 0);
|
|
36
|
+
// Top Border
|
|
37
|
+
if (showTopBorder) {
|
|
38
|
+
const styles = resolveBorderStyles(borderTopColor ?? borderColor, borderTopDimColor ?? borderDimColor);
|
|
39
|
+
if (showLeftBorder) {
|
|
40
|
+
output.fill(x, y, 1, box.topLeft, stringWidth(box.topLeft), styles);
|
|
53
41
|
}
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
rightBorder = ansis.dim(rightBorder);
|
|
42
|
+
if (contentWidth > 0) {
|
|
43
|
+
output.fill(x + (showLeftBorder ? 1 : 0), y, contentWidth, box.top, stringWidth(box.top), styles);
|
|
57
44
|
}
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
box.bottom.repeat(contentWidth) +
|
|
61
|
-
(showRightBorder ? box.bottomRight : ""), bottomBorderColor, "foreground")
|
|
62
|
-
: undefined;
|
|
63
|
-
if (showBottomBorder && dimBottomBorderColor) {
|
|
64
|
-
bottomBorder = ansis.dim(bottomBorder);
|
|
45
|
+
if (showRightBorder) {
|
|
46
|
+
output.fill(x + width - 1, y, 1, box.topRight, stringWidth(box.topRight), styles);
|
|
65
47
|
}
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
48
|
+
}
|
|
49
|
+
// Vertical Borders
|
|
50
|
+
let verticalBorderHeight = height;
|
|
51
|
+
if (showTopBorder)
|
|
52
|
+
verticalBorderHeight -= 1;
|
|
53
|
+
if (showBottomBorder)
|
|
54
|
+
verticalBorderHeight -= 1;
|
|
55
|
+
if (verticalBorderHeight > 0) {
|
|
56
|
+
const offsetY = y + (showTopBorder ? 1 : 0);
|
|
57
|
+
// Optimization: Resolve styles once
|
|
58
|
+
const leftStyles = showLeftBorder
|
|
59
|
+
? resolveBorderStyles(borderLeftColor ?? borderColor, borderLeftDimColor ?? borderDimColor)
|
|
60
|
+
: [];
|
|
61
|
+
const rightStyles = showRightBorder
|
|
62
|
+
? resolveBorderStyles(borderRightColor ?? borderColor, borderRightDimColor ?? borderDimColor)
|
|
63
|
+
: [];
|
|
64
|
+
// Optimization: Calculate widths once
|
|
65
|
+
const leftWidth = showLeftBorder ? stringWidth(box.left) : 0;
|
|
66
|
+
const rightWidth = showRightBorder ? stringWidth(box.right) : 0;
|
|
67
|
+
for (let i = 0; i < verticalBorderHeight; i++) {
|
|
68
|
+
const row = offsetY + i;
|
|
69
|
+
if (showLeftBorder) {
|
|
70
|
+
output.fill(x, row, 1, box.left, leftWidth, leftStyles);
|
|
71
|
+
}
|
|
72
|
+
if (showRightBorder) {
|
|
73
|
+
output.fill(x + width - 1, row, 1, box.right, rightWidth, rightStyles);
|
|
74
|
+
}
|
|
69
75
|
}
|
|
76
|
+
}
|
|
77
|
+
// Bottom Border
|
|
78
|
+
if (showBottomBorder) {
|
|
79
|
+
const styles = resolveBorderStyles(borderBottomColor ?? borderColor, borderBottomDimColor ?? borderDimColor);
|
|
80
|
+
const bottomY = y + height - 1;
|
|
70
81
|
if (showLeftBorder) {
|
|
71
|
-
output.
|
|
82
|
+
output.fill(x, bottomY, 1, box.bottomLeft, stringWidth(box.bottomLeft), styles);
|
|
72
83
|
}
|
|
73
|
-
if (
|
|
74
|
-
output.
|
|
75
|
-
transformers: [],
|
|
76
|
-
});
|
|
84
|
+
if (contentWidth > 0) {
|
|
85
|
+
output.fill(x + (showLeftBorder ? 1 : 0), bottomY, contentWidth, box.bottom, stringWidth(box.bottom), styles);
|
|
77
86
|
}
|
|
78
|
-
if (
|
|
79
|
-
output.
|
|
87
|
+
if (showRightBorder) {
|
|
88
|
+
output.fill(x + width - 1, bottomY, 1, box.bottomRight, stringWidth(box.bottomRight), styles);
|
|
80
89
|
}
|
|
81
90
|
}
|
|
82
91
|
};
|
package/lib/core/render.d.ts
CHANGED
|
@@ -76,6 +76,7 @@ export interface RenderOptions {
|
|
|
76
76
|
* - `{ strategy: "run" }` (or omitted strategy) enables run-diff rendering.
|
|
77
77
|
*
|
|
78
78
|
* Runtime notes:
|
|
79
|
+
* - In `run` strategy, terminal writes are skipped entirely when frames are unchanged.
|
|
79
80
|
* - In `debug` mode, Tinky always writes full frames.
|
|
80
81
|
* - In screen-reader mode, Tinky uses the screen-reader output path.
|
|
81
82
|
* - In CI mode, Tinky avoids cursor-diff updates.
|
package/lib/core/renderer.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { renderNodeToOutput, renderNodeToScreenReaderOutput, } from "./render-node-to-output.js";
|
|
2
|
-
import {
|
|
2
|
+
import { CellBuffer } from "./cell-buffer.js";
|
|
3
|
+
import { CellOutput } from "./cell-output.js";
|
|
3
4
|
/**
|
|
4
5
|
* Renders the DOM tree to a string output.
|
|
5
6
|
*
|
|
@@ -27,31 +28,33 @@ export const renderer = (node, isScreenReaderEnabled) => {
|
|
|
27
28
|
};
|
|
28
29
|
}
|
|
29
30
|
const layout = node.taffyNode.tree.getLayout(node.taffyNode.id);
|
|
30
|
-
const
|
|
31
|
+
const buffer = new CellBuffer({
|
|
31
32
|
width: layout.width,
|
|
32
33
|
height: layout.height,
|
|
33
34
|
});
|
|
35
|
+
const output = new CellOutput(buffer);
|
|
34
36
|
renderNodeToOutput(node, output, {
|
|
35
37
|
skipStaticElements: true,
|
|
36
38
|
});
|
|
37
39
|
let staticOutput;
|
|
38
40
|
if (node.staticNode?.taffyNode) {
|
|
39
41
|
const staticLayout = node.staticNode.taffyNode.tree.getLayout(node.staticNode.taffyNode.id);
|
|
40
|
-
|
|
42
|
+
const staticBuffer = new CellBuffer({
|
|
41
43
|
width: staticLayout.width,
|
|
42
44
|
height: staticLayout.height,
|
|
43
45
|
});
|
|
46
|
+
staticOutput = new CellOutput(staticBuffer);
|
|
44
47
|
renderNodeToOutput(node.staticNode, staticOutput, {
|
|
45
48
|
skipStaticElements: false,
|
|
46
49
|
});
|
|
47
50
|
}
|
|
48
|
-
const
|
|
51
|
+
const generatedOutput = buffer.toString();
|
|
49
52
|
return {
|
|
50
53
|
output: generatedOutput,
|
|
51
|
-
outputHeight,
|
|
54
|
+
outputHeight: buffer.height,
|
|
52
55
|
// Newline at the end is needed, because static output doesn't have one,
|
|
53
56
|
// so interactive output will override last line of static output
|
|
54
|
-
staticOutput: staticOutput ? `${staticOutput.
|
|
57
|
+
staticOutput: staticOutput ? `${staticOutput.buffer.toString()}\n` : "",
|
|
55
58
|
};
|
|
56
59
|
}
|
|
57
60
|
return {
|
package/lib/core/tinky.d.ts
CHANGED
|
@@ -153,7 +153,6 @@ export declare class Tinky {
|
|
|
153
153
|
* Handles string generation, static output, screen reader logic, and writing.
|
|
154
154
|
*/
|
|
155
155
|
onRender: () => void;
|
|
156
|
-
private isRunFrameEqual;
|
|
157
156
|
/**
|
|
158
157
|
* Swaps front and back buffers. After swap, the caller's newly-rendered
|
|
159
158
|
* frame becomes the front buffer. The old front becomes the back buffer
|
package/lib/core/tinky.js
CHANGED
|
@@ -5,6 +5,7 @@ import { isCI } from "../utils/check-ci.js";
|
|
|
5
5
|
import autoBind from "auto-bind";
|
|
6
6
|
import { onExit } from "../utils/signal-exit.js";
|
|
7
7
|
import { patchConsole } from "../utils/patch-console.js";
|
|
8
|
+
import { advanceSquashTextEpoch } from "../utils/squash-text-nodes.js";
|
|
8
9
|
import { LegacyRoot } from "react-reconciler/constants.js";
|
|
9
10
|
import wrapAnsi from "wrap-ansi";
|
|
10
11
|
import { reconciler } from "./reconciler.js";
|
|
@@ -202,6 +203,8 @@ export class Tinky {
|
|
|
202
203
|
if (this.rootNode.taffyNode === undefined) {
|
|
203
204
|
return;
|
|
204
205
|
}
|
|
206
|
+
// Keep text-squash cache scoped to one layout/render cycle.
|
|
207
|
+
advanceSquashTextEpoch();
|
|
205
208
|
const terminalWidth = this.getTerminalWidth();
|
|
206
209
|
const { tree } = this.rootNode.taffyNode;
|
|
207
210
|
const rootStyle = tree.getStyle(this.rootNode.taffyNode.id);
|
|
@@ -255,9 +258,7 @@ export class Tinky {
|
|
|
255
258
|
this.swapRunBuffers();
|
|
256
259
|
}
|
|
257
260
|
}
|
|
258
|
-
else if (this.frontBuffer &&
|
|
259
|
-
this.cellLog &&
|
|
260
|
-
!this.isRunFrameEqual(buffer)) {
|
|
261
|
+
else if (this.frontBuffer && this.cellLog) {
|
|
261
262
|
this.cellLog(this.frontBuffer, buffer);
|
|
262
263
|
this.swapRunBuffers();
|
|
263
264
|
}
|
|
@@ -342,9 +343,6 @@ export class Tinky {
|
|
|
342
343
|
this.lastOutput = output;
|
|
343
344
|
this.lastOutputHeight = outputHeight;
|
|
344
345
|
};
|
|
345
|
-
isRunFrameEqual(nextBuffer) {
|
|
346
|
-
return this.frontBuffer?.isEqual(nextBuffer) ?? false;
|
|
347
|
-
}
|
|
348
346
|
/**
|
|
349
347
|
* Swaps front and back buffers. After swap, the caller's newly-rendered
|
|
350
348
|
* frame becomes the front buffer. The old front becomes the back buffer
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/lib/utils/colorize.d.ts
CHANGED
|
@@ -20,5 +20,10 @@ type ColorType = "foreground" | "background";
|
|
|
20
20
|
* @param type - Whether to apply the color to the foreground or background.
|
|
21
21
|
* @returns The colorized string.
|
|
22
22
|
*/
|
|
23
|
+
import { type AnsiCode } from "../types/ansi.js";
|
|
24
|
+
/**
|
|
25
|
+
* Gets the ANSI style for a color.
|
|
26
|
+
*/
|
|
27
|
+
export declare const getStyle: (color: string | undefined, type: ColorType) => AnsiCode | undefined;
|
|
23
28
|
export declare const colorize: (str: string, color: string | undefined, type: ColorType) => string;
|
|
24
29
|
export {};
|
package/lib/utils/colorize.js
CHANGED
|
@@ -5,49 +5,71 @@ const isNamedColor = (color) => {
|
|
|
5
5
|
return color in ansis;
|
|
6
6
|
};
|
|
7
7
|
/**
|
|
8
|
-
*
|
|
9
|
-
* Supports named colors, hex codes, RGB values, and ANSI-256 color codes.
|
|
10
|
-
*
|
|
11
|
-
* @param str - The string to colorize.
|
|
12
|
-
* @param color - The color to apply.
|
|
13
|
-
* @param type - Whether to apply the color to the foreground or background.
|
|
14
|
-
* @returns The colorized string.
|
|
8
|
+
* Gets the ANSI style for a color.
|
|
15
9
|
*/
|
|
16
|
-
export const
|
|
10
|
+
export const getStyle = (color, type) => {
|
|
17
11
|
if (!color) {
|
|
18
|
-
return
|
|
12
|
+
return undefined;
|
|
19
13
|
}
|
|
20
14
|
if (isNamedColor(color)) {
|
|
21
15
|
if (type === "foreground") {
|
|
22
|
-
return
|
|
16
|
+
return {
|
|
17
|
+
type: "ansi",
|
|
18
|
+
code: ansis[color].open,
|
|
19
|
+
endCode: ansis[color].close,
|
|
20
|
+
};
|
|
23
21
|
}
|
|
24
22
|
const methodName = `bg${color.charAt(0).toUpperCase() + color.slice(1)}`;
|
|
25
|
-
return
|
|
23
|
+
return {
|
|
24
|
+
type: "ansi",
|
|
25
|
+
code: ansis[methodName].open,
|
|
26
|
+
endCode: ansis[methodName].close,
|
|
27
|
+
};
|
|
26
28
|
}
|
|
27
29
|
if (color.startsWith("#")) {
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
:
|
|
30
|
+
const style = type === "foreground" ? ansis.hex(color) : ansis.bgHex(color);
|
|
31
|
+
return {
|
|
32
|
+
type: "ansi",
|
|
33
|
+
code: style.open,
|
|
34
|
+
endCode: style.close,
|
|
35
|
+
};
|
|
31
36
|
}
|
|
32
37
|
if (color.startsWith("ansi256")) {
|
|
33
38
|
const matches = ansiRegex.exec(color);
|
|
34
39
|
if (!matches) {
|
|
35
|
-
return
|
|
40
|
+
return undefined;
|
|
36
41
|
}
|
|
37
42
|
const value = Number(matches[1]);
|
|
38
|
-
|
|
43
|
+
const style = type === "foreground" ? ansis.fg(value) : ansis.bg(value);
|
|
44
|
+
return {
|
|
45
|
+
type: "ansi",
|
|
46
|
+
code: style.open,
|
|
47
|
+
endCode: style.close,
|
|
48
|
+
};
|
|
39
49
|
}
|
|
40
50
|
if (color.startsWith("rgb")) {
|
|
41
51
|
const matches = rgbRegex.exec(color);
|
|
42
52
|
if (!matches) {
|
|
43
|
-
return
|
|
53
|
+
return undefined;
|
|
44
54
|
}
|
|
45
55
|
const firstValue = Number(matches[1]);
|
|
46
56
|
const secondValue = Number(matches[2]);
|
|
47
57
|
const thirdValue = Number(matches[3]);
|
|
48
|
-
|
|
49
|
-
? ansis.rgb(firstValue, secondValue, thirdValue)
|
|
50
|
-
: ansis.bgRgb(firstValue, secondValue, thirdValue)
|
|
58
|
+
const style = type === "foreground"
|
|
59
|
+
? ansis.rgb(firstValue, secondValue, thirdValue)
|
|
60
|
+
: ansis.bgRgb(firstValue, secondValue, thirdValue);
|
|
61
|
+
return {
|
|
62
|
+
type: "ansi",
|
|
63
|
+
code: style.open,
|
|
64
|
+
endCode: style.close,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
return undefined;
|
|
68
|
+
};
|
|
69
|
+
export const colorize = (str, color, type) => {
|
|
70
|
+
const style = getStyle(color, type);
|
|
71
|
+
if (style) {
|
|
72
|
+
return style.code + str + style.endCode;
|
|
51
73
|
}
|
|
52
74
|
return str;
|
|
53
75
|
};
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { getStyle } from "./colorize.js";
|
|
2
2
|
/**
|
|
3
3
|
* Renders the background color of a node to the output.
|
|
4
4
|
*
|
|
@@ -25,8 +25,9 @@ export const renderBackground = (x, y, node, output) => {
|
|
|
25
25
|
return;
|
|
26
26
|
}
|
|
27
27
|
// Create background fill for each row
|
|
28
|
-
const
|
|
28
|
+
const style = getStyle(node.style.backgroundColor, "background");
|
|
29
|
+
const styles = style ? [style] : [];
|
|
29
30
|
for (let row = 0; row < contentHeight; row++) {
|
|
30
|
-
output.
|
|
31
|
+
output.fill(x + leftBorderWidth, y + topBorderHeight + row, contentWidth, " ", 1, styles);
|
|
31
32
|
}
|
|
32
33
|
};
|
|
@@ -1,4 +1,9 @@
|
|
|
1
1
|
import { type DOMElement } from "../core/dom.js";
|
|
2
|
+
/**
|
|
3
|
+
* Advances the squash cache epoch.
|
|
4
|
+
* Cached results are valid only within one layout/render cycle.
|
|
5
|
+
*/
|
|
6
|
+
export declare const advanceSquashTextEpoch: () => number;
|
|
2
7
|
/**
|
|
3
8
|
* Consolidates multiple text nodes into a single string.
|
|
4
9
|
* Useful for combining adjacent text nodes to minimize write operations.
|
|
@@ -1,3 +1,13 @@
|
|
|
1
|
+
const cache = new WeakMap();
|
|
2
|
+
let currentEpoch = 0;
|
|
3
|
+
/**
|
|
4
|
+
* Advances the squash cache epoch.
|
|
5
|
+
* Cached results are valid only within one layout/render cycle.
|
|
6
|
+
*/
|
|
7
|
+
export const advanceSquashTextEpoch = () => {
|
|
8
|
+
currentEpoch += 1;
|
|
9
|
+
return currentEpoch;
|
|
10
|
+
};
|
|
1
11
|
/**
|
|
2
12
|
* Consolidates multiple text nodes into a single string.
|
|
3
13
|
* Useful for combining adjacent text nodes to minimize write operations.
|
|
@@ -7,7 +17,11 @@
|
|
|
7
17
|
* @returns The consolidated text string.
|
|
8
18
|
*/
|
|
9
19
|
export const squashTextNodes = (node) => {
|
|
10
|
-
|
|
20
|
+
const cached = cache.get(node);
|
|
21
|
+
if (cached && cached.epoch === currentEpoch) {
|
|
22
|
+
return cached.text;
|
|
23
|
+
}
|
|
24
|
+
const parts = [];
|
|
11
25
|
for (let index = 0; index < node.childNodes.length; index++) {
|
|
12
26
|
const childNode = node.childNodes[index];
|
|
13
27
|
if (childNode === undefined) {
|
|
@@ -30,7 +44,9 @@ export const squashTextNodes = (node) => {
|
|
|
30
44
|
nodeText = childNode.internal_transform(nodeText, index);
|
|
31
45
|
}
|
|
32
46
|
}
|
|
33
|
-
|
|
47
|
+
parts.push(nodeText);
|
|
34
48
|
}
|
|
49
|
+
const text = parts.join("");
|
|
50
|
+
cache.set(node, { epoch: currentEpoch, text });
|
|
35
51
|
return text;
|
|
36
52
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tinky",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.7.0",
|
|
4
4
|
"description": "React for CLIs, re-imagined with the Taffy engine",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"react",
|
|
@@ -32,6 +32,7 @@
|
|
|
32
32
|
],
|
|
33
33
|
"scripts": {
|
|
34
34
|
"test": "bun test",
|
|
35
|
+
"benchmark": "bun run benchmark/index.ts",
|
|
35
36
|
"build": "tsc && npm run docs",
|
|
36
37
|
"lint": "eslint src/**/*.ts",
|
|
37
38
|
"prepublish": "npm run build",
|
|
@@ -82,6 +83,7 @@
|
|
|
82
83
|
"eslint": "^9.39.2",
|
|
83
84
|
"eslint-plugin-react": "^7.37.5",
|
|
84
85
|
"husky": "^9.1.7",
|
|
86
|
+
"ink": "^6.7.0",
|
|
85
87
|
"jiti": "^2.6.1",
|
|
86
88
|
"lint-staged": "^16.2.7",
|
|
87
89
|
"prettier": "^3.7.4",
|