cliedit 0.4.0 → 0.5.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/README.md +10 -2
- package/dist/constants.d.ts +1 -0
- package/dist/constants.js +1 -0
- package/dist/editor.clipboard.js +3 -3
- package/dist/editor.d.ts +6 -2
- package/dist/editor.editing.js +0 -5
- package/dist/editor.js +39 -5
- package/dist/editor.keys.js +38 -29
- 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 +165 -105
- package/dist/editor.search.js +0 -2
- package/dist/editor.syntax.js +26 -62
- package/dist/index.d.ts +1 -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 +0 -8
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -26,10 +26,19 @@ It includes line wrapping, visual navigation, smart auto-indentation, undo/redo,
|
|
|
26
26
|
- **Piping Support:** Works with standard Unix pipes (e.g. `cat file.txt | cliedit`).
|
|
27
27
|
- **Crash Recovery:** Automatically saves changes to a hidden swap file (e.g. `.filename.swp`) to prevent data loss.
|
|
28
28
|
|
|
29
|
+
### Architecture Improvements
|
|
30
|
+
|
|
31
|
+
`cliedit` employs a optimization strategy to handle large files efficiently while maintaining a responsive UI:
|
|
32
|
+
|
|
33
|
+
* **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.
|
|
34
|
+
* **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.
|
|
35
|
+
* **Worker Threads:** Syntax highlighting runs asynchronously in a background Worker Thread, preventing UI freezes during rendering of complex lines.
|
|
36
|
+
* **Recommended Limits:** Good for files up to 50k lines (perfect for configs, scripts, and logs).
|
|
37
|
+
|
|
29
38
|
## Installation
|
|
30
39
|
```shell
|
|
31
40
|
npm install cliedit
|
|
32
|
-
|
|
41
|
+
```
|
|
33
42
|
|
|
34
43
|
## Usage
|
|
35
44
|
|
|
@@ -116,7 +125,6 @@ Key types are also exported for convenience:
|
|
|
116
125
|
```typescript
|
|
117
126
|
import type {
|
|
118
127
|
DocumentState,
|
|
119
|
-
VisualRow,
|
|
120
128
|
EditorMode,
|
|
121
129
|
NormalizedRange,
|
|
122
130
|
} 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');
|
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,8 +61,10 @@ 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;
|
|
@@ -71,6 +74,7 @@ export declare class CliEditor {
|
|
|
71
74
|
inputStream: any;
|
|
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.js
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
|
-
//
|
|
1
|
+
// src/editor.ts
|
|
2
2
|
import keypress from './vendor/keypress.js';
|
|
3
3
|
import { ANSI } from './constants.js';
|
|
4
4
|
import { HistoryManager } from './history.js';
|
|
5
5
|
import { SwapManager } from './editor.swap.js';
|
|
6
|
-
|
|
6
|
+
import { ScreenBuffer } from './screen_buffer.js';
|
|
7
|
+
import { Worker } from 'worker_threads';
|
|
8
|
+
import { fileURLToPath } from 'url';
|
|
9
|
+
import { dirname, join } from 'path';
|
|
7
10
|
// Import all functional modules
|
|
8
11
|
import { editingMethods } from './editor.editing.js';
|
|
9
12
|
import { clipboardMethods } from './editor.clipboard.js';
|
|
@@ -31,7 +34,6 @@ export class CliEditor {
|
|
|
31
34
|
this.gutterWidth = 5;
|
|
32
35
|
this.tabSize = 4;
|
|
33
36
|
this.screenStartRow = 1;
|
|
34
|
-
this.visualRows = [];
|
|
35
37
|
this.mode = 'edit';
|
|
36
38
|
this.statusMessage = DEFAULT_STATUS;
|
|
37
39
|
this.statusTimeout = null;
|
|
@@ -46,6 +48,7 @@ export class CliEditor {
|
|
|
46
48
|
this.searchResultMap = new Map();
|
|
47
49
|
this.searchResultIndex = -1;
|
|
48
50
|
this.syntaxCache = new Map();
|
|
51
|
+
this.syntaxWorker = null;
|
|
49
52
|
this.isCleanedUp = false;
|
|
50
53
|
this.resolvePromise = null;
|
|
51
54
|
this.rejectPromise = null;
|
|
@@ -60,9 +63,35 @@ export class CliEditor {
|
|
|
60
63
|
this.tabSize = options.tabSize ?? 4;
|
|
61
64
|
this.inputStream = options.inputStream || process.stdin;
|
|
62
65
|
this.history = new HistoryManager();
|
|
66
|
+
this.screenBuffer = new ScreenBuffer();
|
|
63
67
|
this.saveState(true);
|
|
64
68
|
// Initialize SwapManager
|
|
65
69
|
this.swapManager = new SwapManager(this.filepath, () => this.lines.join('\n'));
|
|
70
|
+
// Initialize Worker
|
|
71
|
+
try {
|
|
72
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
73
|
+
const __dirname = dirname(__filename);
|
|
74
|
+
// Assuming compiled code is in dist/ and syntax.worker.js is there.
|
|
75
|
+
const workerPath = join(__dirname, 'syntax.worker.js');
|
|
76
|
+
this.syntaxWorker = new Worker(workerPath);
|
|
77
|
+
this.syntaxWorker.on('message', this.handleWorkerMessage.bind(this));
|
|
78
|
+
}
|
|
79
|
+
catch (e) {
|
|
80
|
+
// Fallback or log error
|
|
81
|
+
// console.error("Failed to load worker", e);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
handleWorkerMessage(msg) {
|
|
85
|
+
const { lineIndex, colorMap } = msg;
|
|
86
|
+
this.syntaxCache.set(lineIndex, colorMap);
|
|
87
|
+
// Trigger Partial Render?
|
|
88
|
+
// For simplicity, just render. The screen buffer diffing handles optimization.
|
|
89
|
+
// But we only need to render IF the line is currently visible?
|
|
90
|
+
// Checking visibility is optimization.
|
|
91
|
+
// Let's just render.
|
|
92
|
+
if (!this.isCleanedUp) {
|
|
93
|
+
this.render();
|
|
94
|
+
}
|
|
66
95
|
}
|
|
67
96
|
// --- Lifecycle Methods ---
|
|
68
97
|
run() {
|
|
@@ -72,6 +101,7 @@ export class CliEditor {
|
|
|
72
101
|
return new Promise((resolve, reject) => {
|
|
73
102
|
const performCleanup = (callback) => {
|
|
74
103
|
this.swapManager.stop(); // Stop swap interval
|
|
104
|
+
this.syntaxWorker?.terminate();
|
|
75
105
|
if (this.isCleanedUp) {
|
|
76
106
|
if (callback)
|
|
77
107
|
callback();
|
|
@@ -113,7 +143,11 @@ export class CliEditor {
|
|
|
113
143
|
throw new Error('Editor requires a TTY environment (stdout).');
|
|
114
144
|
}
|
|
115
145
|
this.updateScreenSize();
|
|
116
|
-
this.
|
|
146
|
+
this.screenBuffer.resize(this.screenRows + 2, this.screenCols); // +2 for Status Bar space if needed?
|
|
147
|
+
// screenRows = stdout.rows - 2.
|
|
148
|
+
// ScreenBuffer should cover the FULL terminal size (rows, cols) to handle status bar rendering too.
|
|
149
|
+
// So pass process.stdout.rows.
|
|
150
|
+
this.screenBuffer.resize(process.stdout.rows, process.stdout.columns);
|
|
117
151
|
// Enter alternate screen and hide cursor + Enable SGR Mouse (1006) and Button Event (1000)
|
|
118
152
|
process.stdout.write(ANSI.ENTER_ALTERNATE_SCREEN + ANSI.HIDE_CURSOR + ANSI.CLEAR_SCREEN + '\x1b[?1000h' + '\x1b[?1006h');
|
|
119
153
|
if (this.inputStream.setRawMode) {
|
|
@@ -128,7 +162,7 @@ export class CliEditor {
|
|
|
128
162
|
}
|
|
129
163
|
handleResize() {
|
|
130
164
|
this.updateScreenSize();
|
|
131
|
-
this.
|
|
165
|
+
this.screenBuffer.resize(process.stdout.rows, process.stdout.columns);
|
|
132
166
|
this.render();
|
|
133
167
|
}
|
|
134
168
|
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,7 +54,7 @@ function handleKeypressEvent(ch, key) {
|
|
|
56
54
|
keyName = key.sequence;
|
|
57
55
|
}
|
|
58
56
|
else {
|
|
59
|
-
// 2.2.
|
|
57
|
+
// 2.2. Map standard keys (Arrow, Home, End, Enter, Tab)
|
|
60
58
|
if (key.name === 'up')
|
|
61
59
|
keyName = KEYS.ARROW_UP;
|
|
62
60
|
else if (key.name === 'down')
|
|
@@ -97,7 +95,7 @@ function handleKeypressEvent(ch, key) {
|
|
|
97
95
|
else
|
|
98
96
|
keyName = key.sequence;
|
|
99
97
|
}
|
|
100
|
-
// --- 3.
|
|
98
|
+
// --- 3. Route according to Mode ---
|
|
101
99
|
if (this.mode === 'search_find' || this.mode === 'search_replace') {
|
|
102
100
|
this.handleSearchKeys(keyName || ch);
|
|
103
101
|
}
|
|
@@ -108,7 +106,7 @@ function handleKeypressEvent(ch, key) {
|
|
|
108
106
|
this.handleGoToLineKeys(keyName || ch);
|
|
109
107
|
}
|
|
110
108
|
else {
|
|
111
|
-
// 4.
|
|
109
|
+
// 4. Handle selection keys (Ctrl+Arrow) - Navigation
|
|
112
110
|
switch (keyName) {
|
|
113
111
|
case KEYS.CTRL_ARROW_UP:
|
|
114
112
|
case KEYS.CTRL_ARROW_DOWN:
|
|
@@ -126,22 +124,27 @@ function handleKeypressEvent(ch, key) {
|
|
|
126
124
|
this.render();
|
|
127
125
|
return;
|
|
128
126
|
}
|
|
129
|
-
// 5.
|
|
127
|
+
// 5. Handle all other command/edit keys
|
|
130
128
|
if (keyName === 'SCROLL_UP') {
|
|
131
129
|
const scrollAmount = 3;
|
|
132
130
|
this.rowOffset = Math.max(0, this.rowOffset - scrollAmount);
|
|
133
131
|
// Adjust cursor if it falls out of view (below the viewport)
|
|
134
132
|
// Actually if we scroll UP, the viewport moves UP. The cursor might be BELOW the viewport.
|
|
135
|
-
//
|
|
133
|
+
// scroll UP means viewing lines ABOVE. Viewport index decreases.
|
|
136
134
|
// Cursor (if previously in view) might now be >= rowOffset + screenRows.
|
|
137
135
|
// We need to ensure cursor is within [rowOffset, rowOffset + screenRows - 1]
|
|
138
136
|
// But verify after setting rowOffset.
|
|
139
137
|
const currentVisualRow = this.findCurrentVisualRowIndex();
|
|
140
138
|
const bottomEdge = this.rowOffset + this.screenRows - 1;
|
|
141
139
|
if (currentVisualRow > bottomEdge) {
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
140
|
+
// Move cursor to bottom edge
|
|
141
|
+
// Need logic to move cursor to visual row 'bottomEdge'
|
|
142
|
+
// Use getLogicalFromVisual
|
|
143
|
+
const targetPos = this.getLogicalFromVisual(bottomEdge);
|
|
144
|
+
this.cursorY = targetPos.logicalY;
|
|
145
|
+
// Set to start of that chunk
|
|
146
|
+
const contentWidth = Math.max(1, this.screenCols - this.gutterWidth);
|
|
147
|
+
this.cursorX = targetPos.visualYOffset * contentWidth;
|
|
145
148
|
}
|
|
146
149
|
else if (currentVisualRow < this.rowOffset) {
|
|
147
150
|
// Should not happen when scrolling up (moving viewport up), unless cursor was already above?
|
|
@@ -155,35 +158,44 @@ function handleKeypressEvent(ch, key) {
|
|
|
155
158
|
// if (currentVisualRow >= this.rowOffset + this.screenRows) -> this.rowOffset = ...
|
|
156
159
|
// So we MUST move cursor inside the new viewport.
|
|
157
160
|
if (currentVisualRow > bottomEdge) {
|
|
158
|
-
const
|
|
159
|
-
this.cursorY =
|
|
160
|
-
this.
|
|
161
|
+
const targetPos = this.getLogicalFromVisual(bottomEdge);
|
|
162
|
+
this.cursorY = targetPos.logicalY;
|
|
163
|
+
const contentWidth = Math.max(1, this.screenCols - this.gutterWidth);
|
|
164
|
+
this.cursorX = targetPos.visualYOffset * contentWidth;
|
|
161
165
|
}
|
|
162
166
|
}
|
|
163
167
|
else if (keyName === 'SCROLL_DOWN') {
|
|
164
168
|
const scrollAmount = 3;
|
|
165
|
-
|
|
169
|
+
// Need total visual rows to clamp maxOffset.
|
|
170
|
+
// Calculating total visual rows is O(N).
|
|
171
|
+
// Let's do a safe scroll?
|
|
172
|
+
// Or just allow scrolling until end?
|
|
173
|
+
// Let's calculate total visual rows for now (performance cost accepted for correct scrolling).
|
|
174
|
+
let totalVisualRows = 0;
|
|
175
|
+
for (let i = 0; i < this.lines.length; i++)
|
|
176
|
+
totalVisualRows += this.getLineVisualHeight(i);
|
|
177
|
+
const maxOffset = Math.max(0, totalVisualRows - this.screenRows);
|
|
166
178
|
this.rowOffset = Math.min(maxOffset, this.rowOffset + scrollAmount);
|
|
167
179
|
// Scroll DOWN means viewport index increases.
|
|
168
180
|
// Cursor might be ABOVE the new viewport (currentVisualRow < rowOffset).
|
|
169
181
|
const currentVisualRow = this.findCurrentVisualRowIndex();
|
|
170
182
|
if (currentVisualRow < this.rowOffset) {
|
|
171
|
-
const
|
|
172
|
-
this.cursorY =
|
|
173
|
-
this.
|
|
183
|
+
const targetPos = this.getLogicalFromVisual(this.rowOffset);
|
|
184
|
+
this.cursorY = targetPos.logicalY;
|
|
185
|
+
const contentWidth = Math.max(1, this.screenCols - this.gutterWidth);
|
|
186
|
+
this.cursorX = targetPos.visualYOffset * contentWidth;
|
|
174
187
|
}
|
|
175
188
|
}
|
|
176
189
|
else {
|
|
177
190
|
edited = this.handleEditKeys(keyName || ch);
|
|
178
191
|
}
|
|
179
192
|
}
|
|
180
|
-
// 6.
|
|
193
|
+
// 6. Update State and Render
|
|
181
194
|
if (edited) {
|
|
182
|
-
this.saveState(); // <--
|
|
183
|
-
this.recalculateVisualRows(); // Tính toán lại layout
|
|
195
|
+
this.saveState(); // <-- Called only when typing, deleting, etc.
|
|
184
196
|
}
|
|
185
197
|
if (!this.isExiting) {
|
|
186
|
-
this.render(); //
|
|
198
|
+
this.render(); // Final render (with visual rows updated if necessary)
|
|
187
199
|
}
|
|
188
200
|
}
|
|
189
201
|
function handleAltArrows(keyName) {
|
|
@@ -332,15 +344,12 @@ function handleEditKeys(key) {
|
|
|
332
344
|
// For now, let's use Ctrl+B?
|
|
333
345
|
this.matchBracket();
|
|
334
346
|
return false;
|
|
335
|
-
//
|
|
336
|
-
// Sau khi undo/redo, chúng ta PHẢI tính toán lại visual rows
|
|
347
|
+
// After undo/redo, we MUST recalculate visual rows
|
|
337
348
|
case KEYS.CTRL_Z:
|
|
338
349
|
this.undo();
|
|
339
|
-
this.recalculateVisualRows(); // <-- THÊM DÒNG NÀY
|
|
340
350
|
return false;
|
|
341
351
|
case KEYS.CTRL_Y:
|
|
342
352
|
this.redo();
|
|
343
|
-
this.recalculateVisualRows(); // <-- THÊM DÒNG NÀY
|
|
344
353
|
return false;
|
|
345
354
|
// --- Clipboard ---
|
|
346
355
|
case KEYS.CTRL_K: // Cut Line (Traditional)
|
|
@@ -355,7 +364,7 @@ function handleEditKeys(key) {
|
|
|
355
364
|
case KEYS.CTRL_V: // Paste Selection
|
|
356
365
|
this.pasteSelection();
|
|
357
366
|
return true;
|
|
358
|
-
//
|
|
367
|
+
// Handle Printable Characters
|
|
359
368
|
default:
|
|
360
369
|
if (key.length === 1 && key >= ' ' && key <= '~') {
|
|
361
370
|
this.clearSearchResults();
|
|
@@ -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 {};
|
|
@@ -4,32 +4,19 @@
|
|
|
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
|
function findCurrentVisualRowIndex() {
|
|
9
|
-
const contentWidth = this.screenCols - this.gutterWidth;
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
const row = this.visualRows[i];
|
|
15
|
-
if (row.logicalY === this.cursorY) {
|
|
16
|
-
// Check if cursorX falls within this visual row's content chunk
|
|
17
|
-
if (this.cursorX >= row.logicalXStart &&
|
|
18
|
-
this.cursorX <= row.logicalXStart + row.content.length) {
|
|
19
|
-
// Edge case: If cursorX is exactly at the start of a wrapped line (and not start of logical line),
|
|
20
|
-
// treat it as the end of the previous visual row for consistent movement.
|
|
21
|
-
if (this.cursorX > 0 && this.cursorX === row.logicalXStart && i > 0) {
|
|
22
|
-
return i - 1;
|
|
23
|
-
}
|
|
24
|
-
return i;
|
|
25
|
-
}
|
|
26
|
-
}
|
|
27
|
-
// Optimization: if we've passed the cursor's logical line, the row must be the last one processed.
|
|
28
|
-
if (row.logicalY > this.cursorY) {
|
|
29
|
-
return i - 1;
|
|
30
|
-
}
|
|
10
|
+
const contentWidth = Math.max(1, this.screenCols - this.gutterWidth);
|
|
11
|
+
let visualRowIndex = 0;
|
|
12
|
+
// Sum visual height of all lines before current cursorY
|
|
13
|
+
for (let i = 0; i < this.cursorY; i++) {
|
|
14
|
+
visualRowIndex += this.getLineVisualHeight(i);
|
|
31
15
|
}
|
|
32
|
-
|
|
16
|
+
// Add visual offset within current line
|
|
17
|
+
// e.g. if cursorX is 250 and width is 100, we are on the 3rd row (index 2) of this line.
|
|
18
|
+
visualRowIndex += Math.floor(this.cursorX / contentWidth);
|
|
19
|
+
return visualRowIndex;
|
|
33
20
|
}
|
|
34
21
|
/**
|
|
35
22
|
* Moves the cursor one position left or right (logically, wrapping lines).
|
|
@@ -60,34 +47,54 @@ function moveCursorLogically(dx) {
|
|
|
60
47
|
*/
|
|
61
48
|
function moveCursorVisually(dy) {
|
|
62
49
|
const currentVisualRow = this.findCurrentVisualRowIndex();
|
|
63
|
-
|
|
64
|
-
if (currentVisualRow ===
|
|
50
|
+
// Prevent moving out of bounds (top)
|
|
51
|
+
if (dy < 0 && currentVisualRow === 0)
|
|
52
|
+
return;
|
|
53
|
+
// Calculate total visual rows (O(N) - expensive but necessary for bounds check at bottom)
|
|
54
|
+
// Optimization: If dy > 0, we can check as we go. But for now, let's keep it safe.
|
|
55
|
+
// Or just let getLogicalFromVisual handle out of bounds.
|
|
56
|
+
const targetVisualRow = Math.max(0, currentVisualRow + dy);
|
|
57
|
+
// Determine logical position of target visual row
|
|
58
|
+
const targetPos = this.getLogicalFromVisual(targetVisualRow);
|
|
59
|
+
// If we went past the end, clamp to end
|
|
60
|
+
if (targetPos.logicalY > this.lines.length - 1) {
|
|
61
|
+
this.cursorY = this.lines.length - 1;
|
|
62
|
+
this.cursorX = this.lines[this.cursorY].length;
|
|
65
63
|
return;
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
const
|
|
69
|
-
|
|
70
|
-
//
|
|
71
|
-
|
|
64
|
+
}
|
|
65
|
+
this.cursorY = targetPos.logicalY;
|
|
66
|
+
const contentWidth = Math.max(1, this.screenCols - this.gutterWidth);
|
|
67
|
+
// We want to maintain visual X (column on screen)
|
|
68
|
+
// Current visual X offset in the row
|
|
69
|
+
const currentVisualXOffset = this.cursorX % contentWidth;
|
|
70
|
+
// Target logical X start for that visual row chunk
|
|
71
|
+
const targetChunkStart = targetPos.visualYOffset * contentWidth;
|
|
72
|
+
// New cursor X
|
|
73
|
+
this.cursorX = targetChunkStart + currentVisualXOffset;
|
|
74
|
+
// Clamp to line length
|
|
75
|
+
const lineLength = this.lines[this.cursorY].length;
|
|
76
|
+
if (this.cursorX > lineLength) {
|
|
77
|
+
this.cursorX = lineLength;
|
|
78
|
+
}
|
|
72
79
|
}
|
|
73
80
|
/**
|
|
74
81
|
* Finds the start of the current visual line (Home key behavior).
|
|
75
82
|
*/
|
|
76
83
|
function findVisualRowStart() {
|
|
77
|
-
const
|
|
78
|
-
|
|
84
|
+
const contentWidth = Math.max(1, this.screenCols - this.gutterWidth);
|
|
85
|
+
const chunkIndex = Math.floor(this.cursorX / contentWidth);
|
|
86
|
+
return chunkIndex * contentWidth;
|
|
79
87
|
}
|
|
80
88
|
/**
|
|
81
89
|
* Finds the end of the current visual line (End key behavior).
|
|
82
90
|
*/
|
|
83
91
|
function findVisualRowEnd() {
|
|
84
|
-
const
|
|
85
|
-
const
|
|
86
|
-
const
|
|
87
|
-
|
|
88
|
-
const
|
|
89
|
-
|
|
90
|
-
return Math.min(lineLength, visualEnd);
|
|
92
|
+
const contentWidth = Math.max(1, this.screenCols - this.gutterWidth);
|
|
93
|
+
const chunkIndex = Math.floor(this.cursorX / contentWidth);
|
|
94
|
+
const lineLength = this.lines[this.cursorY].length;
|
|
95
|
+
const chunkStart = chunkIndex * contentWidth;
|
|
96
|
+
const chunkEnd = chunkStart + contentWidth;
|
|
97
|
+
return Math.min(lineLength, chunkEnd);
|
|
91
98
|
}
|
|
92
99
|
/**
|
|
93
100
|
* Clamps the cursor position to valid coordinates and ensures it stays within line bounds.
|
|
@@ -143,19 +150,6 @@ function enterGoToLineMode() {
|
|
|
143
150
|
this.goToLineQuery = '';
|
|
144
151
|
this.setStatusMessage('Go to Line (ESC to cancel): ');
|
|
145
152
|
}
|
|
146
|
-
export const navigationMethods = {
|
|
147
|
-
findCurrentVisualRowIndex,
|
|
148
|
-
moveCursorLogically,
|
|
149
|
-
moveCursorVisually,
|
|
150
|
-
findVisualRowStart,
|
|
151
|
-
findVisualRowEnd,
|
|
152
|
-
adjustCursorPosition,
|
|
153
|
-
scroll,
|
|
154
|
-
jumpToLine,
|
|
155
|
-
enterGoToLineMode,
|
|
156
|
-
moveCursorByWord,
|
|
157
|
-
matchBracket,
|
|
158
|
-
};
|
|
159
153
|
function moveCursorByWord(direction) {
|
|
160
154
|
const line = this.lines[this.cursorY];
|
|
161
155
|
if (direction === 'left') {
|
|
@@ -166,13 +160,9 @@ function moveCursorByWord(direction) {
|
|
|
166
160
|
}
|
|
167
161
|
}
|
|
168
162
|
else {
|
|
169
|
-
// Move left until we hit a non-word char, then until we hit a word char
|
|
170
|
-
// Simple logic: skip whitespace, then skip word chars
|
|
171
163
|
let i = this.cursorX - 1;
|
|
172
|
-
// 1. Skip spaces if we are currently on a space
|
|
173
164
|
while (i > 0 && line[i] === ' ')
|
|
174
165
|
i--;
|
|
175
|
-
// 2. Skip non-spaces
|
|
176
166
|
while (i > 0 && line[i - 1] !== ' ')
|
|
177
167
|
i--;
|
|
178
168
|
this.cursorX = i;
|
|
@@ -187,10 +177,8 @@ function moveCursorByWord(direction) {
|
|
|
187
177
|
}
|
|
188
178
|
else {
|
|
189
179
|
let i = this.cursorX;
|
|
190
|
-
// 1. Skip current word chars
|
|
191
180
|
while (i < line.length && line[i] !== ' ')
|
|
192
181
|
i++;
|
|
193
|
-
// 2. Skip spaces
|
|
194
182
|
while (i < line.length && line[i] === ' ')
|
|
195
183
|
i++;
|
|
196
184
|
this.cursorX = i;
|
|
@@ -203,9 +191,7 @@ function matchBracket() {
|
|
|
203
191
|
const pairs = { '(': ')', '[': ']', '{': '}' };
|
|
204
192
|
const revPairs = { ')': '(', ']': '[', '}': '{' };
|
|
205
193
|
if (pairs[char]) {
|
|
206
|
-
// Find closing
|
|
207
194
|
let depth = 1;
|
|
208
|
-
// Search forward
|
|
209
195
|
for (let y = this.cursorY; y < this.lines.length; y++) {
|
|
210
196
|
const l = this.lines[y];
|
|
211
197
|
const startX = (y === this.cursorY) ? this.cursorX + 1 : 0;
|
|
@@ -224,9 +210,7 @@ function matchBracket() {
|
|
|
224
210
|
}
|
|
225
211
|
}
|
|
226
212
|
else if (revPairs[char]) {
|
|
227
|
-
// Find opening
|
|
228
213
|
let depth = 1;
|
|
229
|
-
// Search backward
|
|
230
214
|
for (let y = this.cursorY; y >= 0; y--) {
|
|
231
215
|
const l = this.lines[y];
|
|
232
216
|
const startX = (y === this.cursorY) ? this.cursorX - 1 : l.length - 1;
|
|
@@ -245,3 +229,16 @@ function matchBracket() {
|
|
|
245
229
|
}
|
|
246
230
|
}
|
|
247
231
|
}
|
|
232
|
+
export const navigationMethods = {
|
|
233
|
+
findCurrentVisualRowIndex,
|
|
234
|
+
moveCursorLogically,
|
|
235
|
+
moveCursorVisually,
|
|
236
|
+
findVisualRowStart,
|
|
237
|
+
findVisualRowEnd,
|
|
238
|
+
adjustCursorPosition,
|
|
239
|
+
scroll,
|
|
240
|
+
jumpToLine,
|
|
241
|
+
enterGoToLineMode,
|
|
242
|
+
moveCursorByWord,
|
|
243
|
+
matchBracket,
|
|
244
|
+
};
|