vt220.js 0.0.1 → 0.1.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 +29 -5
- package/src/index.ts +7 -0
- package/src/screen.ts +1037 -0
package/package.json
CHANGED
|
@@ -1,12 +1,36 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "vt220.js",
|
|
3
|
-
"version": "0.0
|
|
4
|
-
"description": "VT220
|
|
5
|
-
"
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "VT220 terminal emulator — 8 colors, insert/delete, selective erase. Pure TypeScript, zero dependencies.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"ansi",
|
|
7
|
+
"headless",
|
|
8
|
+
"terminal-emulator",
|
|
9
|
+
"tui",
|
|
10
|
+
"typescript",
|
|
11
|
+
"vt220",
|
|
12
|
+
"zero-dependency"
|
|
13
|
+
],
|
|
14
|
+
"homepage": "https://github.com/beorn/vterm/tree/main/packages/vt220",
|
|
15
|
+
"bugs": "https://github.com/beorn/vterm/issues",
|
|
6
16
|
"license": "MIT",
|
|
17
|
+
"author": "Bjørn Stabell",
|
|
7
18
|
"repository": {
|
|
8
19
|
"type": "git",
|
|
9
|
-
"url": "https://github.com/beorn/
|
|
20
|
+
"url": "https://github.com/beorn/vterm.git",
|
|
21
|
+
"directory": "packages/vt220"
|
|
10
22
|
},
|
|
11
|
-
"
|
|
23
|
+
"files": [
|
|
24
|
+
"src"
|
|
25
|
+
],
|
|
26
|
+
"type": "module",
|
|
27
|
+
"exports": {
|
|
28
|
+
".": "./src/index.ts"
|
|
29
|
+
},
|
|
30
|
+
"publishConfig": {
|
|
31
|
+
"access": "public"
|
|
32
|
+
},
|
|
33
|
+
"engines": {
|
|
34
|
+
"node": ">=23.6.0"
|
|
35
|
+
}
|
|
12
36
|
}
|
package/src/index.ts
ADDED
package/src/screen.ts
ADDED
|
@@ -0,0 +1,1037 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure TypeScript VT220 terminal emulator.
|
|
3
|
+
*
|
|
4
|
+
* Extends VT100 with VT220 features: 8 standard colors (SGR 30-37/40-47),
|
|
5
|
+
* insert/replace mode (IRM), insert/delete characters (ICH/DCH),
|
|
6
|
+
* insert/delete lines (IL/DL), erase characters (ECH), selective erase
|
|
7
|
+
* (DECSED/DECSEL), hidden/conceal (SGR 8/28), and soft reset (DECSTR).
|
|
8
|
+
*
|
|
9
|
+
* Zero dependencies. No truecolor, no 256 colors, no wide chars —
|
|
10
|
+
* those belong in vterm.js.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
// ═══════════════════════════════════════════════════════
|
|
14
|
+
// Internal cell representation
|
|
15
|
+
// ═══════════════════════════════════════════════════════
|
|
16
|
+
|
|
17
|
+
export interface CellColor {
|
|
18
|
+
r: number
|
|
19
|
+
g: number
|
|
20
|
+
b: number
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface ScreenCell {
|
|
24
|
+
char: string
|
|
25
|
+
fg: CellColor | null
|
|
26
|
+
bg: CellColor | null
|
|
27
|
+
bold: boolean
|
|
28
|
+
underline: boolean
|
|
29
|
+
blink: boolean
|
|
30
|
+
inverse: boolean
|
|
31
|
+
hidden: boolean
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Frozen sentinel for unwritten cells — never mutate, copy-on-write in writeChar(). */
|
|
35
|
+
const EMPTY_CELL: ScreenCell = Object.freeze({
|
|
36
|
+
char: "",
|
|
37
|
+
fg: null,
|
|
38
|
+
bg: null,
|
|
39
|
+
bold: false,
|
|
40
|
+
underline: false,
|
|
41
|
+
blink: false,
|
|
42
|
+
inverse: false,
|
|
43
|
+
hidden: false,
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
function emptyCell(): ScreenCell {
|
|
47
|
+
return { ...EMPTY_CELL }
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// ═══════════════════════════════════════════════════════
|
|
51
|
+
// ANSI 8-color palette (standard VT220 colors)
|
|
52
|
+
// ═══════════════════════════════════════════════════════
|
|
53
|
+
|
|
54
|
+
const ANSI_8: readonly CellColor[] = [
|
|
55
|
+
{ r: 0x00, g: 0x00, b: 0x00 }, // 0 Black
|
|
56
|
+
{ r: 0x80, g: 0x00, b: 0x00 }, // 1 Red
|
|
57
|
+
{ r: 0x00, g: 0x80, b: 0x00 }, // 2 Green
|
|
58
|
+
{ r: 0x80, g: 0x80, b: 0x00 }, // 3 Yellow
|
|
59
|
+
{ r: 0x00, g: 0x00, b: 0x80 }, // 4 Blue
|
|
60
|
+
{ r: 0x80, g: 0x00, b: 0x80 }, // 5 Magenta
|
|
61
|
+
{ r: 0x00, g: 0x80, b: 0x80 }, // 6 Cyan
|
|
62
|
+
{ r: 0xc0, g: 0xc0, b: 0xc0 }, // 7 White
|
|
63
|
+
]
|
|
64
|
+
|
|
65
|
+
// ═══════════════════════════════════════════════════════
|
|
66
|
+
// Screen
|
|
67
|
+
// ═══════════════════════════════════════════════════════
|
|
68
|
+
|
|
69
|
+
export interface ScreenOptions {
|
|
70
|
+
cols: number
|
|
71
|
+
rows: number
|
|
72
|
+
scrollbackLimit?: number
|
|
73
|
+
/** Callback for DA1/DA2/DSR responses — write these back to the PTY */
|
|
74
|
+
onResponse?: (data: string) => void
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
interface Attrs {
|
|
78
|
+
fg: CellColor | null
|
|
79
|
+
bg: CellColor | null
|
|
80
|
+
bold: boolean
|
|
81
|
+
underline: boolean
|
|
82
|
+
blink: boolean
|
|
83
|
+
inverse: boolean
|
|
84
|
+
hidden: boolean
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export interface Screen {
|
|
88
|
+
readonly cols: number
|
|
89
|
+
readonly rows: number
|
|
90
|
+
process(data: Uint8Array): void
|
|
91
|
+
resize(cols: number, rows: number): void
|
|
92
|
+
reset(): void
|
|
93
|
+
getCell(row: number, col: number): ScreenCell
|
|
94
|
+
getLine(row: number): ScreenCell[]
|
|
95
|
+
getText(): string
|
|
96
|
+
getTextRange(startRow: number, startCol: number, endRow: number, endCol: number): string
|
|
97
|
+
getCursorPosition(): { x: number; y: number }
|
|
98
|
+
getCursorVisible(): boolean
|
|
99
|
+
getTitle(): string
|
|
100
|
+
getMode(mode: string): boolean
|
|
101
|
+
getScrollbackLength(): number
|
|
102
|
+
getViewportOffset(): number
|
|
103
|
+
scrollViewport(delta: number): void
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function createScreen(opts: ScreenOptions): Screen {
|
|
107
|
+
let cols = opts.cols
|
|
108
|
+
let rows = opts.rows
|
|
109
|
+
const scrollbackLimit = opts.scrollbackLimit ?? 1000
|
|
110
|
+
const onResponse = opts.onResponse
|
|
111
|
+
|
|
112
|
+
// Main screen buffer (no alternate screen in VT220)
|
|
113
|
+
let grid: ScreenCell[][] = makeGrid(cols, rows)
|
|
114
|
+
let scrollback: ScreenCell[][] = []
|
|
115
|
+
|
|
116
|
+
// Cursor
|
|
117
|
+
let curX = 0
|
|
118
|
+
let curY = 0
|
|
119
|
+
let curVisible = true
|
|
120
|
+
let savedCurX = 0
|
|
121
|
+
let savedCurY = 0
|
|
122
|
+
|
|
123
|
+
// DECSC/DECRC saved state (cursor + attrs + modes)
|
|
124
|
+
interface SavedState {
|
|
125
|
+
curX: number
|
|
126
|
+
curY: number
|
|
127
|
+
attrs: Attrs
|
|
128
|
+
originMode: boolean
|
|
129
|
+
autoWrap: boolean
|
|
130
|
+
}
|
|
131
|
+
let savedState: SavedState = {
|
|
132
|
+
curX: 0,
|
|
133
|
+
curY: 0,
|
|
134
|
+
attrs: resetAttrs(),
|
|
135
|
+
originMode: false,
|
|
136
|
+
autoWrap: true,
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Current drawing attributes
|
|
140
|
+
let attrs: Attrs = resetAttrs()
|
|
141
|
+
|
|
142
|
+
// Terminal state
|
|
143
|
+
let title = ""
|
|
144
|
+
let applicationCursor = false
|
|
145
|
+
let applicationKeypad = false
|
|
146
|
+
let autoWrap = true
|
|
147
|
+
let originMode = false
|
|
148
|
+
let insertMode = false
|
|
149
|
+
let reverseVideo = false
|
|
150
|
+
|
|
151
|
+
// Scroll region (inclusive, 0-based)
|
|
152
|
+
let scrollTop = 0
|
|
153
|
+
let scrollBottom = rows - 1
|
|
154
|
+
|
|
155
|
+
// Viewport scroll offset for scrollViewport()
|
|
156
|
+
let viewportOffset = 0
|
|
157
|
+
|
|
158
|
+
// Parser state
|
|
159
|
+
let parserState: "ground" | "escape" | "csi" | "osc" | "dcs" | "oscString" = "ground"
|
|
160
|
+
let escBuf = ""
|
|
161
|
+
let oscBuf = ""
|
|
162
|
+
|
|
163
|
+
// Decoder for incoming bytes
|
|
164
|
+
const decoder = new TextDecoder()
|
|
165
|
+
|
|
166
|
+
function makeGrid(c: number, r: number): ScreenCell[][] {
|
|
167
|
+
const g: ScreenCell[][] = []
|
|
168
|
+
for (let row = 0; row < r; row++) {
|
|
169
|
+
g.push(makeRow(c))
|
|
170
|
+
}
|
|
171
|
+
return g
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function makeRow(c: number): ScreenCell[] {
|
|
175
|
+
const row: ScreenCell[] = []
|
|
176
|
+
for (let col = 0; col < c; col++) {
|
|
177
|
+
row.push(EMPTY_CELL)
|
|
178
|
+
}
|
|
179
|
+
return row
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function resetAttrs(): Attrs {
|
|
183
|
+
return {
|
|
184
|
+
fg: null,
|
|
185
|
+
bg: null,
|
|
186
|
+
bold: false,
|
|
187
|
+
underline: false,
|
|
188
|
+
blink: false,
|
|
189
|
+
inverse: false,
|
|
190
|
+
hidden: false,
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function clampCursor(): void {
|
|
195
|
+
if (curX < 0) curX = 0
|
|
196
|
+
if (curX >= cols) curX = cols - 1
|
|
197
|
+
if (curY < 0) curY = 0
|
|
198
|
+
if (curY >= rows) curY = rows - 1
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// ── Scrolling ──
|
|
202
|
+
|
|
203
|
+
function scrollUp(top: number, bottom: number): void {
|
|
204
|
+
// Move top row to scrollback (only if top of screen)
|
|
205
|
+
if (top === 0) {
|
|
206
|
+
scrollback.push(grid[0]!)
|
|
207
|
+
// Bulk trim when exceeding 2x limit to avoid O(n) shift() on every scroll
|
|
208
|
+
if (scrollback.length > scrollbackLimit * 2) {
|
|
209
|
+
scrollback.splice(0, scrollback.length - scrollbackLimit)
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
// Shift rows up within the region
|
|
213
|
+
for (let i = top; i < bottom; i++) {
|
|
214
|
+
grid[i] = grid[i + 1]!
|
|
215
|
+
}
|
|
216
|
+
grid[bottom] = makeRow(cols)
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function scrollDown(top: number, bottom: number): void {
|
|
220
|
+
for (let i = bottom; i > top; i--) {
|
|
221
|
+
grid[i] = grid[i - 1]!
|
|
222
|
+
}
|
|
223
|
+
grid[top] = makeRow(cols)
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// ── Character writing ──
|
|
227
|
+
|
|
228
|
+
function writeChar(ch: string): void {
|
|
229
|
+
// Handle autowrap at end of line
|
|
230
|
+
if (curX >= cols) {
|
|
231
|
+
if (autoWrap) {
|
|
232
|
+
curX = 0
|
|
233
|
+
curY++
|
|
234
|
+
if (curY > scrollBottom) {
|
|
235
|
+
curY = scrollBottom
|
|
236
|
+
scrollUp(scrollTop, scrollBottom)
|
|
237
|
+
}
|
|
238
|
+
} else {
|
|
239
|
+
curX = cols - 1
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// Insert mode: shift existing characters right before writing
|
|
244
|
+
if (insertMode) {
|
|
245
|
+
const row = grid[curY]!
|
|
246
|
+
row.splice(curX, 0, EMPTY_CELL)
|
|
247
|
+
row.pop()
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// Copy-on-write: if cell is the shared EMPTY_CELL sentinel, create a fresh object
|
|
251
|
+
const row = grid[curY]!
|
|
252
|
+
let cell = row[curX]!
|
|
253
|
+
if (cell === EMPTY_CELL) {
|
|
254
|
+
cell = { ...EMPTY_CELL }
|
|
255
|
+
row[curX] = cell
|
|
256
|
+
}
|
|
257
|
+
cell.char = ch
|
|
258
|
+
cell.fg = attrs.fg ? { ...attrs.fg } : null
|
|
259
|
+
cell.bg = attrs.bg ? { ...attrs.bg } : null
|
|
260
|
+
cell.bold = attrs.bold
|
|
261
|
+
cell.underline = attrs.underline
|
|
262
|
+
cell.blink = attrs.blink
|
|
263
|
+
cell.inverse = attrs.inverse
|
|
264
|
+
cell.hidden = attrs.hidden
|
|
265
|
+
|
|
266
|
+
curX++
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// ── CSI handler ──
|
|
270
|
+
|
|
271
|
+
function handleCSI(params: string, finalByte: string): void {
|
|
272
|
+
const parts = params.split(";").map((s) => (s === "" ? 0 : parseInt(s, 10)))
|
|
273
|
+
|
|
274
|
+
switch (finalByte) {
|
|
275
|
+
case "A": // CUU - Cursor Up
|
|
276
|
+
curY -= Math.max(parts[0] ?? 1, 1)
|
|
277
|
+
clampCursor()
|
|
278
|
+
break
|
|
279
|
+
case "B": // CUD - Cursor Down
|
|
280
|
+
curY += Math.max(parts[0] ?? 1, 1)
|
|
281
|
+
clampCursor()
|
|
282
|
+
break
|
|
283
|
+
case "C": // CUF - Cursor Forward
|
|
284
|
+
curX += Math.max(parts[0] ?? 1, 1)
|
|
285
|
+
clampCursor()
|
|
286
|
+
break
|
|
287
|
+
case "D": // CUB - Cursor Back
|
|
288
|
+
curX -= Math.max(parts[0] ?? 1, 1)
|
|
289
|
+
clampCursor()
|
|
290
|
+
break
|
|
291
|
+
case "E": // CNL - Cursor Next Line
|
|
292
|
+
curY += Math.max(parts[0] ?? 1, 1)
|
|
293
|
+
curX = 0
|
|
294
|
+
clampCursor()
|
|
295
|
+
break
|
|
296
|
+
case "F": // CPL - Cursor Previous Line
|
|
297
|
+
curY -= Math.max(parts[0] ?? 1, 1)
|
|
298
|
+
curX = 0
|
|
299
|
+
clampCursor()
|
|
300
|
+
break
|
|
301
|
+
case "G": // CHA - Cursor Horizontal Absolute
|
|
302
|
+
curX = (parts[0] ?? 1) - 1
|
|
303
|
+
clampCursor()
|
|
304
|
+
break
|
|
305
|
+
case "H": // CUP - Cursor Position
|
|
306
|
+
case "f": // HVP - same as CUP
|
|
307
|
+
curY = (parts[0] ?? 1) - 1
|
|
308
|
+
curX = (parts[1] ?? 1) - 1
|
|
309
|
+
clampCursor()
|
|
310
|
+
break
|
|
311
|
+
case "J": // ED - Erase in Display
|
|
312
|
+
handleEraseDisplay(parts[0] ?? 0)
|
|
313
|
+
break
|
|
314
|
+
case "K": // EL - Erase in Line
|
|
315
|
+
handleEraseLine(parts[0] ?? 0)
|
|
316
|
+
break
|
|
317
|
+
case "L": // IL - Insert Lines
|
|
318
|
+
handleInsertLines(Math.max(parts[0] ?? 1, 1))
|
|
319
|
+
break
|
|
320
|
+
case "M": // DL - Delete Lines
|
|
321
|
+
handleDeleteLines(Math.max(parts[0] ?? 1, 1))
|
|
322
|
+
break
|
|
323
|
+
case "P": // DCH - Delete Characters
|
|
324
|
+
handleDeleteChars(Math.max(parts[0] ?? 1, 1))
|
|
325
|
+
break
|
|
326
|
+
case "@": // ICH - Insert Characters
|
|
327
|
+
handleInsertChars(Math.max(parts[0] ?? 1, 1))
|
|
328
|
+
break
|
|
329
|
+
case "X": // ECH - Erase Characters
|
|
330
|
+
handleEraseChars(Math.max(parts[0] ?? 1, 1))
|
|
331
|
+
break
|
|
332
|
+
case "S": // SU - Scroll Up
|
|
333
|
+
for (let i = 0; i < Math.max(parts[0] ?? 1, 1); i++) {
|
|
334
|
+
scrollUp(scrollTop, scrollBottom)
|
|
335
|
+
}
|
|
336
|
+
break
|
|
337
|
+
case "T": // SD - Scroll Down
|
|
338
|
+
for (let i = 0; i < Math.max(parts[0] ?? 1, 1); i++) {
|
|
339
|
+
scrollDown(scrollTop, scrollBottom)
|
|
340
|
+
}
|
|
341
|
+
break
|
|
342
|
+
case "d": // VPA - Line Position Absolute
|
|
343
|
+
curY = (parts[0] ?? 1) - 1
|
|
344
|
+
clampCursor()
|
|
345
|
+
break
|
|
346
|
+
case "m": // SGR - Select Graphic Rendition
|
|
347
|
+
handleSGR(params)
|
|
348
|
+
break
|
|
349
|
+
case "r": // DECSTBM - Set Scrolling Region
|
|
350
|
+
scrollTop = (parts[0] ?? 1) - 1
|
|
351
|
+
scrollBottom = (parts[1] ?? rows) - 1
|
|
352
|
+
if (scrollTop < 0) scrollTop = 0
|
|
353
|
+
if (scrollBottom >= rows) scrollBottom = rows - 1
|
|
354
|
+
if (scrollTop > scrollBottom) {
|
|
355
|
+
scrollTop = 0
|
|
356
|
+
scrollBottom = rows - 1
|
|
357
|
+
}
|
|
358
|
+
curX = 0
|
|
359
|
+
curY = originMode ? scrollTop : 0
|
|
360
|
+
break
|
|
361
|
+
case "n": // DSR - Device Status Report
|
|
362
|
+
if (onResponse) {
|
|
363
|
+
if (parts[0] === 5) {
|
|
364
|
+
// Status report - OK
|
|
365
|
+
onResponse("\x1b[0n")
|
|
366
|
+
} else if (parts[0] === 6) {
|
|
367
|
+
// CPR - Cursor position report (1-based)
|
|
368
|
+
onResponse(`\x1b[${curY + 1};${curX + 1}R`)
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
break
|
|
372
|
+
case "c": // DA1 - Primary Device Attributes
|
|
373
|
+
if (onResponse) {
|
|
374
|
+
if (params === "" || params === "0") {
|
|
375
|
+
// VT220: class 2 with features: printer (1), selective erase (2),
|
|
376
|
+
// user windows (6), horizontal scrolling (7), ANSI color (8), NRCS (9)
|
|
377
|
+
onResponse("\x1b[?62;1;2;6;7;8;9c")
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
break
|
|
381
|
+
case "s": // SCP - Save Cursor Position
|
|
382
|
+
savedCurX = curX
|
|
383
|
+
savedCurY = curY
|
|
384
|
+
break
|
|
385
|
+
case "u": // RCP - Restore Cursor Position
|
|
386
|
+
curX = savedCurX
|
|
387
|
+
curY = savedCurY
|
|
388
|
+
clampCursor()
|
|
389
|
+
break
|
|
390
|
+
default:
|
|
391
|
+
// Unknown CSI sequence — ignore
|
|
392
|
+
break
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
function handleCSIWithIntermediate(params: string, intermediate: string, finalByte: string): void {
|
|
397
|
+
if (intermediate === "!" && finalByte === "p") {
|
|
398
|
+
// DECSTR - Soft Terminal Reset
|
|
399
|
+
softReset()
|
|
400
|
+
} else if (intermediate === ">" && finalByte === "c") {
|
|
401
|
+
// DA2 - Secondary Device Attributes
|
|
402
|
+
if (onResponse) {
|
|
403
|
+
// VT220: type 1, firmware version 10, ROM cartridge 0
|
|
404
|
+
onResponse("\x1b[>1;10;0c")
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
// Unknown intermediate sequences — ignore
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
function handleCSIPrivate(params: string, finalByte: string): void {
|
|
411
|
+
const parts = params.split(";").map((s) => (s === "" ? 0 : parseInt(s, 10)))
|
|
412
|
+
|
|
413
|
+
if (finalByte === "J") {
|
|
414
|
+
// DECSED - Selective Erase in Display
|
|
415
|
+
handleSelectiveEraseDisplay(parts[0] ?? 0)
|
|
416
|
+
return
|
|
417
|
+
}
|
|
418
|
+
if (finalByte === "K") {
|
|
419
|
+
// DECSEL - Selective Erase in Line
|
|
420
|
+
handleSelectiveEraseLine(parts[0] ?? 0)
|
|
421
|
+
return
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
const set = finalByte === "h"
|
|
425
|
+
|
|
426
|
+
for (const code of parts) {
|
|
427
|
+
switch (code) {
|
|
428
|
+
case 1: // DECCKM - Application Cursor
|
|
429
|
+
applicationCursor = set
|
|
430
|
+
break
|
|
431
|
+
case 4: // IRM - Insert Mode (via DEC private)
|
|
432
|
+
insertMode = set
|
|
433
|
+
break
|
|
434
|
+
case 5: // DECSCNM - Reverse Video
|
|
435
|
+
reverseVideo = set
|
|
436
|
+
break
|
|
437
|
+
case 6: // DECOM - Origin Mode
|
|
438
|
+
originMode = set
|
|
439
|
+
break
|
|
440
|
+
case 7: // DECAWM - Autowrap Mode
|
|
441
|
+
autoWrap = set
|
|
442
|
+
break
|
|
443
|
+
case 25: // DECTCEM - Cursor Visible
|
|
444
|
+
curVisible = set
|
|
445
|
+
break
|
|
446
|
+
case 66: // DECNKM - Application Keypad
|
|
447
|
+
applicationKeypad = set
|
|
448
|
+
break
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
// Also handle standard (non-private) set/reset modes: CSI Ps h / CSI Ps l
|
|
454
|
+
function handleSetResetMode(params: string, finalByte: string): void {
|
|
455
|
+
const parts = params.split(";").map((s) => (s === "" ? 0 : parseInt(s, 10)))
|
|
456
|
+
const set = finalByte === "h"
|
|
457
|
+
|
|
458
|
+
for (const code of parts) {
|
|
459
|
+
switch (code) {
|
|
460
|
+
case 4: // IRM - Insert/Replace Mode
|
|
461
|
+
insertMode = set
|
|
462
|
+
break
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
function handleEraseDisplay(mode: number): void {
|
|
468
|
+
switch (mode) {
|
|
469
|
+
case 0: // Erase from cursor to end
|
|
470
|
+
eraseCells(curY, curX, curY, cols - 1)
|
|
471
|
+
for (let row = curY + 1; row < rows; row++) {
|
|
472
|
+
eraseCells(row, 0, row, cols - 1)
|
|
473
|
+
}
|
|
474
|
+
break
|
|
475
|
+
case 1: // Erase from start to cursor
|
|
476
|
+
for (let row = 0; row < curY; row++) {
|
|
477
|
+
eraseCells(row, 0, row, cols - 1)
|
|
478
|
+
}
|
|
479
|
+
eraseCells(curY, 0, curY, curX)
|
|
480
|
+
break
|
|
481
|
+
case 2: // Erase entire display
|
|
482
|
+
case 3: // Erase entire display + scrollback
|
|
483
|
+
for (let row = 0; row < rows; row++) {
|
|
484
|
+
eraseCells(row, 0, row, cols - 1)
|
|
485
|
+
}
|
|
486
|
+
if (mode === 3) {
|
|
487
|
+
scrollback.length = 0
|
|
488
|
+
}
|
|
489
|
+
break
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
function handleEraseLine(mode: number): void {
|
|
494
|
+
switch (mode) {
|
|
495
|
+
case 0: // Erase from cursor to end of line
|
|
496
|
+
eraseCells(curY, curX, curY, cols - 1)
|
|
497
|
+
break
|
|
498
|
+
case 1: // Erase from start to cursor
|
|
499
|
+
eraseCells(curY, 0, curY, curX)
|
|
500
|
+
break
|
|
501
|
+
case 2: // Erase entire line
|
|
502
|
+
eraseCells(curY, 0, curY, cols - 1)
|
|
503
|
+
break
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
// ── Selective erase (DECSED/DECSEL) ──
|
|
508
|
+
// These only erase cells that do NOT have the "protected" attribute.
|
|
509
|
+
// Since we don't track DECSCA (protected attribute), selective erase
|
|
510
|
+
// behaves identically to normal erase for now.
|
|
511
|
+
|
|
512
|
+
function handleSelectiveEraseDisplay(mode: number): void {
|
|
513
|
+
handleEraseDisplay(mode)
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
function handleSelectiveEraseLine(mode: number): void {
|
|
517
|
+
handleEraseLine(mode)
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
function eraseCells(row: number, startCol: number, _endRow: number, endCol: number): void {
|
|
521
|
+
const r = grid[row]
|
|
522
|
+
if (!r) return
|
|
523
|
+
for (let col = startCol; col <= endCol && col < cols; col++) {
|
|
524
|
+
r[col] = emptyCell()
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
function handleInsertLines(count: number): void {
|
|
529
|
+
if (curY < scrollTop || curY > scrollBottom) return
|
|
530
|
+
for (let i = 0; i < count; i++) {
|
|
531
|
+
scrollDown(curY, scrollBottom)
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
function handleDeleteLines(count: number): void {
|
|
536
|
+
if (curY < scrollTop || curY > scrollBottom) return
|
|
537
|
+
for (let i = 0; i < count; i++) {
|
|
538
|
+
scrollUp(curY, scrollBottom)
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
function handleDeleteChars(count: number): void {
|
|
543
|
+
const row = grid[curY]
|
|
544
|
+
if (!row) return
|
|
545
|
+
for (let i = 0; i < count; i++) {
|
|
546
|
+
if (curX < cols) {
|
|
547
|
+
row.splice(curX, 1)
|
|
548
|
+
row.push(emptyCell())
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
function handleInsertChars(count: number): void {
|
|
554
|
+
const row = grid[curY]
|
|
555
|
+
if (!row) return
|
|
556
|
+
for (let i = 0; i < count; i++) {
|
|
557
|
+
row.splice(curX, 0, emptyCell())
|
|
558
|
+
row.pop()
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
function handleEraseChars(count: number): void {
|
|
563
|
+
const row = grid[curY]
|
|
564
|
+
if (!row) return
|
|
565
|
+
for (let i = 0; i < count && curX + i < cols; i++) {
|
|
566
|
+
row[curX + i] = emptyCell()
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
// ── SGR (Select Graphic Rendition) ──
|
|
571
|
+
|
|
572
|
+
function handleSGR(rawParams: string): void {
|
|
573
|
+
const params = rawParams.split(";").map((s) => (s === "" ? 0 : parseInt(s, 10)))
|
|
574
|
+
|
|
575
|
+
if (params.length === 0 || (params.length === 1 && params[0] === 0)) {
|
|
576
|
+
attrs = resetAttrs()
|
|
577
|
+
return
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
let i = 0
|
|
581
|
+
while (i < params.length) {
|
|
582
|
+
const code = params[i]!
|
|
583
|
+
switch (code) {
|
|
584
|
+
case 0:
|
|
585
|
+
attrs = resetAttrs()
|
|
586
|
+
break
|
|
587
|
+
case 1:
|
|
588
|
+
attrs.bold = true
|
|
589
|
+
break
|
|
590
|
+
case 4:
|
|
591
|
+
attrs.underline = true
|
|
592
|
+
break
|
|
593
|
+
case 5: // Blink
|
|
594
|
+
attrs.blink = true
|
|
595
|
+
break
|
|
596
|
+
case 7:
|
|
597
|
+
attrs.inverse = true
|
|
598
|
+
break
|
|
599
|
+
case 8: // Hidden/conceal
|
|
600
|
+
attrs.hidden = true
|
|
601
|
+
break
|
|
602
|
+
case 22: // Normal intensity (turn off bold)
|
|
603
|
+
attrs.bold = false
|
|
604
|
+
break
|
|
605
|
+
case 24:
|
|
606
|
+
attrs.underline = false
|
|
607
|
+
break
|
|
608
|
+
case 25: // Blink off
|
|
609
|
+
attrs.blink = false
|
|
610
|
+
break
|
|
611
|
+
case 27:
|
|
612
|
+
attrs.inverse = false
|
|
613
|
+
break
|
|
614
|
+
case 28: // Reveal (turn off hidden/conceal)
|
|
615
|
+
attrs.hidden = false
|
|
616
|
+
break
|
|
617
|
+
// Foreground colors 30-37
|
|
618
|
+
case 30:
|
|
619
|
+
case 31:
|
|
620
|
+
case 32:
|
|
621
|
+
case 33:
|
|
622
|
+
case 34:
|
|
623
|
+
case 35:
|
|
624
|
+
case 36:
|
|
625
|
+
case 37:
|
|
626
|
+
attrs.fg = { ...ANSI_8[code - 30]! }
|
|
627
|
+
break
|
|
628
|
+
case 39: // Default foreground
|
|
629
|
+
attrs.fg = null
|
|
630
|
+
break
|
|
631
|
+
// Background colors 40-47
|
|
632
|
+
case 40:
|
|
633
|
+
case 41:
|
|
634
|
+
case 42:
|
|
635
|
+
case 43:
|
|
636
|
+
case 44:
|
|
637
|
+
case 45:
|
|
638
|
+
case 46:
|
|
639
|
+
case 47:
|
|
640
|
+
attrs.bg = { ...ANSI_8[code - 40]! }
|
|
641
|
+
break
|
|
642
|
+
case 49: // Default background
|
|
643
|
+
attrs.bg = null
|
|
644
|
+
break
|
|
645
|
+
// Skip extended color sequences (not supported, but must consume params)
|
|
646
|
+
case 38:
|
|
647
|
+
case 48:
|
|
648
|
+
if (i + 1 < params.length && params[i + 1] === 2) {
|
|
649
|
+
i += 4 // skip 38;2;R;G;B or 48;2;R;G;B
|
|
650
|
+
} else if (i + 1 < params.length && params[i + 1] === 5) {
|
|
651
|
+
i += 2 // skip 38;5;N or 48;5;N
|
|
652
|
+
}
|
|
653
|
+
break
|
|
654
|
+
}
|
|
655
|
+
i++
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
// ── OSC handler ──
|
|
660
|
+
|
|
661
|
+
function handleOSC(oscString: string): void {
|
|
662
|
+
const semicolonIdx = oscString.indexOf(";")
|
|
663
|
+
if (semicolonIdx === -1) return
|
|
664
|
+
|
|
665
|
+
const code = parseInt(oscString.substring(0, semicolonIdx), 10)
|
|
666
|
+
const value = oscString.substring(semicolonIdx + 1)
|
|
667
|
+
|
|
668
|
+
switch (code) {
|
|
669
|
+
case 0: // Set icon name and window title
|
|
670
|
+
case 2: // Set window title
|
|
671
|
+
title = value
|
|
672
|
+
break
|
|
673
|
+
case 1: // Set icon name (ignore)
|
|
674
|
+
break
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
// ── Main parser ──
|
|
679
|
+
|
|
680
|
+
function process(data: Uint8Array): void {
|
|
681
|
+
const text = decoder.decode(data, { stream: true })
|
|
682
|
+
|
|
683
|
+
for (let i = 0; i < text.length; i++) {
|
|
684
|
+
const ch = text[i]!
|
|
685
|
+
const code = text.charCodeAt(i)
|
|
686
|
+
|
|
687
|
+
switch (parserState) {
|
|
688
|
+
case "ground":
|
|
689
|
+
if (code === 0x1b) {
|
|
690
|
+
parserState = "escape"
|
|
691
|
+
escBuf = ""
|
|
692
|
+
} else if (code === 0x07) {
|
|
693
|
+
// BEL — ignore
|
|
694
|
+
} else if (code === 0x08) {
|
|
695
|
+
// BS - Backspace
|
|
696
|
+
if (curX > 0) curX--
|
|
697
|
+
} else if (code === 0x09) {
|
|
698
|
+
// TAB
|
|
699
|
+
curX = Math.min((Math.floor(curX / 8) + 1) * 8, cols - 1)
|
|
700
|
+
} else if (code === 0x0a || code === 0x0b || code === 0x0c) {
|
|
701
|
+
// LF, VT, FF — linefeed
|
|
702
|
+
curY++
|
|
703
|
+
if (curY > scrollBottom) {
|
|
704
|
+
curY = scrollBottom
|
|
705
|
+
scrollUp(scrollTop, scrollBottom)
|
|
706
|
+
}
|
|
707
|
+
} else if (code === 0x0d) {
|
|
708
|
+
// CR - Carriage Return
|
|
709
|
+
curX = 0
|
|
710
|
+
} else if (code >= 0x20) {
|
|
711
|
+
writeChar(ch)
|
|
712
|
+
}
|
|
713
|
+
break
|
|
714
|
+
|
|
715
|
+
case "escape":
|
|
716
|
+
if (ch === "[") {
|
|
717
|
+
parserState = "csi"
|
|
718
|
+
escBuf = ""
|
|
719
|
+
} else if (ch === "]") {
|
|
720
|
+
parserState = "osc"
|
|
721
|
+
oscBuf = ""
|
|
722
|
+
} else if (ch === "P") {
|
|
723
|
+
parserState = "dcs"
|
|
724
|
+
escBuf = ""
|
|
725
|
+
} else if (ch === "c") {
|
|
726
|
+
// RIS - Reset to Initial State
|
|
727
|
+
fullReset()
|
|
728
|
+
} else if (ch === "D") {
|
|
729
|
+
// IND - Index (move cursor down, scroll if needed)
|
|
730
|
+
curY++
|
|
731
|
+
if (curY > scrollBottom) {
|
|
732
|
+
curY = scrollBottom
|
|
733
|
+
scrollUp(scrollTop, scrollBottom)
|
|
734
|
+
}
|
|
735
|
+
parserState = "ground"
|
|
736
|
+
} else if (ch === "M") {
|
|
737
|
+
// RI - Reverse Index (move cursor up, scroll if needed)
|
|
738
|
+
curY--
|
|
739
|
+
if (curY < scrollTop) {
|
|
740
|
+
curY = scrollTop
|
|
741
|
+
scrollDown(scrollTop, scrollBottom)
|
|
742
|
+
}
|
|
743
|
+
parserState = "ground"
|
|
744
|
+
} else if (ch === "7") {
|
|
745
|
+
// DECSC - Save Cursor + attributes + modes
|
|
746
|
+
savedState = {
|
|
747
|
+
curX,
|
|
748
|
+
curY,
|
|
749
|
+
attrs: { ...attrs, fg: attrs.fg ? { ...attrs.fg } : null, bg: attrs.bg ? { ...attrs.bg } : null },
|
|
750
|
+
originMode,
|
|
751
|
+
autoWrap,
|
|
752
|
+
}
|
|
753
|
+
parserState = "ground"
|
|
754
|
+
} else if (ch === "8") {
|
|
755
|
+
// DECRC - Restore Cursor + attributes + modes
|
|
756
|
+
curX = savedState.curX
|
|
757
|
+
curY = savedState.curY
|
|
758
|
+
attrs = {
|
|
759
|
+
...savedState.attrs,
|
|
760
|
+
fg: savedState.attrs.fg ? { ...savedState.attrs.fg } : null,
|
|
761
|
+
bg: savedState.attrs.bg ? { ...savedState.attrs.bg } : null,
|
|
762
|
+
}
|
|
763
|
+
originMode = savedState.originMode
|
|
764
|
+
autoWrap = savedState.autoWrap
|
|
765
|
+
clampCursor()
|
|
766
|
+
parserState = "ground"
|
|
767
|
+
} else if (ch === "E") {
|
|
768
|
+
// NEL - Next Line
|
|
769
|
+
curX = 0
|
|
770
|
+
curY++
|
|
771
|
+
if (curY > scrollBottom) {
|
|
772
|
+
curY = scrollBottom
|
|
773
|
+
scrollUp(scrollTop, scrollBottom)
|
|
774
|
+
}
|
|
775
|
+
parserState = "ground"
|
|
776
|
+
} else if (ch === "=") {
|
|
777
|
+
// DECKPAM - Application Keypad Mode
|
|
778
|
+
applicationKeypad = true
|
|
779
|
+
parserState = "ground"
|
|
780
|
+
} else if (ch === ">") {
|
|
781
|
+
// DECKPNM - Normal Keypad Mode
|
|
782
|
+
applicationKeypad = false
|
|
783
|
+
parserState = "ground"
|
|
784
|
+
} else {
|
|
785
|
+
// Unknown escape — return to ground
|
|
786
|
+
parserState = "ground"
|
|
787
|
+
}
|
|
788
|
+
break
|
|
789
|
+
|
|
790
|
+
case "csi":
|
|
791
|
+
if (code >= 0x40 && code <= 0x7e) {
|
|
792
|
+
// Final byte — dispatch CSI
|
|
793
|
+
if (escBuf.startsWith("?")) {
|
|
794
|
+
handleCSIPrivate(escBuf.substring(1), ch)
|
|
795
|
+
} else if (ch === "h" || ch === "l") {
|
|
796
|
+
// Standard set/reset mode (non-private)
|
|
797
|
+
handleSetResetMode(escBuf, ch)
|
|
798
|
+
} else {
|
|
799
|
+
// Check for intermediate bytes (0x20-0x2F range, e.g., "!" in CSI ! p)
|
|
800
|
+
// Also check for ">" prefix for DA2 (CSI > c)
|
|
801
|
+
let intermediateIdx = -1
|
|
802
|
+
for (let j = 0; j < escBuf.length; j++) {
|
|
803
|
+
const c = escBuf.charCodeAt(j)
|
|
804
|
+
if ((c >= 0x20 && c <= 0x2f) || c === 0x3e) {
|
|
805
|
+
// 0x3e = '>'
|
|
806
|
+
intermediateIdx = j
|
|
807
|
+
break
|
|
808
|
+
}
|
|
809
|
+
}
|
|
810
|
+
if (intermediateIdx >= 0) {
|
|
811
|
+
const paramPart = escBuf.substring(0, intermediateIdx)
|
|
812
|
+
const intermediatePart = escBuf.substring(intermediateIdx)
|
|
813
|
+
handleCSIWithIntermediate(paramPart, intermediatePart, ch)
|
|
814
|
+
} else {
|
|
815
|
+
handleCSI(escBuf, ch)
|
|
816
|
+
}
|
|
817
|
+
}
|
|
818
|
+
parserState = "ground"
|
|
819
|
+
} else if (escBuf.length >= 256) {
|
|
820
|
+
// Buffer overflow — drop to ground to avoid unbounded accumulation
|
|
821
|
+
parserState = "ground"
|
|
822
|
+
} else {
|
|
823
|
+
// Parameter or intermediate byte
|
|
824
|
+
escBuf += ch
|
|
825
|
+
}
|
|
826
|
+
break
|
|
827
|
+
|
|
828
|
+
case "osc":
|
|
829
|
+
if (code === 0x07) {
|
|
830
|
+
// BEL terminates OSC
|
|
831
|
+
handleOSC(oscBuf)
|
|
832
|
+
parserState = "ground"
|
|
833
|
+
} else if (code === 0x1b) {
|
|
834
|
+
// ESC might be start of ST (\x1b\\)
|
|
835
|
+
parserState = "oscString"
|
|
836
|
+
} else if (oscBuf.length >= 4096) {
|
|
837
|
+
// Buffer overflow — drop to ground to avoid unbounded accumulation
|
|
838
|
+
parserState = "ground"
|
|
839
|
+
} else {
|
|
840
|
+
oscBuf += ch
|
|
841
|
+
}
|
|
842
|
+
break
|
|
843
|
+
|
|
844
|
+
case "oscString":
|
|
845
|
+
if (ch === "\\") {
|
|
846
|
+
// ST (String Terminator) — end of OSC
|
|
847
|
+
handleOSC(oscBuf)
|
|
848
|
+
}
|
|
849
|
+
// Either way, back to ground
|
|
850
|
+
parserState = "ground"
|
|
851
|
+
break
|
|
852
|
+
|
|
853
|
+
case "dcs":
|
|
854
|
+
// Consume until ST
|
|
855
|
+
if (code === 0x1b) {
|
|
856
|
+
parserState = "oscString" // Reuse ST detection
|
|
857
|
+
}
|
|
858
|
+
break
|
|
859
|
+
}
|
|
860
|
+
}
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
function fullReset(): void {
|
|
864
|
+
grid = makeGrid(cols, rows)
|
|
865
|
+
scrollback = []
|
|
866
|
+
curX = 0
|
|
867
|
+
curY = 0
|
|
868
|
+
curVisible = true
|
|
869
|
+
savedCurX = 0
|
|
870
|
+
savedCurY = 0
|
|
871
|
+
savedState = { curX: 0, curY: 0, attrs: resetAttrs(), originMode: false, autoWrap: true }
|
|
872
|
+
attrs = resetAttrs()
|
|
873
|
+
title = ""
|
|
874
|
+
applicationCursor = false
|
|
875
|
+
applicationKeypad = false
|
|
876
|
+
autoWrap = true
|
|
877
|
+
originMode = false
|
|
878
|
+
insertMode = false
|
|
879
|
+
reverseVideo = false
|
|
880
|
+
scrollTop = 0
|
|
881
|
+
scrollBottom = rows - 1
|
|
882
|
+
viewportOffset = 0
|
|
883
|
+
parserState = "ground"
|
|
884
|
+
escBuf = ""
|
|
885
|
+
oscBuf = ""
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
function softReset(): void {
|
|
889
|
+
attrs = resetAttrs()
|
|
890
|
+
applicationCursor = false
|
|
891
|
+
applicationKeypad = false
|
|
892
|
+
autoWrap = true
|
|
893
|
+
originMode = false
|
|
894
|
+
insertMode = false
|
|
895
|
+
reverseVideo = false
|
|
896
|
+
curVisible = true
|
|
897
|
+
scrollTop = 0
|
|
898
|
+
scrollBottom = rows - 1
|
|
899
|
+
savedState = { curX: 0, curY: 0, attrs: resetAttrs(), originMode: false, autoWrap: true }
|
|
900
|
+
savedCurX = 0
|
|
901
|
+
savedCurY = 0
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
function resize(newCols: number, newRows: number): void {
|
|
905
|
+
const newGrid = makeGrid(newCols, newRows)
|
|
906
|
+
|
|
907
|
+
// Copy content from old grid
|
|
908
|
+
copyGrid(grid, newGrid, Math.min(cols, newCols), Math.min(rows, newRows))
|
|
909
|
+
|
|
910
|
+
grid = newGrid
|
|
911
|
+
cols = newCols
|
|
912
|
+
rows = newRows
|
|
913
|
+
scrollTop = 0
|
|
914
|
+
scrollBottom = rows - 1
|
|
915
|
+
clampCursor()
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
function copyGrid(src: ScreenCell[][], dst: ScreenCell[][], copyCols: number, copyRows: number): void {
|
|
919
|
+
for (let row = 0; row < copyRows; row++) {
|
|
920
|
+
for (let col = 0; col < copyCols; col++) {
|
|
921
|
+
const srcCell = src[row]?.[col]
|
|
922
|
+
if (srcCell) {
|
|
923
|
+
dst[row]![col] = { ...srcCell }
|
|
924
|
+
}
|
|
925
|
+
}
|
|
926
|
+
}
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
function getCell(row: number, col: number): ScreenCell {
|
|
930
|
+
const r = grid[row]
|
|
931
|
+
if (!r || col >= cols) return emptyCell()
|
|
932
|
+
return { ...r[col]! }
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
function getLine(row: number): ScreenCell[] {
|
|
936
|
+
const r = grid[row]
|
|
937
|
+
if (!r) return makeRow(cols)
|
|
938
|
+
return r.map((cell) => ({ ...cell }))
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
function getText(): string {
|
|
942
|
+
const lines: string[] = []
|
|
943
|
+
|
|
944
|
+
// Scrollback
|
|
945
|
+
for (const row of scrollback) {
|
|
946
|
+
lines.push(rowToString(row))
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
// Screen
|
|
950
|
+
for (let r = 0; r < rows; r++) {
|
|
951
|
+
lines.push(rowToString(grid[r]!))
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
return lines.join("\n")
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
function rowToString(row: ScreenCell[]): string {
|
|
958
|
+
let line = ""
|
|
959
|
+
for (let i = 0; i < row.length; i++) {
|
|
960
|
+
const cell = row[i]!
|
|
961
|
+
if (cell.char === "") {
|
|
962
|
+
line += " "
|
|
963
|
+
} else {
|
|
964
|
+
line += cell.char
|
|
965
|
+
}
|
|
966
|
+
}
|
|
967
|
+
return line.replace(/\s+$/, "") // Trim trailing whitespace
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
function getTextRange(startRow: number, startCol: number, endRow: number, endCol: number): string {
|
|
971
|
+
const parts: string[] = []
|
|
972
|
+
|
|
973
|
+
for (let row = startRow; row <= endRow; row++) {
|
|
974
|
+
const r = grid[row]
|
|
975
|
+
if (!r) continue
|
|
976
|
+
|
|
977
|
+
const colStart = row === startRow ? startCol : 0
|
|
978
|
+
const colEnd = row === endRow ? endCol : cols
|
|
979
|
+
|
|
980
|
+
let line = ""
|
|
981
|
+
for (let col = colStart; col < colEnd; col++) {
|
|
982
|
+
const cell = r[col]
|
|
983
|
+
if (!cell) continue
|
|
984
|
+
line += cell.char || " "
|
|
985
|
+
}
|
|
986
|
+
parts.push(line.replace(/\s+$/, ""))
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
return parts.join("\n")
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
function getMode(mode: string): boolean {
|
|
993
|
+
switch (mode) {
|
|
994
|
+
case "cursorVisible":
|
|
995
|
+
return curVisible
|
|
996
|
+
case "applicationCursor":
|
|
997
|
+
return applicationCursor
|
|
998
|
+
case "applicationKeypad":
|
|
999
|
+
return applicationKeypad
|
|
1000
|
+
case "autoWrap":
|
|
1001
|
+
return autoWrap
|
|
1002
|
+
case "originMode":
|
|
1003
|
+
return originMode
|
|
1004
|
+
case "insertMode":
|
|
1005
|
+
return insertMode
|
|
1006
|
+
case "reverseVideo":
|
|
1007
|
+
return reverseVideo
|
|
1008
|
+
default:
|
|
1009
|
+
return false
|
|
1010
|
+
}
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
return {
|
|
1014
|
+
get cols() {
|
|
1015
|
+
return cols
|
|
1016
|
+
},
|
|
1017
|
+
get rows() {
|
|
1018
|
+
return rows
|
|
1019
|
+
},
|
|
1020
|
+
process,
|
|
1021
|
+
resize,
|
|
1022
|
+
reset: fullReset,
|
|
1023
|
+
getCell,
|
|
1024
|
+
getLine,
|
|
1025
|
+
getText,
|
|
1026
|
+
getTextRange,
|
|
1027
|
+
getCursorPosition: () => ({ x: curX, y: curY }),
|
|
1028
|
+
getCursorVisible: () => curVisible,
|
|
1029
|
+
getTitle: () => title,
|
|
1030
|
+
getMode,
|
|
1031
|
+
getScrollbackLength: () => scrollback.length,
|
|
1032
|
+
getViewportOffset: () => viewportOffset,
|
|
1033
|
+
scrollViewport: (delta: number) => {
|
|
1034
|
+
viewportOffset = Math.max(0, Math.min(scrollback.length, viewportOffset + delta))
|
|
1035
|
+
},
|
|
1036
|
+
}
|
|
1037
|
+
}
|