@vemjs/core 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/src/editor.ts ADDED
@@ -0,0 +1,928 @@
1
+ import type { Position, EditorMode } from './index';
2
+ import { VimBuffer, UndoManager } from './buffer';
3
+ import { getWordForward, getWordBackward, getWordEndForward, getTextObjectRange } from './motions';
4
+ import { parseKeys } from './parser';
5
+ import type { ParsedCommand } from './parser';
6
+
7
+ export interface RegisterContent {
8
+ text: string;
9
+ type: 'char' | 'line' | 'block';
10
+ }
11
+
12
+ export type DiagnosticSeverity = 'error' | 'warning' | 'info' | 'hint';
13
+
14
+ export interface Diagnostic {
15
+ /** Zero-based line number */
16
+ line: number;
17
+ /** Zero-based start character */
18
+ startCharacter: number;
19
+ /** Zero-based end character */
20
+ endCharacter: number;
21
+ severity: DiagnosticSeverity;
22
+ message: string;
23
+ /** Optional: source language server name, e.g. 'tsserver' */
24
+ source?: string;
25
+ }
26
+
27
+ export type VisualType = 'char' | 'line' | 'block';
28
+
29
+ export interface VisualSelection {
30
+ type: VisualType;
31
+ anchor: Position;
32
+ active: Position;
33
+ }
34
+
35
+ export class VemEditorState {
36
+ private mode: EditorMode = 'NORMAL';
37
+ private cursor: Position = { line: 0, character: 0 };
38
+ private desiredCol: number = 0;
39
+ private buffer: VimBuffer;
40
+ private undoManager: UndoManager;
41
+ private register: RegisterContent | null = null;
42
+ private pendingKeys: string[] = [];
43
+ private visualSelection: VisualSelection | null = null;
44
+ private isInsertMutated = false;
45
+ private changeCallbacks: (() => void)[] = [];
46
+ private commandText = '';
47
+ private saveCallbacks: (() => void)[] = [];
48
+ private quitCallbacks: (() => void)[] = [];
49
+ private splitCallbacks: ((direction: 'horizontal' | 'vertical') => void)[] = [];
50
+ private customKeybindings: Map<EditorMode, Map<string, string>> = new Map();
51
+ private didOpenBufferCallbacks: (() => void)[] = [];
52
+ private changeBufferCallbacks: (() => void)[] = [];
53
+ private changeModeCallbacks: ((mode: EditorMode) => void)[] = [];
54
+ private pluginCommandCallbacks: ((commandName: string) => void)[] = [];
55
+ private diagnostics: Diagnostic[] = [];
56
+ private publishDiagnosticsCallbacks: ((diagnostics: Diagnostic[]) => void)[] = [];
57
+
58
+ constructor(initialText?: string) {
59
+ this.buffer = new VimBuffer(initialText);
60
+ this.undoManager = new UndoManager();
61
+ this.buffer.onChange(() => {
62
+ this.triggerChangeBuffer();
63
+ });
64
+ setTimeout(() => {
65
+ this.triggerDidOpenBuffer();
66
+ }, 0);
67
+ }
68
+
69
+ // --- Callbacks & Events ---
70
+ public onChange(callback: () => void): void {
71
+ this.changeCallbacks.push(callback);
72
+ }
73
+
74
+ private triggerChange(): void {
75
+ for (const cb of this.changeCallbacks) {
76
+ cb();
77
+ }
78
+ }
79
+
80
+ public onSave(callback: () => void): void {
81
+ this.saveCallbacks.push(callback);
82
+ }
83
+
84
+ public onQuit(callback: () => void): void {
85
+ this.quitCallbacks.push(callback);
86
+ }
87
+
88
+ public onSplit(callback: (direction: 'horizontal' | 'vertical') => void): void {
89
+ this.splitCallbacks.push(callback);
90
+ }
91
+
92
+ private triggerSave(): void {
93
+ for (const cb of this.saveCallbacks) {
94
+ cb();
95
+ }
96
+ }
97
+
98
+ private triggerQuit(): void {
99
+ for (const cb of this.quitCallbacks) {
100
+ cb();
101
+ }
102
+ }
103
+
104
+ private triggerSplit(direction: 'horizontal' | 'vertical'): void {
105
+ for (const cb of this.splitCallbacks) {
106
+ cb(direction);
107
+ }
108
+ }
109
+
110
+ public registerKeybinding(mode: EditorMode, keys: string, commandName: string): void {
111
+ if (!this.customKeybindings.has(mode)) {
112
+ this.customKeybindings.set(mode, new Map());
113
+ }
114
+ this.customKeybindings.get(mode)!.set(keys, commandName);
115
+ }
116
+
117
+ public onDidOpenBuffer(callback: () => void): void {
118
+ this.didOpenBufferCallbacks.push(callback);
119
+ }
120
+
121
+ public onDidChangeBuffer(callback: () => void): void {
122
+ this.changeBufferCallbacks.push(callback);
123
+ }
124
+
125
+ public onDidChangeMode(callback: (mode: EditorMode) => void): void {
126
+ this.changeModeCallbacks.push(callback);
127
+ }
128
+
129
+ public onExecutePluginCommand(callback: (commandName: string) => void): void {
130
+ this.pluginCommandCallbacks.push(callback);
131
+ }
132
+
133
+ public onPublishDiagnostics(callback: (diagnostics: Diagnostic[]) => void): void {
134
+ this.publishDiagnosticsCallbacks.push(callback);
135
+ }
136
+
137
+ public setDiagnostics(diagnostics: Diagnostic[]): void {
138
+ this.diagnostics = diagnostics;
139
+ for (const cb of this.publishDiagnosticsCallbacks) {
140
+ cb(this.diagnostics);
141
+ }
142
+ // Trigger a render-change so renderers can repaint highlight overlays
143
+ for (const cb of this.changeCallbacks) {
144
+ cb();
145
+ }
146
+ }
147
+
148
+ public getDiagnostics(): Diagnostic[] {
149
+ return this.diagnostics;
150
+ }
151
+
152
+ private triggerDidOpenBuffer(): void {
153
+ for (const cb of this.didOpenBufferCallbacks) {
154
+ cb();
155
+ }
156
+ }
157
+
158
+ private triggerChangeBuffer(): void {
159
+ for (const cb of this.changeBufferCallbacks) {
160
+ cb();
161
+ }
162
+ }
163
+
164
+ private triggerChangeMode(mode: EditorMode): void {
165
+ for (const cb of this.changeModeCallbacks) {
166
+ cb(mode);
167
+ }
168
+ }
169
+
170
+ private executePluginCommand(commandName: string): void {
171
+ for (const cb of this.pluginCommandCallbacks) {
172
+ cb(commandName);
173
+ }
174
+ }
175
+
176
+ // --- Getters & Setters ---
177
+ public getMode(): EditorMode {
178
+ return this.mode;
179
+ }
180
+
181
+ public getText(): string {
182
+ return this.buffer.getText();
183
+ }
184
+
185
+ public setMode(mode: EditorMode): void {
186
+ if (this.mode === mode) return;
187
+
188
+ // Handle exiting insert mode
189
+ if (this.mode === 'INSERT') {
190
+ this.isInsertMutated = false;
191
+ }
192
+
193
+ this.mode = mode;
194
+
195
+ // Initialize visual selection if entering visual mode
196
+ if (mode === 'VISUAL') {
197
+ this.visualSelection = {
198
+ type: 'char',
199
+ anchor: { ...this.cursor },
200
+ active: { ...this.cursor },
201
+ };
202
+ } else {
203
+ this.visualSelection = null;
204
+ }
205
+
206
+ this.triggerChangeMode(mode);
207
+ this.triggerChange();
208
+ }
209
+
210
+ public getCursor(): Position {
211
+ return { ...this.cursor };
212
+ }
213
+
214
+ public getBuffer(): VimBuffer {
215
+ return this.buffer;
216
+ }
217
+
218
+ public getPendingKeys(): string[] {
219
+ return [...this.pendingKeys];
220
+ }
221
+
222
+ public getVisualSelection(): VisualSelection | null {
223
+ return this.visualSelection;
224
+ }
225
+
226
+ public getRegister(): RegisterContent | null {
227
+ return this.register;
228
+ }
229
+
230
+ public getCommandText(): string {
231
+ return this.commandText;
232
+ }
233
+
234
+ // --- Key Input Entry Point ---
235
+ public handleKey(key: string): void {
236
+ if (this.mode === 'COMMAND') {
237
+ if (key === 'Escape') {
238
+ this.setMode('NORMAL');
239
+ this.commandText = '';
240
+ this.triggerChange();
241
+ return;
242
+ }
243
+ if (key === 'Enter') {
244
+ this.executeCommandLineText(this.commandText);
245
+ this.setMode('NORMAL');
246
+ this.commandText = '';
247
+ this.triggerChange();
248
+ return;
249
+ }
250
+ if (key === 'Backspace') {
251
+ this.commandText = this.commandText.substring(0, this.commandText.length - 1);
252
+ this.triggerChange();
253
+ return;
254
+ }
255
+ if (key.length === 1) {
256
+ this.commandText += key;
257
+ this.triggerChange();
258
+ return;
259
+ }
260
+ return;
261
+ }
262
+
263
+ // Check custom keybindings
264
+ const modeBindings = this.customKeybindings.get(this.mode);
265
+ if (modeBindings) {
266
+ const currentSequence = [...this.pendingKeys, key].join('');
267
+ let hasExactMatch = false;
268
+ let hasPartialMatch = false;
269
+ let matchedCommand = '';
270
+
271
+ for (const [keys, cmd] of modeBindings.entries()) {
272
+ if (keys === currentSequence) {
273
+ hasExactMatch = true;
274
+ matchedCommand = cmd;
275
+ } else if (keys.startsWith(currentSequence)) {
276
+ hasPartialMatch = true;
277
+ }
278
+ }
279
+
280
+ if (hasExactMatch && !hasPartialMatch) {
281
+ this.pendingKeys = [];
282
+ this.executePluginCommand(matchedCommand);
283
+ this.triggerChange();
284
+ return;
285
+ }
286
+
287
+ if (hasPartialMatch) {
288
+ this.pendingKeys.push(key);
289
+ this.triggerChange();
290
+ return;
291
+ }
292
+
293
+ if (this.pendingKeys.length > 0) {
294
+ const keysToReplay = [...this.pendingKeys, key];
295
+ this.pendingKeys = [];
296
+ for (const k of keysToReplay) {
297
+ this.handleKeyStandard(k);
298
+ }
299
+ return;
300
+ }
301
+ }
302
+
303
+ this.handleKeyStandard(key);
304
+ }
305
+
306
+ private handleKeyStandard(key: string): void {
307
+ if (this.mode === 'INSERT') {
308
+ if (key === 'Escape') {
309
+ this.setMode('NORMAL');
310
+ // Move cursor back one character in Normal mode
311
+ this.cursor.character = Math.max(0, this.cursor.character - 1);
312
+ this.desiredCol = this.cursor.character;
313
+ this.triggerChange();
314
+ return;
315
+ }
316
+ if (key === 'Backspace') {
317
+ this.handleBackspaceInInsert();
318
+ this.triggerChange();
319
+ return;
320
+ }
321
+ if (key === 'Enter') {
322
+ this.handleEnterInInsert();
323
+ this.triggerChange();
324
+ return;
325
+ }
326
+ if (key.length === 1) {
327
+ this.handleCharInputInInsert(key);
328
+ this.triggerChange();
329
+ return;
330
+ }
331
+ return;
332
+ }
333
+
334
+ // Normal or Visual Mode
335
+ if (key === 'Escape') {
336
+ this.pendingKeys = [];
337
+ if (this.mode === 'VISUAL') {
338
+ this.setMode('NORMAL');
339
+ }
340
+ this.triggerChange();
341
+ return;
342
+ }
343
+
344
+ this.pendingKeys.push(key);
345
+ const parsed = parseKeys(this.pendingKeys, this.mode);
346
+
347
+ if (!parsed.isValid) {
348
+ this.pendingKeys = [];
349
+ return;
350
+ }
351
+
352
+ if (parsed.isComplete) {
353
+ this.pendingKeys = [];
354
+ this.executeCommand(parsed);
355
+ this.triggerChange();
356
+ }
357
+ }
358
+
359
+ // --- Insertion Helpers ---
360
+ private handleCharInputInInsert(char: string): void {
361
+ if (!this.isInsertMutated) {
362
+ this.saveStateForUndo();
363
+ this.isInsertMutated = true;
364
+ }
365
+ const line = this.buffer.getLine(this.cursor.line);
366
+ const before = line.substring(0, this.cursor.character);
367
+ const after = line.substring(this.cursor.character);
368
+ this.buffer.setLine(this.cursor.line, before + char + after);
369
+ this.cursor.character++;
370
+ this.desiredCol = this.cursor.character;
371
+ }
372
+
373
+ private handleBackspaceInInsert(): void {
374
+ if (!this.isInsertMutated) {
375
+ this.saveStateForUndo();
376
+ this.isInsertMutated = true;
377
+ }
378
+ if (this.cursor.character > 0) {
379
+ const line = this.buffer.getLine(this.cursor.line);
380
+ const before = line.substring(0, this.cursor.character - 1);
381
+ const after = line.substring(this.cursor.character);
382
+ this.buffer.setLine(this.cursor.line, before + after);
383
+ this.cursor.character--;
384
+ this.desiredCol = this.cursor.character;
385
+ } else if (this.cursor.line > 0) {
386
+ const prevLine = this.buffer.getLine(this.cursor.line - 1);
387
+ const currLine = this.buffer.getLine(this.cursor.line);
388
+ this.buffer.setLine(this.cursor.line - 1, prevLine + currLine);
389
+ this.buffer.deleteLines(this.cursor.line, this.cursor.line);
390
+ this.cursor.line--;
391
+ this.cursor.character = prevLine.length;
392
+ this.desiredCol = this.cursor.character;
393
+ }
394
+ }
395
+
396
+ private handleEnterInInsert(): void {
397
+ if (!this.isInsertMutated) {
398
+ this.saveStateForUndo();
399
+ this.isInsertMutated = true;
400
+ }
401
+ const line = this.buffer.getLine(this.cursor.line);
402
+ const before = line.substring(0, this.cursor.character);
403
+ const after = line.substring(this.cursor.character);
404
+
405
+ this.buffer.setLine(this.cursor.line, before);
406
+ this.buffer.insertLine(this.cursor.line + 1, after);
407
+ this.cursor.line++;
408
+ this.cursor.character = 0;
409
+ this.desiredCol = 0;
410
+ }
411
+
412
+ // --- Command Execution ---
413
+ private executeCommand(cmd: ParsedCommand): void {
414
+ if (this.mode === 'VISUAL') {
415
+ this.executeVisualCommand(cmd);
416
+ return;
417
+ }
418
+
419
+ // NORMAL mode commands
420
+ if (cmd.operator) {
421
+ this.executeOperatorCommand(cmd);
422
+ return;
423
+ }
424
+
425
+ if (cmd.motion) {
426
+ this.moveCursorByMotion(cmd.motion, cmd.count);
427
+ return;
428
+ }
429
+
430
+ if (cmd.command) {
431
+ switch (cmd.command) {
432
+ case 'i':
433
+ this.setMode('INSERT');
434
+ break;
435
+ case 'I':
436
+ this.moveToFirstNonWhitespace();
437
+ this.setMode('INSERT');
438
+ break;
439
+ case 'a':
440
+ this.moveCursorRightForInsert();
441
+ this.setMode('INSERT');
442
+ break;
443
+ case 'A':
444
+ this.moveToEndOfLine();
445
+ this.setMode('INSERT');
446
+ break;
447
+ case 'o':
448
+ this.saveStateForUndo();
449
+ this.buffer.insertLine(this.cursor.line + 1, '');
450
+ this.cursor.line++;
451
+ this.cursor.character = 0;
452
+ this.desiredCol = 0;
453
+ this.setMode('INSERT');
454
+ break;
455
+ case 'O':
456
+ this.saveStateForUndo();
457
+ this.buffer.insertLine(this.cursor.line, '');
458
+ this.cursor.character = 0;
459
+ this.desiredCol = 0;
460
+ this.setMode('INSERT');
461
+ break;
462
+ case 'x':
463
+ this.saveStateForUndo();
464
+ this.deleteCharUnderCursor(cmd.count);
465
+ break;
466
+ case 'u':
467
+ this.undo();
468
+ break;
469
+ case '<C-r>':
470
+ this.redo();
471
+ break;
472
+ case 'v':
473
+ this.setMode('VISUAL');
474
+ if (this.visualSelection) this.visualSelection.type = 'char';
475
+ break;
476
+ case 'V':
477
+ this.setMode('VISUAL');
478
+ if (this.visualSelection) this.visualSelection.type = 'line';
479
+ break;
480
+ case '<C-v>':
481
+ this.setMode('VISUAL');
482
+ if (this.visualSelection) this.visualSelection.type = 'block';
483
+ break;
484
+ case 'p':
485
+ this.saveStateForUndo();
486
+ this.paste(false);
487
+ break;
488
+ case 'P':
489
+ this.saveStateForUndo();
490
+ this.paste(true);
491
+ break;
492
+ case ':':
493
+ this.setMode('COMMAND');
494
+ this.commandText = '';
495
+ break;
496
+ }
497
+ }
498
+ }
499
+
500
+ private executeCommandLineText(cmd: string): void {
501
+ if (cmd === 'w') {
502
+ this.triggerSave();
503
+ } else if (cmd === 'q') {
504
+ this.triggerQuit();
505
+ } else if (cmd === 'vsp') {
506
+ this.triggerSplit('vertical');
507
+ } else if (cmd === 'sp') {
508
+ this.triggerSplit('horizontal');
509
+ }
510
+ }
511
+
512
+ private executeVisualCommand(cmd: ParsedCommand): void {
513
+ if (cmd.motion) {
514
+ this.moveCursorByMotion(cmd.motion, cmd.count);
515
+ if (this.visualSelection) {
516
+ this.visualSelection.active = { ...this.cursor };
517
+ }
518
+ return;
519
+ }
520
+
521
+ const op = cmd.command || cmd.operator;
522
+ if (op === 'd' || op === 'x' || op === 'c' || op === 'y') {
523
+ this.saveStateForUndo();
524
+ this.operateOnVisualSelection(op);
525
+ if (op === 'c') {
526
+ this.setMode('INSERT');
527
+ } else {
528
+ this.setMode('NORMAL');
529
+ }
530
+ }
531
+ }
532
+
533
+ // --- Motions Execution ---
534
+ private moveCursorByMotion(motion: string, count: number): void {
535
+ for (let i = 0; i < count; i++) {
536
+ switch (motion) {
537
+ case 'h':
538
+ this.cursor.character = Math.max(0, this.cursor.character - 1);
539
+ this.desiredCol = this.cursor.character;
540
+ break;
541
+ case 'l': {
542
+ const lineLen = this.buffer.getLine(this.cursor.line).length;
543
+ const maxChar = this.mode === 'INSERT' ? lineLen : Math.max(0, lineLen - 1);
544
+ this.cursor.character = Math.min(maxChar, this.cursor.character + 1);
545
+ this.desiredCol = this.cursor.character;
546
+ break;
547
+ }
548
+ case 'j':
549
+ if (this.cursor.line < this.buffer.getLineCount() - 1) {
550
+ this.cursor.line++;
551
+ const lineLen = this.buffer.getLine(this.cursor.line).length;
552
+ const maxChar = this.mode === 'INSERT' ? lineLen : Math.max(0, lineLen - 1);
553
+ this.cursor.character = Math.min(maxChar, this.desiredCol);
554
+ }
555
+ break;
556
+ case 'k':
557
+ if (this.cursor.line > 0) {
558
+ this.cursor.line--;
559
+ const lineLen = this.buffer.getLine(this.cursor.line).length;
560
+ const maxChar = this.mode === 'INSERT' ? lineLen : Math.max(0, lineLen - 1);
561
+ this.cursor.character = Math.min(maxChar, this.desiredCol);
562
+ }
563
+ break;
564
+ case 'w':
565
+ this.cursor = getWordForward(this.buffer, this.cursor);
566
+ this.desiredCol = this.cursor.character;
567
+ break;
568
+ case 'b':
569
+ this.cursor = getWordBackward(this.buffer, this.cursor);
570
+ this.desiredCol = this.cursor.character;
571
+ break;
572
+ case 'e':
573
+ this.cursor = getWordEndForward(this.buffer, this.cursor);
574
+ this.desiredCol = this.cursor.character;
575
+ break;
576
+ case '0':
577
+ this.cursor.character = 0;
578
+ this.desiredCol = 0;
579
+ break;
580
+ case '$': {
581
+ const lineLen = this.buffer.getLine(this.cursor.line).length;
582
+ this.cursor.character = Math.max(0, lineLen - 1);
583
+ this.desiredCol = Infinity;
584
+ break;
585
+ }
586
+ case 'gg':
587
+ this.cursor.line = 0;
588
+ this.cursor.character = 0;
589
+ this.desiredCol = 0;
590
+ break;
591
+ case 'G':
592
+ this.cursor.line = this.buffer.getLineCount() - 1;
593
+ this.cursor.character = 0;
594
+ this.desiredCol = 0;
595
+ break;
596
+ }
597
+ }
598
+ }
599
+
600
+ // --- Operator Command Execution ---
601
+ private executeOperatorCommand(cmd: ParsedCommand): void {
602
+ const op = cmd.operator!;
603
+ const count = cmd.count;
604
+
605
+ // Double operator e.g. dd, cc, yy
606
+ if (cmd.command === op + op) {
607
+ this.saveStateForUndo();
608
+ const startLine = this.cursor.line;
609
+ const endLine = Math.min(this.buffer.getLineCount() - 1, this.cursor.line + count - 1);
610
+
611
+ // Extract text for yank
612
+ const yankedLines: string[] = [];
613
+ for (let l = startLine; l <= endLine; l++) {
614
+ yankedLines.push(this.buffer.getLine(l));
615
+ }
616
+ this.register = {
617
+ text: yankedLines.join('\n') + '\n',
618
+ type: 'line',
619
+ };
620
+
621
+ if (op === 'd' || op === 'c') {
622
+ this.buffer.deleteLines(startLine, endLine);
623
+ this.cursor.line = Math.min(this.cursor.line, this.buffer.getLineCount() - 1);
624
+ this.cursor.character = 0;
625
+ this.desiredCol = 0;
626
+ if (op === 'c') {
627
+ this.buffer.insertLine(this.cursor.line, '');
628
+ this.setMode('INSERT');
629
+ }
630
+ }
631
+ return;
632
+ }
633
+
634
+ // Motions or Text Objects
635
+ let range: { start: Position; end: Position } | null = null;
636
+ let isLineWise = false;
637
+
638
+ if (cmd.textObject) {
639
+ range = getTextObjectRange(this.buffer, this.cursor, cmd.textObject);
640
+ } else if (cmd.motion) {
641
+ const startPos = { ...this.cursor };
642
+ this.moveCursorByMotion(cmd.motion, count);
643
+ const endPos = { ...this.cursor };
644
+ this.cursor = { ...startPos }; // Restore cursor before operation
645
+
646
+ range = { start: startPos, end: endPos };
647
+
648
+ // Line-wise motion check
649
+ if (cmd.motion === 'j' || cmd.motion === 'k' || cmd.motion === 'gg' || cmd.motion === 'G') {
650
+ isLineWise = true;
651
+ }
652
+ }
653
+
654
+ if (!range) return;
655
+
656
+ this.saveStateForUndo();
657
+
658
+ // Ensure start is before end
659
+ let s = { ...range.start };
660
+ let e = { ...range.end };
661
+ if (s.line > e.line || (s.line === e.line && s.character > e.character)) {
662
+ const temp = s;
663
+ s = e;
664
+ e = temp;
665
+ }
666
+
667
+ if (isLineWise) {
668
+ const yankedLines: string[] = [];
669
+ for (let l = s.line; l <= e.line; l++) {
670
+ yankedLines.push(this.buffer.getLine(l));
671
+ }
672
+ this.register = {
673
+ text: yankedLines.join('\n') + '\n',
674
+ type: 'line',
675
+ };
676
+
677
+ if (op === 'd' || op === 'c') {
678
+ this.buffer.deleteLines(s.line, e.line);
679
+ this.cursor.line = Math.min(s.line, this.buffer.getLineCount() - 1);
680
+ this.cursor.character = 0;
681
+ this.desiredCol = 0;
682
+ if (op === 'c') {
683
+ this.buffer.insertLine(this.cursor.line, '');
684
+ this.setMode('INSERT');
685
+ }
686
+ }
687
+ } else {
688
+ // Character-wise operation
689
+ // Inclusive vs Exclusive: motions like $ or text objects are inclusive. Others are exclusive.
690
+ // E.g. 'dw' deletes from start to start of next word (exclusive).
691
+ // 'd$' deletes to end of line (inclusive).
692
+ let isInclusive = cmd.textObject !== undefined || cmd.motion === '$';
693
+
694
+ const startLineText = this.buffer.getLine(s.line);
695
+ const endLineText = this.buffer.getLine(e.line);
696
+
697
+ let yankText = '';
698
+ if (s.line === e.line) {
699
+ yankText = startLineText.substring(s.character, e.character + (isInclusive ? 1 : 0));
700
+ } else {
701
+ yankText = startLineText.substring(s.character) + '\n';
702
+ for (let l = s.line + 1; l < e.line; l++) {
703
+ yankText += this.buffer.getLine(l) + '\n';
704
+ }
705
+ yankText += endLineText.substring(0, e.character + (isInclusive ? 1 : 0));
706
+ }
707
+
708
+ this.register = {
709
+ text: yankText,
710
+ type: 'char',
711
+ };
712
+
713
+ if (op === 'd' || op === 'c') {
714
+ const deleteEnd = { ...e };
715
+ if (isInclusive) {
716
+ deleteEnd.character++;
717
+ }
718
+ this.buffer.deleteRange(s, deleteEnd);
719
+ this.cursor = { ...s };
720
+ this.desiredCol = this.cursor.character;
721
+ if (op === 'c') {
722
+ this.setMode('INSERT');
723
+ }
724
+ }
725
+ }
726
+ }
727
+
728
+ // --- Visual Mode Operations ---
729
+ private operateOnVisualSelection(op: string): void {
730
+ if (!this.visualSelection) return;
731
+
732
+ const { type, anchor, active } = this.visualSelection;
733
+ let s = { ...anchor };
734
+ let e = { ...active };
735
+
736
+ // Standard ordering
737
+ if (s.line > e.line || (s.line === e.line && s.character > e.character)) {
738
+ const temp = s;
739
+ s = e;
740
+ e = temp;
741
+ }
742
+
743
+ if (type === 'line') {
744
+ const startLine = Math.min(s.line, e.line);
745
+ const endLine = Math.max(s.line, e.line);
746
+
747
+ const yankedLines: string[] = [];
748
+ for (let l = startLine; l <= endLine; l++) {
749
+ yankedLines.push(this.buffer.getLine(l));
750
+ }
751
+ this.register = {
752
+ text: yankedLines.join('\n') + '\n',
753
+ type: 'line',
754
+ };
755
+
756
+ if (op === 'd' || op === 'c') {
757
+ this.buffer.deleteLines(startLine, endLine);
758
+ this.cursor.line = Math.min(startLine, this.buffer.getLineCount() - 1);
759
+ this.cursor.character = 0;
760
+ this.desiredCol = 0;
761
+ if (op === 'c') {
762
+ this.buffer.insertLine(this.cursor.line, '');
763
+ }
764
+ }
765
+ } else if (type === 'char') {
766
+ const startLineText = this.buffer.getLine(s.line);
767
+ const endLineText = this.buffer.getLine(e.line);
768
+
769
+ let yankText = '';
770
+ if (s.line === e.line) {
771
+ yankText = startLineText.substring(s.character, e.character + 1);
772
+ } else {
773
+ yankText = startLineText.substring(s.character) + '\n';
774
+ for (let l = s.line + 1; l < e.line; l++) {
775
+ yankText += this.buffer.getLine(l) + '\n';
776
+ }
777
+ yankText += endLineText.substring(0, e.character + 1);
778
+ }
779
+
780
+ this.register = {
781
+ text: yankText,
782
+ type: 'char',
783
+ };
784
+
785
+ if (op === 'd' || op === 'c') {
786
+ const deleteEnd = { ...e };
787
+ deleteEnd.character++; // Inclusive delete
788
+ this.buffer.deleteRange(s, deleteEnd);
789
+ this.cursor = { ...s };
790
+ this.desiredCol = this.cursor.character;
791
+ }
792
+ } else if (type === 'block') {
793
+ // Block-wise selection yanks/deletes character rectangles
794
+ const minCol = Math.min(anchor.character, active.character);
795
+ const maxCol = Math.max(anchor.character, active.character);
796
+ const minLine = Math.min(anchor.line, active.line);
797
+ const maxLine = Math.max(anchor.line, active.line);
798
+
799
+ const yankedBlocks: string[] = [];
800
+ for (let l = minLine; l <= maxLine; l++) {
801
+ const lineText = this.buffer.getLine(l);
802
+ const chunk = lineText.substring(minCol, maxCol + 1);
803
+ yankedBlocks.push(chunk);
804
+ }
805
+
806
+ this.register = {
807
+ text: yankedBlocks.join('\n'),
808
+ type: 'block',
809
+ };
810
+
811
+ if (op === 'd' || op === 'c') {
812
+ for (let l = minLine; l <= maxLine; l++) {
813
+ const lineText = this.buffer.getLine(l);
814
+ const before = lineText.substring(0, minCol);
815
+ const after = lineText.substring(maxCol + 1);
816
+ this.buffer.setLine(l, before + after);
817
+ }
818
+ this.cursor = { line: minLine, character: minCol };
819
+ this.desiredCol = minCol;
820
+ }
821
+ }
822
+ }
823
+
824
+ // --- Editing Actions ---
825
+ private moveToFirstNonWhitespace(): void {
826
+ const line = this.buffer.getLine(this.cursor.line);
827
+ const match = /^[ \t]*/.exec(line);
828
+ const index = match ? match[0].length : 0;
829
+ this.cursor.character = Math.min(index, Math.max(0, line.length - 1));
830
+ this.desiredCol = this.cursor.character;
831
+ }
832
+
833
+ private moveCursorRightForInsert(): void {
834
+ const lineLen = this.buffer.getLine(this.cursor.line).length;
835
+ this.cursor.character = Math.min(lineLen, this.cursor.character + 1);
836
+ this.desiredCol = this.cursor.character;
837
+ }
838
+
839
+ private moveToEndOfLine(): void {
840
+ const lineLen = this.buffer.getLine(this.cursor.line).length;
841
+ this.cursor.character = lineLen;
842
+ this.desiredCol = Infinity;
843
+ }
844
+
845
+ private deleteCharUnderCursor(count: number): void {
846
+ const line = this.buffer.getLine(this.cursor.line);
847
+ if (line.length === 0) return;
848
+
849
+ const deleteCount = Math.min(count, line.length - this.cursor.character);
850
+ const deletedText = line.substring(this.cursor.character, this.cursor.character + deleteCount);
851
+
852
+ this.register = {
853
+ text: deletedText,
854
+ type: 'char',
855
+ };
856
+
857
+ const before = line.substring(0, this.cursor.character);
858
+ const after = line.substring(this.cursor.character + deleteCount);
859
+ this.buffer.setLine(this.cursor.line, before + after);
860
+
861
+ // Adjust cursor if it's past the end of the line in Normal mode
862
+ const newLineLen = this.buffer.getLine(this.cursor.line).length;
863
+ this.cursor.character = Math.min(this.cursor.character, Math.max(0, newLineLen - 1));
864
+ this.desiredCol = this.cursor.character;
865
+ }
866
+
867
+ private paste(before: boolean): void {
868
+ if (!this.register) return;
869
+
870
+ const { text, type } = this.register;
871
+
872
+ if (type === 'line') {
873
+ const targetLine = before ? this.cursor.line : this.cursor.line + 1;
874
+ const pasteLines = text.split('\n');
875
+ // If trailing newline, split leaves empty string at end, remove it
876
+ if (pasteLines[pasteLines.length - 1] === '') {
877
+ pasteLines.pop();
878
+ }
879
+ for (let i = 0; i < pasteLines.length; i++) {
880
+ this.buffer.insertLine(targetLine + i, pasteLines[i]);
881
+ }
882
+ this.cursor.line = targetLine;
883
+ this.cursor.character = 0;
884
+ this.desiredCol = 0;
885
+ } else {
886
+ // Character-wise or block-wise paste (block-wise simple fallback to char paste)
887
+ const line = this.buffer.getLine(this.cursor.line);
888
+ const insertIdx = before
889
+ ? this.cursor.character
890
+ : Math.min(line.length, this.cursor.character + 1);
891
+
892
+ const endPos = this.buffer.insertText({ line: this.cursor.line, character: insertIdx }, text);
893
+ this.cursor = endPos;
894
+ // In Normal mode, leave cursor on last pasted character
895
+ this.cursor.character = Math.max(0, this.cursor.character - 1);
896
+ this.desiredCol = this.cursor.character;
897
+ }
898
+ }
899
+
900
+ // --- Undo & Redo ---
901
+ private saveStateForUndo(): void {
902
+ this.undoManager.push(this.buffer.getLines());
903
+ }
904
+
905
+ public undo(): void {
906
+ const prevState = this.undoManager.undo(this.buffer.getLines());
907
+ if (prevState) {
908
+ this.buffer.setLines(prevState);
909
+ // Clamp cursor
910
+ this.cursor.line = Math.min(this.cursor.line, this.buffer.getLineCount() - 1);
911
+ const lineLen = this.buffer.getLine(this.cursor.line).length;
912
+ this.cursor.character = Math.min(this.cursor.character, Math.max(0, lineLen - 1));
913
+ this.desiredCol = this.cursor.character;
914
+ }
915
+ }
916
+
917
+ public redo(): void {
918
+ const nextState = this.undoManager.redo(this.buffer.getLines());
919
+ if (nextState) {
920
+ this.buffer.setLines(nextState);
921
+ // Clamp cursor
922
+ this.cursor.line = Math.min(this.cursor.line, this.buffer.getLineCount() - 1);
923
+ const lineLen = this.buffer.getLine(this.cursor.line).length;
924
+ this.cursor.character = Math.min(this.cursor.character, Math.max(0, lineLen - 1));
925
+ this.desiredCol = this.cursor.character;
926
+ }
927
+ }
928
+ }