tinky 1.4.4 → 1.6.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/README.ja-JP.md +40 -0
- package/README.md +39 -0
- package/README.zh-CN.md +38 -0
- package/lib/components/Text.js +10 -3
- package/lib/core/cell-buffer.d.ts +89 -0
- package/lib/core/cell-buffer.js +288 -0
- package/lib/core/cell-log-update-run.d.ts +39 -0
- package/lib/core/cell-log-update-run.js +518 -0
- package/lib/core/cell-output.d.ts +13 -0
- package/lib/core/cell-output.js +107 -0
- package/lib/core/cell-renderer.d.ts +25 -0
- package/lib/core/cell-renderer.js +60 -0
- package/lib/core/incremental-rendering.d.ts +43 -0
- package/lib/core/incremental-rendering.js +22 -0
- package/lib/core/output.d.ts +18 -6
- package/lib/core/output.js +61 -8
- package/lib/core/render-border.d.ts +2 -2
- package/lib/core/render-node-to-output.d.ts +2 -2
- package/lib/core/render.d.ts +16 -4
- package/lib/core/tinky.d.ts +28 -3
- package/lib/core/tinky.js +127 -5
- package/lib/index.d.ts +1 -0
- package/lib/utils/ansi-escapes.d.ts +2 -0
- package/lib/utils/ansi-escapes.js +2 -0
- package/lib/utils/render-background.d.ts +2 -2
- package/package.json +1 -1
|
@@ -0,0 +1,518 @@
|
|
|
1
|
+
import ansiEscapes from "../utils/ansi-escapes.js";
|
|
2
|
+
import * as cliCursor from "../utils/cli-cursor.js";
|
|
3
|
+
const cursorToColumn = (x) => ansiEscapes.cursorTo(x);
|
|
4
|
+
const cursorForward = (count) => ansiEscapes.cursorForward(count);
|
|
5
|
+
/**
|
|
6
|
+
* Compares two cells at an absolute row/column, including style and width.
|
|
7
|
+
*/
|
|
8
|
+
const cellsEqualAt = (prevBuffer, nextBuffer, row, x) => {
|
|
9
|
+
const idx = row * nextBuffer.width + x;
|
|
10
|
+
return (prevBuffer.styleIds[idx] === nextBuffer.styleIds[idx] &&
|
|
11
|
+
prevBuffer.widths[idx] === nextBuffer.widths[idx] &&
|
|
12
|
+
prevBuffer.chars[idx] === nextBuffer.chars[idx]);
|
|
13
|
+
};
|
|
14
|
+
/**
|
|
15
|
+
* Rewinds to the first cell of a glyph when `x` lands in a continuation cell.
|
|
16
|
+
*/
|
|
17
|
+
const findGlyphStart = (buffer, row, x) => {
|
|
18
|
+
if (x <= 0) {
|
|
19
|
+
return 0;
|
|
20
|
+
}
|
|
21
|
+
const rowOffset = row * buffer.width;
|
|
22
|
+
let currentX = x;
|
|
23
|
+
while (currentX > 0 && buffer.widths[rowOffset + currentX] === 0) {
|
|
24
|
+
currentX--;
|
|
25
|
+
}
|
|
26
|
+
return currentX;
|
|
27
|
+
};
|
|
28
|
+
/**
|
|
29
|
+
* Extends an end position so we never split a wide character.
|
|
30
|
+
*/
|
|
31
|
+
const alignEndToGlyphBoundary = (buffer, row, end) => {
|
|
32
|
+
const width = buffer.width;
|
|
33
|
+
let safeEnd = Math.max(0, Math.min(width, end));
|
|
34
|
+
if (safeEnd <= 0 || safeEnd >= width) {
|
|
35
|
+
return safeEnd <= 0 ? 0 : width;
|
|
36
|
+
}
|
|
37
|
+
const rowOffset = row * width;
|
|
38
|
+
let x = safeEnd - 1;
|
|
39
|
+
while (x > 0 && buffer.widths[rowOffset + x] === 0) {
|
|
40
|
+
x--;
|
|
41
|
+
}
|
|
42
|
+
const cellWidth = buffer.widths[rowOffset + x] || 1;
|
|
43
|
+
const glyphEnd = x + cellWidth;
|
|
44
|
+
if (glyphEnd > safeEnd) {
|
|
45
|
+
safeEnd = Math.min(width, glyphEnd);
|
|
46
|
+
}
|
|
47
|
+
return safeEnd;
|
|
48
|
+
};
|
|
49
|
+
/**
|
|
50
|
+
* Finds changed cell runs for one row and normalizes them to glyph boundaries.
|
|
51
|
+
*/
|
|
52
|
+
const computeSegments = (prevBuffer, nextBuffer, row, scanEnd) => {
|
|
53
|
+
const width = nextBuffer.width;
|
|
54
|
+
const end = Math.max(0, Math.min(width, scanEnd));
|
|
55
|
+
const segments = [];
|
|
56
|
+
let inSegment = false;
|
|
57
|
+
let segmentStart = 0;
|
|
58
|
+
for (let x = 0; x < end; x++) {
|
|
59
|
+
const isDiff = !cellsEqualAt(prevBuffer, nextBuffer, row, x);
|
|
60
|
+
if (isDiff && !inSegment) {
|
|
61
|
+
inSegment = true;
|
|
62
|
+
segmentStart = x;
|
|
63
|
+
}
|
|
64
|
+
else if (!isDiff && inSegment) {
|
|
65
|
+
segments.push({ start: segmentStart, end: x });
|
|
66
|
+
inSegment = false;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
if (inSegment) {
|
|
70
|
+
segments.push({ start: segmentStart, end });
|
|
71
|
+
}
|
|
72
|
+
for (const segment of segments) {
|
|
73
|
+
segment.start = findGlyphStart(nextBuffer, row, segment.start);
|
|
74
|
+
segment.end = alignEndToGlyphBoundary(nextBuffer, row, segment.end);
|
|
75
|
+
}
|
|
76
|
+
segments.sort((a, b) => a.start - b.start);
|
|
77
|
+
const merged = [];
|
|
78
|
+
for (const segment of segments) {
|
|
79
|
+
if (merged.length === 0) {
|
|
80
|
+
merged.push({ ...segment });
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
const last = merged.at(-1);
|
|
84
|
+
if (!last) {
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
if (segment.start <= last.end) {
|
|
88
|
+
last.end = Math.max(last.end, segment.end);
|
|
89
|
+
}
|
|
90
|
+
else {
|
|
91
|
+
merged.push({ ...segment });
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return merged;
|
|
95
|
+
};
|
|
96
|
+
/**
|
|
97
|
+
* Merges nearby segments using a fixed gap threshold.
|
|
98
|
+
*/
|
|
99
|
+
const mergeByGapThreshold = (segments, mergeThreshold) => {
|
|
100
|
+
if (segments.length <= 1) {
|
|
101
|
+
return segments;
|
|
102
|
+
}
|
|
103
|
+
const merged = [];
|
|
104
|
+
for (const segment of segments) {
|
|
105
|
+
if (merged.length === 0) {
|
|
106
|
+
merged.push({ ...segment });
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
const last = merged.at(-1);
|
|
110
|
+
if (!last) {
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
const gap = segment.start - last.end;
|
|
114
|
+
if (gap <= mergeThreshold) {
|
|
115
|
+
last.end = Math.max(last.end, segment.end);
|
|
116
|
+
}
|
|
117
|
+
else {
|
|
118
|
+
merged.push({ ...segment });
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
return merged;
|
|
122
|
+
};
|
|
123
|
+
const asciiByteLength = (value) => value.length;
|
|
124
|
+
const utf8ByteLength = (value) => {
|
|
125
|
+
let bytes = 0;
|
|
126
|
+
for (const char of value) {
|
|
127
|
+
const codePoint = char.codePointAt(0) ?? 0;
|
|
128
|
+
if (codePoint <= 0x7f) {
|
|
129
|
+
bytes += 1;
|
|
130
|
+
}
|
|
131
|
+
else if (codePoint <= 0x7ff) {
|
|
132
|
+
bytes += 2;
|
|
133
|
+
}
|
|
134
|
+
else if (codePoint <= 0xffff) {
|
|
135
|
+
bytes += 3;
|
|
136
|
+
}
|
|
137
|
+
else {
|
|
138
|
+
bytes += 4;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
return bytes;
|
|
142
|
+
};
|
|
143
|
+
const digits10 = (input) => {
|
|
144
|
+
let value = input;
|
|
145
|
+
let digits = 1;
|
|
146
|
+
while (value >= 10) {
|
|
147
|
+
value = Math.floor(value / 10);
|
|
148
|
+
digits++;
|
|
149
|
+
}
|
|
150
|
+
return digits;
|
|
151
|
+
};
|
|
152
|
+
const bytesCursorForward = (delta) => {
|
|
153
|
+
if (delta <= 0) {
|
|
154
|
+
return 0;
|
|
155
|
+
}
|
|
156
|
+
return 3 + digits10(delta);
|
|
157
|
+
};
|
|
158
|
+
const bytesCursorToColumn = (column0) => {
|
|
159
|
+
const column = Math.max(0, column0) + 1;
|
|
160
|
+
return 3 + digits10(column);
|
|
161
|
+
};
|
|
162
|
+
/**
|
|
163
|
+
* Chooses between relative forward movement and absolute column movement based
|
|
164
|
+
* on encoded byte cost.
|
|
165
|
+
*/
|
|
166
|
+
const chooseHorizontalMove = (fromX, toX) => {
|
|
167
|
+
if (toX <= fromX) {
|
|
168
|
+
return { bytes: bytesCursorToColumn(toX), method: "abs" };
|
|
169
|
+
}
|
|
170
|
+
const delta = toX - fromX;
|
|
171
|
+
const forwardBytes = bytesCursorForward(delta);
|
|
172
|
+
const absoluteBytes = bytesCursorToColumn(toX);
|
|
173
|
+
if (forwardBytes <= absoluteBytes) {
|
|
174
|
+
return { bytes: forwardBytes, method: "forward" };
|
|
175
|
+
}
|
|
176
|
+
return { bytes: absoluteBytes, method: "abs" };
|
|
177
|
+
};
|
|
178
|
+
const getStyleLens = (buffer, styleLenCache, styleId) => {
|
|
179
|
+
const cached = styleLenCache.get(styleId);
|
|
180
|
+
if (cached) {
|
|
181
|
+
return cached;
|
|
182
|
+
}
|
|
183
|
+
const styles = buffer.styleRegistry.getStyles(styleId);
|
|
184
|
+
let open = 0;
|
|
185
|
+
let close = 0;
|
|
186
|
+
for (const style of styles) {
|
|
187
|
+
open += asciiByteLength(style.code);
|
|
188
|
+
close += asciiByteLength(style.endCode);
|
|
189
|
+
}
|
|
190
|
+
const lens = { open, close };
|
|
191
|
+
styleLenCache.set(styleId, lens);
|
|
192
|
+
return lens;
|
|
193
|
+
};
|
|
194
|
+
const transitionBytes = (buffer, styleLenCache, fromId, toId) => {
|
|
195
|
+
if (fromId === toId) {
|
|
196
|
+
return 0;
|
|
197
|
+
}
|
|
198
|
+
const from = getStyleLens(buffer, styleLenCache, fromId);
|
|
199
|
+
const to = getStyleLens(buffer, styleLenCache, toId);
|
|
200
|
+
return from.close + to.open;
|
|
201
|
+
};
|
|
202
|
+
const closeBytes = (buffer, styleLenCache, activeStyleId) => {
|
|
203
|
+
if (activeStyleId === 0) {
|
|
204
|
+
return 0;
|
|
205
|
+
}
|
|
206
|
+
return getStyleLens(buffer, styleLenCache, activeStyleId).close;
|
|
207
|
+
};
|
|
208
|
+
/**
|
|
209
|
+
* Estimates bytes written for a styled range and returns resulting cursor/style.
|
|
210
|
+
*/
|
|
211
|
+
const measureStyledRange = (buffer, styleLenCache, row, start, end, activeStyleId, cursorX) => {
|
|
212
|
+
const width = buffer.width;
|
|
213
|
+
const safeStart = Math.max(0, Math.min(width, start));
|
|
214
|
+
const safeEnd = Math.max(0, Math.min(width, end));
|
|
215
|
+
if (safeEnd <= safeStart) {
|
|
216
|
+
return { bytes: 0, activeStyleId, cursorX };
|
|
217
|
+
}
|
|
218
|
+
let bytes = 0;
|
|
219
|
+
const rowOffset = row * width;
|
|
220
|
+
let x = safeStart;
|
|
221
|
+
while (x < safeEnd) {
|
|
222
|
+
const index = rowOffset + x;
|
|
223
|
+
const cellWidth = buffer.widths[index];
|
|
224
|
+
if (cellWidth === 0) {
|
|
225
|
+
x++;
|
|
226
|
+
continue;
|
|
227
|
+
}
|
|
228
|
+
const styleId = buffer.styleIds[index] ?? 0;
|
|
229
|
+
if (styleId !== activeStyleId) {
|
|
230
|
+
bytes += transitionBytes(buffer, styleLenCache, activeStyleId, styleId);
|
|
231
|
+
activeStyleId = styleId;
|
|
232
|
+
}
|
|
233
|
+
const char = buffer.chars[index] ?? " ";
|
|
234
|
+
bytes += utf8ByteLength(char);
|
|
235
|
+
const advance = cellWidth || 1;
|
|
236
|
+
cursorX += advance;
|
|
237
|
+
x += advance;
|
|
238
|
+
}
|
|
239
|
+
return { bytes, activeStyleId, cursorX };
|
|
240
|
+
};
|
|
241
|
+
/**
|
|
242
|
+
* Builds row operations and compares patch cost versus full-row redraw cost.
|
|
243
|
+
*/
|
|
244
|
+
const planRowOpsWithCostModel = (nextBuffer, styleLenCache, row, segments, nextEnd, tailClear, overwriteGapPenaltyBytes) => {
|
|
245
|
+
const width = nextBuffer.width;
|
|
246
|
+
const ops = [];
|
|
247
|
+
let patchBytes = 0;
|
|
248
|
+
let cursorX = 0;
|
|
249
|
+
let activeStyleId = 0;
|
|
250
|
+
for (const segment of segments) {
|
|
251
|
+
let segStart = segment.start;
|
|
252
|
+
const segEnd = Math.min(width, segment.end);
|
|
253
|
+
if (segStart < cursorX) {
|
|
254
|
+
segStart = cursorX;
|
|
255
|
+
}
|
|
256
|
+
if (segStart >= segEnd) {
|
|
257
|
+
continue;
|
|
258
|
+
}
|
|
259
|
+
if (segStart > cursorX) {
|
|
260
|
+
const move = chooseHorizontalMove(cursorX, segStart);
|
|
261
|
+
const split = measureStyledRange(nextBuffer, styleLenCache, row, segStart, segEnd, activeStyleId, segStart);
|
|
262
|
+
const splitCost = move.bytes + split.bytes;
|
|
263
|
+
const gap = measureStyledRange(nextBuffer, styleLenCache, row, cursorX, segStart, activeStyleId, cursorX);
|
|
264
|
+
const mergedSegment = measureStyledRange(nextBuffer, styleLenCache, row, segStart, segEnd, gap.activeStyleId, segStart);
|
|
265
|
+
const mergeCost = gap.bytes + mergedSegment.bytes + overwriteGapPenaltyBytes;
|
|
266
|
+
if (mergeCost <= splitCost) {
|
|
267
|
+
ops.push({ kind: "write", start: cursorX, end: segStart });
|
|
268
|
+
patchBytes += gap.bytes;
|
|
269
|
+
cursorX = gap.cursorX;
|
|
270
|
+
activeStyleId = gap.activeStyleId;
|
|
271
|
+
ops.push({ kind: "write", start: segStart, end: segEnd });
|
|
272
|
+
patchBytes += mergedSegment.bytes;
|
|
273
|
+
cursorX = mergedSegment.cursorX;
|
|
274
|
+
activeStyleId = mergedSegment.activeStyleId;
|
|
275
|
+
}
|
|
276
|
+
else {
|
|
277
|
+
ops.push({ kind: "move", toX: segStart, method: move.method });
|
|
278
|
+
patchBytes += move.bytes;
|
|
279
|
+
cursorX = segStart;
|
|
280
|
+
ops.push({ kind: "write", start: segStart, end: segEnd });
|
|
281
|
+
patchBytes += split.bytes;
|
|
282
|
+
cursorX = split.cursorX;
|
|
283
|
+
activeStyleId = split.activeStyleId;
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
else {
|
|
287
|
+
const fullSegment = measureStyledRange(nextBuffer, styleLenCache, row, segStart, segEnd, activeStyleId, cursorX);
|
|
288
|
+
ops.push({ kind: "write", start: segStart, end: segEnd });
|
|
289
|
+
patchBytes += fullSegment.bytes;
|
|
290
|
+
cursorX = fullSegment.cursorX;
|
|
291
|
+
activeStyleId = fullSegment.activeStyleId;
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
patchBytes += closeBytes(nextBuffer, styleLenCache, activeStyleId);
|
|
295
|
+
activeStyleId = 0;
|
|
296
|
+
if (tailClear) {
|
|
297
|
+
if (cursorX !== nextEnd) {
|
|
298
|
+
patchBytes += chooseHorizontalMove(cursorX, nextEnd).bytes;
|
|
299
|
+
cursorX = nextEnd;
|
|
300
|
+
}
|
|
301
|
+
patchBytes += asciiByteLength(ansiEscapes.eraseEndLine);
|
|
302
|
+
}
|
|
303
|
+
patchBytes += asciiByteLength(ansiEscapes.cursorNextLine);
|
|
304
|
+
let fullBytes = 0;
|
|
305
|
+
const fullRange = measureStyledRange(nextBuffer, styleLenCache, row, 0, nextEnd, 0, 0);
|
|
306
|
+
fullBytes += fullRange.bytes;
|
|
307
|
+
fullBytes += closeBytes(nextBuffer, styleLenCache, fullRange.activeStyleId);
|
|
308
|
+
if (tailClear) {
|
|
309
|
+
fullBytes += asciiByteLength(ansiEscapes.eraseEndLine);
|
|
310
|
+
}
|
|
311
|
+
fullBytes += asciiByteLength(ansiEscapes.cursorNextLine);
|
|
312
|
+
if (patchBytes >= fullBytes) {
|
|
313
|
+
return { useFullRow: true, ops: [] };
|
|
314
|
+
}
|
|
315
|
+
return { useFullRow: false, ops };
|
|
316
|
+
};
|
|
317
|
+
/**
|
|
318
|
+
* Creates a run-diff renderer bound to one output stream.
|
|
319
|
+
*/
|
|
320
|
+
const create = (stream, options = {}) => {
|
|
321
|
+
const incremental = options.incremental ?? true;
|
|
322
|
+
const showCursor = options.showCursor ?? false;
|
|
323
|
+
const mergeStrategy = options.mergeStrategy ?? "cost";
|
|
324
|
+
const mergeThreshold = Math.max(0, options.mergeThreshold ?? 2);
|
|
325
|
+
const maxSegmentsBeforeFullRow = Math.max(1, options.maxSegmentsBeforeFullRow ?? 12);
|
|
326
|
+
const overwriteGapPenaltyBytes = Math.max(0, options.overwriteGapPenaltyBytes ?? 0);
|
|
327
|
+
const styleLenCache = new Map();
|
|
328
|
+
let previousHeight = 0;
|
|
329
|
+
let hasHiddenCursor = false;
|
|
330
|
+
const previousFrameLineCount = () => previousHeight > 0 ? previousHeight + 1 : 0;
|
|
331
|
+
const render = ((prevBuffer, nextBuffer, renderOptions = {}) => {
|
|
332
|
+
const forceFull = renderOptions.forceFull ?? false;
|
|
333
|
+
if (!showCursor && !hasHiddenCursor) {
|
|
334
|
+
cliCursor.hide(stream);
|
|
335
|
+
hasHiddenCursor = true;
|
|
336
|
+
}
|
|
337
|
+
const nextHeight = nextBuffer.height;
|
|
338
|
+
const dimsChanged = previousHeight === 0 ||
|
|
339
|
+
prevBuffer.width !== nextBuffer.width ||
|
|
340
|
+
prevBuffer.height !== nextBuffer.height;
|
|
341
|
+
// Full redraw path: used on first frame, dimension changes, or forced mode.
|
|
342
|
+
if (forceFull || !incremental || dimsChanged) {
|
|
343
|
+
const output = nextBuffer.toString();
|
|
344
|
+
stream.write(ansiEscapes.eraseLines(previousFrameLineCount()) + output + "\n");
|
|
345
|
+
previousHeight = nextHeight;
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
348
|
+
const prevCount = previousHeight + 1;
|
|
349
|
+
const nextCount = nextHeight + 1;
|
|
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
|
+
}
|
|
359
|
+
const width = nextBuffer.width;
|
|
360
|
+
// Incremental path: process one row at a time and emit minimal writes.
|
|
361
|
+
for (let row = 0; row < visibleCount; row++) {
|
|
362
|
+
if (prevBuffer.isRowEqual(nextBuffer, row)) {
|
|
363
|
+
chunks.push(ansiEscapes.cursorNextLine);
|
|
364
|
+
continue;
|
|
365
|
+
}
|
|
366
|
+
chunks.push(cursorToColumn(0));
|
|
367
|
+
const nextEnd = nextBuffer.getRowRightEdge(row);
|
|
368
|
+
const prevEnd = prevBuffer.getRowRightEdge(row);
|
|
369
|
+
const tailClear = prevEnd > nextEnd;
|
|
370
|
+
const scanEnd = tailClear ? nextEnd : Math.max(nextEnd, prevEnd);
|
|
371
|
+
if (scanEnd === 0 && tailClear) {
|
|
372
|
+
chunks.push(ansiEscapes.eraseEndLine);
|
|
373
|
+
chunks.push(ansiEscapes.cursorNextLine);
|
|
374
|
+
continue;
|
|
375
|
+
}
|
|
376
|
+
let segments = computeSegments(prevBuffer, nextBuffer, row, scanEnd);
|
|
377
|
+
if (mergeStrategy === "threshold") {
|
|
378
|
+
segments = mergeByGapThreshold(segments, mergeThreshold);
|
|
379
|
+
}
|
|
380
|
+
if (segments.length > maxSegmentsBeforeFullRow) {
|
|
381
|
+
const full = nextBuffer.appendStyledRange(row, 0, nextEnd, chunks, 0, 0);
|
|
382
|
+
let activeStyleId = full.activeStyleId;
|
|
383
|
+
activeStyleId = nextBuffer.appendCloseActiveStyle(chunks, activeStyleId);
|
|
384
|
+
if (tailClear) {
|
|
385
|
+
chunks.push(ansiEscapes.eraseEndLine);
|
|
386
|
+
}
|
|
387
|
+
chunks.push(ansiEscapes.cursorNextLine);
|
|
388
|
+
continue;
|
|
389
|
+
}
|
|
390
|
+
if (segments.length === 0) {
|
|
391
|
+
if (tailClear) {
|
|
392
|
+
chunks.push(ansiEscapes.eraseEndLine);
|
|
393
|
+
}
|
|
394
|
+
chunks.push(ansiEscapes.cursorNextLine);
|
|
395
|
+
continue;
|
|
396
|
+
}
|
|
397
|
+
let cursorX = 0;
|
|
398
|
+
let activeStyleId = 0;
|
|
399
|
+
if (mergeStrategy === "cost") {
|
|
400
|
+
const plan = planRowOpsWithCostModel(nextBuffer, styleLenCache, row, segments, nextEnd, tailClear, overwriteGapPenaltyBytes);
|
|
401
|
+
if (plan.useFullRow) {
|
|
402
|
+
const full = nextBuffer.appendStyledRange(row, 0, nextEnd, chunks, activeStyleId, cursorX);
|
|
403
|
+
cursorX = full.cursorX;
|
|
404
|
+
activeStyleId = full.activeStyleId;
|
|
405
|
+
activeStyleId = nextBuffer.appendCloseActiveStyle(chunks, activeStyleId);
|
|
406
|
+
if (tailClear) {
|
|
407
|
+
chunks.push(ansiEscapes.eraseEndLine);
|
|
408
|
+
}
|
|
409
|
+
chunks.push(ansiEscapes.cursorNextLine);
|
|
410
|
+
continue;
|
|
411
|
+
}
|
|
412
|
+
for (const op of plan.ops) {
|
|
413
|
+
if (op.kind === "move") {
|
|
414
|
+
const toX = Math.max(0, Math.min(width, op.toX));
|
|
415
|
+
const delta = toX - cursorX;
|
|
416
|
+
if (delta === 0) {
|
|
417
|
+
continue;
|
|
418
|
+
}
|
|
419
|
+
if (delta > 0) {
|
|
420
|
+
if (op.method === "abs") {
|
|
421
|
+
chunks.push(cursorToColumn(toX));
|
|
422
|
+
}
|
|
423
|
+
else {
|
|
424
|
+
chunks.push(cursorForward(delta));
|
|
425
|
+
}
|
|
426
|
+
cursorX = toX;
|
|
427
|
+
}
|
|
428
|
+
else {
|
|
429
|
+
chunks.push(cursorToColumn(toX));
|
|
430
|
+
cursorX = toX;
|
|
431
|
+
}
|
|
432
|
+
continue;
|
|
433
|
+
}
|
|
434
|
+
let start = op.start;
|
|
435
|
+
const end = op.end;
|
|
436
|
+
if (start < cursorX) {
|
|
437
|
+
start = cursorX;
|
|
438
|
+
}
|
|
439
|
+
if (start >= end) {
|
|
440
|
+
continue;
|
|
441
|
+
}
|
|
442
|
+
if (start !== cursorX) {
|
|
443
|
+
const move = chooseHorizontalMove(cursorX, start);
|
|
444
|
+
if (move.method === "abs") {
|
|
445
|
+
chunks.push(cursorToColumn(start));
|
|
446
|
+
}
|
|
447
|
+
else {
|
|
448
|
+
chunks.push(cursorForward(start - cursorX));
|
|
449
|
+
}
|
|
450
|
+
cursorX = start;
|
|
451
|
+
}
|
|
452
|
+
const result = nextBuffer.appendStyledRange(row, start, end, chunks, activeStyleId, cursorX);
|
|
453
|
+
cursorX = result.cursorX;
|
|
454
|
+
activeStyleId = result.activeStyleId;
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
else {
|
|
458
|
+
for (const segment of segments) {
|
|
459
|
+
let segStart = segment.start;
|
|
460
|
+
const segEnd = Math.min(width, segment.end);
|
|
461
|
+
if (segStart < cursorX) {
|
|
462
|
+
segStart = cursorX;
|
|
463
|
+
}
|
|
464
|
+
if (segStart >= segEnd) {
|
|
465
|
+
continue;
|
|
466
|
+
}
|
|
467
|
+
const delta = segStart - cursorX;
|
|
468
|
+
if (delta > 0) {
|
|
469
|
+
chunks.push(cursorForward(delta));
|
|
470
|
+
cursorX = segStart;
|
|
471
|
+
}
|
|
472
|
+
else if (delta < 0) {
|
|
473
|
+
chunks.push(cursorToColumn(segStart));
|
|
474
|
+
cursorX = segStart;
|
|
475
|
+
}
|
|
476
|
+
const result = nextBuffer.appendStyledRange(row, segStart, segEnd, chunks, activeStyleId, cursorX);
|
|
477
|
+
cursorX = result.cursorX;
|
|
478
|
+
activeStyleId = result.activeStyleId;
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
activeStyleId = nextBuffer.appendCloseActiveStyle(chunks, activeStyleId);
|
|
482
|
+
if (tailClear) {
|
|
483
|
+
if (cursorX !== nextEnd) {
|
|
484
|
+
const move = chooseHorizontalMove(cursorX, nextEnd);
|
|
485
|
+
if (move.method === "abs") {
|
|
486
|
+
chunks.push(cursorToColumn(nextEnd));
|
|
487
|
+
}
|
|
488
|
+
else {
|
|
489
|
+
chunks.push(cursorForward(Math.max(0, nextEnd - cursorX)));
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
chunks.push(ansiEscapes.eraseEndLine);
|
|
493
|
+
}
|
|
494
|
+
chunks.push(ansiEscapes.cursorNextLine);
|
|
495
|
+
}
|
|
496
|
+
stream.write(chunks.join(""));
|
|
497
|
+
previousHeight = nextHeight;
|
|
498
|
+
});
|
|
499
|
+
render.clear = () => {
|
|
500
|
+
stream.write(ansiEscapes.eraseLines(previousFrameLineCount()));
|
|
501
|
+
previousHeight = 0;
|
|
502
|
+
};
|
|
503
|
+
render.reset = () => {
|
|
504
|
+
previousHeight = 0;
|
|
505
|
+
};
|
|
506
|
+
render.sync = (buffer) => {
|
|
507
|
+
previousHeight = buffer.height;
|
|
508
|
+
};
|
|
509
|
+
render.done = () => {
|
|
510
|
+
previousHeight = 0;
|
|
511
|
+
if (!showCursor && hasHiddenCursor) {
|
|
512
|
+
cliCursor.show(stream);
|
|
513
|
+
hasHiddenCursor = false;
|
|
514
|
+
}
|
|
515
|
+
};
|
|
516
|
+
return render;
|
|
517
|
+
};
|
|
518
|
+
export const cellLogUpdateRun = { create };
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Output implementation that writes directly into CellBuffer.
|
|
3
|
+
*/
|
|
4
|
+
import { type CellBuffer } from "./cell-buffer.js";
|
|
5
|
+
import { type Clip, type OutputLike, type OutputWriteOptions } from "./output.js";
|
|
6
|
+
export declare class CellOutput implements OutputLike {
|
|
7
|
+
private readonly clips;
|
|
8
|
+
readonly buffer: CellBuffer;
|
|
9
|
+
constructor(buffer: CellBuffer);
|
|
10
|
+
clip(clip: Clip): void;
|
|
11
|
+
unclip(): void;
|
|
12
|
+
write(x: number, y: number, text: string, options: OutputWriteOptions): void;
|
|
13
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Output implementation that writes directly into CellBuffer.
|
|
3
|
+
*/
|
|
4
|
+
import sliceAnsi from "slice-ansi";
|
|
5
|
+
import stringWidth from "string-width";
|
|
6
|
+
import widestLine from "widest-line";
|
|
7
|
+
import { styledCharsFromTokens, tokenize } from "@alcalzone/ansi-tokenize";
|
|
8
|
+
export class CellOutput {
|
|
9
|
+
clips = [];
|
|
10
|
+
buffer;
|
|
11
|
+
constructor(buffer) {
|
|
12
|
+
this.buffer = buffer;
|
|
13
|
+
}
|
|
14
|
+
clip(clip) {
|
|
15
|
+
this.clips.push(clip);
|
|
16
|
+
}
|
|
17
|
+
unclip() {
|
|
18
|
+
this.clips.pop();
|
|
19
|
+
}
|
|
20
|
+
write(x, y, text, options) {
|
|
21
|
+
const { transformers } = options;
|
|
22
|
+
if (!text) {
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
let writeX = x;
|
|
26
|
+
let writeY = y;
|
|
27
|
+
let lines = text.split("\n");
|
|
28
|
+
// Match Output.get() semantics: transformed lines may intentionally keep
|
|
29
|
+
// trailing spaces (for alignment or visual effects), so mark them.
|
|
30
|
+
const preserveLineTrailingSpaces = transformers.length > 0;
|
|
31
|
+
const clip = this.clips.at(-1);
|
|
32
|
+
if (clip) {
|
|
33
|
+
const clipHorizontally = typeof clip.x1 === "number" && typeof clip.x2 === "number";
|
|
34
|
+
const clipVertically = typeof clip.y1 === "number" && typeof clip.y2 === "number";
|
|
35
|
+
const clipX1 = clip.x1 ?? 0;
|
|
36
|
+
const clipX2 = clip.x2 ?? 0;
|
|
37
|
+
const clipY1 = clip.y1 ?? 0;
|
|
38
|
+
const clipY2 = clip.y2 ?? 0;
|
|
39
|
+
if (clipHorizontally) {
|
|
40
|
+
const width = widestLine(text);
|
|
41
|
+
if (writeX + width < clipX1 || writeX > clipX2) {
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
if (clipVertically) {
|
|
46
|
+
const height = lines.length;
|
|
47
|
+
if (writeY + height < clipY1 || writeY > clipY2) {
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
if (clipHorizontally) {
|
|
52
|
+
lines = lines.map((line) => {
|
|
53
|
+
const from = writeX < clipX1 ? clipX1 - writeX : 0;
|
|
54
|
+
const width = stringWidth(line);
|
|
55
|
+
const to = writeX + width > clipX2 ? clipX2 - writeX : width;
|
|
56
|
+
return sliceAnsi(line, from, to);
|
|
57
|
+
});
|
|
58
|
+
if (writeX < clipX1) {
|
|
59
|
+
writeX = clipX1;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
if (clipVertically) {
|
|
63
|
+
const from = writeY < clipY1 ? clipY1 - writeY : 0;
|
|
64
|
+
const height = lines.length;
|
|
65
|
+
const to = writeY + height > clipY2 ? clipY2 - writeY : height;
|
|
66
|
+
lines = lines.slice(from, to);
|
|
67
|
+
if (writeY < clipY1) {
|
|
68
|
+
writeY = clipY1;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
let offsetY = 0;
|
|
73
|
+
for (const [lineIndex, line] of lines.entries()) {
|
|
74
|
+
const row = writeY + offsetY;
|
|
75
|
+
if (row < 0) {
|
|
76
|
+
offsetY++;
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
if (row >= this.buffer.height) {
|
|
80
|
+
break;
|
|
81
|
+
}
|
|
82
|
+
let transformedLine = line;
|
|
83
|
+
for (const transformer of transformers) {
|
|
84
|
+
transformedLine = transformer(transformedLine, lineIndex);
|
|
85
|
+
}
|
|
86
|
+
const styledChars = styledCharsFromTokens(tokenize(transformedLine));
|
|
87
|
+
let offsetX = writeX;
|
|
88
|
+
for (const char of styledChars) {
|
|
89
|
+
const charWidth = Math.max(1, stringWidth(char.value));
|
|
90
|
+
if (offsetX < 0) {
|
|
91
|
+
offsetX += charWidth;
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
if (offsetX >= this.buffer.width) {
|
|
95
|
+
break;
|
|
96
|
+
}
|
|
97
|
+
if (offsetX + charWidth > this.buffer.width) {
|
|
98
|
+
break;
|
|
99
|
+
}
|
|
100
|
+
const styleId = this.buffer.styleRegistry.getId(char.styles);
|
|
101
|
+
this.buffer.setCell(offsetX, row, char.value, charWidth, styleId, preserveLineTrailingSpaces);
|
|
102
|
+
offsetX += charWidth;
|
|
103
|
+
}
|
|
104
|
+
offsetY++;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { type DOMElement } from "./dom.js";
|
|
2
|
+
import { type CellBuffer } from "./cell-buffer.js";
|
|
3
|
+
interface CellRendererResult {
|
|
4
|
+
/** Interactive frame rendered into `buffer`. */
|
|
5
|
+
buffer: CellBuffer;
|
|
6
|
+
/** Height (rows) of the interactive frame. */
|
|
7
|
+
outputHeight: number;
|
|
8
|
+
/** Newly appended `<Static>` output, with trailing newline when present. */
|
|
9
|
+
staticOutput: string;
|
|
10
|
+
/** Screen-reader text when screen-reader mode is enabled. */
|
|
11
|
+
screenReaderOutput?: string;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Renderer that writes interactive output into a CellBuffer.
|
|
15
|
+
*
|
|
16
|
+
* Static output is still rendered as string output because it is emitted once
|
|
17
|
+
* and not updated incrementally.
|
|
18
|
+
*
|
|
19
|
+
* @param node - Root DOM node to render.
|
|
20
|
+
* @param buffer - Reusable destination buffer for the interactive frame.
|
|
21
|
+
* @param isScreenReaderEnabled - Enables screen-reader rendering path.
|
|
22
|
+
* @returns The rendered interactive frame and any static output emitted this pass.
|
|
23
|
+
*/
|
|
24
|
+
export declare const cellRenderer: (node: DOMElement, buffer: CellBuffer, isScreenReaderEnabled: boolean) => CellRendererResult;
|
|
25
|
+
export {};
|