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.
@@ -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
- export interface AnsiCode {
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
  /**
@@ -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 + this.width;
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 lines = [];
332
+ const out = [];
283
333
  for (let row = 0; row < this.height; row++) {
284
- lines.push(this.serializeRow(row));
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 lines.join("\n");
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 chunks = [];
352
- if (nextCount < prevCount) {
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
- chunks.push(ansiEscapes.cursorNextLine);
357
+ rowChunks.push(ansiEscapes.cursorNextLine);
364
358
  continue;
365
359
  }
366
- chunks.push(cursorToColumn(0));
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
- chunks.push(ansiEscapes.eraseEndLine);
373
- chunks.push(ansiEscapes.cursorNextLine);
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, chunks, 0, 0);
376
+ const full = nextBuffer.appendStyledRange(row, 0, nextEnd, rowChunks, 0, 0);
382
377
  let activeStyleId = full.activeStyleId;
383
- activeStyleId = nextBuffer.appendCloseActiveStyle(chunks, activeStyleId);
378
+ activeStyleId = nextBuffer.appendCloseActiveStyle(rowChunks, activeStyleId);
384
379
  if (tailClear) {
385
- chunks.push(ansiEscapes.eraseEndLine);
380
+ rowChunks.push(ansiEscapes.eraseEndLine);
386
381
  }
387
- chunks.push(ansiEscapes.cursorNextLine);
382
+ rowChunks.push(ansiEscapes.cursorNextLine);
388
383
  continue;
389
384
  }
390
385
  if (segments.length === 0) {
391
386
  if (tailClear) {
392
- chunks.push(ansiEscapes.eraseEndLine);
387
+ rowChunks.push(ansiEscapes.eraseEndLine);
393
388
  }
394
- chunks.push(ansiEscapes.cursorNextLine);
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, chunks, activeStyleId, cursorX);
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(chunks, activeStyleId);
400
+ activeStyleId = nextBuffer.appendCloseActiveStyle(rowChunks, activeStyleId);
406
401
  if (tailClear) {
407
- chunks.push(ansiEscapes.eraseEndLine);
402
+ rowChunks.push(ansiEscapes.eraseEndLine);
408
403
  }
409
- chunks.push(ansiEscapes.cursorNextLine);
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
- chunks.push(cursorToColumn(toX));
416
+ rowChunks.push(cursorToColumn(toX));
422
417
  }
423
418
  else {
424
- chunks.push(cursorForward(delta));
419
+ rowChunks.push(cursorForward(delta));
425
420
  }
426
421
  cursorX = toX;
427
422
  }
428
423
  else {
429
- chunks.push(cursorToColumn(toX));
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
- chunks.push(cursorToColumn(start));
440
+ rowChunks.push(cursorToColumn(start));
446
441
  }
447
442
  else {
448
- chunks.push(cursorForward(start - cursorX));
443
+ rowChunks.push(cursorForward(start - cursorX));
449
444
  }
450
445
  cursorX = start;
451
446
  }
452
- const result = nextBuffer.appendStyledRange(row, start, end, chunks, activeStyleId, cursorX);
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
- chunks.push(cursorForward(delta));
464
+ rowChunks.push(cursorForward(delta));
470
465
  cursorX = segStart;
471
466
  }
472
467
  else if (delta < 0) {
473
- chunks.push(cursorToColumn(segStart));
468
+ rowChunks.push(cursorToColumn(segStart));
474
469
  cursorX = segStart;
475
470
  }
476
- const result = nextBuffer.appendStyledRange(row, segStart, segEnd, chunks, activeStyleId, cursorX);
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(chunks, activeStyleId);
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
- chunks.push(cursorToColumn(nextEnd));
481
+ rowChunks.push(cursorToColumn(nextEnd));
487
482
  }
488
483
  else {
489
- chunks.push(cursorForward(Math.max(0, nextEnd - cursorX)));
484
+ rowChunks.push(cursorForward(Math.max(0, nextEnd - cursorX)));
490
485
  }
491
486
  }
492
- chunks.push(ansiEscapes.eraseEndLine);
487
+ rowChunks.push(ansiEscapes.eraseEndLine);
493
488
  }
494
- chunks.push(ansiEscapes.cursorNextLine);
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
  }
@@ -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 = Math.max(1, stringWidth(char.value));
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.buffer.styleRegistry.getId(char.styles);
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
  }
@@ -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
  *
@@ -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 { colorize } from "../utils/colorize.js";
4
- /**
5
- * Renders the border for a DOM node.
6
- * Calculates border dimensions and draws border characters with specified
7
- * styles.
8
- *
9
- * @param x - The x-coordinate (column) where the border starts.
10
- * @param y - The y-coordinate (row) where the border starts.
11
- * @param node - The DOM node for which to render the border.
12
- * @param output - The output instance to write the border to.
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
- if (node.style.borderStyle) {
16
- const layout = node.taffyNode?.tree.getLayout(node.taffyNode.id);
17
- const width = layout?.width ?? 0;
18
- const height = layout?.height ?? 0;
19
- const box = typeof node.style.borderStyle === "string"
20
- ? boxStyles[node.style.borderStyle]
21
- : node.style.borderStyle;
22
- const topBorderColor = node.style.borderTopColor ?? node.style.borderColor;
23
- const bottomBorderColor = node.style.borderBottomColor ?? node.style.borderColor;
24
- const leftBorderColor = node.style.borderLeftColor ?? node.style.borderColor;
25
- const rightBorderColor = node.style.borderRightColor ?? node.style.borderColor;
26
- const dimTopBorderColor = node.style.borderTopDimColor ?? node.style.borderDimColor;
27
- const dimBottomBorderColor = node.style.borderBottomDimColor ?? node.style.borderDimColor;
28
- const dimLeftBorderColor = node.style.borderLeftDimColor ?? node.style.borderDimColor;
29
- const dimRightBorderColor = node.style.borderRightDimColor ?? node.style.borderDimColor;
30
- const showTopBorder = node.style.borderTop !== false;
31
- const showBottomBorder = node.style.borderBottom !== false;
32
- const showLeftBorder = node.style.borderLeft !== false;
33
- const showRightBorder = node.style.borderRight !== false;
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
- let rightBorder = (colorize(box.right, rightBorderColor, "foreground") + "\n").repeat(verticalBorderHeight);
55
- if (dimRightBorderColor) {
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
- let bottomBorder = showBottomBorder
59
- ? colorize((showLeftBorder ? box.bottomLeft : "") +
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
- const offsetY = showTopBorder ? 1 : 0;
67
- if (topBorder) {
68
- output.write(x, y, topBorder, { transformers: [] });
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.write(x, y + offsetY, leftBorder, { transformers: [] });
82
+ output.fill(x, bottomY, 1, box.bottomLeft, stringWidth(box.bottomLeft), styles);
72
83
  }
73
- if (showRightBorder) {
74
- output.write(x + width - 1, y + offsetY, rightBorder, {
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 (bottomBorder) {
79
- output.write(x, y + height - 1, bottomBorder, { transformers: [] });
87
+ if (showRightBorder) {
88
+ output.fill(x + width - 1, bottomY, 1, box.bottomRight, stringWidth(box.bottomRight), styles);
80
89
  }
81
90
  }
82
91
  };
@@ -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.
@@ -1,5 +1,6 @@
1
1
  import { renderNodeToOutput, renderNodeToScreenReaderOutput, } from "./render-node-to-output.js";
2
- import { Output } from "./output.js";
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 output = new Output({
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
- staticOutput = new Output({
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 { output: generatedOutput, height: outputHeight } = output.get();
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.get().output}\n` : "",
57
+ staticOutput: staticOutput ? `${staticOutput.buffer.toString()}\n` : "",
55
58
  };
56
59
  }
57
60
  return {
@@ -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,5 @@
1
+ export interface AnsiCode {
2
+ type: "ansi";
3
+ code: string;
4
+ endCode: string;
5
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -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 {};
@@ -5,49 +5,71 @@ const isNamedColor = (color) => {
5
5
  return color in ansis;
6
6
  };
7
7
  /**
8
- * Applies a color to a string using Ansis.
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 colorize = (str, color, type) => {
10
+ export const getStyle = (color, type) => {
17
11
  if (!color) {
18
- return str;
12
+ return undefined;
19
13
  }
20
14
  if (isNamedColor(color)) {
21
15
  if (type === "foreground") {
22
- return ansis[color](str);
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 ansis[methodName](str);
23
+ return {
24
+ type: "ansi",
25
+ code: ansis[methodName].open,
26
+ endCode: ansis[methodName].close,
27
+ };
26
28
  }
27
29
  if (color.startsWith("#")) {
28
- return type === "foreground"
29
- ? ansis.hex(color)(str)
30
- : ansis.bgHex(color)(str);
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 str;
40
+ return undefined;
36
41
  }
37
42
  const value = Number(matches[1]);
38
- return type === "foreground" ? ansis.fg(value)(str) : ansis.bg(value)(str);
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 str;
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
- return type === "foreground"
49
- ? ansis.rgb(firstValue, secondValue, thirdValue)(str)
50
- : ansis.bgRgb(firstValue, secondValue, thirdValue)(str);
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 { colorize } from "./colorize.js";
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 backgroundLine = colorize(" ".repeat(contentWidth), node.style.backgroundColor, "background");
28
+ const style = getStyle(node.style.backgroundColor, "background");
29
+ const styles = style ? [style] : [];
29
30
  for (let row = 0; row < contentHeight; row++) {
30
- output.write(x + leftBorderWidth, y + topBorderHeight + row, backgroundLine, { transformers: [] });
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
- let text = "";
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
- text += nodeText;
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.6.0",
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",