@valyrianjs/terminal 0.2.2 → 0.2.4

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/src/ansi.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { terminalCellWidth, terminalGraphemes } from "./text.js";
1
+ import { dropTerminalCells, terminalCellWidth, terminalGraphemes } from "./text.js";
2
2
  import { resolveTerminalStyle, resolveTerminalStyleToken } from "./theme.js";
3
3
  import type { CursorPosition, TerminalFrame, TerminalStyleSpan, TerminalTheme } from "./types.js";
4
4
 
@@ -29,12 +29,28 @@ function spanLayerPriority(span: TerminalStyleSpan) {
29
29
  return span.kind === "focus" ? 0 : 1;
30
30
  }
31
31
 
32
- function lineSpans(spans: TerminalStyleSpan[], y: number) {
32
+ type RowSpanEntry = {
33
+ span: TerminalStyleSpan;
34
+ index: number;
35
+ };
36
+
37
+ function compareSpanStart(a: RowSpanEntry, b: RowSpanEntry) {
38
+ return a.span.x1 - b.span.x1 || compareSpanPaintOrder(a, b) || b.span.x2 - a.span.x2;
39
+ }
40
+
41
+ function compareSpanPaintOrder(a: RowSpanEntry, b: RowSpanEntry) {
42
+ return spanLayerPriority(a.span) - spanLayerPriority(b.span) || a.index - b.index;
43
+ }
44
+
45
+ function lineSpanEntries(spans: TerminalStyleSpan[], y: number) {
33
46
  return spans
34
47
  .map((span, index) => ({ span, index }))
35
48
  .filter(({ span }) => span.y === y)
36
- .sort((a, b) => a.span.x1 - b.span.x1 || spanLayerPriority(a.span) - spanLayerPriority(b.span) || b.span.x2 - a.span.x2 || a.index - b.index)
37
- .map(({ span }) => span);
49
+ .sort(compareSpanStart);
50
+ }
51
+
52
+ function lineSpans(spans: TerminalStyleSpan[], y: number) {
53
+ return lineSpanEntries(spans, y).map(({ span }) => span);
38
54
  }
39
55
 
40
56
  function tokenStyle(token: ReturnType<typeof resolveTerminalStyleToken>) {
@@ -95,65 +111,110 @@ function colorAnsi(value: string | undefined, background: boolean) {
95
111
  return `\u001b[${background ? 48 : 38};2;${red};${green};${blue}m`;
96
112
  }
97
113
 
98
- function renderAnsiLine(line: string, spans: TerminalStyleSpan[], y: number, theme?: TerminalTheme) {
99
- let output = "";
100
- let visibleColumn = 1;
101
- const rowSpans = lineSpans(spans, y);
102
- let activeSpanIndex = 0;
114
+ function sameActiveAnsiStack(previous: RowSpanEntry[], next: RowSpanEntry[]) {
115
+ if (previous.length !== next.length) {
116
+ return false;
117
+ }
118
+ return previous.every((entry, index) => entry.index === next[index]?.index);
119
+ }
103
120
 
104
- for (const grapheme of terminalGraphemes(line)) {
105
- while (rowSpans[activeSpanIndex]?.x1 === visibleColumn) {
106
- output += spanAnsiOpen(rowSpans[activeSpanIndex], theme);
107
- activeSpanIndex += 1;
108
- }
121
+ function spanContains(container: TerminalStyleSpan, contained: TerminalStyleSpan) {
122
+ return container.y === contained.y && container.x1 <= contained.x1 && container.x2 >= contained.x2 && (container.x1 < contained.x1 || container.x2 > contained.x2);
123
+ }
109
124
 
110
- if (grapheme === "|") {
111
- continue;
112
- }
125
+ function hasLaterContainedSpan(entry: RowSpanEntry, rowEntries: RowSpanEntry[]) {
126
+ return rowEntries.some((candidate) => candidate.index > entry.index && spanContains(entry.span, candidate.span));
127
+ }
113
128
 
114
- output += grapheme;
115
- visibleColumn += terminalCellWidth(grapheme);
129
+ function compareActiveSpanPaintOrder(rowEntries: RowSpanEntry[], a: RowSpanEntry, b: RowSpanEntry) {
130
+ const priority = spanLayerPriority(a.span) - spanLayerPriority(b.span);
131
+ if (priority !== 0) {
132
+ return priority;
133
+ }
116
134
 
117
- let closedSpan = false;
118
- for (const span of rowSpans) {
119
- if (span.x2 === visibleColumn) {
120
- if (span.kind !== "focus") {
121
- output += spanAnsiClose(span, theme);
122
- closedSpan = true;
123
- }
124
- }
135
+ const aContainsB = spanContains(a.span, b.span);
136
+ const bContainsA = spanContains(b.span, a.span);
137
+ if (aContainsB && !bContainsA) {
138
+ if (a.index < b.index) {
139
+ return -1;
125
140
  }
126
- for (const span of rowSpans) {
127
- if (span.x2 === visibleColumn && span.kind === "focus") {
128
- output += spanAnsiClose(span, theme);
129
- closedSpan = true;
130
- }
131
- }
132
- if (closedSpan && visibleColumn <= terminalCellWidth(line)) {
133
- for (const span of rowSpans) {
134
- if (span.x1 < visibleColumn && span.x2 > visibleColumn) {
135
- output += spanAnsiOpen(span, theme);
136
- }
137
- }
141
+ return hasLaterContainedSpan(a, rowEntries) ? 1 : -1;
142
+ }
143
+ if (bContainsA && !aContainsB) {
144
+ if (b.index < a.index) {
145
+ return 1;
138
146
  }
147
+ return hasLaterContainedSpan(b, rowEntries) ? -1 : 1;
139
148
  }
140
149
 
141
- for (const span of rowSpans) {
142
- if (span.x1 < visibleColumn && span.x2 > visibleColumn) {
143
- if (span.kind !== "focus") {
144
- output += spanAnsiClose(span, theme);
145
- }
150
+ return a.index - b.index;
151
+ }
152
+
153
+ function activeAnsiStack(rowEntries: RowSpanEntry[], column: number) {
154
+ return rowEntries
155
+ .filter(({ span }) => span.x1 <= column && span.x2 > column)
156
+ .sort((a, b) => compareActiveSpanPaintOrder(rowEntries, a, b));
157
+ }
158
+
159
+ function commonAnsiStackPrefixLength(previous: RowSpanEntry[], next: RowSpanEntry[]) {
160
+ const length = Math.min(previous.length, next.length);
161
+ for (let index = 0; index < length; index += 1) {
162
+ if (previous[index]?.index !== next[index]?.index) {
163
+ return index;
146
164
  }
147
165
  }
148
- for (const span of rowSpans) {
149
- if (span.x1 < visibleColumn && span.x2 > visibleColumn && span.kind === "focus") {
150
- output += spanAnsiClose(span, theme);
166
+ return length;
167
+ }
168
+
169
+ function compareAnsiCloseOrder(a: RowSpanEntry, b: RowSpanEntry) {
170
+ const aFocus = a.span.kind === "focus" ? 1 : 0;
171
+ const bFocus = b.span.kind === "focus" ? 1 : 0;
172
+ return aFocus - bFocus || a.index - b.index;
173
+ }
174
+
175
+ function switchAnsiStack(previous: RowSpanEntry[], next: RowSpanEntry[], theme?: TerminalTheme) {
176
+ if (sameActiveAnsiStack(previous, next)) {
177
+ return "";
178
+ }
179
+
180
+ const commonPrefixLength = commonAnsiStackPrefixLength(previous, next);
181
+ const removed = previous.slice(commonPrefixLength).sort(compareAnsiCloseOrder);
182
+ const commonPrefix = previous.slice(0, commonPrefixLength);
183
+ return [
184
+ ...removed.map(({ span }) => spanAnsiClose(span, theme)),
185
+ ...(removed.length > 0 ? commonPrefix.map(({ span }) => spanAnsiOpen(span, theme)) : []),
186
+ ...next.slice(commonPrefixLength).map(({ span }) => spanAnsiOpen(span, theme))
187
+ ].join("");
188
+ }
189
+
190
+ function renderAnsiLineFromColumn(line: string, spans: TerminalStyleSpan[], y: number, startColumn: number, theme?: TerminalTheme) {
191
+ let output = "";
192
+ let visibleColumn = Math.max(1, startColumn);
193
+ const rowEntries = lineSpanEntries(spans, y);
194
+ let currentStack: RowSpanEntry[] = [];
195
+
196
+ for (const grapheme of terminalGraphemes(dropTerminalCells(line, visibleColumn - 1))) {
197
+ const nextStack = activeAnsiStack(rowEntries, visibleColumn);
198
+ output += switchAnsiStack(currentStack, nextStack, theme);
199
+ currentStack = nextStack;
200
+
201
+ if (grapheme === "|") {
202
+ continue;
151
203
  }
204
+
205
+ output += grapheme;
206
+ visibleColumn += terminalCellWidth(grapheme);
152
207
  }
153
208
 
209
+ output += switchAnsiStack(currentStack, [], theme);
210
+
154
211
  return output;
155
212
  }
156
213
 
214
+ function renderAnsiLine(line: string, spans: TerminalStyleSpan[], y: number, theme?: TerminalTheme) {
215
+ return renderAnsiLineFromColumn(line, spans, y, 1, theme);
216
+ }
217
+
157
218
  function hasPlainAffordance(token: ReturnType<typeof resolveTerminalStyleToken>) {
158
219
  return typeof token !== "undefined" && ("plainPrefix" in token || "plainSuffix" in token);
159
220
  }
@@ -234,6 +295,70 @@ export function toAnsiFrame(lines: string[], cursor: CursorPosition | null, span
234
295
  return `\u001b[?25l\u001b[H${ansiLines.join("\n")}${cursorCode}`;
235
296
  }
236
297
 
298
+ function commonPrefixCellWidth(previousLine: string, nextLine: string) {
299
+ const previousGraphemes = terminalGraphemes(previousLine);
300
+ const nextGraphemes = terminalGraphemes(nextLine);
301
+ const length = Math.min(previousGraphemes.length, nextGraphemes.length);
302
+ let width = 0;
303
+
304
+ for (let index = 0; index < length; index += 1) {
305
+ if (previousGraphemes[index] !== nextGraphemes[index]) {
306
+ return width;
307
+ }
308
+ if (previousGraphemes[index] !== "|") {
309
+ width += terminalCellWidth(previousGraphemes[index]);
310
+ }
311
+ }
312
+
313
+ return width;
314
+ }
315
+
316
+ function renderAnsiLineSuffix(line: string, spans: TerminalStyleSpan[], y: number, startColumn: number, theme?: TerminalTheme) {
317
+ return renderAnsiLineFromColumn(line, spans, y, startColumn, theme);
318
+ }
319
+
320
+
321
+ function sameSpanStyle(previous: TerminalStyleSpan["style"], next: TerminalStyleSpan["style"]) {
322
+ if (previous === next) {
323
+ return true;
324
+ }
325
+ if (!previous || !next) {
326
+ return false;
327
+ }
328
+ return previous.color === next.color
329
+ && previous.background === next.background
330
+ && previous.plainPrefix === next.plainPrefix
331
+ && previous.plainSuffix === next.plainSuffix
332
+ && JSON.stringify(previous.border) === JSON.stringify(next.border)
333
+ && JSON.stringify(previous.padding) === JSON.stringify(next.padding);
334
+ }
335
+
336
+ function sameLineSpans(previousSpans: TerminalStyleSpan[], nextSpans: TerminalStyleSpan[], y: number) {
337
+ if (previousSpans.length === 0 && nextSpans.length === 0) {
338
+ return true;
339
+ }
340
+
341
+ const previousRowSpans = lineSpans(previousSpans, y);
342
+ const nextRowSpans = lineSpans(nextSpans, y);
343
+ if (previousRowSpans.length !== nextRowSpans.length) {
344
+ return false;
345
+ }
346
+
347
+ for (let index = 0; index < previousRowSpans.length; index += 1) {
348
+ const previous = previousRowSpans[index];
349
+ const next = nextRowSpans[index];
350
+ if (previous.kind !== next.kind
351
+ || previous.x1 !== next.x1
352
+ || previous.x2 !== next.x2
353
+ || previous.y !== next.y
354
+ || !sameSpanStyle(previous.style, next.style)) {
355
+ return false;
356
+ }
357
+ }
358
+
359
+ return true;
360
+ }
361
+
237
362
  export function createAnsiFrameDiff(
238
363
  previousAnsiLines: string[],
239
364
  nextAnsiLines: string[],
@@ -267,13 +392,77 @@ export function createAnsiFrameDiff(
267
392
  };
268
393
  }
269
394
 
395
+ function createAnsiFramePatch(
396
+ previousLines: string[],
397
+ previousAnsiLines: string[],
398
+ nextLines: string[],
399
+ previousSpans: TerminalStyleSpan[],
400
+ nextSpans: TerminalStyleSpan[],
401
+ cursor: CursorPosition | null,
402
+ options: AnsiFrameOptions = {}
403
+ ): AnsiFrameDiffResult & { nextAnsiLines: string[] } {
404
+ const nextAnsiLines: string[] = [];
405
+ const changedLineIndexes: number[] = [];
406
+ const writes: string[] = ["\u001b[?25l"];
407
+ const maxLines = Math.max(previousAnsiLines.length, nextLines.length);
408
+
409
+ for (let i = 0; i < maxLines; i += 1) {
410
+ const previousLine = previousLines[i] || "";
411
+ const nextLine = nextLines[i] || "";
412
+ const previousAnsiLine = previousAnsiLines[i] || "";
413
+ const spansMatch = sameLineSpans(previousSpans, nextSpans, i + 1);
414
+ const nextAnsiLine = i < nextLines.length && previousLine === nextLine && spansMatch
415
+ ? previousAnsiLine
416
+ : i < nextLines.length
417
+ ? renderAnsiLine(nextLine, nextSpans, i + 1, options.theme)
418
+ : "";
419
+ nextAnsiLines[i] = nextAnsiLine;
420
+ if (nextAnsiLine === previousAnsiLine) {
421
+ continue;
422
+ }
423
+
424
+ changedLineIndexes.push(i);
425
+ const canPatchSuffix = cursor?.y === i + 1
426
+ && lineSpans(previousSpans, i + 1).length === 0
427
+ && lineSpans(nextSpans, i + 1).length === 0;
428
+ const prefixWidth = canPatchSuffix && i < previousLines.length && i < nextLines.length && previousLine !== nextLine
429
+ ? commonPrefixCellWidth(previousLine, nextLine)
430
+ : 0;
431
+
432
+ if (prefixWidth > 0) {
433
+ const startColumn = prefixWidth + 1;
434
+ const suffix = renderAnsiLineSuffix(nextLine, nextSpans, i + 1, startColumn, options.theme);
435
+ writes.push(`\u001b[${i + 1};${startColumn}H${suffix}\u001b[0m\u001b[K`);
436
+ } else {
437
+ writes.push(`\u001b[${i + 1};1H${nextAnsiLine}\u001b[0m\u001b[K`);
438
+ }
439
+ }
440
+
441
+ if (cursor) {
442
+ writes.push(`\u001b[${cursor.y};${cursor.x}H`);
443
+ }
444
+ if (options.showCursor !== false || (options.showCursorWhenFrameHasCursor === true && cursor)) {
445
+ writes.push(ANSI_SHOW_CURSOR);
446
+ }
447
+
448
+ return {
449
+ changedLineIndexes,
450
+ outputChunk: writes.join(""),
451
+ restoresCursor: Boolean(cursor),
452
+ nextAnsiLines: nextAnsiLines.slice(0, nextLines.length)
453
+ };
454
+ }
455
+
270
456
  export function createAnsiDiffWriter(options: AnsiFrameOptions = {}) {
457
+ let previousLines: string[] = [];
271
458
  let previousAnsiLines: string[] = [];
459
+ let previousSpans: TerminalStyleSpan[] = [];
272
460
 
273
461
  return function toAnsiDiff(lines: string[], cursor: CursorPosition | null, spans: TerminalStyleSpan[] = []) {
274
- const ansiLines = lines.map((line, index) => renderAnsiLine(line, spans, index + 1, options.theme));
275
- const diff = createAnsiFrameDiff(previousAnsiLines, ansiLines, cursor, options);
276
- previousAnsiLines = ansiLines.slice();
462
+ const diff = createAnsiFramePatch(previousLines, previousAnsiLines, lines, previousSpans, spans, cursor, options);
463
+ previousLines = lines.slice();
464
+ previousAnsiLines = diff.nextAnsiLines.slice();
465
+ previousSpans = spans.map((span) => ({ ...span }));
277
466
  return diff.outputChunk;
278
467
  };
279
468
  }
package/src/layout.ts CHANGED
@@ -242,6 +242,8 @@ function overlayMetric(value: number, name: string) {
242
242
  return metric;
243
243
  }
244
244
 
245
+ const MODAL_OVERLAY_HITBOX_ID = "\u0000valyrian-overlay-modal-shield";
246
+
245
247
  function nextPointerLayer(hitboxes: TerminalHitbox[]) {
246
248
  let layer = 0;
247
249
  for (const box of hitboxes) {
@@ -250,7 +252,7 @@ function nextPointerLayer(hitboxes: TerminalHitbox[]) {
250
252
  return layer + 1;
251
253
  }
252
254
 
253
- export function overlayFrame(base: TerminalFrame, overlay: TerminalFrame, options: { x: number; y: number; width: number; height: number }): TerminalFrame {
255
+ export function overlayFrame(base: TerminalFrame, overlay: TerminalFrame, options: { x: number; y: number; width: number; height: number; backdropSpans?: TerminalStyleSpan[] }): TerminalFrame {
254
256
  const x = overlayMetric(options.x, "Overlay x");
255
257
  const y = overlayMetric(options.y, "Overlay y");
256
258
  const width = overlayMetric(options.width, "Overlay width");
@@ -289,9 +291,18 @@ export function overlayFrame(base: TerminalFrame, overlay: TerminalFrame, option
289
291
  ...box,
290
292
  pointerLayer: overlayLayer + (box.pointerLayer ?? 0)
291
293
  }));
294
+ const overlayShield = {
295
+ id: MODAL_OVERLAY_HITBOX_ID,
296
+ tag: "terminal-overlay" as const,
297
+ x1: 1,
298
+ x2: Math.max(1, getFrameWidth(base)),
299
+ y1: 1,
300
+ y2: Math.max(1, getFrameHeight(base)),
301
+ pointerLayer: overlayLayer - 0.5
302
+ };
292
303
  const cursor = visibleOverlay.cursor || base.cursor;
293
304
 
294
- return createFrame(lines, [...overlayHitboxes, ...base.hitboxes], cursor, [...base.spans, ...visibleOverlay.spans]);
305
+ return createFrame(lines, [...overlayHitboxes, overlayShield, ...base.hitboxes], cursor, [...base.spans, ...(options.backdropSpans ?? []), ...visibleOverlay.spans]);
295
306
  }
296
307
 
297
308
  export function cropFrame(frame: TerminalFrame, offset: number, height: number) {