typescript-virtual-container 1.5.7 → 1.5.8

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.
@@ -0,0 +1,956 @@
1
+ // ── ANSI helpers ─────────────────────────────────────────────────────────────
2
+ const ESC = "\x1b";
3
+ const CSI = `${ESC}[`;
4
+ function stripAnsi(s) {
5
+ let out = "";
6
+ let i = 0;
7
+ while (i < s.length) {
8
+ if (s[i] === ESC && s[i + 1] === "[") {
9
+ i += 2;
10
+ while (i < s.length && (s[i] < "@" || s[i] > "~"))
11
+ i++;
12
+ i++;
13
+ }
14
+ else {
15
+ out += s[i];
16
+ i++;
17
+ }
18
+ }
19
+ return out;
20
+ }
21
+ const ansi = {
22
+ cup: (row, col) => `${CSI}${row};${col}H`,
23
+ el: () => `${CSI}K`,
24
+ ed: () => `${CSI}2J`,
25
+ home: () => `${CSI}H`,
26
+ cursorHide: () => `${CSI}?25l`,
27
+ cursorShow: () => `${CSI}?25h`,
28
+ bold: (s) => `${ESC}[1m${s}${ESC}[0m`,
29
+ reverse: (s) => `${ESC}[7m${s}${ESC}[0m`,
30
+ color: (fg, s) => `${ESC}[${fg}m${s}${ESC}[0m`,
31
+ };
32
+ // ── NanoEditor ───────────────────────────────────────────────────────────────
33
+ export class NanoEditor {
34
+ lines;
35
+ cursorRow = 0;
36
+ cursorCol = 0;
37
+ scrollTop = 0;
38
+ modified = false;
39
+ filename;
40
+ mode = "normal";
41
+ inputBuffer = ""; // for multi-char prompts
42
+ searchState = null;
43
+ clipboard = []; // ^K cut lines
44
+ undoStack = [];
45
+ redoStack = [];
46
+ markActive = false;
47
+ stream;
48
+ terminalSize;
49
+ onExit;
50
+ onSave;
51
+ constructor(opts) {
52
+ this.stream = opts.stream;
53
+ this.terminalSize = opts.terminalSize;
54
+ this.filename = opts.filename;
55
+ this.onExit = opts.onExit;
56
+ this.onSave = opts.onSave;
57
+ this.lines = opts.content.split("\n");
58
+ // Remove trailing empty line that split adds for files ending in \n
59
+ if (this.lines.length > 1 && this.lines.at(-1) === "") {
60
+ this.lines.pop();
61
+ }
62
+ if (this.lines.length === 0)
63
+ this.lines = [""];
64
+ }
65
+ // ── Public API ────────────────────────────────────────────────────────────
66
+ start() {
67
+ this.fullRedraw();
68
+ }
69
+ resize(size) {
70
+ this.terminalSize = size;
71
+ this.fullRedraw();
72
+ }
73
+ handleInput(chunk) {
74
+ const data = chunk.toString("utf8");
75
+ for (let i = 0; i < data.length;) {
76
+ const consumed = this.consumeSequence(data, i);
77
+ i += consumed;
78
+ }
79
+ }
80
+ // ── Input dispatch ────────────────────────────────────────────────────────
81
+ consumeSequence(data, i) {
82
+ const ch = data[i];
83
+ // ESC sequences
84
+ if (ch === ESC) {
85
+ if (data[i + 1] === "[") {
86
+ // CSI sequence
87
+ let j = i + 2;
88
+ while (j < data.length && (data[j] < "@" || data[j] > "~"))
89
+ j++;
90
+ const seq = data.slice(i, j + 1);
91
+ this.handleEscape(seq);
92
+ return j - i + 1;
93
+ }
94
+ if (data[i + 1] === "O") {
95
+ // SS3 (xterm function keys)
96
+ const seq = data.slice(i, i + 3);
97
+ this.handleEscape(seq);
98
+ return 3;
99
+ }
100
+ // Alt+key
101
+ if (i + 1 < data.length) {
102
+ this.handleAlt(data[i + 1]);
103
+ return 2;
104
+ }
105
+ return 1;
106
+ }
107
+ this.handleChar(ch);
108
+ return 1;
109
+ }
110
+ handleEscape(seq) {
111
+ switch (seq) {
112
+ case `${ESC}[A`:
113
+ case `${ESC}OA`:
114
+ this.dispatch("up");
115
+ break;
116
+ case `${ESC}[B`:
117
+ case `${ESC}OB`:
118
+ this.dispatch("down");
119
+ break;
120
+ case `${ESC}[C`:
121
+ case `${ESC}OC`:
122
+ this.dispatch("right");
123
+ break;
124
+ case `${ESC}[D`:
125
+ case `${ESC}OD`:
126
+ this.dispatch("left");
127
+ break;
128
+ case `${ESC}[H`:
129
+ case `${ESC}[1~`:
130
+ this.dispatch("home");
131
+ break;
132
+ case `${ESC}[F`:
133
+ case `${ESC}[4~`:
134
+ this.dispatch("end");
135
+ break;
136
+ case `${ESC}[5~`:
137
+ this.dispatch("pageup");
138
+ break;
139
+ case `${ESC}[6~`:
140
+ this.dispatch("pagedown");
141
+ break;
142
+ case `${ESC}[3~`:
143
+ this.dispatch("delete");
144
+ break;
145
+ case `${ESC}[1;5C`:
146
+ this.dispatch("ctrl-right");
147
+ break;
148
+ case `${ESC}[1;5D`:
149
+ this.dispatch("ctrl-left");
150
+ break;
151
+ case `${ESC}[1;5A`:
152
+ this.dispatch("ctrl-up");
153
+ break;
154
+ case `${ESC}[1;5B`:
155
+ this.dispatch("ctrl-down");
156
+ break;
157
+ }
158
+ }
159
+ handleAlt(key) {
160
+ const k = key.toLowerCase();
161
+ if (k === "u") {
162
+ this.doUndo();
163
+ return;
164
+ }
165
+ if (k === "e") {
166
+ this.doRedo();
167
+ return;
168
+ }
169
+ if (k === "g") {
170
+ this.enterGotoLine();
171
+ return;
172
+ }
173
+ if (k === "r") {
174
+ this.doSearchReplace();
175
+ return;
176
+ }
177
+ if (k === "a") {
178
+ this.toggleMark();
179
+ return;
180
+ }
181
+ if (k === "^") {
182
+ this.doUndo();
183
+ return;
184
+ } // Alt+6 = Alt+^
185
+ }
186
+ handleChar(ch) {
187
+ const code = ch.charCodeAt(0);
188
+ // Route by mode first
189
+ if (this.mode !== "normal") {
190
+ this.handlePromptChar(ch);
191
+ return;
192
+ }
193
+ // Control characters
194
+ if (code < 32 || code === 127) {
195
+ this.handleControl(ch, code);
196
+ return;
197
+ }
198
+ // Printable
199
+ this.doInsertChar(ch);
200
+ }
201
+ handleControl(_ch, code) {
202
+ switch (code) {
203
+ // Navigation
204
+ case 1:
205
+ this.dispatch("home");
206
+ break; // ^A
207
+ case 5:
208
+ this.dispatch("end");
209
+ break; // ^E
210
+ case 16:
211
+ this.dispatch("up");
212
+ break; // ^P (emacs)
213
+ case 14:
214
+ this.dispatch("down");
215
+ break; // ^N (emacs)
216
+ case 2:
217
+ this.dispatch("left");
218
+ break; // ^B
219
+ case 6:
220
+ this.dispatch("right");
221
+ break; // ^F
222
+ // Editing
223
+ case 8:
224
+ case 127:
225
+ this.doBackspace();
226
+ break; // ^H / DEL
227
+ case 13:
228
+ this.doEnter();
229
+ break; // ^M / Enter
230
+ case 11:
231
+ this.doCutLine();
232
+ break; // ^K
233
+ case 21:
234
+ this.doUncut();
235
+ break; // ^U
236
+ case 9:
237
+ this.doInsertChar("\t");
238
+ break; // ^I / Tab
239
+ // File ops
240
+ case 15:
241
+ this.enterWriteout();
242
+ break; // ^O
243
+ case 19:
244
+ this.doSave();
245
+ break; // ^S (save without prompt)
246
+ case 24:
247
+ this.doExit();
248
+ break; // ^X
249
+ case 18:
250
+ this.doSearch();
251
+ break; // ^R (reused as search-next)
252
+ // Search
253
+ case 23:
254
+ this.enterSearch();
255
+ break; // ^W
256
+ case 12:
257
+ this.doSearchNext();
258
+ break; // ^L (refresh / search next)
259
+ // Info
260
+ case 3:
261
+ this.showCursorPos();
262
+ break; // ^C
263
+ case 7:
264
+ this.enterHelp();
265
+ break; // ^G
266
+ // Undo/Redo (nano uses Alt+U / Alt+E, but also ^Z in some builds)
267
+ case 26:
268
+ this.doUndo();
269
+ break; // ^Z (non-standard but common)
270
+ // Goto line
271
+ case 31:
272
+ this.enterGotoLine();
273
+ break; // ^_
274
+ }
275
+ }
276
+ dispatch(action) {
277
+ if (this.mode !== "normal")
278
+ return;
279
+ switch (action) {
280
+ case "up":
281
+ this.moveCursor(-1, 0);
282
+ break;
283
+ case "down":
284
+ this.moveCursor(1, 0);
285
+ break;
286
+ case "left":
287
+ this.moveCursorLeft();
288
+ break;
289
+ case "right":
290
+ this.moveCursorRight();
291
+ break;
292
+ case "home":
293
+ this.moveCursorHome();
294
+ break;
295
+ case "end":
296
+ this.moveCursorEnd();
297
+ break;
298
+ case "pageup":
299
+ this.movePage(-1);
300
+ break;
301
+ case "pagedown":
302
+ this.movePage(1);
303
+ break;
304
+ case "delete":
305
+ this.doDelete();
306
+ break;
307
+ case "ctrl-right":
308
+ this.moveWordRight();
309
+ break;
310
+ case "ctrl-left":
311
+ this.moveWordLeft();
312
+ break;
313
+ case "ctrl-up":
314
+ this.moveCursor(-1, 0);
315
+ break;
316
+ case "ctrl-down":
317
+ this.moveCursor(1, 0);
318
+ break;
319
+ }
320
+ }
321
+ // ── Prompt mode handler ───────────────────────────────────────────────────
322
+ handlePromptChar(ch) {
323
+ const code = ch.charCodeAt(0);
324
+ if (this.mode === "help") {
325
+ this.mode = "normal";
326
+ this.fullRedraw();
327
+ return;
328
+ }
329
+ if (this.mode === "exit-confirm") {
330
+ const k = ch.toLowerCase();
331
+ if (k === "y") {
332
+ // Save then exit
333
+ this.mode = "exit-filename";
334
+ this.inputBuffer = this.filename;
335
+ this.renderStatusBar(`File Name to Write: ${this.inputBuffer}`);
336
+ return;
337
+ }
338
+ if (k === "n") {
339
+ this.onExit("aborted", this.getCurrentContent());
340
+ return;
341
+ }
342
+ if (code === 3 || code === 7 || k === "c") {
343
+ // ^C or ^G = cancel
344
+ this.mode = "normal";
345
+ this.fullRedraw();
346
+ return;
347
+ }
348
+ return;
349
+ }
350
+ if (this.mode === "exit-filename" || this.mode === "writeout") {
351
+ if (code === 13) {
352
+ // Confirm filename
353
+ const name = this.inputBuffer.trim();
354
+ if (name)
355
+ this.filename = name;
356
+ const content = this.getCurrentContent();
357
+ this.modified = false;
358
+ if (this.mode === "exit-filename") {
359
+ this.onExit("saved", content);
360
+ }
361
+ else {
362
+ this.mode = "normal";
363
+ this.renderStatusLine(`Wrote ${this.lines.length} lines`);
364
+ this.onExit("saved", content);
365
+ }
366
+ return;
367
+ }
368
+ if (code === 7 || code === 3) {
369
+ // ^G / ^C = cancel
370
+ this.mode = "normal";
371
+ this.fullRedraw();
372
+ return;
373
+ }
374
+ if (code === 127 || code === 8) {
375
+ this.inputBuffer = this.inputBuffer.slice(0, -1);
376
+ }
377
+ else if (code >= 32) {
378
+ this.inputBuffer += ch;
379
+ }
380
+ const label = this.mode === "writeout" ? "File Name to Write" : "File Name to Write";
381
+ this.renderStatusBar(`${label}: ${this.inputBuffer}`);
382
+ return;
383
+ }
384
+ if (this.mode === "search") {
385
+ if (code === 13) {
386
+ // Execute search
387
+ const query = this.inputBuffer.trim();
388
+ if (query) {
389
+ this.searchState = { query, caseSensitive: false, row: this.cursorRow, col: this.cursorCol + 1 };
390
+ }
391
+ this.mode = "normal";
392
+ if (this.searchState)
393
+ this.doSearchNext();
394
+ else
395
+ this.fullRedraw();
396
+ return;
397
+ }
398
+ if (code === 7 || code === 3) {
399
+ this.mode = "normal";
400
+ this.fullRedraw();
401
+ return;
402
+ }
403
+ if (code === 127 || code === 8) {
404
+ this.inputBuffer = this.inputBuffer.slice(0, -1);
405
+ }
406
+ else if (code >= 32) {
407
+ this.inputBuffer += ch;
408
+ }
409
+ this.renderStatusBar(`Search: ${this.inputBuffer}`);
410
+ return;
411
+ }
412
+ if (this.mode === "goto-line") {
413
+ if (code === 13) {
414
+ const n = Number.parseInt(this.inputBuffer.trim(), 10);
415
+ if (!Number.isNaN(n) && n > 0) {
416
+ this.cursorRow = Math.min(n - 1, this.lines.length - 1);
417
+ this.cursorCol = 0;
418
+ this.clampScroll();
419
+ }
420
+ this.mode = "normal";
421
+ this.fullRedraw();
422
+ return;
423
+ }
424
+ if (code === 7 || code === 3) {
425
+ this.mode = "normal";
426
+ this.fullRedraw();
427
+ return;
428
+ }
429
+ if (code === 127 || code === 8) {
430
+ this.inputBuffer = this.inputBuffer.slice(0, -1);
431
+ }
432
+ else if (ch >= "0" && ch <= "9") {
433
+ this.inputBuffer += ch;
434
+ }
435
+ this.renderStatusBar(`Enter line number: ${this.inputBuffer}`);
436
+ return;
437
+ }
438
+ if (this.mode === "search-confirm") {
439
+ this.mode = "normal";
440
+ this.fullRedraw();
441
+ }
442
+ }
443
+ // ── Cursor movement ───────────────────────────────────────────────────────
444
+ moveCursor(dRow, _dCol) {
445
+ this.cursorRow = Math.max(0, Math.min(this.lines.length - 1, this.cursorRow + dRow));
446
+ this.cursorCol = Math.min(this.cursorCol, this.currentLine().length);
447
+ this.clampScroll();
448
+ this.renderCursor();
449
+ }
450
+ moveCursorLeft() {
451
+ if (this.cursorCol > 0) {
452
+ this.cursorCol--;
453
+ }
454
+ else if (this.cursorRow > 0) {
455
+ this.cursorRow--;
456
+ this.cursorCol = this.currentLine().length;
457
+ }
458
+ this.clampScroll();
459
+ this.renderCursor();
460
+ }
461
+ moveCursorRight() {
462
+ const line = this.currentLine();
463
+ if (this.cursorCol < line.length) {
464
+ this.cursorCol++;
465
+ }
466
+ else if (this.cursorRow < this.lines.length - 1) {
467
+ this.cursorRow++;
468
+ this.cursorCol = 0;
469
+ }
470
+ this.clampScroll();
471
+ this.renderCursor();
472
+ }
473
+ moveCursorHome() {
474
+ this.cursorCol = 0;
475
+ this.renderCursor();
476
+ }
477
+ moveCursorEnd() {
478
+ this.cursorCol = this.currentLine().length;
479
+ this.renderCursor();
480
+ }
481
+ movePage(dir) {
482
+ const editRows = this.editAreaRows();
483
+ this.cursorRow = Math.max(0, Math.min(this.lines.length - 1, this.cursorRow + dir * editRows));
484
+ this.cursorCol = Math.min(this.cursorCol, this.currentLine().length);
485
+ this.clampScroll();
486
+ this.renderCursor();
487
+ }
488
+ moveWordRight() {
489
+ const line = this.currentLine();
490
+ let col = this.cursorCol;
491
+ // Skip current word chars
492
+ while (col < line.length && /\w/.test(line[col]))
493
+ col++;
494
+ // Skip spaces
495
+ while (col < line.length && !/\w/.test(line[col]))
496
+ col++;
497
+ this.cursorCol = col;
498
+ this.renderCursor();
499
+ }
500
+ moveWordLeft() {
501
+ const line = this.currentLine();
502
+ let col = this.cursorCol;
503
+ if (col > 0)
504
+ col--;
505
+ while (col > 0 && !/\w/.test(line[col]))
506
+ col--;
507
+ while (col > 0 && /\w/.test(line[col - 1]))
508
+ col--;
509
+ this.cursorCol = col;
510
+ this.renderCursor();
511
+ }
512
+ // ── Edit operations ───────────────────────────────────────────────────────
513
+ pushUndo() {
514
+ this.undoStack.push({ lines: [...this.lines], cursorRow: this.cursorRow, cursorCol: this.cursorCol });
515
+ if (this.undoStack.length > 200)
516
+ this.undoStack.shift();
517
+ this.redoStack = [];
518
+ }
519
+ doInsertChar(ch) {
520
+ this.pushUndo();
521
+ const line = this.currentLine();
522
+ this.lines[this.cursorRow] = line.slice(0, this.cursorCol) + ch + line.slice(this.cursorCol);
523
+ this.cursorCol++;
524
+ this.modified = true;
525
+ this.renderLine(this.cursorRow);
526
+ this.renderCursor();
527
+ this.renderTitleBar();
528
+ }
529
+ doEnter() {
530
+ this.pushUndo();
531
+ const line = this.currentLine();
532
+ const before = line.slice(0, this.cursorCol);
533
+ const after = line.slice(this.cursorCol);
534
+ this.lines[this.cursorRow] = before;
535
+ this.lines.splice(this.cursorRow + 1, 0, after);
536
+ this.cursorRow++;
537
+ this.cursorCol = 0;
538
+ this.modified = true;
539
+ this.clampScroll();
540
+ this.renderEditArea();
541
+ this.renderCursor();
542
+ this.renderTitleBar();
543
+ }
544
+ doBackspace() {
545
+ if (this.cursorCol === 0 && this.cursorRow === 0)
546
+ return;
547
+ this.pushUndo();
548
+ if (this.cursorCol > 0) {
549
+ const line = this.currentLine();
550
+ this.lines[this.cursorRow] = line.slice(0, this.cursorCol - 1) + line.slice(this.cursorCol);
551
+ this.cursorCol--;
552
+ }
553
+ else {
554
+ const prevLine = this.lines[this.cursorRow - 1];
555
+ const curLine = this.currentLine();
556
+ this.cursorCol = prevLine.length;
557
+ this.lines[this.cursorRow - 1] = prevLine + curLine;
558
+ this.lines.splice(this.cursorRow, 1);
559
+ this.cursorRow--;
560
+ }
561
+ this.modified = true;
562
+ this.clampScroll();
563
+ this.renderEditArea();
564
+ this.renderCursor();
565
+ this.renderTitleBar();
566
+ }
567
+ doDelete() {
568
+ const line = this.currentLine();
569
+ if (this.cursorCol === line.length && this.cursorRow === this.lines.length - 1)
570
+ return;
571
+ this.pushUndo();
572
+ if (this.cursorCol < line.length) {
573
+ this.lines[this.cursorRow] = line.slice(0, this.cursorCol) + line.slice(this.cursorCol + 1);
574
+ }
575
+ else {
576
+ const nextLine = this.lines[this.cursorRow + 1] ?? "";
577
+ this.lines[this.cursorRow] = line + nextLine;
578
+ this.lines.splice(this.cursorRow + 1, 1);
579
+ }
580
+ this.modified = true;
581
+ this.renderEditArea();
582
+ this.renderCursor();
583
+ this.renderTitleBar();
584
+ }
585
+ doCutLine() {
586
+ this.pushUndo();
587
+ if (this.lines.length === 1 && this.lines[0] === "")
588
+ return;
589
+ const cut = this.lines.splice(this.cursorRow, 1)[0] ?? "";
590
+ this.clipboard.push(cut);
591
+ if (this.lines.length === 0)
592
+ this.lines = [""];
593
+ this.cursorRow = Math.min(this.cursorRow, this.lines.length - 1);
594
+ this.cursorCol = Math.min(this.cursorCol, this.currentLine().length);
595
+ this.modified = true;
596
+ this.clampScroll();
597
+ this.renderEditArea();
598
+ this.renderCursor();
599
+ this.renderTitleBar();
600
+ this.renderStatusLine("Cut 1 line");
601
+ }
602
+ doUncut() {
603
+ if (this.clipboard.length === 0)
604
+ return;
605
+ this.pushUndo();
606
+ const lines = [...this.clipboard];
607
+ this.clipboard = [];
608
+ this.lines.splice(this.cursorRow, 0, ...lines);
609
+ this.cursorRow = Math.min(this.cursorRow + lines.length - 1, this.lines.length - 1);
610
+ this.modified = true;
611
+ this.clampScroll();
612
+ this.renderEditArea();
613
+ this.renderCursor();
614
+ this.renderTitleBar();
615
+ this.renderStatusLine("Uncut 1 line");
616
+ }
617
+ doUndo() {
618
+ if (this.undoStack.length === 0) {
619
+ this.renderStatusLine("Nothing to undo");
620
+ return;
621
+ }
622
+ const current = { lines: [...this.lines], cursorRow: this.cursorRow, cursorCol: this.cursorCol };
623
+ this.redoStack.push(current);
624
+ const prev = this.undoStack.pop();
625
+ this.lines = prev.lines;
626
+ this.cursorRow = prev.cursorRow;
627
+ this.cursorCol = prev.cursorCol;
628
+ this.modified = true;
629
+ this.clampScroll();
630
+ this.fullRedraw();
631
+ }
632
+ doRedo() {
633
+ if (this.redoStack.length === 0) {
634
+ this.renderStatusLine("Nothing to redo");
635
+ return;
636
+ }
637
+ const current = { lines: [...this.lines], cursorRow: this.cursorRow, cursorCol: this.cursorCol };
638
+ this.undoStack.push(current);
639
+ const next = this.redoStack.pop();
640
+ this.lines = next.lines;
641
+ this.cursorRow = next.cursorRow;
642
+ this.cursorCol = next.cursorCol;
643
+ this.modified = true;
644
+ this.clampScroll();
645
+ this.fullRedraw();
646
+ }
647
+ // ── Search ────────────────────────────────────────────────────────────────
648
+ enterSearch() {
649
+ this.mode = "search";
650
+ this.inputBuffer = this.searchState?.query ?? "";
651
+ this.renderStatusBar(`Search: ${this.inputBuffer}`);
652
+ }
653
+ doSearch() {
654
+ // ^R = read file in real nano; reuse as search-next alias here
655
+ this.doSearchNext();
656
+ }
657
+ doSearchNext() {
658
+ if (!this.searchState) {
659
+ this.enterSearch();
660
+ return;
661
+ }
662
+ const { query, caseSensitive } = this.searchState;
663
+ const q = caseSensitive ? query : query.toLowerCase();
664
+ let startRow = this.searchState.row;
665
+ let startCol = this.searchState.col;
666
+ for (let pass = 0; pass < 2; pass++) {
667
+ for (let r = startRow; r < this.lines.length; r++) {
668
+ const line = caseSensitive ? this.lines[r] : this.lines[r].toLowerCase();
669
+ const col = line.indexOf(q, r === startRow ? startCol : 0);
670
+ if (col !== -1) {
671
+ this.cursorRow = r;
672
+ this.cursorCol = col;
673
+ this.searchState.row = r;
674
+ this.searchState.col = col + 1;
675
+ this.clampScroll();
676
+ this.fullRedraw();
677
+ this.renderStatusLine(`Searching for: ${query}`);
678
+ return;
679
+ }
680
+ }
681
+ // Wrap around
682
+ startRow = 0;
683
+ startCol = 0;
684
+ }
685
+ this.mode = "search-confirm";
686
+ this.renderStatusLine(`"${query}" not found`);
687
+ }
688
+ doSearchReplace() {
689
+ // Minimal: just enter search for now
690
+ this.enterSearch();
691
+ }
692
+ // ── Mark ─────────────────────────────────────────────────────────────────
693
+ toggleMark() {
694
+ this.markActive = !this.markActive;
695
+ if (this.markActive) {
696
+ this.renderStatusLine("Mark Set");
697
+ }
698
+ else {
699
+ this.renderStatusLine("Mark Unset");
700
+ }
701
+ }
702
+ // ── Exit / Save ───────────────────────────────────────────────────────────
703
+ doExit() {
704
+ if (this.modified) {
705
+ this.mode = "exit-confirm";
706
+ this.renderStatusBar("Save modified buffer? (Answering \"No\" will DISCARD changes.) Y N");
707
+ return;
708
+ }
709
+ this.onExit("aborted", this.getCurrentContent());
710
+ }
711
+ doSave() {
712
+ // ^S: save without closing (if onSave provided), else fall back to ^O flow
713
+ const content = this.getCurrentContent();
714
+ if (this.onSave) {
715
+ this.modified = false;
716
+ this.onSave(content);
717
+ this.renderStatusLine(`Saved: ${this.filename}`);
718
+ this.renderTitleBar();
719
+ }
720
+ else {
721
+ // No silent-save callback: behave like ^O (prompt filename)
722
+ this.enterWriteout();
723
+ }
724
+ }
725
+ enterWriteout() {
726
+ this.mode = "writeout";
727
+ this.inputBuffer = this.filename;
728
+ this.renderStatusBar(`File Name to Write: ${this.inputBuffer}`);
729
+ }
730
+ showCursorPos() {
731
+ const row = this.cursorRow + 1;
732
+ const col = this.cursorCol + 1;
733
+ const total = this.lines.length;
734
+ const pct = Math.round((row / total) * 100);
735
+ this.renderStatusLine(`line ${row}/${total} (${pct}%), col ${col}`);
736
+ }
737
+ enterGotoLine() {
738
+ this.mode = "goto-line";
739
+ this.inputBuffer = "";
740
+ this.renderStatusBar("Enter line number: ");
741
+ }
742
+ enterHelp() {
743
+ this.mode = "help";
744
+ this.renderHelp();
745
+ }
746
+ // ── Render ────────────────────────────────────────────────────────────────
747
+ get cols() { return Math.max(1, this.terminalSize.cols); }
748
+ get rows() { return Math.max(4, this.terminalSize.rows); }
749
+ editAreaRows() { return this.rows - 3; } // title + 2 help rows
750
+ editAreaStart() { return 2; } // row 1 = title, rows 2..N-2 = edit
751
+ currentLine() { return this.lines[this.cursorRow] ?? ""; }
752
+ clampScroll() {
753
+ const editRows = this.editAreaRows();
754
+ if (this.cursorRow < this.scrollTop) {
755
+ this.scrollTop = this.cursorRow;
756
+ }
757
+ else if (this.cursorRow >= this.scrollTop + editRows) {
758
+ this.scrollTop = this.cursorRow - editRows + 1;
759
+ }
760
+ this.scrollTop = Math.max(0, this.scrollTop);
761
+ }
762
+ getCurrentContent() {
763
+ return `${this.lines.join("\n")}\n`;
764
+ }
765
+ pad(s, width) {
766
+ if (s.length >= width)
767
+ return s.slice(0, width);
768
+ return s + " ".repeat(width - s.length);
769
+ }
770
+ fullRedraw() {
771
+ const buf = [];
772
+ buf.push(ansi.cursorHide());
773
+ buf.push(ansi.ed());
774
+ buf.push(ansi.home());
775
+ this.buildTitleBar(buf);
776
+ this.buildEditArea(buf);
777
+ this.buildHelpBar(buf);
778
+ buf.push(ansi.cursorShow());
779
+ buf.push(this.buildCursorPosition());
780
+ this.stream.write(buf.join(""));
781
+ }
782
+ renderTitleBar() {
783
+ const buf = [];
784
+ buf.push(ansi.cursorHide());
785
+ buf.push(ansi.cup(1, 1));
786
+ this.buildTitleBar(buf);
787
+ buf.push(ansi.cursorShow());
788
+ buf.push(this.buildCursorPosition());
789
+ this.stream.write(buf.join(""));
790
+ }
791
+ renderEditArea() {
792
+ const buf = [];
793
+ buf.push(ansi.cursorHide());
794
+ this.buildEditArea(buf);
795
+ buf.push(ansi.cursorShow());
796
+ buf.push(this.buildCursorPosition());
797
+ this.stream.write(buf.join(""));
798
+ }
799
+ renderLine(row) {
800
+ const screenRow = row - this.scrollTop + this.editAreaStart();
801
+ if (screenRow < this.editAreaStart() || screenRow >= this.editAreaStart() + this.editAreaRows())
802
+ return;
803
+ const buf = [];
804
+ buf.push(ansi.cursorHide());
805
+ buf.push(ansi.cup(screenRow, 1));
806
+ buf.push(ansi.el());
807
+ const line = this.lines[row] ?? "";
808
+ buf.push(this.renderLineText(line));
809
+ buf.push(ansi.cursorShow());
810
+ buf.push(this.buildCursorPosition());
811
+ this.stream.write(buf.join(""));
812
+ }
813
+ renderCursor() {
814
+ this.stream.write(this.buildCursorPosition());
815
+ }
816
+ renderStatusLine(msg) {
817
+ const buf = [];
818
+ buf.push(ansi.cursorHide());
819
+ // Status line is 1 row above the bottom help bar (row = rows - 1)
820
+ buf.push(ansi.cup(this.rows - 1, 1));
821
+ buf.push(ansi.el());
822
+ buf.push(ansi.reverse(this.pad(msg, this.cols)));
823
+ buf.push(ansi.cursorShow());
824
+ buf.push(this.buildCursorPosition());
825
+ this.stream.write(buf.join(""));
826
+ }
827
+ renderStatusBar(msg) {
828
+ // Overwrite the bottom help bar area with the prompt
829
+ const buf = [];
830
+ buf.push(ansi.cursorHide());
831
+ buf.push(ansi.cup(this.rows, 1));
832
+ buf.push(ansi.el());
833
+ buf.push(msg.slice(0, this.cols));
834
+ buf.push(ansi.cursorShow());
835
+ // Keep cursor in status bar
836
+ buf.push(ansi.cup(this.rows, Math.min(msg.length + 1, this.cols)));
837
+ this.stream.write(buf.join(""));
838
+ }
839
+ buildTitleBar(buf) {
840
+ const modMark = this.modified ? "Modified" : "";
841
+ const title = ` GNU nano ${this.filename || "New Buffer"}`;
842
+ const right = modMark;
843
+ const mid = this.pad(title + " ".repeat(Math.max(0, Math.floor((this.cols - title.length - right.length) / 2))), this.cols - right.length);
844
+ const full = this.pad(mid + right, this.cols);
845
+ buf.push(ansi.cup(1, 1));
846
+ buf.push(ansi.reverse(full));
847
+ }
848
+ buildEditArea(buf) {
849
+ const editRows = this.editAreaRows();
850
+ for (let r = 0; r < editRows; r++) {
851
+ const lineIdx = this.scrollTop + r;
852
+ const screenRow = this.editAreaStart() + r;
853
+ buf.push(ansi.cup(screenRow, 1));
854
+ buf.push(ansi.el());
855
+ if (lineIdx < this.lines.length) {
856
+ buf.push(this.renderLineText(this.lines[lineIdx]));
857
+ }
858
+ }
859
+ }
860
+ renderLineText(line) {
861
+ // Expand tabs, truncate to cols
862
+ let result = "";
863
+ let visLen = 0;
864
+ for (let i = 0; i < line.length && visLen < this.cols; i++) {
865
+ if (line[i] === "\t") {
866
+ const spaces = 8 - (visLen % 8);
867
+ const add = Math.min(spaces, this.cols - visLen);
868
+ result += " ".repeat(add);
869
+ visLen += add;
870
+ }
871
+ else {
872
+ result += line[i];
873
+ visLen++;
874
+ }
875
+ }
876
+ return result;
877
+ }
878
+ buildHelpBar(buf) {
879
+ // Two rows at bottom like real nano
880
+ const shortcuts1 = [
881
+ ["^G", "Help"], ["^X", "Exit"], ["^O", "WriteOut"], ["^R", "ReadFile"],
882
+ ["^W", "Where Is"], ["^\\", "Replace"],
883
+ ];
884
+ const shortcuts2 = [
885
+ ["^K", "Cut"], ["^U", "UnCut"], ["^T", "Execute"], ["^J", "Justify"],
886
+ ["^C", "Cur Pos"], ["^/", "Go To Line"],
887
+ ];
888
+ buf.push(ansi.cup(this.rows - 1, 1));
889
+ buf.push(ansi.el());
890
+ buf.push(this.buildShortcutRow(shortcuts1));
891
+ buf.push(ansi.cup(this.rows, 1));
892
+ buf.push(ansi.el());
893
+ buf.push(this.buildShortcutRow(shortcuts2));
894
+ }
895
+ buildShortcutRow(shortcuts) {
896
+ const colWidth = Math.floor(this.cols / (shortcuts.length / 2));
897
+ let result = "";
898
+ for (let i = 0; i < shortcuts.length; i += 2) {
899
+ const key = (shortcuts[i][0] ?? "").padEnd(3);
900
+ const label = shortcuts[i][1] ?? "";
901
+ const key2 = (shortcuts[i + 1]?.[0] ?? "").padEnd(3);
902
+ const label2 = shortcuts[i + 1]?.[1] ?? "";
903
+ const cell = `${ansi.reverse(key)} ${label.padEnd(colWidth - 5)}${ansi.reverse(key2)} ${label2.padEnd(colWidth - 5)}`;
904
+ result += cell;
905
+ if (stripAnsi(result).length >= this.cols)
906
+ break;
907
+ }
908
+ return result;
909
+ }
910
+ buildCursorPosition() {
911
+ // Map cursorCol to screen col (account for tab expansion)
912
+ const line = this.currentLine();
913
+ let screenCol = 0;
914
+ for (let i = 0; i < this.cursorCol && i < line.length; i++) {
915
+ if (line[i] === "\t") {
916
+ screenCol += 8 - (screenCol % 8);
917
+ }
918
+ else {
919
+ screenCol++;
920
+ }
921
+ }
922
+ const screenRow = this.cursorRow - this.scrollTop + this.editAreaStart();
923
+ return ansi.cup(screenRow, screenCol + 1);
924
+ }
925
+ renderHelp() {
926
+ const buf = [];
927
+ buf.push(ansi.cursorHide());
928
+ buf.push(ansi.ed());
929
+ buf.push(ansi.cup(1, 1));
930
+ buf.push(ansi.reverse(this.pad(" GNU nano — Help", this.cols)));
931
+ const help = [
932
+ "",
933
+ "^G This help text",
934
+ "^X Exit nano (prompts if modified)",
935
+ "^O Write file (WriteOut)",
936
+ "^W Search forward (Where Is)",
937
+ "^K Cut current line",
938
+ "^U Uncut / Paste",
939
+ "^C Show cursor position",
940
+ "^_ Go to line number",
941
+ "Alt+U Undo",
942
+ "Alt+E Redo",
943
+ "Alt+A Toggle mark",
944
+ "",
945
+ "Arrows / PgUp / PgDn / Home / End: navigation",
946
+ "",
947
+ "Press any key to return...",
948
+ ];
949
+ for (let i = 0; i < help.length && i + 2 <= this.rows - 2; i++) {
950
+ buf.push(ansi.cup(i + 2, 1));
951
+ buf.push(help[i].slice(0, this.cols));
952
+ }
953
+ buf.push(ansi.cursorShow());
954
+ this.stream.write(buf.join(""));
955
+ }
956
+ }