@silvery/term 0.3.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/package.json +54 -0
- package/src/adapters/canvas-adapter.ts +356 -0
- package/src/adapters/dom-adapter.ts +452 -0
- package/src/adapters/flexily-zero-adapter.ts +368 -0
- package/src/adapters/terminal-adapter.ts +305 -0
- package/src/adapters/yoga-adapter.ts +370 -0
- package/src/ansi/ansi.ts +251 -0
- package/src/ansi/constants.ts +76 -0
- package/src/ansi/detection.ts +441 -0
- package/src/ansi/hyperlink.ts +38 -0
- package/src/ansi/index.ts +201 -0
- package/src/ansi/patch-console.ts +159 -0
- package/src/ansi/sgr-codes.ts +34 -0
- package/src/ansi/storybook.ts +209 -0
- package/src/ansi/term.ts +724 -0
- package/src/ansi/types.ts +202 -0
- package/src/ansi/underline.ts +156 -0
- package/src/ansi/utils.ts +65 -0
- package/src/ansi-sanitize.ts +509 -0
- package/src/app.ts +571 -0
- package/src/bound-term.ts +94 -0
- package/src/bracketed-paste.ts +75 -0
- package/src/browser-renderer.ts +174 -0
- package/src/buffer.ts +1984 -0
- package/src/clipboard.ts +74 -0
- package/src/cursor-query.ts +85 -0
- package/src/device-attrs.ts +228 -0
- package/src/devtools.ts +123 -0
- package/src/dom/index.ts +194 -0
- package/src/errors.ts +39 -0
- package/src/focus-reporting.ts +48 -0
- package/src/hit-registry-core.ts +228 -0
- package/src/hit-registry.ts +176 -0
- package/src/index.ts +458 -0
- package/src/input.ts +119 -0
- package/src/inspector.ts +155 -0
- package/src/kitty-detect.ts +95 -0
- package/src/kitty-manager.ts +160 -0
- package/src/layout-engine.ts +296 -0
- package/src/layout.ts +26 -0
- package/src/measurer.ts +74 -0
- package/src/mode-query.ts +106 -0
- package/src/mouse-events.ts +419 -0
- package/src/mouse.ts +83 -0
- package/src/non-tty.ts +223 -0
- package/src/osc-markers.ts +32 -0
- package/src/osc-palette.ts +169 -0
- package/src/output.ts +406 -0
- package/src/pane-manager.ts +248 -0
- package/src/pipeline/CLAUDE.md +587 -0
- package/src/pipeline/content-phase-adapter.ts +976 -0
- package/src/pipeline/content-phase.ts +1765 -0
- package/src/pipeline/helpers.ts +42 -0
- package/src/pipeline/index.ts +416 -0
- package/src/pipeline/layout-phase.ts +686 -0
- package/src/pipeline/measure-phase.ts +198 -0
- package/src/pipeline/measure-stats.ts +21 -0
- package/src/pipeline/output-phase.ts +2593 -0
- package/src/pipeline/render-box.ts +343 -0
- package/src/pipeline/render-helpers.ts +243 -0
- package/src/pipeline/render-text.ts +1255 -0
- package/src/pipeline/types.ts +161 -0
- package/src/pipeline.ts +29 -0
- package/src/pixel-size.ts +119 -0
- package/src/render-adapter.ts +179 -0
- package/src/renderer.ts +1330 -0
- package/src/runtime/create-app.tsx +1845 -0
- package/src/runtime/create-buffer.ts +18 -0
- package/src/runtime/create-runtime.ts +325 -0
- package/src/runtime/diff.ts +56 -0
- package/src/runtime/event-handlers.ts +254 -0
- package/src/runtime/index.ts +119 -0
- package/src/runtime/keys.ts +8 -0
- package/src/runtime/layout.ts +164 -0
- package/src/runtime/run.tsx +318 -0
- package/src/runtime/term-provider.ts +399 -0
- package/src/runtime/terminal-lifecycle.ts +246 -0
- package/src/runtime/tick.ts +219 -0
- package/src/runtime/types.ts +210 -0
- package/src/scheduler.ts +723 -0
- package/src/screenshot.ts +57 -0
- package/src/scroll-region.ts +69 -0
- package/src/scroll-utils.ts +97 -0
- package/src/term-def.ts +267 -0
- package/src/terminal-caps.ts +5 -0
- package/src/terminal-colors.ts +216 -0
- package/src/termtest.ts +224 -0
- package/src/text-sizing.ts +109 -0
- package/src/toolbelt/index.ts +72 -0
- package/src/unicode.ts +1763 -0
- package/src/xterm/index.ts +491 -0
- package/src/xterm/xterm-provider.ts +204 -0
package/src/buffer.ts
ADDED
|
@@ -0,0 +1,1984 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Terminal buffer implementation for Silvery.
|
|
3
|
+
*
|
|
4
|
+
* Uses packed Uint32Array for efficient cell metadata storage,
|
|
5
|
+
* with separate string array for character storage (needed for
|
|
6
|
+
* multi-byte Unicode graphemes and combining characters).
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { fgColorCode, bgColorCode } from "./ansi/sgr-codes"
|
|
10
|
+
|
|
11
|
+
// ============================================================================
|
|
12
|
+
// Types
|
|
13
|
+
// ============================================================================
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Underline style variants (SGR 4:x codes).
|
|
17
|
+
* - false: no underline
|
|
18
|
+
* - 'single': standard underline (SGR 4 or 4:1)
|
|
19
|
+
* - 'double': double underline (SGR 4:2)
|
|
20
|
+
* - 'curly': curly/wavy underline (SGR 4:3)
|
|
21
|
+
* - 'dotted': dotted underline (SGR 4:4)
|
|
22
|
+
* - 'dashed': dashed underline (SGR 4:5)
|
|
23
|
+
*/
|
|
24
|
+
export type UnderlineStyle = false | "single" | "double" | "curly" | "dotted" | "dashed"
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Text attributes that can be applied to a cell.
|
|
28
|
+
*/
|
|
29
|
+
export interface CellAttrs {
|
|
30
|
+
bold?: boolean
|
|
31
|
+
dim?: boolean
|
|
32
|
+
italic?: boolean
|
|
33
|
+
/** Simple underline flag (for backwards compatibility) */
|
|
34
|
+
underline?: boolean
|
|
35
|
+
/**
|
|
36
|
+
* Underline style: 'single' | 'double' | 'curly' | 'dotted' | 'dashed'.
|
|
37
|
+
* When set, takes precedence over the underline boolean.
|
|
38
|
+
*/
|
|
39
|
+
underlineStyle?: UnderlineStyle
|
|
40
|
+
blink?: boolean
|
|
41
|
+
inverse?: boolean
|
|
42
|
+
hidden?: boolean
|
|
43
|
+
strikethrough?: boolean
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Color representation.
|
|
48
|
+
* - number: 256-color index (0-255)
|
|
49
|
+
* - RGB object: true color
|
|
50
|
+
* - null: default/inherit
|
|
51
|
+
* - DEFAULT_BG: terminal's default background (SGR 49), opaque but uses terminal's own bg color
|
|
52
|
+
*/
|
|
53
|
+
export type Color = number | { r: number; g: number; b: number } | null
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Sentinel color representing the terminal's default background (SGR 49).
|
|
57
|
+
* Unlike `null` (transparent/inherit), this actively fills cells with the
|
|
58
|
+
* terminal's configured background, making the element opaque.
|
|
59
|
+
*/
|
|
60
|
+
export const DEFAULT_BG: Color = Object.freeze({ r: -1, g: -1, b: -1 })
|
|
61
|
+
|
|
62
|
+
/** Check if a color is the default bg sentinel. */
|
|
63
|
+
export function isDefaultBg(color: Color): boolean {
|
|
64
|
+
return color !== null && typeof color === "object" && color.r === -1
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* A single cell in the terminal buffer.
|
|
69
|
+
*/
|
|
70
|
+
export interface Cell {
|
|
71
|
+
/** The character/grapheme in this cell */
|
|
72
|
+
char: string
|
|
73
|
+
/** Foreground color */
|
|
74
|
+
fg: Color
|
|
75
|
+
/** Background color */
|
|
76
|
+
bg: Color
|
|
77
|
+
/**
|
|
78
|
+
* Underline color (independent of fg).
|
|
79
|
+
* Uses SGR 58. If null, underline uses fg color.
|
|
80
|
+
*/
|
|
81
|
+
underlineColor: Color
|
|
82
|
+
/** Text attributes */
|
|
83
|
+
attrs: CellAttrs
|
|
84
|
+
/** True if this is a wide character (CJK, emoji, etc.) */
|
|
85
|
+
wide: boolean
|
|
86
|
+
/** True if this is the continuation cell after a wide character */
|
|
87
|
+
continuation: boolean
|
|
88
|
+
/**
|
|
89
|
+
* OSC 8 hyperlink URL.
|
|
90
|
+
* When set, the cell is part of a clickable hyperlink in supporting terminals.
|
|
91
|
+
*/
|
|
92
|
+
hyperlink?: string
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Style information for a cell (excludes char and position flags).
|
|
97
|
+
*/
|
|
98
|
+
export interface Style {
|
|
99
|
+
fg: Color
|
|
100
|
+
bg: Color
|
|
101
|
+
/**
|
|
102
|
+
* Underline color (independent of fg).
|
|
103
|
+
* Uses SGR 58. If null, underline uses fg color.
|
|
104
|
+
*/
|
|
105
|
+
underlineColor?: Color
|
|
106
|
+
attrs: CellAttrs
|
|
107
|
+
/**
|
|
108
|
+
* OSC 8 hyperlink URL.
|
|
109
|
+
* When set, the cell is part of a clickable hyperlink in supporting terminals.
|
|
110
|
+
*/
|
|
111
|
+
hyperlink?: string
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// ============================================================================
|
|
115
|
+
// Constants
|
|
116
|
+
// ============================================================================
|
|
117
|
+
|
|
118
|
+
// Bit packing layout for cell metadata in Uint32Array:
|
|
119
|
+
// [0-7]: foreground color index (8 bits)
|
|
120
|
+
// [8-15]: background color index (8 bits)
|
|
121
|
+
// [16-23]: attributes (8 bits): bold, dim, italic, blink, inverse, hidden, strikethrough + 1 spare
|
|
122
|
+
// [24-26]: underline style (3 bits): 0=none, 1=single, 2=double, 3=curly, 4=dotted, 5=dashed
|
|
123
|
+
// [27-31]: flags (5 bits): wide, continuation, true_color_fg, true_color_bg + 1 spare
|
|
124
|
+
|
|
125
|
+
// Attribute bit positions (within bits 16-23)
|
|
126
|
+
const ATTR_BOLD = 1 << 16
|
|
127
|
+
const ATTR_DIM = 1 << 17
|
|
128
|
+
const ATTR_ITALIC = 1 << 18
|
|
129
|
+
const ATTR_BLINK = 1 << 19
|
|
130
|
+
const ATTR_INVERSE = 1 << 20
|
|
131
|
+
const ATTR_HIDDEN = 1 << 21
|
|
132
|
+
const ATTR_STRIKETHROUGH = 1 << 22
|
|
133
|
+
// bit 23 spare
|
|
134
|
+
|
|
135
|
+
// Underline style (3 bits in positions 24-26)
|
|
136
|
+
// 0 = no underline, 1 = single, 2 = double, 3 = curly, 4 = dotted, 5 = dashed
|
|
137
|
+
const UNDERLINE_STYLE_SHIFT = 24
|
|
138
|
+
const UNDERLINE_STYLE_MASK = 0x7 << UNDERLINE_STYLE_SHIFT // 3 bits
|
|
139
|
+
|
|
140
|
+
// Flag bit positions (in bits 27-31)
|
|
141
|
+
const WIDE_FLAG = 1 << 27
|
|
142
|
+
const CONTINUATION_FLAG = 1 << 28
|
|
143
|
+
const TRUE_COLOR_FG_FLAG = 1 << 29
|
|
144
|
+
const TRUE_COLOR_BG_FLAG = 1 << 30
|
|
145
|
+
// bit 31 spare
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Packed attribute bits that make a space character visually meaningful.
|
|
149
|
+
* Inverse makes spaces visible (block of color), underline draws a line under spaces,
|
|
150
|
+
* strikethrough draws a line through spaces. Other attrs (bold, dim, italic) don't
|
|
151
|
+
* visually affect space characters.
|
|
152
|
+
*/
|
|
153
|
+
export const VISIBLE_SPACE_ATTR_MASK = ATTR_INVERSE | ATTR_STRIKETHROUGH | UNDERLINE_STYLE_MASK
|
|
154
|
+
|
|
155
|
+
// Default empty cell
|
|
156
|
+
const EMPTY_CELL: Cell = {
|
|
157
|
+
char: " ",
|
|
158
|
+
fg: null,
|
|
159
|
+
bg: null,
|
|
160
|
+
underlineColor: null,
|
|
161
|
+
attrs: {},
|
|
162
|
+
wide: false,
|
|
163
|
+
continuation: false,
|
|
164
|
+
hyperlink: undefined,
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** Frozen empty attrs object, shared across zero-allocation reads for OOB cells */
|
|
168
|
+
const EMPTY_ATTRS: CellAttrs = Object.freeze({})
|
|
169
|
+
|
|
170
|
+
// ============================================================================
|
|
171
|
+
// Packing/Unpacking Helpers
|
|
172
|
+
// ============================================================================
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Map UnderlineStyle to numeric value for bit packing.
|
|
176
|
+
*/
|
|
177
|
+
function underlineStyleToNumber(style: UnderlineStyle | undefined): number {
|
|
178
|
+
switch (style) {
|
|
179
|
+
case false:
|
|
180
|
+
return 0
|
|
181
|
+
case "single":
|
|
182
|
+
return 1
|
|
183
|
+
case "double":
|
|
184
|
+
return 2
|
|
185
|
+
case "curly":
|
|
186
|
+
return 3
|
|
187
|
+
case "dotted":
|
|
188
|
+
return 4
|
|
189
|
+
case "dashed":
|
|
190
|
+
return 5
|
|
191
|
+
default:
|
|
192
|
+
return 0 // undefined or unknown = no underline
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Map numeric value back to UnderlineStyle.
|
|
198
|
+
*/
|
|
199
|
+
function numberToUnderlineStyle(n: number): UnderlineStyle | undefined {
|
|
200
|
+
switch (n) {
|
|
201
|
+
case 0:
|
|
202
|
+
return undefined // No underline
|
|
203
|
+
case 1:
|
|
204
|
+
return "single"
|
|
205
|
+
case 2:
|
|
206
|
+
return "double"
|
|
207
|
+
case 3:
|
|
208
|
+
return "curly"
|
|
209
|
+
case 4:
|
|
210
|
+
return "dotted"
|
|
211
|
+
case 5:
|
|
212
|
+
return "dashed"
|
|
213
|
+
default:
|
|
214
|
+
return undefined
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Convert CellAttrs to bits for packing (used internally by packCell).
|
|
220
|
+
* Note: This packs into the full 32-bit word, not just the attrs byte.
|
|
221
|
+
*/
|
|
222
|
+
export function attrsToNumber(attrs: CellAttrs): number {
|
|
223
|
+
let n = 0
|
|
224
|
+
if (attrs.bold) n |= ATTR_BOLD
|
|
225
|
+
if (attrs.dim) n |= ATTR_DIM
|
|
226
|
+
if (attrs.italic) n |= ATTR_ITALIC
|
|
227
|
+
if (attrs.blink) n |= ATTR_BLINK
|
|
228
|
+
if (attrs.inverse) n |= ATTR_INVERSE
|
|
229
|
+
if (attrs.hidden) n |= ATTR_HIDDEN
|
|
230
|
+
if (attrs.strikethrough) n |= ATTR_STRIKETHROUGH
|
|
231
|
+
|
|
232
|
+
// Pack underline style (3 bits)
|
|
233
|
+
// If underlineStyle is set, use it. Otherwise, check underline boolean.
|
|
234
|
+
const ulStyle = attrs.underlineStyle ?? (attrs.underline ? "single" : undefined)
|
|
235
|
+
n |= underlineStyleToNumber(ulStyle) << UNDERLINE_STYLE_SHIFT
|
|
236
|
+
|
|
237
|
+
return n
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Convert a number back to CellAttrs.
|
|
242
|
+
*/
|
|
243
|
+
export function numberToAttrs(n: number): CellAttrs {
|
|
244
|
+
const attrs: CellAttrs = {}
|
|
245
|
+
if (n & ATTR_BOLD) attrs.bold = true
|
|
246
|
+
if (n & ATTR_DIM) attrs.dim = true
|
|
247
|
+
if (n & ATTR_ITALIC) attrs.italic = true
|
|
248
|
+
if (n & ATTR_BLINK) attrs.blink = true
|
|
249
|
+
if (n & ATTR_INVERSE) attrs.inverse = true
|
|
250
|
+
if (n & ATTR_HIDDEN) attrs.hidden = true
|
|
251
|
+
if (n & ATTR_STRIKETHROUGH) attrs.strikethrough = true
|
|
252
|
+
|
|
253
|
+
// Unpack underline style
|
|
254
|
+
const ulStyleNum = (n & UNDERLINE_STYLE_MASK) >> UNDERLINE_STYLE_SHIFT
|
|
255
|
+
const ulStyle = numberToUnderlineStyle(ulStyleNum)
|
|
256
|
+
if (ulStyle) {
|
|
257
|
+
attrs.underlineStyle = ulStyle
|
|
258
|
+
attrs.underline = true
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
return attrs
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Convert a color to an index value for packing.
|
|
266
|
+
* Returns 0 for null (default), or (index + 1) for 256-color.
|
|
267
|
+
* This +1 offset allows distinguishing null from black (color index 0).
|
|
268
|
+
* True color is handled separately via flags and auxiliary storage.
|
|
269
|
+
*/
|
|
270
|
+
function colorToIndex(color: Color): number {
|
|
271
|
+
if (color === null) return 0
|
|
272
|
+
if (typeof color === "number") return (color & 0xff) + 1 // +1 to distinguish from null
|
|
273
|
+
// True color - return 0, handle via flag
|
|
274
|
+
return 0
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Check if a color is true color (RGB).
|
|
279
|
+
*/
|
|
280
|
+
function isTrueColor(color: Color): color is { r: number; g: number; b: number } {
|
|
281
|
+
return color !== null && typeof color === "object"
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Pack cell metadata into a 32-bit number.
|
|
286
|
+
*/
|
|
287
|
+
export function packCell(cell: Cell): number {
|
|
288
|
+
let packed = 0
|
|
289
|
+
|
|
290
|
+
// Foreground color index (bits 0-7)
|
|
291
|
+
packed |= colorToIndex(cell.fg) & 0xff
|
|
292
|
+
|
|
293
|
+
// Background color index (bits 8-15)
|
|
294
|
+
packed |= (colorToIndex(cell.bg) & 0xff) << 8
|
|
295
|
+
|
|
296
|
+
// Attributes (bits 16-22) and underline style (bits 24-26)
|
|
297
|
+
// attrsToNumber returns bits already in their final positions
|
|
298
|
+
packed |= attrsToNumber(cell.attrs)
|
|
299
|
+
|
|
300
|
+
// Flags (bits 27-30)
|
|
301
|
+
if (cell.wide) packed |= WIDE_FLAG
|
|
302
|
+
if (cell.continuation) packed |= CONTINUATION_FLAG
|
|
303
|
+
if (isTrueColor(cell.fg)) packed |= TRUE_COLOR_FG_FLAG
|
|
304
|
+
if (isTrueColor(cell.bg)) packed |= TRUE_COLOR_BG_FLAG
|
|
305
|
+
|
|
306
|
+
return packed
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* Unpack foreground color index from packed value.
|
|
311
|
+
*/
|
|
312
|
+
function unpackFgIndex(packed: number): number {
|
|
313
|
+
return packed & 0xff
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* Unpack background color index from packed value.
|
|
318
|
+
*/
|
|
319
|
+
function unpackBgIndex(packed: number): number {
|
|
320
|
+
return (packed >> 8) & 0xff
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* Unpack attributes from packed value.
|
|
325
|
+
* Extracts both the boolean attrs (bits 16-22) and underline style (bits 24-26).
|
|
326
|
+
*/
|
|
327
|
+
function unpackAttrs(packed: number): CellAttrs {
|
|
328
|
+
// numberToAttrs expects the full packed value with attrs in bits 16-22
|
|
329
|
+
// and underline style in bits 24-26
|
|
330
|
+
return numberToAttrs(packed)
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* Check if wide flag is set.
|
|
335
|
+
*/
|
|
336
|
+
function unpackWide(packed: number): boolean {
|
|
337
|
+
return (packed & WIDE_FLAG) !== 0
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* Check if continuation flag is set.
|
|
342
|
+
*/
|
|
343
|
+
function unpackContinuation(packed: number): boolean {
|
|
344
|
+
return (packed & CONTINUATION_FLAG) !== 0
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* Check if true color foreground flag is set.
|
|
349
|
+
*/
|
|
350
|
+
function unpackTrueColorFg(packed: number): boolean {
|
|
351
|
+
return (packed & TRUE_COLOR_FG_FLAG) !== 0
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* Check if true color background flag is set.
|
|
356
|
+
*/
|
|
357
|
+
function unpackTrueColorBg(packed: number): boolean {
|
|
358
|
+
return (packed & TRUE_COLOR_BG_FLAG) !== 0
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
// ============================================================================
|
|
362
|
+
// TerminalBuffer Class
|
|
363
|
+
// ============================================================================
|
|
364
|
+
|
|
365
|
+
/**
|
|
366
|
+
* Efficient terminal cell buffer.
|
|
367
|
+
*
|
|
368
|
+
* Uses packed Uint32Array for cell metadata and separate string array
|
|
369
|
+
* for characters. This allows efficient diffing while supporting
|
|
370
|
+
* full Unicode grapheme clusters.
|
|
371
|
+
*/
|
|
372
|
+
export class TerminalBuffer {
|
|
373
|
+
/** Packed cell metadata */
|
|
374
|
+
private cells: Uint32Array
|
|
375
|
+
/** Character storage (one per cell, may be multi-byte grapheme) */
|
|
376
|
+
private chars: string[]
|
|
377
|
+
/** True color foreground storage (only for cells with true color fg) */
|
|
378
|
+
private fgColors: Map<number, { r: number; g: number; b: number }>
|
|
379
|
+
/** True color background storage (only for cells with true color bg) */
|
|
380
|
+
private bgColors: Map<number, { r: number; g: number; b: number }>
|
|
381
|
+
/** Underline color storage (independent of fg, for SGR 58) */
|
|
382
|
+
private underlineColors: Map<number, Color>
|
|
383
|
+
/** OSC 8 hyperlink URL storage (only for cells that are part of a hyperlink) */
|
|
384
|
+
private hyperlinks: Map<number, string>
|
|
385
|
+
/**
|
|
386
|
+
* Per-row dirty tracking for diff optimization.
|
|
387
|
+
* When set, diffBuffers() can skip clean rows entirely.
|
|
388
|
+
* 0 = clean (unchanged since last resetDirtyRows), 1 = dirty (modified).
|
|
389
|
+
*/
|
|
390
|
+
private _dirtyRows: Uint8Array
|
|
391
|
+
/** Bounding box: first dirty row (inclusive). -1 when no rows are dirty. */
|
|
392
|
+
private _minDirtyRow: number
|
|
393
|
+
/** Bounding box: last dirty row (inclusive). -1 when no rows are dirty. */
|
|
394
|
+
private _maxDirtyRow: number
|
|
395
|
+
|
|
396
|
+
readonly width: number
|
|
397
|
+
readonly height: number
|
|
398
|
+
|
|
399
|
+
constructor(width: number, height: number) {
|
|
400
|
+
this.width = width
|
|
401
|
+
this.height = height
|
|
402
|
+
const size = width * height
|
|
403
|
+
this.cells = new Uint32Array(size)
|
|
404
|
+
this.chars = new Array<string>(size).fill(" ")
|
|
405
|
+
this.fgColors = new Map()
|
|
406
|
+
this.bgColors = new Map()
|
|
407
|
+
this.underlineColors = new Map()
|
|
408
|
+
this.hyperlinks = new Map()
|
|
409
|
+
// All rows start dirty (fresh buffer needs full diff on first comparison)
|
|
410
|
+
this._dirtyRows = new Uint8Array(height).fill(1)
|
|
411
|
+
this._minDirtyRow = 0
|
|
412
|
+
this._maxDirtyRow = height - 1
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/**
|
|
416
|
+
* Get the index for a cell position.
|
|
417
|
+
*/
|
|
418
|
+
private index(x: number, y: number): number {
|
|
419
|
+
return y * this.width + x
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/**
|
|
423
|
+
* Check if coordinates are within bounds.
|
|
424
|
+
*/
|
|
425
|
+
inBounds(x: number, y: number): boolean {
|
|
426
|
+
return x >= 0 && x < this.width && y >= 0 && y < this.height
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/**
|
|
430
|
+
* Get a cell at the given position.
|
|
431
|
+
*/
|
|
432
|
+
getCell(x: number, y: number): Cell {
|
|
433
|
+
if (!this.inBounds(x, y)) {
|
|
434
|
+
return { ...EMPTY_CELL }
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
const idx = this.index(x, y)
|
|
438
|
+
const packed = this.cells[idx]
|
|
439
|
+
const char = this.chars[idx]
|
|
440
|
+
|
|
441
|
+
// Determine foreground color
|
|
442
|
+
// Color indices are stored with +1 offset (0=null, 1=black, 2=red, etc.)
|
|
443
|
+
let fg: Color = null
|
|
444
|
+
if (unpackTrueColorFg(packed!)) {
|
|
445
|
+
fg = this.fgColors.get(idx) ?? null
|
|
446
|
+
} else {
|
|
447
|
+
const fgIndex = unpackFgIndex(packed!)
|
|
448
|
+
fg = fgIndex > 0 ? fgIndex - 1 : null // -1 to restore actual color index
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
// Determine background color
|
|
452
|
+
let bg: Color = null
|
|
453
|
+
if (unpackTrueColorBg(packed!)) {
|
|
454
|
+
bg = this.bgColors.get(idx) ?? null
|
|
455
|
+
} else {
|
|
456
|
+
const bgIndex = unpackBgIndex(packed!)
|
|
457
|
+
bg = bgIndex > 0 ? bgIndex - 1 : null // -1 to restore actual color index
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
const hyperlink = this.hyperlinks.get(idx)
|
|
461
|
+
return {
|
|
462
|
+
char: char!,
|
|
463
|
+
fg,
|
|
464
|
+
bg,
|
|
465
|
+
underlineColor: this.underlineColors.get(idx) ?? null,
|
|
466
|
+
attrs: unpackAttrs(packed!),
|
|
467
|
+
wide: unpackWide(packed!),
|
|
468
|
+
continuation: unpackContinuation(packed!),
|
|
469
|
+
...(hyperlink !== undefined ? { hyperlink } : {}),
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
// --------------------------------------------------------------------------
|
|
474
|
+
// Zero-allocation cell accessors for hot paths
|
|
475
|
+
// --------------------------------------------------------------------------
|
|
476
|
+
|
|
477
|
+
/**
|
|
478
|
+
* Get just the character at a cell position (no object allocation).
|
|
479
|
+
* Returns " " for out-of-bounds positions.
|
|
480
|
+
*/
|
|
481
|
+
getCellChar(x: number, y: number): string {
|
|
482
|
+
if (!this.inBounds(x, y)) return " "
|
|
483
|
+
return this.chars[this.index(x, y)]!
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
/**
|
|
487
|
+
* Get just the background color at a cell position (no object allocation).
|
|
488
|
+
* Returns null for out-of-bounds positions.
|
|
489
|
+
*/
|
|
490
|
+
getCellBg(x: number, y: number): Color {
|
|
491
|
+
if (!this.inBounds(x, y)) return null
|
|
492
|
+
const idx = this.index(x, y)
|
|
493
|
+
const packed = this.cells[idx]!
|
|
494
|
+
if (unpackTrueColorBg(packed)) {
|
|
495
|
+
return this.bgColors.get(idx) ?? null
|
|
496
|
+
}
|
|
497
|
+
const bgIndex = unpackBgIndex(packed)
|
|
498
|
+
return bgIndex > 0 ? bgIndex - 1 : null
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
/**
|
|
502
|
+
* Get just the foreground color at a cell position (no object allocation).
|
|
503
|
+
* Returns null for out-of-bounds positions.
|
|
504
|
+
*/
|
|
505
|
+
getCellFg(x: number, y: number): Color {
|
|
506
|
+
if (!this.inBounds(x, y)) return null
|
|
507
|
+
const idx = this.index(x, y)
|
|
508
|
+
const packed = this.cells[idx]!
|
|
509
|
+
if (unpackTrueColorFg(packed)) {
|
|
510
|
+
return this.fgColors.get(idx) ?? null
|
|
511
|
+
}
|
|
512
|
+
const fgIndex = unpackFgIndex(packed)
|
|
513
|
+
return fgIndex > 0 ? fgIndex - 1 : null
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
/**
|
|
517
|
+
* Get the raw packed metadata at a cell position (no unpackAttrs allocation).
|
|
518
|
+
* Returns 0 for out-of-bounds positions. The packed value contains color
|
|
519
|
+
* indices, attr bits, underline style, and flags in a single Uint32.
|
|
520
|
+
*/
|
|
521
|
+
getCellAttrs(x: number, y: number): number {
|
|
522
|
+
if (!this.inBounds(x, y)) return 0
|
|
523
|
+
return this.cells[this.index(x, y)]!
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
/**
|
|
527
|
+
* Check if a cell is a wide character (no object allocation).
|
|
528
|
+
* Returns false for out-of-bounds positions.
|
|
529
|
+
*/
|
|
530
|
+
isCellWide(x: number, y: number): boolean {
|
|
531
|
+
if (!this.inBounds(x, y)) return false
|
|
532
|
+
return unpackWide(this.cells[this.index(x, y)]!)
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
/**
|
|
536
|
+
* Check if a cell is a continuation of a wide character (no object allocation).
|
|
537
|
+
* Returns false for out-of-bounds positions.
|
|
538
|
+
*/
|
|
539
|
+
isCellContinuation(x: number, y: number): boolean {
|
|
540
|
+
if (!this.inBounds(x, y)) return false
|
|
541
|
+
return unpackContinuation(this.cells[this.index(x, y)]!)
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
/**
|
|
545
|
+
* Read cell data into a caller-provided Cell object (zero-allocation).
|
|
546
|
+
* For hot loops that need the full Cell, reuse a single object:
|
|
547
|
+
*
|
|
548
|
+
* const cell = createMutableCell()
|
|
549
|
+
* for (...) { buffer.readCellInto(x, y, cell) }
|
|
550
|
+
*
|
|
551
|
+
* Returns the same `out` object for chaining convenience.
|
|
552
|
+
*/
|
|
553
|
+
readCellInto(x: number, y: number, out: Cell): Cell {
|
|
554
|
+
if (!this.inBounds(x, y)) {
|
|
555
|
+
out.char = " "
|
|
556
|
+
out.fg = null
|
|
557
|
+
out.bg = null
|
|
558
|
+
out.underlineColor = null
|
|
559
|
+
out.attrs = EMPTY_ATTRS
|
|
560
|
+
out.wide = false
|
|
561
|
+
out.continuation = false
|
|
562
|
+
out.hyperlink = undefined
|
|
563
|
+
return out
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
const idx = this.index(x, y)
|
|
567
|
+
const packed = this.cells[idx]!
|
|
568
|
+
|
|
569
|
+
out.char = this.chars[idx]!
|
|
570
|
+
|
|
571
|
+
// Foreground color
|
|
572
|
+
if (unpackTrueColorFg(packed)) {
|
|
573
|
+
out.fg = this.fgColors.get(idx) ?? null
|
|
574
|
+
} else {
|
|
575
|
+
const fgIndex = unpackFgIndex(packed)
|
|
576
|
+
out.fg = fgIndex > 0 ? fgIndex - 1 : null
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
// Background color
|
|
580
|
+
if (unpackTrueColorBg(packed)) {
|
|
581
|
+
out.bg = this.bgColors.get(idx) ?? null
|
|
582
|
+
} else {
|
|
583
|
+
const bgIndex = unpackBgIndex(packed)
|
|
584
|
+
out.bg = bgIndex > 0 ? bgIndex - 1 : null
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
out.underlineColor = this.underlineColors.get(idx) ?? null
|
|
588
|
+
|
|
589
|
+
// Unpack attrs inline to avoid allocating a new CellAttrs object.
|
|
590
|
+
// We reuse the existing out.attrs object when possible.
|
|
591
|
+
const attrs = out.attrs === EMPTY_ATTRS ? ((out.attrs = {}), out.attrs) : out.attrs
|
|
592
|
+
attrs.bold = (packed & ATTR_BOLD) !== 0 ? true : undefined
|
|
593
|
+
attrs.dim = (packed & ATTR_DIM) !== 0 ? true : undefined
|
|
594
|
+
attrs.italic = (packed & ATTR_ITALIC) !== 0 ? true : undefined
|
|
595
|
+
attrs.blink = (packed & ATTR_BLINK) !== 0 ? true : undefined
|
|
596
|
+
attrs.inverse = (packed & ATTR_INVERSE) !== 0 ? true : undefined
|
|
597
|
+
attrs.hidden = (packed & ATTR_HIDDEN) !== 0 ? true : undefined
|
|
598
|
+
attrs.strikethrough = (packed & ATTR_STRIKETHROUGH) !== 0 ? true : undefined
|
|
599
|
+
|
|
600
|
+
const ulStyleNum = (packed & UNDERLINE_STYLE_MASK) >> UNDERLINE_STYLE_SHIFT
|
|
601
|
+
const ulStyle = numberToUnderlineStyle(ulStyleNum)
|
|
602
|
+
if (ulStyle) {
|
|
603
|
+
attrs.underlineStyle = ulStyle
|
|
604
|
+
attrs.underline = true
|
|
605
|
+
} else {
|
|
606
|
+
attrs.underlineStyle = undefined
|
|
607
|
+
attrs.underline = undefined
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
out.wide = (packed & WIDE_FLAG) !== 0
|
|
611
|
+
out.continuation = (packed & CONTINUATION_FLAG) !== 0
|
|
612
|
+
out.hyperlink = this.hyperlinks.get(idx)
|
|
613
|
+
|
|
614
|
+
return out
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
/**
|
|
618
|
+
* Set a cell at the given position.
|
|
619
|
+
*
|
|
620
|
+
* Optimized: resolves defaults and packs metadata inline to avoid
|
|
621
|
+
* allocating an intermediate Cell object.
|
|
622
|
+
*/
|
|
623
|
+
setCell(x: number, y: number, cell: Partial<Cell>): void {
|
|
624
|
+
if (!this.inBounds(x, y)) {
|
|
625
|
+
return
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
// Write trap for SILVERY_STRICT mismatch diagnosis
|
|
629
|
+
const trap = (globalThis as any).__silvery_write_trap
|
|
630
|
+
if (trap && x === trap.x && y === trap.y) {
|
|
631
|
+
const char = cell.char ?? " "
|
|
632
|
+
const stack = new Error().stack?.split("\n").slice(1, 6).join("\n") ?? ""
|
|
633
|
+
trap.log.push(
|
|
634
|
+
` char="${char}" fg=${cell.fg ?? "null"} bg=${cell.bg ?? "null"} dim=${cell.attrs?.dim} ul=${cell.attrs?.underline}\n${stack}`,
|
|
635
|
+
)
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
this._dirtyRows[y] = 1
|
|
639
|
+
if (this._minDirtyRow === -1 || y < this._minDirtyRow) this._minDirtyRow = y
|
|
640
|
+
if (y > this._maxDirtyRow) this._maxDirtyRow = y
|
|
641
|
+
|
|
642
|
+
const idx = this.index(x, y)
|
|
643
|
+
|
|
644
|
+
// Resolve properties with defaults (no intermediate object)
|
|
645
|
+
const char = cell.char ?? " "
|
|
646
|
+
const fg = cell.fg ?? null
|
|
647
|
+
const bg = cell.bg ?? null
|
|
648
|
+
const underlineColor = cell.underlineColor ?? null
|
|
649
|
+
const attrs = cell.attrs ?? EMPTY_ATTRS
|
|
650
|
+
const wide = cell.wide ?? false
|
|
651
|
+
const continuation = cell.continuation ?? false
|
|
652
|
+
|
|
653
|
+
// Store character
|
|
654
|
+
this.chars[idx] = char
|
|
655
|
+
|
|
656
|
+
// Handle true color storage
|
|
657
|
+
if (isTrueColor(fg)) {
|
|
658
|
+
this.fgColors.set(idx, fg)
|
|
659
|
+
} else {
|
|
660
|
+
this.fgColors.delete(idx)
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
if (isTrueColor(bg)) {
|
|
664
|
+
this.bgColors.set(idx, bg)
|
|
665
|
+
} else {
|
|
666
|
+
this.bgColors.delete(idx)
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
// Handle underline color storage
|
|
670
|
+
if (underlineColor !== null) {
|
|
671
|
+
this.underlineColors.set(idx, underlineColor)
|
|
672
|
+
} else {
|
|
673
|
+
this.underlineColors.delete(idx)
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
// Handle hyperlink storage
|
|
677
|
+
const hyperlink = cell.hyperlink
|
|
678
|
+
if (hyperlink !== undefined && hyperlink !== "") {
|
|
679
|
+
this.hyperlinks.set(idx, hyperlink)
|
|
680
|
+
} else {
|
|
681
|
+
this.hyperlinks.delete(idx)
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
// Pack metadata inline (avoids packCell's fullCell parameter overhead)
|
|
685
|
+
let packed = 0
|
|
686
|
+
packed |= colorToIndex(fg) & 0xff
|
|
687
|
+
packed |= (colorToIndex(bg) & 0xff) << 8
|
|
688
|
+
packed |= attrsToNumber(attrs)
|
|
689
|
+
if (wide) packed |= WIDE_FLAG
|
|
690
|
+
if (continuation) packed |= CONTINUATION_FLAG
|
|
691
|
+
if (isTrueColor(fg)) packed |= TRUE_COLOR_FG_FLAG
|
|
692
|
+
if (isTrueColor(bg)) packed |= TRUE_COLOR_BG_FLAG
|
|
693
|
+
this.cells[idx] = packed
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
/**
|
|
697
|
+
* Fill a region with a cell.
|
|
698
|
+
*
|
|
699
|
+
* Optimized: packs cell metadata once and assigns directly to arrays,
|
|
700
|
+
* avoiding O(width*height) intermediate object allocations from setCell().
|
|
701
|
+
*/
|
|
702
|
+
fill(x: number, y: number, width: number, height: number, cell: Partial<Cell>): void {
|
|
703
|
+
const endX = Math.min(x + width, this.width)
|
|
704
|
+
const endY = Math.min(y + height, this.height)
|
|
705
|
+
const startX = Math.max(0, x)
|
|
706
|
+
const startY = Math.max(0, y)
|
|
707
|
+
|
|
708
|
+
if (startX >= endX || startY >= endY) return
|
|
709
|
+
|
|
710
|
+
// Resolve cell properties once (instead of per-cell in setCell)
|
|
711
|
+
const char = cell.char ?? " "
|
|
712
|
+
const fg = cell.fg ?? null
|
|
713
|
+
const bg = cell.bg ?? null
|
|
714
|
+
const underlineColor = cell.underlineColor ?? null
|
|
715
|
+
const attrs = cell.attrs ?? {}
|
|
716
|
+
const wide = cell.wide ?? false
|
|
717
|
+
const continuation = cell.continuation ?? false
|
|
718
|
+
|
|
719
|
+
// Pack metadata once for the entire fill region
|
|
720
|
+
const fullCell: Cell = {
|
|
721
|
+
char,
|
|
722
|
+
fg,
|
|
723
|
+
bg,
|
|
724
|
+
underlineColor,
|
|
725
|
+
attrs,
|
|
726
|
+
wide,
|
|
727
|
+
continuation,
|
|
728
|
+
}
|
|
729
|
+
const packed = packCell(fullCell)
|
|
730
|
+
|
|
731
|
+
// Determine true color values once
|
|
732
|
+
const hasTrueColorFg = isTrueColor(fg)
|
|
733
|
+
const hasTrueColorBg = isTrueColor(bg)
|
|
734
|
+
const trueColorFg = hasTrueColorFg ? (fg as { r: number; g: number; b: number }) : null
|
|
735
|
+
const trueColorBg = hasTrueColorBg ? (bg as { r: number; g: number; b: number }) : null
|
|
736
|
+
const hasUnderlineColor = underlineColor !== null
|
|
737
|
+
const hyperlink = cell.hyperlink
|
|
738
|
+
const hasHyperlink = hyperlink !== undefined && hyperlink !== ""
|
|
739
|
+
|
|
740
|
+
// Mark affected rows dirty + update bounding box
|
|
741
|
+
for (let cy = startY; cy < endY; cy++) {
|
|
742
|
+
this._dirtyRows[cy] = 1
|
|
743
|
+
}
|
|
744
|
+
if (startY < endY) {
|
|
745
|
+
if (this._minDirtyRow === -1 || startY < this._minDirtyRow) this._minDirtyRow = startY
|
|
746
|
+
if (endY - 1 > this._maxDirtyRow) this._maxDirtyRow = endY - 1
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
// Determine which Map operations are actually needed.
|
|
750
|
+
// Skip delete() calls when the map is already empty (common case: no true colors).
|
|
751
|
+
const needFgDelete = !hasTrueColorFg && this.fgColors.size > 0
|
|
752
|
+
const needBgDelete = !hasTrueColorBg && this.bgColors.size > 0
|
|
753
|
+
const needUlDelete = !hasUnderlineColor && this.underlineColors.size > 0
|
|
754
|
+
const needHlDelete = !hasHyperlink && this.hyperlinks.size > 0
|
|
755
|
+
|
|
756
|
+
for (let cy = startY; cy < endY; cy++) {
|
|
757
|
+
const rowBase = cy * this.width
|
|
758
|
+
for (let cx = startX; cx < endX; cx++) {
|
|
759
|
+
const idx = rowBase + cx
|
|
760
|
+
|
|
761
|
+
// Direct array assignment (no setCell overhead)
|
|
762
|
+
this.cells[idx] = packed
|
|
763
|
+
this.chars[idx] = char
|
|
764
|
+
|
|
765
|
+
// Handle true color maps — skip delete when map is empty
|
|
766
|
+
if (hasTrueColorFg) {
|
|
767
|
+
this.fgColors.set(idx, trueColorFg!)
|
|
768
|
+
} else if (needFgDelete) {
|
|
769
|
+
this.fgColors.delete(idx)
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
if (hasTrueColorBg) {
|
|
773
|
+
this.bgColors.set(idx, trueColorBg!)
|
|
774
|
+
} else if (needBgDelete) {
|
|
775
|
+
this.bgColors.delete(idx)
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
if (hasUnderlineColor) {
|
|
779
|
+
this.underlineColors.set(idx, underlineColor)
|
|
780
|
+
} else if (needUlDelete) {
|
|
781
|
+
this.underlineColors.delete(idx)
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
if (hasHyperlink) {
|
|
785
|
+
this.hyperlinks.set(idx, hyperlink!)
|
|
786
|
+
} else if (needHlDelete) {
|
|
787
|
+
this.hyperlinks.delete(idx)
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
/**
|
|
794
|
+
* Clear the buffer (fill with empty cells).
|
|
795
|
+
*/
|
|
796
|
+
clear(): void {
|
|
797
|
+
this.cells.fill(0)
|
|
798
|
+
this.chars.fill(" ")
|
|
799
|
+
this.fgColors.clear()
|
|
800
|
+
this.bgColors.clear()
|
|
801
|
+
this.underlineColors.clear()
|
|
802
|
+
this.hyperlinks.clear()
|
|
803
|
+
this._dirtyRows.fill(1)
|
|
804
|
+
this._minDirtyRow = 0
|
|
805
|
+
this._maxDirtyRow = this.height - 1
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
/**
|
|
809
|
+
* Copy a region from another buffer.
|
|
810
|
+
*/
|
|
811
|
+
copyFrom(
|
|
812
|
+
source: TerminalBuffer,
|
|
813
|
+
srcX: number,
|
|
814
|
+
srcY: number,
|
|
815
|
+
destX: number,
|
|
816
|
+
destY: number,
|
|
817
|
+
width: number,
|
|
818
|
+
height: number,
|
|
819
|
+
): void {
|
|
820
|
+
const cell = createMutableCell()
|
|
821
|
+
for (let dy = 0; dy < height; dy++) {
|
|
822
|
+
const dstY = destY + dy
|
|
823
|
+
if (dstY >= 0 && dstY < this.height) {
|
|
824
|
+
this._dirtyRows[dstY] = 1
|
|
825
|
+
if (this._minDirtyRow === -1 || dstY < this._minDirtyRow) this._minDirtyRow = dstY
|
|
826
|
+
if (dstY > this._maxDirtyRow) this._maxDirtyRow = dstY
|
|
827
|
+
}
|
|
828
|
+
for (let dx = 0; dx < width; dx++) {
|
|
829
|
+
const sx = srcX + dx
|
|
830
|
+
const sy = srcY + dy
|
|
831
|
+
const dX = destX + dx
|
|
832
|
+
|
|
833
|
+
if (source.inBounds(sx, sy) && this.inBounds(dX, dstY)) {
|
|
834
|
+
source.readCellInto(sx, sy, cell)
|
|
835
|
+
this.setCell(dX, dstY, cell)
|
|
836
|
+
}
|
|
837
|
+
}
|
|
838
|
+
}
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
/**
|
|
842
|
+
* Shift content within a rectangular region vertically by `delta` rows.
|
|
843
|
+
* Positive delta = shift content UP (scroll down), negative = shift DOWN (scroll up).
|
|
844
|
+
* Exposed rows (at the bottom for positive delta, top for negative) are filled
|
|
845
|
+
* with the given background cell.
|
|
846
|
+
*
|
|
847
|
+
* Uses Uint32Array.copyWithin for the packed cells (native memcpy) and
|
|
848
|
+
* Array splice for the character array.
|
|
849
|
+
*/
|
|
850
|
+
scrollRegion(
|
|
851
|
+
x: number,
|
|
852
|
+
y: number,
|
|
853
|
+
regionWidth: number,
|
|
854
|
+
regionHeight: number,
|
|
855
|
+
delta: number,
|
|
856
|
+
clearCell: Partial<Cell> = {},
|
|
857
|
+
): void {
|
|
858
|
+
if (delta === 0 || regionHeight <= 0 || regionWidth <= 0) return
|
|
859
|
+
|
|
860
|
+
const startX = Math.max(0, x)
|
|
861
|
+
const endX = Math.min(x + regionWidth, this.width)
|
|
862
|
+
const startY = Math.max(0, y)
|
|
863
|
+
const endY = Math.min(y + regionHeight, this.height)
|
|
864
|
+
const clampedWidth = endX - startX
|
|
865
|
+
const clampedHeight = endY - startY
|
|
866
|
+
|
|
867
|
+
if (clampedWidth <= 0 || clampedHeight <= 0) return
|
|
868
|
+
|
|
869
|
+
// Mark all rows in the scroll region dirty + update bounding box
|
|
870
|
+
for (let r = startY; r < endY; r++) {
|
|
871
|
+
this._dirtyRows[r] = 1
|
|
872
|
+
}
|
|
873
|
+
if (this._minDirtyRow === -1 || startY < this._minDirtyRow) this._minDirtyRow = startY
|
|
874
|
+
if (endY - 1 > this._maxDirtyRow) this._maxDirtyRow = endY - 1
|
|
875
|
+
|
|
876
|
+
if (Math.abs(delta) >= clampedHeight) {
|
|
877
|
+
// Scroll amount exceeds region — just clear everything
|
|
878
|
+
this.fill(startX, startY, clampedWidth, clampedHeight, {
|
|
879
|
+
char: clearCell.char ?? " ",
|
|
880
|
+
bg: clearCell.bg ?? null,
|
|
881
|
+
})
|
|
882
|
+
return
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
const absDelta = Math.abs(delta)
|
|
886
|
+
const w = this.width
|
|
887
|
+
|
|
888
|
+
if (delta > 0) {
|
|
889
|
+
// Shift content UP: copy rows [startY + delta .. endY) to [startY .. endY - delta)
|
|
890
|
+
for (let row = startY; row < endY - absDelta; row++) {
|
|
891
|
+
const dstBase = row * w
|
|
892
|
+
const srcBase = (row + absDelta) * w
|
|
893
|
+
// Copy cells and chars for the region columns
|
|
894
|
+
this.cells.copyWithin(dstBase + startX, srcBase + startX, srcBase + endX)
|
|
895
|
+
for (let cx = startX; cx < endX; cx++) {
|
|
896
|
+
this.chars[dstBase + cx] = this.chars[srcBase + cx]!
|
|
897
|
+
// Move true color maps
|
|
898
|
+
const srcIdx = srcBase + cx
|
|
899
|
+
const dstIdx = dstBase + cx
|
|
900
|
+
const fgc = this.fgColors.get(srcIdx)
|
|
901
|
+
if (fgc) {
|
|
902
|
+
this.fgColors.set(dstIdx, fgc)
|
|
903
|
+
this.fgColors.delete(srcIdx)
|
|
904
|
+
} else {
|
|
905
|
+
this.fgColors.delete(dstIdx)
|
|
906
|
+
}
|
|
907
|
+
const bgc = this.bgColors.get(srcIdx)
|
|
908
|
+
if (bgc) {
|
|
909
|
+
this.bgColors.set(dstIdx, bgc)
|
|
910
|
+
this.bgColors.delete(srcIdx)
|
|
911
|
+
} else {
|
|
912
|
+
this.bgColors.delete(dstIdx)
|
|
913
|
+
}
|
|
914
|
+
const ulc = this.underlineColors.get(srcIdx)
|
|
915
|
+
if (ulc) {
|
|
916
|
+
this.underlineColors.set(dstIdx, ulc)
|
|
917
|
+
this.underlineColors.delete(srcIdx)
|
|
918
|
+
} else {
|
|
919
|
+
this.underlineColors.delete(dstIdx)
|
|
920
|
+
}
|
|
921
|
+
const hl = this.hyperlinks.get(srcIdx)
|
|
922
|
+
if (hl) {
|
|
923
|
+
this.hyperlinks.set(dstIdx, hl)
|
|
924
|
+
this.hyperlinks.delete(srcIdx)
|
|
925
|
+
} else {
|
|
926
|
+
this.hyperlinks.delete(dstIdx)
|
|
927
|
+
}
|
|
928
|
+
}
|
|
929
|
+
}
|
|
930
|
+
// Clear exposed rows at bottom
|
|
931
|
+
this.fill(startX, endY - absDelta, clampedWidth, absDelta, {
|
|
932
|
+
char: clearCell.char ?? " ",
|
|
933
|
+
bg: clearCell.bg ?? null,
|
|
934
|
+
})
|
|
935
|
+
} else {
|
|
936
|
+
// Shift content DOWN: copy rows [startY .. endY - absDelta) to [startY + absDelta .. endY)
|
|
937
|
+
for (let row = endY - 1; row >= startY + absDelta; row--) {
|
|
938
|
+
const dstBase = row * w
|
|
939
|
+
const srcBase = (row - absDelta) * w
|
|
940
|
+
this.cells.copyWithin(dstBase + startX, srcBase + startX, srcBase + endX)
|
|
941
|
+
for (let cx = startX; cx < endX; cx++) {
|
|
942
|
+
this.chars[dstBase + cx] = this.chars[srcBase + cx]!
|
|
943
|
+
const srcIdx = srcBase + cx
|
|
944
|
+
const dstIdx = dstBase + cx
|
|
945
|
+
const fgc = this.fgColors.get(srcIdx)
|
|
946
|
+
if (fgc) {
|
|
947
|
+
this.fgColors.set(dstIdx, fgc)
|
|
948
|
+
this.fgColors.delete(srcIdx)
|
|
949
|
+
} else {
|
|
950
|
+
this.fgColors.delete(dstIdx)
|
|
951
|
+
}
|
|
952
|
+
const bgc = this.bgColors.get(srcIdx)
|
|
953
|
+
if (bgc) {
|
|
954
|
+
this.bgColors.set(dstIdx, bgc)
|
|
955
|
+
this.bgColors.delete(srcIdx)
|
|
956
|
+
} else {
|
|
957
|
+
this.bgColors.delete(dstIdx)
|
|
958
|
+
}
|
|
959
|
+
const ulc = this.underlineColors.get(srcIdx)
|
|
960
|
+
if (ulc) {
|
|
961
|
+
this.underlineColors.set(dstIdx, ulc)
|
|
962
|
+
this.underlineColors.delete(srcIdx)
|
|
963
|
+
} else {
|
|
964
|
+
this.underlineColors.delete(dstIdx)
|
|
965
|
+
}
|
|
966
|
+
const hl = this.hyperlinks.get(srcIdx)
|
|
967
|
+
if (hl) {
|
|
968
|
+
this.hyperlinks.set(dstIdx, hl)
|
|
969
|
+
this.hyperlinks.delete(srcIdx)
|
|
970
|
+
} else {
|
|
971
|
+
this.hyperlinks.delete(dstIdx)
|
|
972
|
+
}
|
|
973
|
+
}
|
|
974
|
+
}
|
|
975
|
+
// Clear exposed rows at top
|
|
976
|
+
this.fill(startX, startY, clampedWidth, absDelta, {
|
|
977
|
+
char: clearCell.char ?? " ",
|
|
978
|
+
bg: clearCell.bg ?? null,
|
|
979
|
+
})
|
|
980
|
+
}
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
/**
|
|
984
|
+
* Clone this buffer.
|
|
985
|
+
*/
|
|
986
|
+
clone(): TerminalBuffer {
|
|
987
|
+
const copy = new TerminalBuffer(this.width, this.height)
|
|
988
|
+
copy.cells.set(this.cells)
|
|
989
|
+
copy.chars = [...this.chars]
|
|
990
|
+
copy.fgColors = new Map(this.fgColors)
|
|
991
|
+
copy.bgColors = new Map(this.bgColors)
|
|
992
|
+
copy.underlineColors = new Map(this.underlineColors)
|
|
993
|
+
copy.hyperlinks = new Map(this.hyperlinks)
|
|
994
|
+
// Clone starts with all rows CLEAN. The content phase will mark only
|
|
995
|
+
// the rows it modifies as dirty. diffBuffers() then skips clean rows,
|
|
996
|
+
// which are guaranteed identical to the prev buffer (since this is a clone).
|
|
997
|
+
copy._dirtyRows.fill(0)
|
|
998
|
+
copy._minDirtyRow = -1
|
|
999
|
+
copy._maxDirtyRow = -1
|
|
1000
|
+
return copy
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
/**
|
|
1004
|
+
* Check if a row has been modified since the last resetDirtyRows() call.
|
|
1005
|
+
* Used by diffBuffers() to skip unchanged rows.
|
|
1006
|
+
*/
|
|
1007
|
+
isRowDirty(y: number): boolean {
|
|
1008
|
+
if (y < 0 || y >= this.height) return false
|
|
1009
|
+
return this._dirtyRows[y] !== 0
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
/** First dirty row (inclusive), or -1 if no rows are dirty. */
|
|
1013
|
+
get minDirtyRow(): number {
|
|
1014
|
+
return this._minDirtyRow
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
/** Last dirty row (inclusive), or -1 if no rows are dirty. */
|
|
1018
|
+
get maxDirtyRow(): number {
|
|
1019
|
+
return this._maxDirtyRow
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
/**
|
|
1023
|
+
* Reset all dirty row flags to clean.
|
|
1024
|
+
* Call after diffing to prepare for the next frame's modifications.
|
|
1025
|
+
*/
|
|
1026
|
+
resetDirtyRows(): void {
|
|
1027
|
+
this._dirtyRows.fill(0)
|
|
1028
|
+
this._minDirtyRow = -1
|
|
1029
|
+
this._maxDirtyRow = -1
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
/**
|
|
1033
|
+
* Mark all rows as dirty.
|
|
1034
|
+
* Used when the buffer's dirty rows may not cover all changes relative
|
|
1035
|
+
* to a different prev buffer (e.g., after multiple doRender calls where
|
|
1036
|
+
* the runtime's prevBuffer skipped intermediate buffers).
|
|
1037
|
+
*/
|
|
1038
|
+
markAllRowsDirty(): void {
|
|
1039
|
+
this._dirtyRows.fill(1)
|
|
1040
|
+
this._minDirtyRow = 0
|
|
1041
|
+
this._maxDirtyRow = this.height - 1
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
/**
|
|
1045
|
+
* Check if two cells at given positions are equal.
|
|
1046
|
+
* Used for diffing.
|
|
1047
|
+
*/
|
|
1048
|
+
cellEquals(x: number, y: number, other: TerminalBuffer): boolean {
|
|
1049
|
+
if (!this.inBounds(x, y) || !other.inBounds(x, y)) {
|
|
1050
|
+
return false
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1053
|
+
const idx = this.index(x, y)
|
|
1054
|
+
const otherIdx = other.index(x, y)
|
|
1055
|
+
|
|
1056
|
+
// Quick check: packed metadata must match
|
|
1057
|
+
if (this.cells[idx] !== other.cells[otherIdx]) {
|
|
1058
|
+
return false
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
// Character must match
|
|
1062
|
+
if (this.chars[idx] !== other.chars[otherIdx]) {
|
|
1063
|
+
return false
|
|
1064
|
+
}
|
|
1065
|
+
|
|
1066
|
+
// If true color flags are set, check the color values
|
|
1067
|
+
const packed = this.cells[idx]!
|
|
1068
|
+
if (unpackTrueColorFg(packed)) {
|
|
1069
|
+
const a = this.fgColors.get(idx)
|
|
1070
|
+
const b = other.fgColors.get(otherIdx)
|
|
1071
|
+
if (!colorEquals(a, b)) return false
|
|
1072
|
+
}
|
|
1073
|
+
if (unpackTrueColorBg(packed!)) {
|
|
1074
|
+
const a = this.bgColors.get(idx)
|
|
1075
|
+
const b = other.bgColors.get(otherIdx)
|
|
1076
|
+
if (!colorEquals(a, b)) return false
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1079
|
+
// Check underline colors
|
|
1080
|
+
const ulA = this.underlineColors.get(idx) ?? null
|
|
1081
|
+
const ulB = other.underlineColors.get(otherIdx) ?? null
|
|
1082
|
+
if (!colorEquals(ulA, ulB)) return false
|
|
1083
|
+
|
|
1084
|
+
// Check hyperlinks
|
|
1085
|
+
const hlA = this.hyperlinks.get(idx)
|
|
1086
|
+
const hlB = other.hyperlinks.get(otherIdx)
|
|
1087
|
+
if (hlA !== hlB) return false
|
|
1088
|
+
|
|
1089
|
+
return true
|
|
1090
|
+
}
|
|
1091
|
+
|
|
1092
|
+
/**
|
|
1093
|
+
* Fast check: are all packed metadata values identical for a row?
|
|
1094
|
+
* This is a bulk pre-check before per-cell comparison. If metadata differs,
|
|
1095
|
+
* we still need per-cell diffing. If metadata matches, we only need to
|
|
1096
|
+
* check chars, true color maps, underline colors, and hyperlinks.
|
|
1097
|
+
* Returns true if all packed 32-bit values in the row are identical.
|
|
1098
|
+
*/
|
|
1099
|
+
rowMetadataEquals(y: number, other: TerminalBuffer): boolean {
|
|
1100
|
+
if (y < 0 || y >= this.height || y >= other.height) return false
|
|
1101
|
+
const start = y * this.width
|
|
1102
|
+
const otherStart = y * other.width
|
|
1103
|
+
const w = Math.min(this.width, other.width)
|
|
1104
|
+
for (let i = 0; i < w; i++) {
|
|
1105
|
+
if (this.cells[start + i] !== other.cells[otherStart + i]) return false
|
|
1106
|
+
}
|
|
1107
|
+
return true
|
|
1108
|
+
}
|
|
1109
|
+
|
|
1110
|
+
/**
|
|
1111
|
+
* Fast check: are all characters identical for a row?
|
|
1112
|
+
* Companion to rowMetadataEquals for a two-phase row comparison.
|
|
1113
|
+
*/
|
|
1114
|
+
rowCharsEquals(y: number, other: TerminalBuffer): boolean {
|
|
1115
|
+
if (y < 0 || y >= this.height || y >= other.height) return false
|
|
1116
|
+
const start = y * this.width
|
|
1117
|
+
const otherStart = y * other.width
|
|
1118
|
+
const w = Math.min(this.width, other.width)
|
|
1119
|
+
for (let i = 0; i < w; i++) {
|
|
1120
|
+
if (this.chars[start + i] !== other.chars[otherStart + i]) return false
|
|
1121
|
+
}
|
|
1122
|
+
return true
|
|
1123
|
+
}
|
|
1124
|
+
|
|
1125
|
+
/**
|
|
1126
|
+
* Check Map-based extras for a row: true color fg/bg, underline colors, hyperlinks.
|
|
1127
|
+
* Must be called AFTER rowMetadataEquals confirms packed metadata matches.
|
|
1128
|
+
* Only checks cells that have true color flags set (the Maps are only populated
|
|
1129
|
+
* for those cells). Also checks underline colors and hyperlinks for all cells.
|
|
1130
|
+
*/
|
|
1131
|
+
rowExtrasEquals(y: number, other: TerminalBuffer): boolean {
|
|
1132
|
+
if (y < 0 || y >= this.height || y >= other.height) return false
|
|
1133
|
+
const start = y * this.width
|
|
1134
|
+
const w = Math.min(this.width, other.width)
|
|
1135
|
+
const otherStart = y * other.width
|
|
1136
|
+
for (let i = 0; i < w; i++) {
|
|
1137
|
+
const idx = start + i
|
|
1138
|
+
const otherIdx = otherStart + i
|
|
1139
|
+
const packed = this.cells[idx]!
|
|
1140
|
+
|
|
1141
|
+
// Check true color fg values
|
|
1142
|
+
if ((packed & TRUE_COLOR_FG_FLAG) !== 0) {
|
|
1143
|
+
const a = this.fgColors.get(idx)
|
|
1144
|
+
const b = other.fgColors.get(otherIdx)
|
|
1145
|
+
if (!colorEquals(a, b)) return false
|
|
1146
|
+
}
|
|
1147
|
+
|
|
1148
|
+
// Check true color bg values
|
|
1149
|
+
if ((packed & TRUE_COLOR_BG_FLAG) !== 0) {
|
|
1150
|
+
const a = this.bgColors.get(idx)
|
|
1151
|
+
const b = other.bgColors.get(otherIdx)
|
|
1152
|
+
if (!colorEquals(a, b)) return false
|
|
1153
|
+
}
|
|
1154
|
+
|
|
1155
|
+
// Check underline colors
|
|
1156
|
+
const ulA = this.underlineColors.get(idx) ?? null
|
|
1157
|
+
const ulB = other.underlineColors.get(otherIdx) ?? null
|
|
1158
|
+
if (!colorEquals(ulA, ulB)) return false
|
|
1159
|
+
|
|
1160
|
+
// Check hyperlinks
|
|
1161
|
+
const hlA = this.hyperlinks.get(idx)
|
|
1162
|
+
const hlB = other.hyperlinks.get(otherIdx)
|
|
1163
|
+
if (hlA !== hlB) return false
|
|
1164
|
+
}
|
|
1165
|
+
return true
|
|
1166
|
+
}
|
|
1167
|
+
}
|
|
1168
|
+
|
|
1169
|
+
// ============================================================================
|
|
1170
|
+
// Utility Functions
|
|
1171
|
+
// ============================================================================
|
|
1172
|
+
|
|
1173
|
+
/**
|
|
1174
|
+
* Compare two colors for equality.
|
|
1175
|
+
*/
|
|
1176
|
+
export function colorEquals(a: Color | undefined, b: Color | undefined): boolean {
|
|
1177
|
+
if (a === b) return true
|
|
1178
|
+
if (a === null || a === undefined) return b === null || b === undefined
|
|
1179
|
+
if (b === null || b === undefined) return false
|
|
1180
|
+
if (typeof a === "number") return a === b
|
|
1181
|
+
if (typeof b === "number") return false
|
|
1182
|
+
return a.r === b.r && a.g === b.g && a.b === b.b
|
|
1183
|
+
}
|
|
1184
|
+
|
|
1185
|
+
/**
|
|
1186
|
+
* Compare two cells for equality.
|
|
1187
|
+
*/
|
|
1188
|
+
export function cellEquals(a: Cell, b: Cell): boolean {
|
|
1189
|
+
return (
|
|
1190
|
+
a.char === b.char &&
|
|
1191
|
+
colorEquals(a.fg, b.fg) &&
|
|
1192
|
+
colorEquals(a.bg, b.bg) &&
|
|
1193
|
+
colorEquals(a.underlineColor, b.underlineColor) &&
|
|
1194
|
+
a.wide === b.wide &&
|
|
1195
|
+
a.continuation === b.continuation &&
|
|
1196
|
+
attrsEquals(a.attrs, b.attrs) &&
|
|
1197
|
+
(a.hyperlink ?? undefined) === (b.hyperlink ?? undefined)
|
|
1198
|
+
)
|
|
1199
|
+
}
|
|
1200
|
+
|
|
1201
|
+
/**
|
|
1202
|
+
* Compare two CellAttrs for equality.
|
|
1203
|
+
*/
|
|
1204
|
+
export function attrsEquals(a: CellAttrs, b: CellAttrs): boolean {
|
|
1205
|
+
return (
|
|
1206
|
+
Boolean(a.bold) === Boolean(b.bold) &&
|
|
1207
|
+
Boolean(a.dim) === Boolean(b.dim) &&
|
|
1208
|
+
Boolean(a.italic) === Boolean(b.italic) &&
|
|
1209
|
+
Boolean(a.underline) === Boolean(b.underline) &&
|
|
1210
|
+
(a.underlineStyle ?? false) === (b.underlineStyle ?? false) &&
|
|
1211
|
+
Boolean(a.blink) === Boolean(b.blink) &&
|
|
1212
|
+
Boolean(a.inverse) === Boolean(b.inverse) &&
|
|
1213
|
+
Boolean(a.hidden) === Boolean(b.hidden) &&
|
|
1214
|
+
Boolean(a.strikethrough) === Boolean(b.strikethrough)
|
|
1215
|
+
)
|
|
1216
|
+
}
|
|
1217
|
+
|
|
1218
|
+
/**
|
|
1219
|
+
* Compare two styles for equality.
|
|
1220
|
+
*/
|
|
1221
|
+
export function styleEquals(a: Style | null, b: Style | null): boolean {
|
|
1222
|
+
if (a === b) return true
|
|
1223
|
+
if (!a || !b) return false
|
|
1224
|
+
return (
|
|
1225
|
+
colorEquals(a.fg, b.fg) &&
|
|
1226
|
+
colorEquals(a.bg, b.bg) &&
|
|
1227
|
+
colorEquals(a.underlineColor, b.underlineColor) &&
|
|
1228
|
+
attrsEquals(a.attrs, b.attrs) &&
|
|
1229
|
+
(a.hyperlink ?? undefined) === (b.hyperlink ?? undefined)
|
|
1230
|
+
)
|
|
1231
|
+
}
|
|
1232
|
+
|
|
1233
|
+
/**
|
|
1234
|
+
* Create a mutable Cell object for use with readCellInto().
|
|
1235
|
+
* The returned object is reusable -- readCellInto() overwrites all fields.
|
|
1236
|
+
*/
|
|
1237
|
+
export function createMutableCell(): Cell {
|
|
1238
|
+
return {
|
|
1239
|
+
char: " ",
|
|
1240
|
+
fg: null,
|
|
1241
|
+
bg: null,
|
|
1242
|
+
underlineColor: null,
|
|
1243
|
+
attrs: {},
|
|
1244
|
+
wide: false,
|
|
1245
|
+
continuation: false,
|
|
1246
|
+
hyperlink: undefined,
|
|
1247
|
+
}
|
|
1248
|
+
}
|
|
1249
|
+
|
|
1250
|
+
/**
|
|
1251
|
+
* Create a buffer initialized with a specific character.
|
|
1252
|
+
*/
|
|
1253
|
+
export function createBuffer(width: number, height: number, char = " "): TerminalBuffer {
|
|
1254
|
+
const buffer = new TerminalBuffer(width, height)
|
|
1255
|
+
if (char !== " ") {
|
|
1256
|
+
buffer.fill(0, 0, width, height, { char })
|
|
1257
|
+
}
|
|
1258
|
+
return buffer
|
|
1259
|
+
}
|
|
1260
|
+
|
|
1261
|
+
// ============================================================================
|
|
1262
|
+
// Buffer Conversion Utilities
|
|
1263
|
+
// ============================================================================
|
|
1264
|
+
|
|
1265
|
+
/**
|
|
1266
|
+
* Convert a terminal buffer to plain text (no ANSI codes).
|
|
1267
|
+
* Useful for snapshot testing and text-based assertions.
|
|
1268
|
+
*
|
|
1269
|
+
* @param buffer The buffer to convert
|
|
1270
|
+
* @param options.trimTrailingWhitespace Remove trailing spaces from each line (default: true)
|
|
1271
|
+
* @param options.trimEmptyLines Remove trailing empty lines (default: true)
|
|
1272
|
+
* @returns Plain text representation of the buffer
|
|
1273
|
+
*/
|
|
1274
|
+
export function bufferToText(
|
|
1275
|
+
buffer: TerminalBuffer,
|
|
1276
|
+
options: {
|
|
1277
|
+
trimTrailingWhitespace?: boolean
|
|
1278
|
+
trimEmptyLines?: boolean
|
|
1279
|
+
} = {},
|
|
1280
|
+
): string {
|
|
1281
|
+
const { trimTrailingWhitespace = true, trimEmptyLines = true } = options
|
|
1282
|
+
|
|
1283
|
+
const lines: string[] = []
|
|
1284
|
+
|
|
1285
|
+
for (let y = 0; y < buffer.height; y++) {
|
|
1286
|
+
let line = ""
|
|
1287
|
+
// Track string offset for each column (needed because continuation cells
|
|
1288
|
+
// are skipped, making string length != column count for wide chars)
|
|
1289
|
+
let strOffset = 0
|
|
1290
|
+
let contentEdgeStrOffset = 0
|
|
1291
|
+
const contentEdge = trimTrailingWhitespace ? getContentEdge(buffer, y) : 0
|
|
1292
|
+
for (let x = 0; x < buffer.width; x++) {
|
|
1293
|
+
// Use zero-allocation accessors instead of getCell()
|
|
1294
|
+
if (buffer.isCellContinuation(x, y)) continue
|
|
1295
|
+
line += buffer.getCellChar(x, y)
|
|
1296
|
+
strOffset++
|
|
1297
|
+
// Track the string offset corresponding to the content edge column
|
|
1298
|
+
if (x < contentEdge) {
|
|
1299
|
+
contentEdgeStrOffset = strOffset
|
|
1300
|
+
}
|
|
1301
|
+
}
|
|
1302
|
+
if (trimTrailingWhitespace) {
|
|
1303
|
+
// Smart trim: use content edge to preserve styled trailing spaces
|
|
1304
|
+
// while removing unstyled buffer padding.
|
|
1305
|
+
const trimmed = line.trimEnd()
|
|
1306
|
+
line = trimmed.length >= contentEdgeStrOffset ? trimmed : line.substring(0, contentEdgeStrOffset)
|
|
1307
|
+
}
|
|
1308
|
+
lines.push(line)
|
|
1309
|
+
}
|
|
1310
|
+
|
|
1311
|
+
let result = lines.join("\n")
|
|
1312
|
+
if (trimEmptyLines) {
|
|
1313
|
+
// Remove trailing empty lines without stripping spaces from last content line
|
|
1314
|
+
while (lines.length > 0 && lines[lines.length - 1]!.length === 0) {
|
|
1315
|
+
lines.pop()
|
|
1316
|
+
}
|
|
1317
|
+
result = lines.join("\n")
|
|
1318
|
+
}
|
|
1319
|
+
return result
|
|
1320
|
+
}
|
|
1321
|
+
|
|
1322
|
+
/**
|
|
1323
|
+
* Find the rightmost column with non-default cell content on a row.
|
|
1324
|
+
* A default cell has packed metadata === 0 (no fg, bg, attrs) AND char === ' '.
|
|
1325
|
+
* Returns the column count (1-indexed), so the content edge for trimming.
|
|
1326
|
+
*/
|
|
1327
|
+
function getContentEdge(buffer: TerminalBuffer, y: number): number {
|
|
1328
|
+
// Mask out structural flags (wide, continuation) that don't indicate actual content.
|
|
1329
|
+
// True-color flags DO indicate styled content (they mean fg/bg is set in Maps).
|
|
1330
|
+
const FLAG_MASK = ~(WIDE_FLAG | CONTINUATION_FLAG)
|
|
1331
|
+
for (let x = buffer.width - 1; x >= 0; x--) {
|
|
1332
|
+
// Skip continuation cells (trailing half of wide chars) — the main cell covers them
|
|
1333
|
+
if (buffer.isCellContinuation(x, y)) continue
|
|
1334
|
+
// Check if cell has any actual styling (fg, bg, text attrs) after masking structural flags
|
|
1335
|
+
const attrs = buffer.getCellAttrs(x, y) & FLAG_MASK
|
|
1336
|
+
if (attrs !== 0) return x + 1
|
|
1337
|
+
// Check if cell has a non-space character
|
|
1338
|
+
if (buffer.getCellChar(x, y) !== " ") return x + 1
|
|
1339
|
+
}
|
|
1340
|
+
return 0
|
|
1341
|
+
}
|
|
1342
|
+
|
|
1343
|
+
/**
|
|
1344
|
+
* Convert a terminal buffer to styled ANSI text.
|
|
1345
|
+
* Unlike bufferToAnsi, this doesn't include cursor control sequences,
|
|
1346
|
+
* making it suitable for displaying in terminals or saving to files.
|
|
1347
|
+
*
|
|
1348
|
+
* @param buffer The buffer to convert
|
|
1349
|
+
* @param options.trimTrailingWhitespace Remove trailing spaces from each line (default: true)
|
|
1350
|
+
* @param options.trimEmptyLines Remove trailing empty lines (default: true)
|
|
1351
|
+
* @returns ANSI-styled text (no cursor control)
|
|
1352
|
+
*/
|
|
1353
|
+
export function bufferToStyledText(
|
|
1354
|
+
buffer: TerminalBuffer,
|
|
1355
|
+
options: {
|
|
1356
|
+
trimTrailingWhitespace?: boolean
|
|
1357
|
+
trimEmptyLines?: boolean
|
|
1358
|
+
} = {},
|
|
1359
|
+
): string {
|
|
1360
|
+
const { trimTrailingWhitespace = true, trimEmptyLines = true } = options
|
|
1361
|
+
|
|
1362
|
+
const lines: string[] = []
|
|
1363
|
+
let currentStyle: Style | null = null
|
|
1364
|
+
let currentHyperlink: string | undefined
|
|
1365
|
+
|
|
1366
|
+
for (let y = 0; y < buffer.height; y++) {
|
|
1367
|
+
let line = ""
|
|
1368
|
+
|
|
1369
|
+
for (let x = 0; x < buffer.width; x++) {
|
|
1370
|
+
// getCell allocates a fresh object each call, which is fine here since
|
|
1371
|
+
// bufferToStyledText is a utility function, not a hot render path.
|
|
1372
|
+
const cell = buffer.getCell(x, y)
|
|
1373
|
+
// Skip continuation cells (part of wide character)
|
|
1374
|
+
if (cell.continuation) continue
|
|
1375
|
+
|
|
1376
|
+
// Check if hyperlink changed (OSC 8 is separate from SGR)
|
|
1377
|
+
const cellHyperlink = cell.hyperlink
|
|
1378
|
+
if (cellHyperlink !== currentHyperlink) {
|
|
1379
|
+
if (currentHyperlink) {
|
|
1380
|
+
// Close previous hyperlink using the same format as the open
|
|
1381
|
+
line += emitHyperlinkClose(currentHyperlink)
|
|
1382
|
+
}
|
|
1383
|
+
if (cellHyperlink) {
|
|
1384
|
+
line += emitHyperlinkOpen(cellHyperlink)
|
|
1385
|
+
}
|
|
1386
|
+
currentHyperlink = cellHyperlink
|
|
1387
|
+
}
|
|
1388
|
+
|
|
1389
|
+
// Check if style changed — emit minimal transition (chalk-compatible)
|
|
1390
|
+
const cellStyle: Style = {
|
|
1391
|
+
fg: cell.fg,
|
|
1392
|
+
bg: cell.bg,
|
|
1393
|
+
underlineColor: cell.underlineColor,
|
|
1394
|
+
attrs: cell.attrs,
|
|
1395
|
+
}
|
|
1396
|
+
if (!styleEquals(currentStyle, cellStyle)) {
|
|
1397
|
+
line += styleTransitionCodes(currentStyle, cellStyle)
|
|
1398
|
+
currentStyle = cellStyle
|
|
1399
|
+
}
|
|
1400
|
+
|
|
1401
|
+
line += cell.char
|
|
1402
|
+
}
|
|
1403
|
+
|
|
1404
|
+
// Close any open hyperlink at end of line
|
|
1405
|
+
if (currentHyperlink) {
|
|
1406
|
+
line += emitHyperlinkClose(currentHyperlink)
|
|
1407
|
+
currentHyperlink = undefined
|
|
1408
|
+
}
|
|
1409
|
+
|
|
1410
|
+
// Reset style at end of line using per-attribute resets (chalk-compatible)
|
|
1411
|
+
if (currentStyle && (currentStyle.bg !== null || hasActiveAttrs(currentStyle.attrs))) {
|
|
1412
|
+
line += styleResetCodes(currentStyle)
|
|
1413
|
+
currentStyle = null
|
|
1414
|
+
}
|
|
1415
|
+
|
|
1416
|
+
if (trimTrailingWhitespace) {
|
|
1417
|
+
// Need to be careful not to strip ANSI codes
|
|
1418
|
+
// Only trim actual whitespace at the end
|
|
1419
|
+
line = trimTrailingWhitespacePreservingAnsi(line)
|
|
1420
|
+
}
|
|
1421
|
+
lines.push(line)
|
|
1422
|
+
}
|
|
1423
|
+
|
|
1424
|
+
// Final per-attribute reset (chalk-compatible)
|
|
1425
|
+
let result = lines.join("\n")
|
|
1426
|
+
if (currentStyle && (currentStyle.bg !== null || hasActiveAttrs(currentStyle.attrs))) {
|
|
1427
|
+
result += styleResetCodes(currentStyle)
|
|
1428
|
+
}
|
|
1429
|
+
|
|
1430
|
+
if (trimEmptyLines) {
|
|
1431
|
+
// Remove empty lines at the end (but preserve ANSI resets)
|
|
1432
|
+
result = result.replace(/\n+$/, "")
|
|
1433
|
+
}
|
|
1434
|
+
|
|
1435
|
+
return result
|
|
1436
|
+
}
|
|
1437
|
+
|
|
1438
|
+
// ============================================================================
|
|
1439
|
+
// Hyperlink Format Helpers
|
|
1440
|
+
// ============================================================================
|
|
1441
|
+
|
|
1442
|
+
/**
|
|
1443
|
+
* Decode hyperlink format metadata from URL prefix.
|
|
1444
|
+
* parseAnsiText encodes the original OSC format (C1 vs ESC, BEL vs ST)
|
|
1445
|
+
* as a prefix: \x01<tag>\x02<url>
|
|
1446
|
+
*
|
|
1447
|
+
* Tags:
|
|
1448
|
+
* c1b = C1 OSC (\x9d) + BEL (\x07) terminator
|
|
1449
|
+
* c1s = C1 OSC (\x9d) + ST (\x1b\\) terminator
|
|
1450
|
+
* e7b = ESC OSC (\x1b]) + BEL (\x07) terminator
|
|
1451
|
+
* (no prefix) = ESC OSC + ST (default)
|
|
1452
|
+
*/
|
|
1453
|
+
function decodeHyperlinkFormat(encoded: string): {
|
|
1454
|
+
url: string
|
|
1455
|
+
oscIntro: string
|
|
1456
|
+
oscClose: string
|
|
1457
|
+
closeIntro: string
|
|
1458
|
+
closeTerminator: string
|
|
1459
|
+
} {
|
|
1460
|
+
if (encoded.charCodeAt(0) === 1) {
|
|
1461
|
+
const sepIdx = encoded.indexOf("\x02")
|
|
1462
|
+
if (sepIdx > 0) {
|
|
1463
|
+
const tag = encoded.slice(1, sepIdx)
|
|
1464
|
+
const url = encoded.slice(sepIdx + 1)
|
|
1465
|
+
if (tag === "c1b") {
|
|
1466
|
+
return {
|
|
1467
|
+
url,
|
|
1468
|
+
oscIntro: "\x9d",
|
|
1469
|
+
oscClose: "\x9d",
|
|
1470
|
+
closeIntro: "\x9d",
|
|
1471
|
+
closeTerminator: "\x07",
|
|
1472
|
+
}
|
|
1473
|
+
}
|
|
1474
|
+
if (tag === "c1s") {
|
|
1475
|
+
return {
|
|
1476
|
+
url,
|
|
1477
|
+
oscIntro: "\x9d",
|
|
1478
|
+
oscClose: "\x9d",
|
|
1479
|
+
closeIntro: "\x9d",
|
|
1480
|
+
closeTerminator: "\x1b\\",
|
|
1481
|
+
}
|
|
1482
|
+
}
|
|
1483
|
+
if (tag === "e7b") {
|
|
1484
|
+
return {
|
|
1485
|
+
url,
|
|
1486
|
+
oscIntro: "\x1b]",
|
|
1487
|
+
oscClose: "\x1b]",
|
|
1488
|
+
closeIntro: "\x1b]",
|
|
1489
|
+
closeTerminator: "\x07",
|
|
1490
|
+
}
|
|
1491
|
+
}
|
|
1492
|
+
}
|
|
1493
|
+
}
|
|
1494
|
+
// Default: ESC OSC + ST
|
|
1495
|
+
return {
|
|
1496
|
+
url: encoded,
|
|
1497
|
+
oscIntro: "\x1b]",
|
|
1498
|
+
oscClose: "\x1b]",
|
|
1499
|
+
closeIntro: "\x1b]",
|
|
1500
|
+
closeTerminator: "\x1b\\",
|
|
1501
|
+
}
|
|
1502
|
+
}
|
|
1503
|
+
|
|
1504
|
+
/** Emit OSC 8 hyperlink open sequence, respecting format metadata in URL. */
|
|
1505
|
+
function emitHyperlinkOpen(encoded: string): string {
|
|
1506
|
+
const fmt = decodeHyperlinkFormat(encoded)
|
|
1507
|
+
return `${fmt.oscIntro}8;;${fmt.url}${fmt.closeTerminator}`
|
|
1508
|
+
}
|
|
1509
|
+
|
|
1510
|
+
/** Emit OSC 8 hyperlink close sequence, respecting format metadata in URL. */
|
|
1511
|
+
function emitHyperlinkClose(encoded: string): string {
|
|
1512
|
+
const fmt = decodeHyperlinkFormat(encoded)
|
|
1513
|
+
return `${fmt.closeIntro}8;;${fmt.closeTerminator}`
|
|
1514
|
+
}
|
|
1515
|
+
|
|
1516
|
+
// ============================================================================
|
|
1517
|
+
// xterm-256 Color Palette
|
|
1518
|
+
// ============================================================================
|
|
1519
|
+
|
|
1520
|
+
/** Standard xterm-256 color palette as hex strings. */
|
|
1521
|
+
const XTERM_256_PALETTE: string[] = (() => {
|
|
1522
|
+
const palette: string[] = new Array(256)
|
|
1523
|
+
|
|
1524
|
+
// Colors 0-7: standard colors
|
|
1525
|
+
const standard = ["#000000", "#cd0000", "#00cd00", "#cdcd00", "#0000ee", "#cd00cd", "#00cdcd", "#e5e5e5"]
|
|
1526
|
+
// Colors 8-15: bright colors
|
|
1527
|
+
const bright = ["#7f7f7f", "#ff0000", "#00ff00", "#ffff00", "#5c5cff", "#ff00ff", "#00ffff", "#ffffff"]
|
|
1528
|
+
for (let i = 0; i < 8; i++) {
|
|
1529
|
+
palette[i] = standard[i]!
|
|
1530
|
+
palette[i + 8] = bright[i]!
|
|
1531
|
+
}
|
|
1532
|
+
|
|
1533
|
+
// Colors 16-231: 6x6x6 RGB cube
|
|
1534
|
+
const cubeValues = [0, 95, 135, 175, 215, 255]
|
|
1535
|
+
for (let i = 0; i < 216; i++) {
|
|
1536
|
+
const r = cubeValues[Math.floor(i / 36)]!
|
|
1537
|
+
const g = cubeValues[Math.floor((i % 36) / 6)]!
|
|
1538
|
+
const b = cubeValues[i % 6]!
|
|
1539
|
+
palette[16 + i] =
|
|
1540
|
+
"#" + r.toString(16).padStart(2, "0") + g.toString(16).padStart(2, "0") + b.toString(16).padStart(2, "0")
|
|
1541
|
+
}
|
|
1542
|
+
|
|
1543
|
+
// Colors 232-255: grayscale ramp
|
|
1544
|
+
for (let i = 0; i < 24; i++) {
|
|
1545
|
+
const v = 8 + i * 10
|
|
1546
|
+
const hex = v.toString(16).padStart(2, "0")
|
|
1547
|
+
palette[232 + i] = "#" + hex + hex + hex
|
|
1548
|
+
}
|
|
1549
|
+
|
|
1550
|
+
return palette
|
|
1551
|
+
})()
|
|
1552
|
+
|
|
1553
|
+
/**
|
|
1554
|
+
* Convert a Color value to a CSS color string.
|
|
1555
|
+
* Returns null for default/inherit colors.
|
|
1556
|
+
*/
|
|
1557
|
+
function colorToCSS(color: Color): string | null {
|
|
1558
|
+
if (color === null) return null
|
|
1559
|
+
if (typeof color === "number") {
|
|
1560
|
+
return XTERM_256_PALETTE[color] ?? null
|
|
1561
|
+
}
|
|
1562
|
+
// DEFAULT_BG sentinel → no CSS color (use inherited/default)
|
|
1563
|
+
if (color.r === -1) return null
|
|
1564
|
+
return `rgb(${color.r},${color.g},${color.b})`
|
|
1565
|
+
}
|
|
1566
|
+
|
|
1567
|
+
// ============================================================================
|
|
1568
|
+
// Buffer to HTML Conversion
|
|
1569
|
+
// ============================================================================
|
|
1570
|
+
|
|
1571
|
+
/**
|
|
1572
|
+
* Convert a terminal buffer to a full HTML document.
|
|
1573
|
+
* Suitable for rendering as a screenshot via headless browser.
|
|
1574
|
+
*
|
|
1575
|
+
* @param buffer The buffer to convert
|
|
1576
|
+
* @param options.fontFamily CSS font-family (default: 'JetBrains Mono, Menlo, monospace')
|
|
1577
|
+
* @param options.fontSize CSS font-size in px (default: 14)
|
|
1578
|
+
* @param options.theme Color scheme (default: 'dark')
|
|
1579
|
+
* @returns Complete HTML document string
|
|
1580
|
+
*/
|
|
1581
|
+
export function bufferToHTML(
|
|
1582
|
+
buffer: TerminalBuffer,
|
|
1583
|
+
options: {
|
|
1584
|
+
fontFamily?: string
|
|
1585
|
+
fontSize?: number
|
|
1586
|
+
theme?: "dark" | "light"
|
|
1587
|
+
} = {},
|
|
1588
|
+
): string {
|
|
1589
|
+
const { fontFamily = "JetBrains Mono, Menlo, monospace", fontSize = 14, theme = "dark" } = options
|
|
1590
|
+
|
|
1591
|
+
const defaultFg = theme === "dark" ? "#d4d4d4" : "#1e1e1e"
|
|
1592
|
+
const defaultBg = theme === "dark" ? "#1e1e1e" : "#ffffff"
|
|
1593
|
+
|
|
1594
|
+
const htmlLines: string[] = []
|
|
1595
|
+
|
|
1596
|
+
for (let y = 0; y < buffer.height; y++) {
|
|
1597
|
+
let lineHTML = ""
|
|
1598
|
+
let currentStyle: Style | null = null
|
|
1599
|
+
let spanOpen = false
|
|
1600
|
+
let linkOpen = false
|
|
1601
|
+
let currentHyperlink: string | undefined
|
|
1602
|
+
|
|
1603
|
+
for (let x = 0; x < buffer.width; x++) {
|
|
1604
|
+
const cell = buffer.getCell(x, y)
|
|
1605
|
+
if (cell.continuation) continue
|
|
1606
|
+
|
|
1607
|
+
// Handle hyperlink transitions
|
|
1608
|
+
const cellHyperlink = cell.hyperlink
|
|
1609
|
+
if (cellHyperlink !== currentHyperlink) {
|
|
1610
|
+
if (linkOpen) {
|
|
1611
|
+
if (spanOpen) {
|
|
1612
|
+
lineHTML += "</span>"
|
|
1613
|
+
spanOpen = false
|
|
1614
|
+
}
|
|
1615
|
+
lineHTML += "</a>"
|
|
1616
|
+
linkOpen = false
|
|
1617
|
+
}
|
|
1618
|
+
if (cellHyperlink) {
|
|
1619
|
+
lineHTML += `<a href="${escapeHTML(cellHyperlink)}">`
|
|
1620
|
+
linkOpen = true
|
|
1621
|
+
}
|
|
1622
|
+
currentHyperlink = cellHyperlink
|
|
1623
|
+
}
|
|
1624
|
+
|
|
1625
|
+
const cellStyle: Style = {
|
|
1626
|
+
fg: cell.fg,
|
|
1627
|
+
bg: cell.bg,
|
|
1628
|
+
underlineColor: cell.underlineColor,
|
|
1629
|
+
attrs: cell.attrs,
|
|
1630
|
+
}
|
|
1631
|
+
|
|
1632
|
+
if (!styleEquals(currentStyle, cellStyle)) {
|
|
1633
|
+
if (spanOpen) {
|
|
1634
|
+
lineHTML += "</span>"
|
|
1635
|
+
spanOpen = false
|
|
1636
|
+
}
|
|
1637
|
+
const css = styleToCSSProperties(cellStyle, defaultFg, defaultBg)
|
|
1638
|
+
if (css) {
|
|
1639
|
+
lineHTML += `<span style="${css}">`
|
|
1640
|
+
spanOpen = true
|
|
1641
|
+
}
|
|
1642
|
+
currentStyle = cellStyle
|
|
1643
|
+
}
|
|
1644
|
+
|
|
1645
|
+
lineHTML += escapeHTML(cell.char)
|
|
1646
|
+
}
|
|
1647
|
+
|
|
1648
|
+
if (spanOpen) {
|
|
1649
|
+
lineHTML += "</span>"
|
|
1650
|
+
}
|
|
1651
|
+
if (linkOpen) {
|
|
1652
|
+
lineHTML += "</a>"
|
|
1653
|
+
currentHyperlink = undefined
|
|
1654
|
+
}
|
|
1655
|
+
|
|
1656
|
+
htmlLines.push(`<div>${lineHTML}</div>`)
|
|
1657
|
+
}
|
|
1658
|
+
|
|
1659
|
+
return `<!DOCTYPE html>
|
|
1660
|
+
<html>
|
|
1661
|
+
<head><meta charset="utf-8"></head>
|
|
1662
|
+
<body style="margin:0;padding:0;background:${defaultBg};color:${defaultFg};font-family:${fontFamily};font-size:${fontSize}px;line-height:1.2;white-space:pre">
|
|
1663
|
+
${htmlLines.join("\n")}
|
|
1664
|
+
</body>
|
|
1665
|
+
</html>`
|
|
1666
|
+
}
|
|
1667
|
+
|
|
1668
|
+
/**
|
|
1669
|
+
* Convert a Style to CSS inline style properties.
|
|
1670
|
+
* Returns null if the style is entirely default.
|
|
1671
|
+
*/
|
|
1672
|
+
function styleToCSSProperties(style: Style, defaultFg: string, defaultBg: string): string | null {
|
|
1673
|
+
const parts: string[] = []
|
|
1674
|
+
|
|
1675
|
+
// Handle inverse: swap fg/bg
|
|
1676
|
+
let fgColor: string | null
|
|
1677
|
+
let bgColor: string | null
|
|
1678
|
+
if (style.attrs.inverse) {
|
|
1679
|
+
fgColor = colorToCSS(style.bg) ?? defaultBg
|
|
1680
|
+
bgColor = colorToCSS(style.fg) ?? defaultFg
|
|
1681
|
+
} else {
|
|
1682
|
+
fgColor = colorToCSS(style.fg)
|
|
1683
|
+
bgColor = colorToCSS(style.bg)
|
|
1684
|
+
}
|
|
1685
|
+
|
|
1686
|
+
if (fgColor) parts.push(`color:${fgColor}`)
|
|
1687
|
+
if (bgColor) parts.push(`background:${bgColor}`)
|
|
1688
|
+
if (style.attrs.bold) parts.push("font-weight:bold")
|
|
1689
|
+
if (style.attrs.dim) parts.push("opacity:0.5")
|
|
1690
|
+
if (style.attrs.italic) parts.push("font-style:italic")
|
|
1691
|
+
if (style.attrs.hidden) parts.push("visibility:hidden")
|
|
1692
|
+
|
|
1693
|
+
// Text decoration: underline and/or strikethrough
|
|
1694
|
+
const decorations: string[] = []
|
|
1695
|
+
const underlineStyle = style.attrs.underlineStyle
|
|
1696
|
+
if (typeof underlineStyle === "string") {
|
|
1697
|
+
const cssStyleMap: Record<string, string> = {
|
|
1698
|
+
single: "solid",
|
|
1699
|
+
double: "double",
|
|
1700
|
+
curly: "wavy",
|
|
1701
|
+
dotted: "dotted",
|
|
1702
|
+
dashed: "dashed",
|
|
1703
|
+
}
|
|
1704
|
+
decorations.push("underline")
|
|
1705
|
+
const cssStyle = cssStyleMap[underlineStyle]
|
|
1706
|
+
if (cssStyle) parts.push(`text-decoration-style:${cssStyle}`)
|
|
1707
|
+
const ulColor = colorToCSS(style.underlineColor ?? null)
|
|
1708
|
+
if (ulColor) parts.push(`text-decoration-color:${ulColor}`)
|
|
1709
|
+
} else if (style.attrs.underline) {
|
|
1710
|
+
decorations.push("underline")
|
|
1711
|
+
}
|
|
1712
|
+
if (style.attrs.strikethrough) decorations.push("line-through")
|
|
1713
|
+
if (decorations.length > 0) parts.push(`text-decoration:${decorations.join(" ")}`)
|
|
1714
|
+
|
|
1715
|
+
return parts.length > 0 ? parts.join(";") : null
|
|
1716
|
+
}
|
|
1717
|
+
|
|
1718
|
+
/** Escape special HTML characters. */
|
|
1719
|
+
function escapeHTML(str: string): string {
|
|
1720
|
+
if (str === " " || str.length === 0) return str
|
|
1721
|
+
return str.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """)
|
|
1722
|
+
}
|
|
1723
|
+
|
|
1724
|
+
/**
|
|
1725
|
+
* Check if any text attributes are active.
|
|
1726
|
+
*/
|
|
1727
|
+
export function hasActiveAttrs(attrs: CellAttrs): boolean {
|
|
1728
|
+
return !!(
|
|
1729
|
+
attrs.bold ||
|
|
1730
|
+
attrs.dim ||
|
|
1731
|
+
attrs.italic ||
|
|
1732
|
+
attrs.underline ||
|
|
1733
|
+
attrs.underlineStyle ||
|
|
1734
|
+
attrs.blink ||
|
|
1735
|
+
attrs.inverse ||
|
|
1736
|
+
attrs.hidden ||
|
|
1737
|
+
attrs.strikethrough
|
|
1738
|
+
)
|
|
1739
|
+
}
|
|
1740
|
+
|
|
1741
|
+
/**
|
|
1742
|
+
* Convert style to ANSI escape sequence.
|
|
1743
|
+
*
|
|
1744
|
+
* Uses SGR 7 for inverse so terminals correctly swap fg/bg
|
|
1745
|
+
* (including default terminal colors that have no explicit ANSI code).
|
|
1746
|
+
*/
|
|
1747
|
+
// =============================================================================
|
|
1748
|
+
// Color code helpers (imported from ansi/sgr-codes.ts)
|
|
1749
|
+
// =============================================================================
|
|
1750
|
+
|
|
1751
|
+
/**
|
|
1752
|
+
* Convert style to ANSI escape sequence (chalk-compatible format).
|
|
1753
|
+
*
|
|
1754
|
+
* Emits only non-default attributes with no reset prefix. Called when there
|
|
1755
|
+
* is no previous style context (first cell), so the terminal is already in
|
|
1756
|
+
* reset state. Each attribute gets its own \x1b[Xm sequence.
|
|
1757
|
+
*/
|
|
1758
|
+
function styleToAnsiCodes(style: Style): string {
|
|
1759
|
+
const fg = style.fg
|
|
1760
|
+
const bg = style.bg
|
|
1761
|
+
|
|
1762
|
+
let result = ""
|
|
1763
|
+
|
|
1764
|
+
// Foreground color
|
|
1765
|
+
if (fg !== null) {
|
|
1766
|
+
result += `\x1b[${fgColorCode(fg)}m`
|
|
1767
|
+
}
|
|
1768
|
+
|
|
1769
|
+
// Background color (DEFAULT_BG sentinel = terminal default, skip)
|
|
1770
|
+
if (bg !== null && !isDefaultBg(bg)) {
|
|
1771
|
+
result += `\x1b[${bgColorCode(bg)}m`
|
|
1772
|
+
}
|
|
1773
|
+
|
|
1774
|
+
// Attributes
|
|
1775
|
+
if (style.attrs.bold) result += "\x1b[1m"
|
|
1776
|
+
if (style.attrs.dim) result += "\x1b[2m"
|
|
1777
|
+
if (style.attrs.italic) result += "\x1b[3m"
|
|
1778
|
+
|
|
1779
|
+
// Underline: use SGR 4:x if style specified, otherwise simple SGR 4
|
|
1780
|
+
const underlineStyle = style.attrs.underlineStyle
|
|
1781
|
+
if (typeof underlineStyle === "string") {
|
|
1782
|
+
const styleMap: Record<string, number> = {
|
|
1783
|
+
single: 1,
|
|
1784
|
+
double: 2,
|
|
1785
|
+
curly: 3,
|
|
1786
|
+
dotted: 4,
|
|
1787
|
+
dashed: 5,
|
|
1788
|
+
}
|
|
1789
|
+
const subparam = styleMap[underlineStyle]
|
|
1790
|
+
if (subparam !== undefined && subparam !== 0) {
|
|
1791
|
+
result += `\x1b[4:${subparam}m`
|
|
1792
|
+
}
|
|
1793
|
+
} else if (style.attrs.underline) {
|
|
1794
|
+
result += "\x1b[4m" // Simple underline
|
|
1795
|
+
}
|
|
1796
|
+
|
|
1797
|
+
// Use SGR 7 for inverse — lets the terminal correctly swap fg/bg
|
|
1798
|
+
if (style.attrs.inverse) result += "\x1b[7m"
|
|
1799
|
+
if (style.attrs.strikethrough) result += "\x1b[9m"
|
|
1800
|
+
|
|
1801
|
+
// Underline color (SGR 58)
|
|
1802
|
+
if (style.underlineColor !== null && style.underlineColor !== undefined) {
|
|
1803
|
+
if (typeof style.underlineColor === "number") {
|
|
1804
|
+
result += `\x1b[58;5;${style.underlineColor}m`
|
|
1805
|
+
} else {
|
|
1806
|
+
result += `\x1b[58;2;${style.underlineColor.r};${style.underlineColor.g};${style.underlineColor.b}m`
|
|
1807
|
+
}
|
|
1808
|
+
}
|
|
1809
|
+
|
|
1810
|
+
return result
|
|
1811
|
+
}
|
|
1812
|
+
|
|
1813
|
+
/**
|
|
1814
|
+
* Compute the minimal SGR transition between two styles (chalk-compatible).
|
|
1815
|
+
*
|
|
1816
|
+
* When oldStyle is null (first cell or after reset), falls through to
|
|
1817
|
+
* full generation via styleToAnsiCodes. Otherwise, diffs attribute
|
|
1818
|
+
* by attribute and emits only changed SGR codes as individual \x1b[Xm sequences.
|
|
1819
|
+
*/
|
|
1820
|
+
function styleTransitionCodes(oldStyle: Style | null, newStyle: Style): string {
|
|
1821
|
+
// First cell or after reset — full generation
|
|
1822
|
+
if (!oldStyle) return styleToAnsiCodes(newStyle)
|
|
1823
|
+
|
|
1824
|
+
// Same style — nothing to emit
|
|
1825
|
+
if (styleEquals(oldStyle, newStyle)) return ""
|
|
1826
|
+
|
|
1827
|
+
let result = ""
|
|
1828
|
+
const oa = oldStyle.attrs
|
|
1829
|
+
const na = newStyle.attrs
|
|
1830
|
+
|
|
1831
|
+
// Bold and dim share SGR 22 as their off-code
|
|
1832
|
+
const boldChanged = Boolean(oa.bold) !== Boolean(na.bold)
|
|
1833
|
+
const dimChanged = Boolean(oa.dim) !== Boolean(na.dim)
|
|
1834
|
+
if (boldChanged || dimChanged) {
|
|
1835
|
+
const boldOff = boldChanged && !na.bold
|
|
1836
|
+
const dimOff = dimChanged && !na.dim
|
|
1837
|
+
if (boldOff || dimOff) {
|
|
1838
|
+
result += "\x1b[22m"
|
|
1839
|
+
if (na.bold) result += "\x1b[1m"
|
|
1840
|
+
if (na.dim) result += "\x1b[2m"
|
|
1841
|
+
} else {
|
|
1842
|
+
if (boldChanged && na.bold) result += "\x1b[1m"
|
|
1843
|
+
if (dimChanged && na.dim) result += "\x1b[2m"
|
|
1844
|
+
}
|
|
1845
|
+
}
|
|
1846
|
+
if (Boolean(oa.italic) !== Boolean(na.italic)) {
|
|
1847
|
+
result += na.italic ? "\x1b[3m" : "\x1b[23m"
|
|
1848
|
+
}
|
|
1849
|
+
|
|
1850
|
+
// Underline
|
|
1851
|
+
const oldUl = Boolean(oa.underline)
|
|
1852
|
+
const newUl = Boolean(na.underline)
|
|
1853
|
+
const oldUlStyle = oa.underlineStyle ?? false
|
|
1854
|
+
const newUlStyle = na.underlineStyle ?? false
|
|
1855
|
+
if (oldUl !== newUl || oldUlStyle !== newUlStyle) {
|
|
1856
|
+
if (typeof na.underlineStyle === "string") {
|
|
1857
|
+
const styleMap: Record<string, number> = {
|
|
1858
|
+
single: 1,
|
|
1859
|
+
double: 2,
|
|
1860
|
+
curly: 3,
|
|
1861
|
+
dotted: 4,
|
|
1862
|
+
dashed: 5,
|
|
1863
|
+
}
|
|
1864
|
+
const sub = styleMap[na.underlineStyle]
|
|
1865
|
+
if (sub !== undefined && sub !== 0) {
|
|
1866
|
+
result += `\x1b[4:${sub}m`
|
|
1867
|
+
} else if (newUl) {
|
|
1868
|
+
result += "\x1b[4m"
|
|
1869
|
+
} else {
|
|
1870
|
+
result += "\x1b[24m"
|
|
1871
|
+
}
|
|
1872
|
+
} else if (newUl) {
|
|
1873
|
+
result += "\x1b[4m"
|
|
1874
|
+
} else {
|
|
1875
|
+
result += "\x1b[24m"
|
|
1876
|
+
}
|
|
1877
|
+
}
|
|
1878
|
+
|
|
1879
|
+
if (Boolean(oa.inverse) !== Boolean(na.inverse)) {
|
|
1880
|
+
result += na.inverse ? "\x1b[7m" : "\x1b[27m"
|
|
1881
|
+
}
|
|
1882
|
+
if (Boolean(oa.strikethrough) !== Boolean(na.strikethrough)) {
|
|
1883
|
+
result += na.strikethrough ? "\x1b[9m" : "\x1b[29m"
|
|
1884
|
+
}
|
|
1885
|
+
|
|
1886
|
+
// Foreground color
|
|
1887
|
+
if (!colorEquals(oldStyle.fg, newStyle.fg)) {
|
|
1888
|
+
if (newStyle.fg === null) {
|
|
1889
|
+
result += "\x1b[39m"
|
|
1890
|
+
} else {
|
|
1891
|
+
result += `\x1b[${fgColorCode(newStyle.fg)}m`
|
|
1892
|
+
}
|
|
1893
|
+
}
|
|
1894
|
+
|
|
1895
|
+
// Background color
|
|
1896
|
+
if (!colorEquals(oldStyle.bg, newStyle.bg)) {
|
|
1897
|
+
if (newStyle.bg === null) {
|
|
1898
|
+
result += "\x1b[49m"
|
|
1899
|
+
} else {
|
|
1900
|
+
result += `\x1b[${bgColorCode(newStyle.bg)}m`
|
|
1901
|
+
}
|
|
1902
|
+
}
|
|
1903
|
+
|
|
1904
|
+
// Underline color (SGR 58/59)
|
|
1905
|
+
if (!colorEquals(oldStyle.underlineColor, newStyle.underlineColor)) {
|
|
1906
|
+
if (newStyle.underlineColor === null || newStyle.underlineColor === undefined) {
|
|
1907
|
+
result += "\x1b[59m"
|
|
1908
|
+
} else if (typeof newStyle.underlineColor === "number") {
|
|
1909
|
+
result += `\x1b[58;5;${newStyle.underlineColor}m`
|
|
1910
|
+
} else {
|
|
1911
|
+
result += `\x1b[58;2;${newStyle.underlineColor.r};${newStyle.underlineColor.g};${newStyle.underlineColor.b}m`
|
|
1912
|
+
}
|
|
1913
|
+
}
|
|
1914
|
+
|
|
1915
|
+
return result
|
|
1916
|
+
}
|
|
1917
|
+
|
|
1918
|
+
/**
|
|
1919
|
+
* Emit per-attribute reset codes for all active attributes in a style.
|
|
1920
|
+
* Returns empty string if no attributes are active.
|
|
1921
|
+
* Uses individual \x1b[Xm sequences to match chalk's format.
|
|
1922
|
+
*/
|
|
1923
|
+
function styleResetCodes(style: Style): string {
|
|
1924
|
+
let result = ""
|
|
1925
|
+
// Attributes (order: underline, bold/dim, italic, strikethrough, inverse — matches chalk close order)
|
|
1926
|
+
if (style.attrs.underline || style.attrs.underlineStyle) result += "\x1b[24m"
|
|
1927
|
+
if (style.attrs.bold || style.attrs.dim) result += "\x1b[22m"
|
|
1928
|
+
if (style.attrs.italic) result += "\x1b[23m"
|
|
1929
|
+
if (style.attrs.strikethrough) result += "\x1b[29m"
|
|
1930
|
+
if (style.attrs.inverse) result += "\x1b[27m"
|
|
1931
|
+
// Colors
|
|
1932
|
+
if (style.bg !== null && !isDefaultBg(style.bg)) result += "\x1b[49m"
|
|
1933
|
+
if (style.fg !== null) result += "\x1b[39m"
|
|
1934
|
+
// Underline color
|
|
1935
|
+
if (style.underlineColor !== null && style.underlineColor !== undefined) result += "\x1b[59m"
|
|
1936
|
+
return result
|
|
1937
|
+
}
|
|
1938
|
+
|
|
1939
|
+
/**
|
|
1940
|
+
* Trim trailing whitespace from a string while preserving ANSI codes.
|
|
1941
|
+
*/
|
|
1942
|
+
function trimTrailingWhitespacePreservingAnsi(str: string): string {
|
|
1943
|
+
// Find the last non-whitespace character or ANSI escape
|
|
1944
|
+
let lastContentIndex = -1
|
|
1945
|
+
let i = 0
|
|
1946
|
+
|
|
1947
|
+
while (i < str.length) {
|
|
1948
|
+
if (str[i] === "\x1b") {
|
|
1949
|
+
// Check for OSC sequence (ESC ] ... ST or BEL)
|
|
1950
|
+
if (str[i + 1] === "]") {
|
|
1951
|
+
// Find the terminator: ST (\x1b\\) or BEL (\x07)
|
|
1952
|
+
let end = -1
|
|
1953
|
+
for (let j = i + 2; j < str.length; j++) {
|
|
1954
|
+
if (str[j] === "\x07") {
|
|
1955
|
+
end = j
|
|
1956
|
+
break
|
|
1957
|
+
}
|
|
1958
|
+
if (str[j] === "\x1b" && str[j + 1] === "\\") {
|
|
1959
|
+
end = j + 1
|
|
1960
|
+
break
|
|
1961
|
+
}
|
|
1962
|
+
}
|
|
1963
|
+
if (end !== -1) {
|
|
1964
|
+
lastContentIndex = end
|
|
1965
|
+
i = end + 1
|
|
1966
|
+
continue
|
|
1967
|
+
}
|
|
1968
|
+
}
|
|
1969
|
+
// Found SGR escape - skip the entire sequence
|
|
1970
|
+
const end = str.indexOf("m", i)
|
|
1971
|
+
if (end !== -1) {
|
|
1972
|
+
lastContentIndex = end
|
|
1973
|
+
i = end + 1
|
|
1974
|
+
continue
|
|
1975
|
+
}
|
|
1976
|
+
}
|
|
1977
|
+
if (str[i] !== " " && str[i] !== "\t") {
|
|
1978
|
+
lastContentIndex = i
|
|
1979
|
+
}
|
|
1980
|
+
i++
|
|
1981
|
+
}
|
|
1982
|
+
|
|
1983
|
+
return str.slice(0, lastContentIndex + 1)
|
|
1984
|
+
}
|