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 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';
@@ -9,6 +9,7 @@ export declare const ANSI: {
9
9
  SHOW_CURSOR: string;
10
10
  INVERT_COLORS: string;
11
11
  RESET_COLORS: string;
12
+ DIM: string;
12
13
  ENTER_ALTERNATE_SCREEN: string;
13
14
  EXIT_ALTERNATE_SCREEN: string;
14
15
  YELLOW: string;
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
@@ -15,7 +15,7 @@ function setClipboard(text) {
15
15
  case 'win32':
16
16
  command = 'clip';
17
17
  break;
18
- case 'linux': // <--- THÊM HỖ TRỢ 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': // <--- THÊM HỖ TRỢ LINUX
50
- command = 'xclip -selection clipboard -o'; // -o (hoặc -out) để đọc
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 { VisualRow, EditorMode, EditorOptions } from './types.js';
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;
@@ -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
- // Cập nhật 1: Import từ ./vendor/keypress.js
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
- // Block `declare module 'keypress'` đã bị xóa
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.recalculateVisualRows();
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.recalculateVisualRows();
165
+ this.screenBuffer.resize(process.stdout.rows, process.stdout.columns);
132
166
  this.render();
133
167
  }
134
168
  updateScreenSize() {
@@ -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. Xử trường hợp key null/undefined ( tự in được) ---
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. Từ đây, 'key' object đảm bảo (phím đặc biệt hoặc Ctrl/Meta) ---
45
- // 2.1. Ánh xạ Control sequences (Ctrl+Arrow cho selection)
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. Ánh xạ phím tiêu chuẩn (Arrow, Home, End, Enter, Tab)
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. Định tuyến theo Mode ---
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. Xử phím lựa chọn (Ctrl+Arrow) - Navigation
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. Xử tất cả các phím lệnh/chỉnh sửa khác
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
- // Wait, scroll UP means viewing lines ABOVE. Viewport index decreases.
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
- const targetRow = this.visualRows[bottomEdge];
143
- this.cursorY = targetRow.logicalY;
144
- this.cursorX = targetRow.logicalXStart;
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 targetRow = this.visualRows[bottomEdge];
159
- this.cursorY = targetRow.logicalY;
160
- this.cursorX = targetRow.logicalXStart;
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
- const maxOffset = Math.max(0, this.visualRows.length - this.screenRows);
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 targetRow = this.visualRows[this.rowOffset];
172
- this.cursorY = targetRow.logicalY;
173
- this.cursorX = targetRow.logicalXStart;
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. Cập nhật Trạng thái và Render
193
+ // 6. Update State and Render
181
194
  if (edited) {
182
- this.saveState(); // <-- Chỉ gọi khi gõ phím, xóa, v.v.
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(); // Render cuối cùng (với visual rows đã được cập nhật nếu cần)
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
- // ***** SỬA LỖI VISUAL *****
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
- // Xử Ký tự in được
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
- if (contentWidth <= 0)
11
- return 0;
12
- // Find the visual row index corresponding to the logical cursor position (cursorY, cursorX)
13
- for (let i = 0; i < this.visualRows.length; i++) {
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
- return Math.max(0, this.visualRows.length - 1);
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
- const targetVisualRow = Math.max(0, Math.min(currentVisualRow + dy, this.visualRows.length - 1));
64
- if (currentVisualRow === targetVisualRow)
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
- const targetRow = this.visualRows[targetVisualRow];
67
- // Calculate the cursor's visual column position relative to its visual row start
68
- const currentVisualX = this.cursorX - (this.visualRows[currentVisualRow]?.logicalXStart || 0);
69
- this.cursorY = targetRow.logicalY;
70
- // Maintain the visual column position as closely as possible
71
- this.cursorX = Math.min(targetRow.logicalXStart + currentVisualX, this.lines[this.cursorY].length);
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 visualRow = this.visualRows[this.findCurrentVisualRowIndex()];
78
- return visualRow.logicalXStart;
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 visualRow = this.visualRows[this.findCurrentVisualRowIndex()];
85
- const lineLength = this.lines[visualRow.logicalY].length;
86
- const contentWidth = this.screenCols - this.gutterWidth;
87
- // The visual end is the start of the visual row + the maximum content width
88
- const visualEnd = visualRow.logicalXStart + contentWidth;
89
- // The actual logical X should be the minimum of the line's end and the visual end
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
+ };