cliedit 0.4.0 → 0.5.1
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/README.md +12 -2
- package/dist/constants.d.ts +1 -0
- package/dist/constants.js +1 -0
- package/dist/editor.clipboard.js +4 -3
- package/dist/editor.d.ts +8 -4
- package/dist/editor.editing.js +0 -5
- package/dist/editor.history.d.ts +1 -1
- package/dist/editor.history.js +1 -1
- package/dist/editor.io.js +1 -0
- package/dist/editor.js +40 -5
- package/dist/editor.keys.js +52 -40
- package/dist/editor.navigation.d.ts +3 -2
- package/dist/editor.navigation.js +60 -63
- package/dist/editor.rendering.d.ts +30 -4
- package/dist/editor.rendering.js +166 -106
- package/dist/editor.search.js +0 -2
- package/dist/editor.selection.js +2 -2
- package/dist/editor.swap.js +2 -1
- package/dist/editor.syntax.js +26 -62
- package/dist/index.d.ts +1 -1
- package/dist/index.js +2 -1
- package/dist/screen_buffer.d.ts +14 -0
- package/dist/screen_buffer.js +120 -0
- package/dist/syntax.common.d.ts +6 -0
- package/dist/syntax.common.js +62 -0
- package/dist/syntax.worker.d.ts +1 -0
- package/dist/syntax.worker.js +10 -0
- package/dist/types.d.ts +1 -9
- package/package.json +10 -3
package/README.md
CHANGED
|
@@ -6,6 +6,8 @@ A lightweight, zero-dependency, raw-mode terminal editor component for Node.js.
|
|
|
6
6
|
|
|
7
7
|
It includes line wrapping, visual navigation, smart auto-indentation, undo/redo, text selection, Find/Replace, and cross-platform clipboard support.
|
|
8
8
|
|
|
9
|
+
A **CodeTease** project.
|
|
10
|
+
|
|
9
11
|
## Features
|
|
10
12
|
|
|
11
13
|
- **Raw Mode TTY:** Takes over the terminal for a full "app-like" feel.
|
|
@@ -26,10 +28,19 @@ It includes line wrapping, visual navigation, smart auto-indentation, undo/redo,
|
|
|
26
28
|
- **Piping Support:** Works with standard Unix pipes (e.g. `cat file.txt | cliedit`).
|
|
27
29
|
- **Crash Recovery:** Automatically saves changes to a hidden swap file (e.g. `.filename.swp`) to prevent data loss.
|
|
28
30
|
|
|
31
|
+
### Architecture Improvements
|
|
32
|
+
|
|
33
|
+
`cliedit` employs a optimization strategy to handle large files efficiently while maintaining a responsive UI:
|
|
34
|
+
|
|
35
|
+
* **Math-Only Viewport:** Rendering is stateless. The editor calculates visual wrapping on the fly (Virtual Scrolling) rather than storing a massive state array, significantly reducing memory usage for large documents.
|
|
36
|
+
* **Screen Buffer Diffing:** A double-buffering system compares the current and next frame to send only the changed characters to the terminal, minimizing I/O and eliminating flicker.
|
|
37
|
+
* **Worker Threads:** Syntax highlighting runs asynchronously in a background Worker Thread, preventing UI freezes during rendering of complex lines.
|
|
38
|
+
* **Recommended Limits:** Good for files up to 50k lines (perfect for configs, scripts, and logs).
|
|
39
|
+
|
|
29
40
|
## Installation
|
|
30
41
|
```shell
|
|
31
42
|
npm install cliedit
|
|
32
|
-
|
|
43
|
+
```
|
|
33
44
|
|
|
34
45
|
## Usage
|
|
35
46
|
|
|
@@ -116,7 +127,6 @@ Key types are also exported for convenience:
|
|
|
116
127
|
```typescript
|
|
117
128
|
import type {
|
|
118
129
|
DocumentState,
|
|
119
|
-
VisualRow,
|
|
120
130
|
EditorMode,
|
|
121
131
|
NormalizedRange,
|
|
122
132
|
} from 'cliedit';
|
package/dist/constants.d.ts
CHANGED
package/dist/constants.js
CHANGED
|
@@ -10,6 +10,7 @@ export const ANSI = {
|
|
|
10
10
|
SHOW_CURSOR: '\x1b[?25h', // Show cursor
|
|
11
11
|
INVERT_COLORS: '\x1b[7m', // Invert background/foreground colors
|
|
12
12
|
RESET_COLORS: '\x1b[0m', // Reset colors
|
|
13
|
+
DIM: '\x1b[2m', // Dim mode (faint)
|
|
13
14
|
ENTER_ALTERNATE_SCREEN: '\x1b[?1049h', // Enter alternate screen
|
|
14
15
|
EXIT_ALTERNATE_SCREEN: '\x1b[?1049l', // Exit alternate screen
|
|
15
16
|
// Syntax Highlighting Colors
|
package/dist/editor.clipboard.js
CHANGED
|
@@ -15,7 +15,7 @@ function setClipboard(text) {
|
|
|
15
15
|
case 'win32':
|
|
16
16
|
command = 'clip';
|
|
17
17
|
break;
|
|
18
|
-
case 'linux':
|
|
18
|
+
case 'linux':
|
|
19
19
|
command = 'xclip -selection clipboard';
|
|
20
20
|
break;
|
|
21
21
|
default:
|
|
@@ -46,8 +46,8 @@ function getClipboard() {
|
|
|
46
46
|
case 'win32':
|
|
47
47
|
command = 'powershell -command "Get-Clipboard"';
|
|
48
48
|
break;
|
|
49
|
-
case 'linux':
|
|
50
|
-
command = 'xclip -selection clipboard -o';
|
|
49
|
+
case 'linux':
|
|
50
|
+
command = 'xclip -selection clipboard -o';
|
|
51
51
|
break;
|
|
52
52
|
default:
|
|
53
53
|
this.setStatusMessage('Clipboard not supported on this platform');
|
|
@@ -99,6 +99,7 @@ async function pasteLine() {
|
|
|
99
99
|
this.insertContentAtCursor(pasteLines);
|
|
100
100
|
}
|
|
101
101
|
catch (error) {
|
|
102
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
102
103
|
this.setStatusMessage(`Paste failed: ${error.message}`);
|
|
103
104
|
}
|
|
104
105
|
}
|
package/dist/editor.d.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { HistoryManager } from './history.js';
|
|
2
|
-
import {
|
|
2
|
+
import { EditorMode, EditorOptions } from './types.js';
|
|
3
3
|
import { SwapManager } from './editor.swap.js';
|
|
4
|
+
import { ScreenBuffer } from './screen_buffer.js';
|
|
5
|
+
import { Worker } from 'worker_threads';
|
|
4
6
|
import { editingMethods } from './editor.editing.js';
|
|
5
7
|
import { clipboardMethods } from './editor.clipboard.js';
|
|
6
8
|
import { navigationMethods } from './editor.navigation.js';
|
|
@@ -40,7 +42,6 @@ export declare class CliEditor {
|
|
|
40
42
|
gutterWidth: number;
|
|
41
43
|
tabSize: number;
|
|
42
44
|
screenStartRow: number;
|
|
43
|
-
visualRows: VisualRow[];
|
|
44
45
|
mode: EditorMode;
|
|
45
46
|
statusMessage: string;
|
|
46
47
|
statusTimeout: NodeJS.Timeout | null;
|
|
@@ -60,17 +61,20 @@ export declare class CliEditor {
|
|
|
60
61
|
}>>;
|
|
61
62
|
searchResultIndex: number;
|
|
62
63
|
syntaxCache: Map<number, Map<number, string>>;
|
|
64
|
+
syntaxWorker: Worker | null;
|
|
63
65
|
history: HistoryManager;
|
|
64
66
|
swapManager: SwapManager;
|
|
67
|
+
screenBuffer: ScreenBuffer;
|
|
65
68
|
isCleanedUp: boolean;
|
|
66
69
|
resolvePromise: ((value: {
|
|
67
70
|
saved: boolean;
|
|
68
71
|
content: string;
|
|
69
72
|
}) => void) | null;
|
|
70
|
-
rejectPromise: ((reason?:
|
|
71
|
-
inputStream:
|
|
73
|
+
rejectPromise: ((reason?: unknown) => void) | null;
|
|
74
|
+
inputStream: NodeJS.ReadStream;
|
|
72
75
|
isExiting: boolean;
|
|
73
76
|
constructor(initialContent: string, filepath: string, options?: EditorOptions);
|
|
77
|
+
private handleWorkerMessage;
|
|
74
78
|
run(): Promise<{
|
|
75
79
|
saved: boolean;
|
|
76
80
|
content: string;
|
package/dist/editor.editing.js
CHANGED
|
@@ -34,7 +34,6 @@ function insertContentAtCursor(contentLines) {
|
|
|
34
34
|
}
|
|
35
35
|
this.setDirty();
|
|
36
36
|
this.invalidateSyntaxCache();
|
|
37
|
-
this.recalculateVisualRows();
|
|
38
37
|
}
|
|
39
38
|
/**
|
|
40
39
|
* Inserts a single character at the cursor position.
|
|
@@ -166,7 +165,6 @@ function indentSelection() {
|
|
|
166
165
|
}
|
|
167
166
|
this.setDirty();
|
|
168
167
|
this.invalidateSyntaxCache();
|
|
169
|
-
this.recalculateVisualRows();
|
|
170
168
|
}
|
|
171
169
|
/**
|
|
172
170
|
* Outdents the selected lines (Block Outdent).
|
|
@@ -206,7 +204,6 @@ function outdentSelection() {
|
|
|
206
204
|
}
|
|
207
205
|
this.setDirty();
|
|
208
206
|
this.invalidateSyntaxCache();
|
|
209
|
-
this.recalculateVisualRows();
|
|
210
207
|
}
|
|
211
208
|
}
|
|
212
209
|
/**
|
|
@@ -241,7 +238,6 @@ function moveLines(direction) {
|
|
|
241
238
|
this.selectionAnchor.y += direction;
|
|
242
239
|
}
|
|
243
240
|
this.setDirty();
|
|
244
|
-
this.recalculateVisualRows();
|
|
245
241
|
}
|
|
246
242
|
/**
|
|
247
243
|
* Duplicates the current line or selection.
|
|
@@ -268,7 +264,6 @@ function duplicateLineOrSelection() {
|
|
|
268
264
|
// CursorX stays same? Usually yes.
|
|
269
265
|
}
|
|
270
266
|
this.setDirty();
|
|
271
|
-
this.recalculateVisualRows();
|
|
272
267
|
}
|
|
273
268
|
export const editingMethods = {
|
|
274
269
|
insertContentAtCursor,
|
package/dist/editor.history.d.ts
CHANGED
|
@@ -10,7 +10,7 @@ declare function getCurrentState(this: CliEditor): DocumentState;
|
|
|
10
10
|
/**
|
|
11
11
|
* Saves the current state to the history manager.
|
|
12
12
|
*/
|
|
13
|
-
declare function saveState(this: CliEditor,
|
|
13
|
+
declare function saveState(this: CliEditor, _initial?: boolean): void;
|
|
14
14
|
/**
|
|
15
15
|
* Loads a document state from the history manager.
|
|
16
16
|
*/
|
package/dist/editor.history.js
CHANGED
|
@@ -16,7 +16,7 @@ function getCurrentState() {
|
|
|
16
16
|
/**
|
|
17
17
|
* Saves the current state to the history manager.
|
|
18
18
|
*/
|
|
19
|
-
function saveState(
|
|
19
|
+
function saveState(_initial = false) {
|
|
20
20
|
// Only save if content is different from the last state,
|
|
21
21
|
// but ALWAYS save the initial state.
|
|
22
22
|
this.history.saveState(this.getCurrentState());
|
package/dist/editor.io.js
CHANGED
package/dist/editor.js
CHANGED
|
@@ -1,9 +1,13 @@
|
|
|
1
|
-
//
|
|
1
|
+
// src/editor.ts
|
|
2
|
+
/* eslint-disable @typescript-eslint/no-unsafe-declaration-merging */
|
|
2
3
|
import keypress from './vendor/keypress.js';
|
|
3
4
|
import { ANSI } from './constants.js';
|
|
4
5
|
import { HistoryManager } from './history.js';
|
|
5
6
|
import { SwapManager } from './editor.swap.js';
|
|
6
|
-
|
|
7
|
+
import { ScreenBuffer } from './screen_buffer.js';
|
|
8
|
+
import { Worker } from 'worker_threads';
|
|
9
|
+
import { fileURLToPath } from 'url';
|
|
10
|
+
import { dirname, join } from 'path';
|
|
7
11
|
// Import all functional modules
|
|
8
12
|
import { editingMethods } from './editor.editing.js';
|
|
9
13
|
import { clipboardMethods } from './editor.clipboard.js';
|
|
@@ -31,7 +35,6 @@ export class CliEditor {
|
|
|
31
35
|
this.gutterWidth = 5;
|
|
32
36
|
this.tabSize = 4;
|
|
33
37
|
this.screenStartRow = 1;
|
|
34
|
-
this.visualRows = [];
|
|
35
38
|
this.mode = 'edit';
|
|
36
39
|
this.statusMessage = DEFAULT_STATUS;
|
|
37
40
|
this.statusTimeout = null;
|
|
@@ -46,6 +49,7 @@ export class CliEditor {
|
|
|
46
49
|
this.searchResultMap = new Map();
|
|
47
50
|
this.searchResultIndex = -1;
|
|
48
51
|
this.syntaxCache = new Map();
|
|
52
|
+
this.syntaxWorker = null;
|
|
49
53
|
this.isCleanedUp = false;
|
|
50
54
|
this.resolvePromise = null;
|
|
51
55
|
this.rejectPromise = null;
|
|
@@ -60,9 +64,35 @@ export class CliEditor {
|
|
|
60
64
|
this.tabSize = options.tabSize ?? 4;
|
|
61
65
|
this.inputStream = options.inputStream || process.stdin;
|
|
62
66
|
this.history = new HistoryManager();
|
|
67
|
+
this.screenBuffer = new ScreenBuffer();
|
|
63
68
|
this.saveState(true);
|
|
64
69
|
// Initialize SwapManager
|
|
65
70
|
this.swapManager = new SwapManager(this.filepath, () => this.lines.join('\n'));
|
|
71
|
+
// Initialize Worker
|
|
72
|
+
try {
|
|
73
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
74
|
+
const __dirname = dirname(__filename);
|
|
75
|
+
// Assuming compiled code is in dist/ and syntax.worker.js is there.
|
|
76
|
+
const workerPath = join(__dirname, 'syntax.worker.js');
|
|
77
|
+
this.syntaxWorker = new Worker(workerPath);
|
|
78
|
+
this.syntaxWorker.on('message', this.handleWorkerMessage.bind(this));
|
|
79
|
+
}
|
|
80
|
+
catch {
|
|
81
|
+
// Fallback or log error
|
|
82
|
+
// console.error("Failed to load worker", e);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
handleWorkerMessage(msg) {
|
|
86
|
+
const { lineIndex, colorMap } = msg;
|
|
87
|
+
this.syntaxCache.set(lineIndex, colorMap);
|
|
88
|
+
// Trigger Partial Render?
|
|
89
|
+
// For simplicity, just render. The screen buffer diffing handles optimization.
|
|
90
|
+
// But we only need to render IF the line is currently visible?
|
|
91
|
+
// Checking visibility is optimization.
|
|
92
|
+
// Let's just render.
|
|
93
|
+
if (!this.isCleanedUp) {
|
|
94
|
+
this.render();
|
|
95
|
+
}
|
|
66
96
|
}
|
|
67
97
|
// --- Lifecycle Methods ---
|
|
68
98
|
run() {
|
|
@@ -72,6 +102,7 @@ export class CliEditor {
|
|
|
72
102
|
return new Promise((resolve, reject) => {
|
|
73
103
|
const performCleanup = (callback) => {
|
|
74
104
|
this.swapManager.stop(); // Stop swap interval
|
|
105
|
+
this.syntaxWorker?.terminate();
|
|
75
106
|
if (this.isCleanedUp) {
|
|
76
107
|
if (callback)
|
|
77
108
|
callback();
|
|
@@ -113,7 +144,11 @@ export class CliEditor {
|
|
|
113
144
|
throw new Error('Editor requires a TTY environment (stdout).');
|
|
114
145
|
}
|
|
115
146
|
this.updateScreenSize();
|
|
116
|
-
this.
|
|
147
|
+
this.screenBuffer.resize(this.screenRows + 2, this.screenCols); // +2 for Status Bar space if needed?
|
|
148
|
+
// screenRows = stdout.rows - 2.
|
|
149
|
+
// ScreenBuffer should cover the FULL terminal size (rows, cols) to handle status bar rendering too.
|
|
150
|
+
// So pass process.stdout.rows.
|
|
151
|
+
this.screenBuffer.resize(process.stdout.rows, process.stdout.columns);
|
|
117
152
|
// Enter alternate screen and hide cursor + Enable SGR Mouse (1006) and Button Event (1000)
|
|
118
153
|
process.stdout.write(ANSI.ENTER_ALTERNATE_SCREEN + ANSI.HIDE_CURSOR + ANSI.CLEAR_SCREEN + '\x1b[?1000h' + '\x1b[?1006h');
|
|
119
154
|
if (this.inputStream.setRawMode) {
|
|
@@ -128,7 +163,7 @@ export class CliEditor {
|
|
|
128
163
|
}
|
|
129
164
|
handleResize() {
|
|
130
165
|
this.updateScreenSize();
|
|
131
|
-
this.
|
|
166
|
+
this.screenBuffer.resize(process.stdout.rows, process.stdout.columns);
|
|
132
167
|
this.render();
|
|
133
168
|
}
|
|
134
169
|
updateScreenSize() {
|
package/dist/editor.keys.js
CHANGED
|
@@ -11,13 +11,12 @@ const PAIR_MAP = {
|
|
|
11
11
|
* Main router for standardized keypress events from the 'keypress' library.
|
|
12
12
|
*/
|
|
13
13
|
function handleKeypressEvent(ch, key) {
|
|
14
|
-
// CRASH FIX: If the editor is already closing, ignore all input.
|
|
15
14
|
if (this.isExiting) {
|
|
16
15
|
return;
|
|
17
16
|
}
|
|
18
17
|
let keyName = undefined;
|
|
19
18
|
let edited = false;
|
|
20
|
-
// --- 1.
|
|
19
|
+
// --- 1. Handle the case where key is null/undefined (Printable characters) ---
|
|
21
20
|
if (!key) {
|
|
22
21
|
if (ch && ch.length === 1 && ch >= ' ' && ch <= '~') {
|
|
23
22
|
if (this.mode === 'search_find' || this.mode === 'search_replace') {
|
|
@@ -30,7 +29,6 @@ function handleKeypressEvent(ch, key) {
|
|
|
30
29
|
edited = this.handleEditKeys(ch);
|
|
31
30
|
if (edited) {
|
|
32
31
|
this.saveState();
|
|
33
|
-
this.recalculateVisualRows(); // Phải tính toán lại sau khi gõ
|
|
34
32
|
}
|
|
35
33
|
}
|
|
36
34
|
else if (this.mode === 'search_confirm') {
|
|
@@ -41,8 +39,8 @@ function handleKeypressEvent(ch, key) {
|
|
|
41
39
|
}
|
|
42
40
|
return;
|
|
43
41
|
}
|
|
44
|
-
// --- 2.
|
|
45
|
-
// 2.1.
|
|
42
|
+
// --- 2. From here, the 'key' object is guaranteed to exist (special keys or Ctrl/Meta) ---
|
|
43
|
+
// 2.1. Map Control sequences (Ctrl+Arrow for selection)
|
|
46
44
|
if (key.ctrl) {
|
|
47
45
|
if (key.name === 'up')
|
|
48
46
|
keyName = KEYS.CTRL_ARROW_UP;
|
|
@@ -56,8 +54,17 @@ function handleKeypressEvent(ch, key) {
|
|
|
56
54
|
keyName = key.sequence;
|
|
57
55
|
}
|
|
58
56
|
else {
|
|
59
|
-
// 2.2.
|
|
60
|
-
|
|
57
|
+
// 2.2. Map standard keys (Arrow, Home, End, Enter, Tab)
|
|
58
|
+
// Check Meta keys first!
|
|
59
|
+
if (key.meta && key.name === 'left')
|
|
60
|
+
keyName = 'ALT_LEFT';
|
|
61
|
+
else if (key.meta && key.name === 'right')
|
|
62
|
+
keyName = 'ALT_RIGHT';
|
|
63
|
+
else if (key.meta && key.name === 'up')
|
|
64
|
+
keyName = KEYS.ALT_UP;
|
|
65
|
+
else if (key.meta && key.name === 'down')
|
|
66
|
+
keyName = KEYS.ALT_DOWN;
|
|
67
|
+
else if (key.name === 'up')
|
|
61
68
|
keyName = KEYS.ARROW_UP;
|
|
62
69
|
else if (key.name === 'down')
|
|
63
70
|
keyName = KEYS.ARROW_DOWN;
|
|
@@ -81,14 +88,6 @@ function handleKeypressEvent(ch, key) {
|
|
|
81
88
|
keyName = KEYS.ENTER;
|
|
82
89
|
else if (key.name === 'tab')
|
|
83
90
|
keyName = key.shift ? KEYS.SHIFT_TAB : KEYS.TAB;
|
|
84
|
-
else if (key.meta && key.name === 'left')
|
|
85
|
-
keyName = 'ALT_LEFT';
|
|
86
|
-
else if (key.meta && key.name === 'right')
|
|
87
|
-
keyName = 'ALT_RIGHT';
|
|
88
|
-
else if (key.meta && key.name === 'up')
|
|
89
|
-
keyName = KEYS.ALT_UP;
|
|
90
|
-
else if (key.meta && key.name === 'down')
|
|
91
|
-
keyName = KEYS.ALT_DOWN;
|
|
92
91
|
// Handle Mouse Scroll events explicitly
|
|
93
92
|
else if (key.name === 'scrollup')
|
|
94
93
|
keyName = 'SCROLL_UP';
|
|
@@ -97,7 +96,7 @@ function handleKeypressEvent(ch, key) {
|
|
|
97
96
|
else
|
|
98
97
|
keyName = key.sequence;
|
|
99
98
|
}
|
|
100
|
-
// --- 3.
|
|
99
|
+
// --- 3. Route according to Mode ---
|
|
101
100
|
if (this.mode === 'search_find' || this.mode === 'search_replace') {
|
|
102
101
|
this.handleSearchKeys(keyName || ch);
|
|
103
102
|
}
|
|
@@ -108,7 +107,7 @@ function handleKeypressEvent(ch, key) {
|
|
|
108
107
|
this.handleGoToLineKeys(keyName || ch);
|
|
109
108
|
}
|
|
110
109
|
else {
|
|
111
|
-
// 4.
|
|
110
|
+
// 4. Handle selection keys (Ctrl+Arrow) - Navigation
|
|
112
111
|
switch (keyName) {
|
|
113
112
|
case KEYS.CTRL_ARROW_UP:
|
|
114
113
|
case KEYS.CTRL_ARROW_DOWN:
|
|
@@ -126,22 +125,27 @@ function handleKeypressEvent(ch, key) {
|
|
|
126
125
|
this.render();
|
|
127
126
|
return;
|
|
128
127
|
}
|
|
129
|
-
// 5.
|
|
128
|
+
// 5. Handle all other command/edit keys
|
|
130
129
|
if (keyName === 'SCROLL_UP') {
|
|
131
130
|
const scrollAmount = 3;
|
|
132
131
|
this.rowOffset = Math.max(0, this.rowOffset - scrollAmount);
|
|
133
132
|
// Adjust cursor if it falls out of view (below the viewport)
|
|
134
133
|
// Actually if we scroll UP, the viewport moves UP. The cursor might be BELOW the viewport.
|
|
135
|
-
//
|
|
134
|
+
// scroll UP means viewing lines ABOVE. Viewport index decreases.
|
|
136
135
|
// Cursor (if previously in view) might now be >= rowOffset + screenRows.
|
|
137
136
|
// We need to ensure cursor is within [rowOffset, rowOffset + screenRows - 1]
|
|
138
137
|
// But verify after setting rowOffset.
|
|
139
138
|
const currentVisualRow = this.findCurrentVisualRowIndex();
|
|
140
139
|
const bottomEdge = this.rowOffset + this.screenRows - 1;
|
|
141
140
|
if (currentVisualRow > bottomEdge) {
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
141
|
+
// Move cursor to bottom edge
|
|
142
|
+
// Need logic to move cursor to visual row 'bottomEdge'
|
|
143
|
+
// Use getLogicalFromVisual
|
|
144
|
+
const targetPos = this.getLogicalFromVisual(bottomEdge);
|
|
145
|
+
this.cursorY = targetPos.logicalY;
|
|
146
|
+
// Set to start of that chunk
|
|
147
|
+
const contentWidth = Math.max(1, this.screenCols - this.gutterWidth);
|
|
148
|
+
this.cursorX = targetPos.visualYOffset * contentWidth;
|
|
145
149
|
}
|
|
146
150
|
else if (currentVisualRow < this.rowOffset) {
|
|
147
151
|
// Should not happen when scrolling up (moving viewport up), unless cursor was already above?
|
|
@@ -155,35 +159,44 @@ function handleKeypressEvent(ch, key) {
|
|
|
155
159
|
// if (currentVisualRow >= this.rowOffset + this.screenRows) -> this.rowOffset = ...
|
|
156
160
|
// So we MUST move cursor inside the new viewport.
|
|
157
161
|
if (currentVisualRow > bottomEdge) {
|
|
158
|
-
const
|
|
159
|
-
this.cursorY =
|
|
160
|
-
this.
|
|
162
|
+
const targetPos = this.getLogicalFromVisual(bottomEdge);
|
|
163
|
+
this.cursorY = targetPos.logicalY;
|
|
164
|
+
const contentWidth = Math.max(1, this.screenCols - this.gutterWidth);
|
|
165
|
+
this.cursorX = targetPos.visualYOffset * contentWidth;
|
|
161
166
|
}
|
|
162
167
|
}
|
|
163
168
|
else if (keyName === 'SCROLL_DOWN') {
|
|
164
169
|
const scrollAmount = 3;
|
|
165
|
-
|
|
170
|
+
// Need total visual rows to clamp maxOffset.
|
|
171
|
+
// Calculating total visual rows is O(N).
|
|
172
|
+
// Let's do a safe scroll?
|
|
173
|
+
// Or just allow scrolling until end?
|
|
174
|
+
// Let's calculate total visual rows for now (performance cost accepted for correct scrolling).
|
|
175
|
+
let totalVisualRows = 0;
|
|
176
|
+
for (let i = 0; i < this.lines.length; i++)
|
|
177
|
+
totalVisualRows += this.getLineVisualHeight(i);
|
|
178
|
+
const maxOffset = Math.max(0, totalVisualRows - this.screenRows);
|
|
166
179
|
this.rowOffset = Math.min(maxOffset, this.rowOffset + scrollAmount);
|
|
167
180
|
// Scroll DOWN means viewport index increases.
|
|
168
181
|
// Cursor might be ABOVE the new viewport (currentVisualRow < rowOffset).
|
|
169
182
|
const currentVisualRow = this.findCurrentVisualRowIndex();
|
|
170
183
|
if (currentVisualRow < this.rowOffset) {
|
|
171
|
-
const
|
|
172
|
-
this.cursorY =
|
|
173
|
-
this.
|
|
184
|
+
const targetPos = this.getLogicalFromVisual(this.rowOffset);
|
|
185
|
+
this.cursorY = targetPos.logicalY;
|
|
186
|
+
const contentWidth = Math.max(1, this.screenCols - this.gutterWidth);
|
|
187
|
+
this.cursorX = targetPos.visualYOffset * contentWidth;
|
|
174
188
|
}
|
|
175
189
|
}
|
|
176
190
|
else {
|
|
177
191
|
edited = this.handleEditKeys(keyName || ch);
|
|
178
192
|
}
|
|
179
193
|
}
|
|
180
|
-
// 6.
|
|
194
|
+
// 6. Update State and Render
|
|
181
195
|
if (edited) {
|
|
182
|
-
this.saveState(); // <--
|
|
183
|
-
this.recalculateVisualRows(); // Tính toán lại layout
|
|
196
|
+
this.saveState(); // <-- Called only when typing, deleting, etc.
|
|
184
197
|
}
|
|
185
198
|
if (!this.isExiting) {
|
|
186
|
-
this.render(); //
|
|
199
|
+
this.render(); // Final render (with visual rows updated if necessary)
|
|
187
200
|
}
|
|
188
201
|
}
|
|
189
202
|
function handleAltArrows(keyName) {
|
|
@@ -251,7 +264,7 @@ function handleEditKeys(key) {
|
|
|
251
264
|
this.clearSearchResults();
|
|
252
265
|
this.insertNewLine();
|
|
253
266
|
return true;
|
|
254
|
-
case KEYS.BACKSPACE:
|
|
267
|
+
case KEYS.BACKSPACE: {
|
|
255
268
|
this.clearSearchResults();
|
|
256
269
|
// Handle auto-pair deletion
|
|
257
270
|
const line = this.lines[this.cursorY] || '';
|
|
@@ -272,6 +285,7 @@ function handleEditKeys(key) {
|
|
|
272
285
|
this.deleteBackward();
|
|
273
286
|
}
|
|
274
287
|
return true;
|
|
288
|
+
}
|
|
275
289
|
case KEYS.DELETE:
|
|
276
290
|
this.clearSearchResults();
|
|
277
291
|
if (this.selectionAnchor)
|
|
@@ -332,15 +346,12 @@ function handleEditKeys(key) {
|
|
|
332
346
|
// For now, let's use Ctrl+B?
|
|
333
347
|
this.matchBracket();
|
|
334
348
|
return false;
|
|
335
|
-
//
|
|
336
|
-
// Sau khi undo/redo, chúng ta PHẢI tính toán lại visual rows
|
|
349
|
+
// After undo/redo, we MUST recalculate visual rows
|
|
337
350
|
case KEYS.CTRL_Z:
|
|
338
351
|
this.undo();
|
|
339
|
-
this.recalculateVisualRows(); // <-- THÊM DÒNG NÀY
|
|
340
352
|
return false;
|
|
341
353
|
case KEYS.CTRL_Y:
|
|
342
354
|
this.redo();
|
|
343
|
-
this.recalculateVisualRows(); // <-- THÊM DÒNG NÀY
|
|
344
355
|
return false;
|
|
345
356
|
// --- Clipboard ---
|
|
346
357
|
case KEYS.CTRL_K: // Cut Line (Traditional)
|
|
@@ -355,7 +366,7 @@ function handleEditKeys(key) {
|
|
|
355
366
|
case KEYS.CTRL_V: // Paste Selection
|
|
356
367
|
this.pasteSelection();
|
|
357
368
|
return true;
|
|
358
|
-
//
|
|
369
|
+
// Handle Printable Characters
|
|
359
370
|
default:
|
|
360
371
|
if (key.length === 1 && key >= ' ' && key <= '~') {
|
|
361
372
|
this.clearSearchResults();
|
|
@@ -536,7 +547,7 @@ function handleGoToLineKeys(key) {
|
|
|
536
547
|
this.setStatusMessage('Cancelled');
|
|
537
548
|
};
|
|
538
549
|
switch (key) {
|
|
539
|
-
case KEYS.ENTER:
|
|
550
|
+
case KEYS.ENTER: {
|
|
540
551
|
const lineNumber = parseInt(this.goToLineQuery, 10);
|
|
541
552
|
if (!isNaN(lineNumber) && lineNumber > 0) {
|
|
542
553
|
this.jumpToLine(lineNumber);
|
|
@@ -547,6 +558,7 @@ function handleGoToLineKeys(key) {
|
|
|
547
558
|
}
|
|
548
559
|
this.goToLineQuery = '';
|
|
549
560
|
break;
|
|
561
|
+
}
|
|
550
562
|
case KEYS.ESCAPE:
|
|
551
563
|
case KEYS.CTRL_C:
|
|
552
564
|
case KEYS.CTRL_Q:
|
|
@@ -4,6 +4,7 @@ import { CliEditor } from './editor.js';
|
|
|
4
4
|
*/
|
|
5
5
|
/**
|
|
6
6
|
* Finds the index of the visual row that currently contains the cursor.
|
|
7
|
+
* Uses math to calculate position based on line lengths and screen width.
|
|
7
8
|
*/
|
|
8
9
|
declare function findCurrentVisualRowIndex(this: CliEditor): number;
|
|
9
10
|
/**
|
|
@@ -38,6 +39,8 @@ declare function jumpToLine(this: CliEditor, lineNumber: number): void;
|
|
|
38
39
|
* Enters Go To Line mode.
|
|
39
40
|
*/
|
|
40
41
|
declare function enterGoToLineMode(this: CliEditor): void;
|
|
42
|
+
declare function moveCursorByWord(this: CliEditor, direction: 'left' | 'right'): void;
|
|
43
|
+
declare function matchBracket(this: CliEditor): void;
|
|
41
44
|
export declare const navigationMethods: {
|
|
42
45
|
findCurrentVisualRowIndex: typeof findCurrentVisualRowIndex;
|
|
43
46
|
moveCursorLogically: typeof moveCursorLogically;
|
|
@@ -51,6 +54,4 @@ export declare const navigationMethods: {
|
|
|
51
54
|
moveCursorByWord: typeof moveCursorByWord;
|
|
52
55
|
matchBracket: typeof matchBracket;
|
|
53
56
|
};
|
|
54
|
-
declare function moveCursorByWord(this: CliEditor, direction: 'left' | 'right'): void;
|
|
55
|
-
declare function matchBracket(this: CliEditor): void;
|
|
56
57
|
export {};
|