@fleetagent/pi-tui 0.1.8 → 0.2.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 +47 -7
- package/dist/autocomplete.d.ts +17 -23
- package/dist/autocomplete.d.ts.map +1 -1
- package/dist/autocomplete.js +129 -195
- package/dist/autocomplete.js.map +1 -1
- package/dist/components/alt-screen-flash.d.ts +13 -0
- package/dist/components/alt-screen-flash.d.ts.map +1 -0
- package/dist/components/alt-screen-flash.js +37 -0
- package/dist/components/alt-screen-flash.js.map +1 -0
- package/dist/components/editor.d.ts +47 -4
- package/dist/components/editor.d.ts.map +1 -1
- package/dist/components/editor.js +615 -585
- package/dist/components/editor.js.map +1 -1
- package/dist/components/h-stack.d.ts +7 -0
- package/dist/components/h-stack.d.ts.map +1 -0
- package/dist/components/h-stack.js +43 -0
- package/dist/components/h-stack.js.map +1 -0
- package/dist/components/image.d.ts +4 -0
- package/dist/components/image.d.ts.map +1 -1
- package/dist/components/image.js +38 -51
- package/dist/components/image.js.map +1 -1
- package/dist/components/input.d.ts +9 -0
- package/dist/components/input.d.ts.map +1 -1
- package/dist/components/input.js +121 -147
- package/dist/components/input.js.map +1 -1
- package/dist/components/markdown.d.ts +34 -0
- package/dist/components/markdown.d.ts.map +1 -1
- package/dist/components/markdown.js +491 -429
- package/dist/components/markdown.js.map +1 -1
- package/dist/components/scroll-view.d.ts +53 -0
- package/dist/components/scroll-view.d.ts.map +1 -0
- package/dist/components/scroll-view.js +173 -0
- package/dist/components/scroll-view.js.map +1 -0
- package/dist/components/select-list.d.ts.map +1 -1
- package/dist/components/select-list.js.map +1 -1
- package/dist/components/settings-list.d.ts +2 -0
- package/dist/components/settings-list.d.ts.map +1 -1
- package/dist/components/settings-list.js +31 -29
- package/dist/components/settings-list.js.map +1 -1
- package/dist/components/stack.d.ts +32 -0
- package/dist/components/stack.d.ts.map +1 -0
- package/dist/components/stack.js +122 -0
- package/dist/components/stack.js.map +1 -0
- package/dist/components/v-stack.d.ts +8 -0
- package/dist/components/v-stack.d.ts.map +1 -0
- package/dist/components/v-stack.js +27 -0
- package/dist/components/v-stack.js.map +1 -0
- package/dist/fuzzy.d.ts.map +1 -1
- package/dist/fuzzy.js +37 -46
- package/dist/fuzzy.js.map +1 -1
- package/dist/index.d.ts +10 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +11 -2
- package/dist/index.js.map +1 -1
- package/dist/keybindings.d.ts +54 -4
- package/dist/keybindings.d.ts.map +1 -1
- package/dist/keybindings.js +39 -4
- package/dist/keybindings.js.map +1 -1
- package/dist/keys.d.ts.map +1 -1
- package/dist/keys.js +359 -479
- package/dist/keys.js.map +1 -1
- package/dist/kill-ring.d.ts +6 -4
- package/dist/kill-ring.d.ts.map +1 -1
- package/dist/kill-ring.js +0 -7
- package/dist/kill-ring.js.map +1 -1
- package/dist/latex.d.ts +10 -0
- package/dist/latex.d.ts.map +1 -0
- package/dist/latex.js +1406 -0
- package/dist/latex.js.map +1 -0
- package/dist/layout-node.d.ts +43 -0
- package/dist/layout-node.d.ts.map +1 -0
- package/dist/layout-node.js +6 -0
- package/dist/layout-node.js.map +1 -0
- package/dist/layout.d.ts +40 -0
- package/dist/layout.d.ts.map +1 -0
- package/dist/layout.js +386 -0
- package/dist/layout.js.map +1 -0
- package/dist/stdin-buffer.d.ts +8 -0
- package/dist/stdin-buffer.d.ts.map +1 -1
- package/dist/stdin-buffer.js +116 -159
- package/dist/stdin-buffer.js.map +1 -1
- package/dist/terminal-image.d.ts +41 -19
- package/dist/terminal-image.d.ts.map +1 -1
- package/dist/terminal-image.js +171 -32
- package/dist/terminal-image.js.map +1 -1
- package/dist/terminal.d.ts +3 -0
- package/dist/terminal.d.ts.map +1 -1
- package/dist/terminal.js +27 -23
- package/dist/terminal.js.map +1 -1
- package/dist/tui-alt-screen.d.ts +114 -0
- package/dist/tui-alt-screen.d.ts.map +1 -0
- package/dist/tui-alt-screen.js +950 -0
- package/dist/tui-alt-screen.js.map +1 -0
- package/dist/tui-main-screen.d.ts +52 -0
- package/dist/tui-main-screen.d.ts.map +1 -0
- package/dist/tui-main-screen.js +511 -0
- package/dist/tui-main-screen.js.map +1 -0
- package/dist/tui.d.ts +112 -44
- package/dist/tui.d.ts.map +1 -1
- package/dist/tui.js +489 -699
- package/dist/tui.js.map +1 -1
- package/dist/utils.d.ts +29 -17
- package/dist/utils.d.ts.map +1 -1
- package/dist/utils.js +605 -528
- package/dist/utils.js.map +1 -1
- package/dist/word-navigation.d.ts.map +1 -1
- package/dist/word-navigation.js +37 -39
- package/dist/word-navigation.js.map +1 -1
- package/package.json +1 -1
|
@@ -3,7 +3,7 @@ import { decodePrintableKey, matchesKey } from "../keys.js";
|
|
|
3
3
|
import { KillRing } from "../kill-ring.js";
|
|
4
4
|
import { CURSOR_MARKER } from "../tui.js";
|
|
5
5
|
import { UndoStack } from "../undo-stack.js";
|
|
6
|
-
import { getGraphemeSegmenter, getWordSegmenter, isWhitespaceChar,
|
|
6
|
+
import { getGraphemeSegmenter, getWordSegmenter, isWhitespaceChar, sliceByColumn, visibleWidth } from "../utils.js";
|
|
7
7
|
import { findWordBackward, findWordForward } from "../word-navigation.js";
|
|
8
8
|
import { SelectList } from "./select-list.js";
|
|
9
9
|
const graphemeSegmenter = getGraphemeSegmenter();
|
|
@@ -23,51 +23,93 @@ function isPasteMarker(segment) {
|
|
|
23
23
|
*
|
|
24
24
|
* Only markers whose numeric ID exists in `validIds` are merged.
|
|
25
25
|
*/
|
|
26
|
-
function
|
|
27
|
-
// Fast path: no paste markers in the text or no valid IDs.
|
|
28
|
-
if (validIds.size === 0 || !text.includes("[paste #")) {
|
|
29
|
-
return baseSegmenter.segment(text);
|
|
30
|
-
}
|
|
31
|
-
// Find all marker spans with valid IDs.
|
|
26
|
+
function findPasteMarkerSpans(text, validIds) {
|
|
32
27
|
const markers = [];
|
|
33
|
-
for (const
|
|
34
|
-
const id = Number.parseInt(
|
|
28
|
+
for (const match of text.matchAll(PASTE_MARKER_REGEX)) {
|
|
29
|
+
const id = Number.parseInt(match[1], 10);
|
|
35
30
|
if (!validIds.has(id))
|
|
36
31
|
continue;
|
|
37
|
-
markers.push({ start:
|
|
38
|
-
}
|
|
39
|
-
if (markers.length === 0) {
|
|
40
|
-
return baseSegmenter.segment(text);
|
|
32
|
+
markers.push({ start: match.index, end: match.index + match[0].length });
|
|
41
33
|
}
|
|
42
|
-
|
|
43
|
-
|
|
34
|
+
return markers;
|
|
35
|
+
}
|
|
36
|
+
function mergePasteMarkerSegments(text, baseSegments, markers) {
|
|
44
37
|
const result = [];
|
|
45
|
-
let
|
|
46
|
-
for (const
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
if (marker && seg.index >= marker.start && seg.index < marker.end) {
|
|
53
|
-
// This segment falls inside a marker.
|
|
54
|
-
// If this is the first segment of the marker, emit a merged segment.
|
|
55
|
-
if (seg.index === marker.start) {
|
|
56
|
-
const markerText = text.slice(marker.start, marker.end);
|
|
38
|
+
let markerIndex = 0;
|
|
39
|
+
for (const segment of baseSegments) {
|
|
40
|
+
while (markerIndex < markers.length && markers[markerIndex].end <= segment.index)
|
|
41
|
+
markerIndex++;
|
|
42
|
+
const marker = markerIndex < markers.length ? markers[markerIndex] : undefined;
|
|
43
|
+
if (marker && segment.index >= marker.start && segment.index < marker.end) {
|
|
44
|
+
if (segment.index === marker.start) {
|
|
57
45
|
result.push({
|
|
58
|
-
segment:
|
|
46
|
+
segment: text.slice(marker.start, marker.end),
|
|
59
47
|
index: marker.start,
|
|
60
48
|
input: text,
|
|
61
49
|
});
|
|
62
50
|
}
|
|
63
|
-
// Otherwise skip (already merged into the first segment).
|
|
64
51
|
}
|
|
65
52
|
else {
|
|
66
|
-
result.push(
|
|
53
|
+
result.push(segment);
|
|
67
54
|
}
|
|
68
55
|
}
|
|
69
56
|
return result;
|
|
70
57
|
}
|
|
58
|
+
function segmentWithMarkers(text, baseSegmenter, validIds) {
|
|
59
|
+
if (validIds.size === 0 || !text.includes("[paste #"))
|
|
60
|
+
return baseSegmenter.segment(text);
|
|
61
|
+
const markers = findPasteMarkerSpans(text, validIds);
|
|
62
|
+
if (markers.length === 0)
|
|
63
|
+
return baseSegmenter.segment(text);
|
|
64
|
+
return mergePasteMarkerSegments(text, baseSegmenter.segment(text), markers);
|
|
65
|
+
}
|
|
66
|
+
function resolveWrapOverflow(input) {
|
|
67
|
+
if (input.wrapOpportunityIndex >= 0 &&
|
|
68
|
+
input.currentWidth - input.wrapOpportunityWidth + input.graphemeWidth <= input.maxWidth) {
|
|
69
|
+
return {
|
|
70
|
+
chunk: {
|
|
71
|
+
text: input.line.slice(input.chunkStart, input.wrapOpportunityIndex),
|
|
72
|
+
startIndex: input.chunkStart,
|
|
73
|
+
endIndex: input.wrapOpportunityIndex,
|
|
74
|
+
},
|
|
75
|
+
chunkStart: input.wrapOpportunityIndex,
|
|
76
|
+
currentWidth: input.currentWidth - input.wrapOpportunityWidth,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
if (input.chunkStart < input.charIndex) {
|
|
80
|
+
return {
|
|
81
|
+
chunk: {
|
|
82
|
+
text: input.line.slice(input.chunkStart, input.charIndex),
|
|
83
|
+
startIndex: input.chunkStart,
|
|
84
|
+
endIndex: input.charIndex,
|
|
85
|
+
},
|
|
86
|
+
chunkStart: input.charIndex,
|
|
87
|
+
currentWidth: 0,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
return { chunkStart: input.chunkStart, currentWidth: input.currentWidth };
|
|
91
|
+
}
|
|
92
|
+
function splitOversizedSegment(grapheme, maxWidth, charIndex) {
|
|
93
|
+
const subChunks = wordWrapLine(grapheme, maxWidth);
|
|
94
|
+
const completedChunks = subChunks.slice(0, -1).map((chunk) => ({
|
|
95
|
+
text: chunk.text,
|
|
96
|
+
startIndex: charIndex + chunk.startIndex,
|
|
97
|
+
endIndex: charIndex + chunk.endIndex,
|
|
98
|
+
}));
|
|
99
|
+
const last = subChunks[subChunks.length - 1];
|
|
100
|
+
return {
|
|
101
|
+
completedChunks,
|
|
102
|
+
chunkStart: charIndex + last.startIndex,
|
|
103
|
+
currentWidth: visibleWidth(last.text),
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
function findWrapOpportunityAfterWhitespace(currentIsWhitespace, next) {
|
|
107
|
+
if (!currentIsWhitespace || !next)
|
|
108
|
+
return undefined;
|
|
109
|
+
if (!isPasteMarker(next.segment) && isWhitespaceChar(next.segment))
|
|
110
|
+
return undefined;
|
|
111
|
+
return next.index;
|
|
112
|
+
}
|
|
71
113
|
/**
|
|
72
114
|
* Split a line into word-wrapped chunks.
|
|
73
115
|
* Wraps at word boundaries when possible, falling back to character-level
|
|
@@ -101,40 +143,30 @@ export function wordWrapLine(line, maxWidth, preSegmented) {
|
|
|
101
143
|
const gWidth = visibleWidth(grapheme);
|
|
102
144
|
const charIndex = seg.index;
|
|
103
145
|
const isWs = !isPasteMarker(grapheme) && isWhitespaceChar(grapheme);
|
|
104
|
-
//
|
|
146
|
+
// Resolve overflow before advancing to the current grapheme.
|
|
105
147
|
if (currentWidth + gWidth > maxWidth) {
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
chunkStart = charIndex;
|
|
121
|
-
currentWidth = 0;
|
|
122
|
-
}
|
|
148
|
+
const resolution = resolveWrapOverflow({
|
|
149
|
+
line,
|
|
150
|
+
maxWidth,
|
|
151
|
+
graphemeWidth: gWidth,
|
|
152
|
+
charIndex,
|
|
153
|
+
chunkStart,
|
|
154
|
+
currentWidth,
|
|
155
|
+
wrapOpportunityIndex: wrapOppIndex,
|
|
156
|
+
wrapOpportunityWidth: wrapOppWidth,
|
|
157
|
+
});
|
|
158
|
+
if (resolution.chunk)
|
|
159
|
+
chunks.push(resolution.chunk);
|
|
160
|
+
chunkStart = resolution.chunkStart;
|
|
161
|
+
currentWidth = resolution.currentWidth;
|
|
123
162
|
wrapOppIndex = -1;
|
|
124
163
|
}
|
|
125
164
|
if (gWidth > maxWidth) {
|
|
126
|
-
//
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
for (let j = 0; j < subChunks.length - 1; j++) {
|
|
132
|
-
const sc = subChunks[j];
|
|
133
|
-
chunks.push({ text: sc.text, startIndex: charIndex + sc.startIndex, endIndex: charIndex + sc.endIndex });
|
|
134
|
-
}
|
|
135
|
-
const last = subChunks[subChunks.length - 1];
|
|
136
|
-
chunkStart = charIndex + last.startIndex;
|
|
137
|
-
currentWidth = visibleWidth(last.text);
|
|
165
|
+
// Keep the segment logically atomic for editing while splitting its visual layout.
|
|
166
|
+
const wrapped = splitOversizedSegment(grapheme, maxWidth, charIndex);
|
|
167
|
+
chunks.push(...wrapped.completedChunks);
|
|
168
|
+
chunkStart = wrapped.chunkStart;
|
|
169
|
+
currentWidth = wrapped.currentWidth;
|
|
138
170
|
wrapOppIndex = -1;
|
|
139
171
|
continue;
|
|
140
172
|
}
|
|
@@ -144,8 +176,9 @@ export function wordWrapLine(line, maxWidth, preSegmented) {
|
|
|
144
176
|
// Multiple spaces join (no break between them); the break point is
|
|
145
177
|
// after the last space before the next word.
|
|
146
178
|
const next = segments[i + 1];
|
|
147
|
-
|
|
148
|
-
|
|
179
|
+
const nextWrapOpportunity = findWrapOpportunityAfterWhitespace(isWs, next);
|
|
180
|
+
if (nextWrapOpportunity !== undefined) {
|
|
181
|
+
wrapOppIndex = nextWrapOpportunity;
|
|
149
182
|
wrapOppWidth = currentWidth;
|
|
150
183
|
}
|
|
151
184
|
}
|
|
@@ -158,6 +191,16 @@ const SLASH_COMMAND_SELECT_LIST_LAYOUT = {
|
|
|
158
191
|
maxPrimaryColumnWidth: 32,
|
|
159
192
|
};
|
|
160
193
|
const ATTACHMENT_AUTOCOMPLETE_DEBOUNCE_MS = 20;
|
|
194
|
+
function createScrollBorder(direction, hiddenLineCount, width) {
|
|
195
|
+
const availableWidth = Math.max(0, width);
|
|
196
|
+
const indicator = `─── ${direction} ${hiddenLineCount} more `;
|
|
197
|
+
const remaining = availableWidth - visibleWidth(indicator);
|
|
198
|
+
if (remaining >= 0)
|
|
199
|
+
return indicator + "─".repeat(remaining);
|
|
200
|
+
const ellipsis = "...".slice(0, availableWidth);
|
|
201
|
+
const indicatorWidth = availableWidth - visibleWidth(ellipsis);
|
|
202
|
+
return sliceByColumn(indicator, 0, indicatorWidth, true) + ellipsis;
|
|
203
|
+
}
|
|
161
204
|
export class Editor {
|
|
162
205
|
state = {
|
|
163
206
|
lines: [""],
|
|
@@ -319,452 +362,411 @@ export class Editor {
|
|
|
319
362
|
invalidate() {
|
|
320
363
|
// No cached state to invalidate currently
|
|
321
364
|
}
|
|
322
|
-
|
|
365
|
+
getRenderGeometry(width) {
|
|
323
366
|
const maxPadding = Math.max(0, Math.floor((width - 1) / 2));
|
|
324
367
|
const paddingX = Math.min(this.paddingX, maxPadding);
|
|
325
368
|
const contentWidth = Math.max(1, width - paddingX * 2);
|
|
326
|
-
// Layout width: with padding the cursor can overflow into it,
|
|
327
|
-
// without padding we reserve 1 column for the cursor.
|
|
328
369
|
const layoutWidth = Math.max(1, contentWidth - (paddingX ? 0 : 1));
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
370
|
+
const leftPadding = " ".repeat(paddingX);
|
|
371
|
+
return {
|
|
372
|
+
width,
|
|
373
|
+
paddingX,
|
|
374
|
+
contentWidth,
|
|
375
|
+
layoutWidth,
|
|
376
|
+
horizontal: this.borderColor("─"),
|
|
377
|
+
leftPadding,
|
|
378
|
+
rightPadding: leftPadding,
|
|
379
|
+
};
|
|
380
|
+
}
|
|
381
|
+
getVisibleLayoutLines(layoutLines) {
|
|
382
|
+
const maxVisibleLines = Math.max(5, Math.floor(this.tui.terminal.rows * 0.3));
|
|
383
|
+
const foundCursorLineIndex = layoutLines.findIndex((line) => line.hasCursor);
|
|
384
|
+
const cursorLineIndex = foundCursorLineIndex === -1 ? 0 : foundCursorLineIndex;
|
|
342
385
|
if (cursorLineIndex < this.scrollOffset) {
|
|
343
386
|
this.scrollOffset = cursorLineIndex;
|
|
344
387
|
}
|
|
345
388
|
else if (cursorLineIndex >= this.scrollOffset + maxVisibleLines) {
|
|
346
389
|
this.scrollOffset = cursorLineIndex - maxVisibleLines + 1;
|
|
347
390
|
}
|
|
348
|
-
// Clamp scroll offset to valid range
|
|
349
391
|
const maxScrollOffset = Math.max(0, layoutLines.length - maxVisibleLines);
|
|
350
392
|
this.scrollOffset = Math.max(0, Math.min(this.scrollOffset, maxScrollOffset));
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
const
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
393
|
+
return layoutLines.slice(this.scrollOffset, this.scrollOffset + maxVisibleLines);
|
|
394
|
+
}
|
|
395
|
+
renderLayoutLineCursor(layoutLine, geometry, emitCursorMarker) {
|
|
396
|
+
const lineWidth = visibleWidth(layoutLine.text);
|
|
397
|
+
if (!layoutLine.hasCursor || layoutLine.cursorPos === undefined) {
|
|
398
|
+
return { displayText: layoutLine.text, visibleWidth: lineWidth, cursorInPadding: false };
|
|
399
|
+
}
|
|
400
|
+
const before = layoutLine.text.slice(0, layoutLine.cursorPos);
|
|
401
|
+
const after = layoutLine.text.slice(layoutLine.cursorPos);
|
|
402
|
+
const marker = emitCursorMarker ? CURSOR_MARKER : "";
|
|
403
|
+
if (after.length > 0) {
|
|
404
|
+
const firstGrapheme = [...this.segment(after, "grapheme")][0]?.segment || "";
|
|
405
|
+
const restAfter = after.slice(firstGrapheme.length);
|
|
406
|
+
return {
|
|
407
|
+
displayText: `${before}${marker}\x1b[7m${firstGrapheme}\x1b[0m${restAfter}`,
|
|
408
|
+
visibleWidth: lineWidth,
|
|
409
|
+
cursorInPadding: false,
|
|
410
|
+
};
|
|
411
|
+
}
|
|
412
|
+
const widthWithCursor = lineWidth + 1;
|
|
413
|
+
return {
|
|
414
|
+
displayText: `${before}${marker}\x1b[7m \x1b[0m`,
|
|
415
|
+
visibleWidth: widthWithCursor,
|
|
416
|
+
cursorInPadding: widthWithCursor > geometry.contentWidth && geometry.paddingX > 0,
|
|
417
|
+
};
|
|
418
|
+
}
|
|
419
|
+
renderVisibleLayoutLine(layoutLine, geometry, emitCursorMarker) {
|
|
420
|
+
const rendered = this.renderLayoutLineCursor(layoutLine, geometry, emitCursorMarker);
|
|
421
|
+
const padding = " ".repeat(Math.max(0, geometry.contentWidth - rendered.visibleWidth));
|
|
422
|
+
const rightPadding = rendered.cursorInPadding ? geometry.rightPadding.slice(1) : geometry.rightPadding;
|
|
423
|
+
return `${geometry.leftPadding}${rendered.displayText}${padding}${rightPadding}`;
|
|
424
|
+
}
|
|
425
|
+
renderTopEditorBorder(geometry) {
|
|
426
|
+
if (this.scrollOffset === 0)
|
|
427
|
+
return geometry.horizontal.repeat(geometry.width);
|
|
428
|
+
return this.borderColor(createScrollBorder("↑", this.scrollOffset, geometry.width));
|
|
429
|
+
}
|
|
430
|
+
renderBottomEditorBorder(layoutLineCount, visibleLineCount, geometry) {
|
|
431
|
+
const linesBelow = layoutLineCount - (this.scrollOffset + visibleLineCount);
|
|
432
|
+
if (linesBelow <= 0)
|
|
433
|
+
return geometry.horizontal.repeat(geometry.width);
|
|
434
|
+
return this.borderColor(createScrollBorder("↓", linesBelow, geometry.width));
|
|
435
|
+
}
|
|
436
|
+
appendAutocompleteLines(result, geometry) {
|
|
437
|
+
if (!this.autocompleteState || !this.autocompleteList)
|
|
438
|
+
return;
|
|
439
|
+
for (const line of this.autocompleteList.render(geometry.contentWidth)) {
|
|
440
|
+
const lineWidth = visibleWidth(line);
|
|
441
|
+
const linePadding = " ".repeat(Math.max(0, geometry.contentWidth - lineWidth));
|
|
442
|
+
result.push(`${geometry.leftPadding}${line}${linePadding}${geometry.rightPadding}`);
|
|
369
443
|
}
|
|
370
|
-
|
|
371
|
-
|
|
444
|
+
}
|
|
445
|
+
render(width) {
|
|
446
|
+
const geometry = this.getRenderGeometry(width);
|
|
447
|
+
this.lastWidth = geometry.layoutWidth;
|
|
448
|
+
const layoutLines = this.layoutText(geometry.layoutWidth);
|
|
449
|
+
const visibleLines = this.getVisibleLayoutLines(layoutLines);
|
|
450
|
+
const result = [this.renderTopEditorBorder(geometry)];
|
|
372
451
|
const emitCursorMarker = this.focused && !this.autocompleteState;
|
|
373
452
|
for (const layoutLine of visibleLines) {
|
|
374
|
-
|
|
375
|
-
let lineVisibleWidth = visibleWidth(layoutLine.text);
|
|
376
|
-
let cursorInPadding = false;
|
|
377
|
-
// Add cursor if this line has it
|
|
378
|
-
if (layoutLine.hasCursor && layoutLine.cursorPos !== undefined) {
|
|
379
|
-
const before = displayText.slice(0, layoutLine.cursorPos);
|
|
380
|
-
const after = displayText.slice(layoutLine.cursorPos);
|
|
381
|
-
// Hardware cursor marker (zero-width, emitted before fake cursor for IME positioning)
|
|
382
|
-
const marker = emitCursorMarker ? CURSOR_MARKER : "";
|
|
383
|
-
if (after.length > 0) {
|
|
384
|
-
// Cursor is on a character (grapheme) - replace it with highlighted version
|
|
385
|
-
// Get the first grapheme from 'after'
|
|
386
|
-
const afterGraphemes = [...this.segment(after, "grapheme")];
|
|
387
|
-
const firstGrapheme = afterGraphemes[0]?.segment || "";
|
|
388
|
-
const restAfter = after.slice(firstGrapheme.length);
|
|
389
|
-
const cursor = `\x1b[7m${firstGrapheme}\x1b[0m`;
|
|
390
|
-
displayText = before + marker + cursor + restAfter;
|
|
391
|
-
// lineVisibleWidth stays the same - we're replacing, not adding
|
|
392
|
-
}
|
|
393
|
-
else {
|
|
394
|
-
// Cursor is at the end - add highlighted space
|
|
395
|
-
const cursor = "\x1b[7m \x1b[0m";
|
|
396
|
-
displayText = before + marker + cursor;
|
|
397
|
-
lineVisibleWidth = lineVisibleWidth + 1;
|
|
398
|
-
// If cursor overflows content width into the padding, flag it
|
|
399
|
-
if (lineVisibleWidth > contentWidth && paddingX > 0) {
|
|
400
|
-
cursorInPadding = true;
|
|
401
|
-
}
|
|
402
|
-
}
|
|
403
|
-
}
|
|
404
|
-
// Calculate padding based on actual visible width
|
|
405
|
-
const padding = " ".repeat(Math.max(0, contentWidth - lineVisibleWidth));
|
|
406
|
-
const lineRightPadding = cursorInPadding ? rightPadding.slice(1) : rightPadding;
|
|
407
|
-
// Render the line (no side borders, just horizontal lines above and below)
|
|
408
|
-
result.push(`${leftPadding}${displayText}${padding}${lineRightPadding}`);
|
|
409
|
-
}
|
|
410
|
-
// Render bottom border (with scroll indicator if more content below)
|
|
411
|
-
const linesBelow = layoutLines.length - (this.scrollOffset + visibleLines.length);
|
|
412
|
-
if (linesBelow > 0) {
|
|
413
|
-
const indicator = `─── ↓ ${linesBelow} more `;
|
|
414
|
-
const remaining = width - visibleWidth(indicator);
|
|
415
|
-
result.push(this.borderColor(indicator + "─".repeat(Math.max(0, remaining))));
|
|
416
|
-
}
|
|
417
|
-
else {
|
|
418
|
-
result.push(horizontal.repeat(width));
|
|
419
|
-
}
|
|
420
|
-
// Add autocomplete list if active
|
|
421
|
-
if (this.autocompleteState && this.autocompleteList) {
|
|
422
|
-
const autocompleteResult = this.autocompleteList.render(contentWidth);
|
|
423
|
-
for (const line of autocompleteResult) {
|
|
424
|
-
const lineWidth = visibleWidth(line);
|
|
425
|
-
const linePadding = " ".repeat(Math.max(0, contentWidth - lineWidth));
|
|
426
|
-
result.push(`${leftPadding}${line}${linePadding}${rightPadding}`);
|
|
427
|
-
}
|
|
453
|
+
result.push(this.renderVisibleLayoutLine(layoutLine, geometry, emitCursorMarker));
|
|
428
454
|
}
|
|
455
|
+
result.push(this.renderBottomEditorBorder(layoutLines.length, visibleLines.length, geometry));
|
|
456
|
+
this.appendAutocompleteLines(result, geometry);
|
|
429
457
|
return result;
|
|
430
458
|
}
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
if (
|
|
435
|
-
// Cancel if the hotkey is pressed again
|
|
436
|
-
if (kb.matches(data, "tui.editor.jumpForward") || kb.matches(data, "tui.editor.jumpBackward")) {
|
|
437
|
-
this.jumpMode = null;
|
|
438
|
-
return;
|
|
439
|
-
}
|
|
440
|
-
const printable = decodePrintableKey(data) ?? (data.charCodeAt(0) >= 32 ? data : undefined);
|
|
441
|
-
if (printable !== undefined) {
|
|
442
|
-
// Printable character - perform the jump
|
|
443
|
-
const direction = this.jumpMode;
|
|
444
|
-
this.jumpMode = null;
|
|
445
|
-
this.jumpToChar(printable, direction);
|
|
446
|
-
return;
|
|
447
|
-
}
|
|
448
|
-
// Control character - cancel and fall through to normal handling
|
|
459
|
+
handleJumpModeInput(data, keybindings) {
|
|
460
|
+
if (this.jumpMode === null)
|
|
461
|
+
return false;
|
|
462
|
+
if (keybindings.matches(data, "tui.editor.jumpForward") || keybindings.matches(data, "tui.editor.jumpBackward")) {
|
|
449
463
|
this.jumpMode = null;
|
|
464
|
+
return true;
|
|
450
465
|
}
|
|
451
|
-
|
|
452
|
-
if (
|
|
466
|
+
const printable = decodePrintableKey(data) ?? (data.charCodeAt(0) >= 32 ? data : undefined);
|
|
467
|
+
if (printable !== undefined) {
|
|
468
|
+
const direction = this.jumpMode;
|
|
469
|
+
this.jumpMode = null;
|
|
470
|
+
this.jumpToChar(printable, direction);
|
|
471
|
+
return true;
|
|
472
|
+
}
|
|
473
|
+
this.jumpMode = null;
|
|
474
|
+
return false;
|
|
475
|
+
}
|
|
476
|
+
handleBracketedPasteInput(data) {
|
|
477
|
+
let remainingData = data;
|
|
478
|
+
if (remainingData.includes("\x1b[200~")) {
|
|
453
479
|
this.isInPaste = true;
|
|
454
480
|
this.pasteBuffer = "";
|
|
455
|
-
|
|
456
|
-
}
|
|
457
|
-
if (this.isInPaste)
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
481
|
+
remainingData = remainingData.replace("\x1b[200~", "");
|
|
482
|
+
}
|
|
483
|
+
if (!this.isInPaste)
|
|
484
|
+
return { data: remainingData, consumed: false };
|
|
485
|
+
this.pasteBuffer += remainingData;
|
|
486
|
+
const endIndex = this.pasteBuffer.indexOf("\x1b[201~");
|
|
487
|
+
if (endIndex === -1)
|
|
488
|
+
return { data: remainingData, consumed: true };
|
|
489
|
+
const pasteContent = this.pasteBuffer.substring(0, endIndex);
|
|
490
|
+
if (pasteContent.length > 0)
|
|
491
|
+
this.handlePaste(pasteContent);
|
|
492
|
+
this.isInPaste = false;
|
|
493
|
+
const trailingInput = this.pasteBuffer.substring(endIndex + 6);
|
|
494
|
+
this.pasteBuffer = "";
|
|
495
|
+
if (trailingInput.length > 0)
|
|
496
|
+
this.handleInput(trailingInput);
|
|
497
|
+
return { data: remainingData, consumed: true };
|
|
498
|
+
}
|
|
499
|
+
applySelectedAutocomplete() {
|
|
500
|
+
const selected = this.autocompleteList?.getSelectedItem();
|
|
501
|
+
if (!selected || !this.autocompleteProvider)
|
|
502
|
+
return false;
|
|
503
|
+
this.pushUndoSnapshot();
|
|
504
|
+
this.lastAction = null;
|
|
505
|
+
const result = this.autocompleteProvider.applyCompletion(this.state.lines, this.state.cursorLine, this.state.cursorCol, selected, this.autocompletePrefix);
|
|
506
|
+
this.state.lines = result.lines;
|
|
507
|
+
this.state.cursorLine = result.cursorLine;
|
|
508
|
+
this.setCursorCol(result.cursorCol);
|
|
509
|
+
return true;
|
|
510
|
+
}
|
|
511
|
+
handleAutocompleteInput(data, keybindings) {
|
|
512
|
+
if (!this.autocompleteState || !this.autocompleteList)
|
|
513
|
+
return false;
|
|
514
|
+
if (keybindings.matches(data, "tui.select.cancel")) {
|
|
515
|
+
this.cancelAutocomplete();
|
|
516
|
+
return true;
|
|
478
517
|
}
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
return;
|
|
518
|
+
if (keybindings.matches(data, "tui.select.up") || keybindings.matches(data, "tui.select.down")) {
|
|
519
|
+
this.autocompleteList.handleInput(data);
|
|
520
|
+
return true;
|
|
483
521
|
}
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
if (kb.matches(data, "tui.select.cancel")) {
|
|
522
|
+
if (keybindings.matches(data, "tui.input.tab")) {
|
|
523
|
+
if (this.applySelectedAutocomplete()) {
|
|
487
524
|
this.cancelAutocomplete();
|
|
488
|
-
|
|
489
|
-
}
|
|
490
|
-
if (kb.matches(data, "tui.select.up") || kb.matches(data, "tui.select.down")) {
|
|
491
|
-
this.autocompleteList.handleInput(data);
|
|
492
|
-
return;
|
|
493
|
-
}
|
|
494
|
-
if (kb.matches(data, "tui.input.tab")) {
|
|
495
|
-
const selected = this.autocompleteList.getSelectedItem();
|
|
496
|
-
if (selected && this.autocompleteProvider) {
|
|
497
|
-
this.pushUndoSnapshot();
|
|
498
|
-
this.lastAction = null;
|
|
499
|
-
const result = this.autocompleteProvider.applyCompletion(this.state.lines, this.state.cursorLine, this.state.cursorCol, selected, this.autocompletePrefix);
|
|
500
|
-
this.state.lines = result.lines;
|
|
501
|
-
this.state.cursorLine = result.cursorLine;
|
|
502
|
-
this.setCursorCol(result.cursorCol);
|
|
503
|
-
this.cancelAutocomplete();
|
|
504
|
-
if (this.onChange)
|
|
505
|
-
this.onChange(this.getText());
|
|
506
|
-
}
|
|
507
|
-
return;
|
|
508
|
-
}
|
|
509
|
-
if (kb.matches(data, "tui.select.confirm")) {
|
|
510
|
-
const selected = this.autocompleteList.getSelectedItem();
|
|
511
|
-
if (selected && this.autocompleteProvider) {
|
|
512
|
-
this.pushUndoSnapshot();
|
|
513
|
-
this.lastAction = null;
|
|
514
|
-
const result = this.autocompleteProvider.applyCompletion(this.state.lines, this.state.cursorLine, this.state.cursorCol, selected, this.autocompletePrefix);
|
|
515
|
-
this.state.lines = result.lines;
|
|
516
|
-
this.state.cursorLine = result.cursorLine;
|
|
517
|
-
this.setCursorCol(result.cursorCol);
|
|
518
|
-
if (this.autocompletePrefix.startsWith("/")) {
|
|
519
|
-
this.cancelAutocomplete();
|
|
520
|
-
// Fall through to submit
|
|
521
|
-
}
|
|
522
|
-
else {
|
|
523
|
-
this.cancelAutocomplete();
|
|
524
|
-
if (this.onChange)
|
|
525
|
-
this.onChange(this.getText());
|
|
526
|
-
return;
|
|
527
|
-
}
|
|
528
|
-
}
|
|
525
|
+
this.onChange?.(this.getText());
|
|
529
526
|
}
|
|
527
|
+
return true;
|
|
530
528
|
}
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
529
|
+
if (!keybindings.matches(data, "tui.select.confirm") || !this.applySelectedAutocomplete())
|
|
530
|
+
return false;
|
|
531
|
+
const submitCompletion = this.autocompletePrefix.startsWith("/");
|
|
532
|
+
this.cancelAutocomplete();
|
|
533
|
+
if (submitCompletion)
|
|
534
|
+
return false;
|
|
535
|
+
this.onChange?.(this.getText());
|
|
536
|
+
return true;
|
|
537
|
+
}
|
|
538
|
+
handleGlobalEditorInput(data, keybindings) {
|
|
539
|
+
if (keybindings.matches(data, "tui.input.copy"))
|
|
540
|
+
return true;
|
|
541
|
+
if (!keybindings.matches(data, "tui.editor.undo"))
|
|
542
|
+
return false;
|
|
543
|
+
this.undo();
|
|
544
|
+
return true;
|
|
545
|
+
}
|
|
546
|
+
handleTabInput(data, keybindings) {
|
|
547
|
+
if (!keybindings.matches(data, "tui.input.tab") || this.autocompleteState)
|
|
548
|
+
return false;
|
|
549
|
+
this.handleTabCompletion();
|
|
550
|
+
return true;
|
|
551
|
+
}
|
|
552
|
+
handleDeletionInput(data, keybindings) {
|
|
553
|
+
if (keybindings.matches(data, "tui.editor.deleteToLineEnd"))
|
|
538
554
|
this.deleteToEndOfLine();
|
|
539
|
-
|
|
540
|
-
}
|
|
541
|
-
if (kb.matches(data, "tui.editor.deleteToLineStart")) {
|
|
555
|
+
else if (keybindings.matches(data, "tui.editor.deleteToLineStart"))
|
|
542
556
|
this.deleteToStartOfLine();
|
|
543
|
-
|
|
544
|
-
}
|
|
545
|
-
if (kb.matches(data, "tui.editor.deleteWordBackward")) {
|
|
557
|
+
else if (keybindings.matches(data, "tui.editor.deleteWordBackward"))
|
|
546
558
|
this.deleteWordBackwards();
|
|
547
|
-
|
|
548
|
-
}
|
|
549
|
-
if (kb.matches(data, "tui.editor.deleteWordForward")) {
|
|
559
|
+
else if (keybindings.matches(data, "tui.editor.deleteWordForward"))
|
|
550
560
|
this.deleteWordForward();
|
|
551
|
-
|
|
552
|
-
}
|
|
553
|
-
if (kb.matches(data, "tui.editor.deleteCharBackward") || matchesKey(data, "shift+backspace")) {
|
|
561
|
+
else if (keybindings.matches(data, "tui.editor.deleteCharBackward") || matchesKey(data, "shift+backspace"))
|
|
554
562
|
this.handleBackspace();
|
|
555
|
-
|
|
556
|
-
}
|
|
557
|
-
if (kb.matches(data, "tui.editor.deleteCharForward") || matchesKey(data, "shift+delete")) {
|
|
563
|
+
else if (keybindings.matches(data, "tui.editor.deleteCharForward") || matchesKey(data, "shift+delete"))
|
|
558
564
|
this.handleForwardDelete();
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
565
|
+
else
|
|
566
|
+
return false;
|
|
567
|
+
return true;
|
|
568
|
+
}
|
|
569
|
+
handleKillRingInput(data, keybindings) {
|
|
570
|
+
if (keybindings.matches(data, "tui.editor.yank"))
|
|
563
571
|
this.yank();
|
|
564
|
-
|
|
565
|
-
}
|
|
566
|
-
if (kb.matches(data, "tui.editor.yankPop")) {
|
|
572
|
+
else if (keybindings.matches(data, "tui.editor.yankPop"))
|
|
567
573
|
this.yankPop();
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
574
|
+
else
|
|
575
|
+
return false;
|
|
576
|
+
return true;
|
|
577
|
+
}
|
|
578
|
+
handleHistoryInput(data, keybindings) {
|
|
579
|
+
let direction;
|
|
580
|
+
if (keybindings.matches(data, "tui.editor.historyPrevious"))
|
|
581
|
+
direction = -1;
|
|
582
|
+
else if (keybindings.matches(data, "tui.editor.historyNext"))
|
|
583
|
+
direction = 1;
|
|
584
|
+
else
|
|
585
|
+
return false;
|
|
586
|
+
this.cancelAutocomplete();
|
|
587
|
+
this.navigateHistory(direction);
|
|
588
|
+
return true;
|
|
589
|
+
}
|
|
590
|
+
handleLineAndWordMovement(data, keybindings) {
|
|
591
|
+
if (keybindings.matches(data, "tui.editor.cursorLineStart"))
|
|
572
592
|
this.moveToLineStart();
|
|
573
|
-
|
|
574
|
-
}
|
|
575
|
-
if (kb.matches(data, "tui.editor.cursorLineEnd")) {
|
|
593
|
+
else if (keybindings.matches(data, "tui.editor.cursorLineEnd"))
|
|
576
594
|
this.moveToLineEnd();
|
|
577
|
-
|
|
578
|
-
}
|
|
579
|
-
if (kb.matches(data, "tui.editor.cursorWordLeft")) {
|
|
595
|
+
else if (keybindings.matches(data, "tui.editor.cursorWordLeft"))
|
|
580
596
|
this.moveWordBackwards();
|
|
581
|
-
|
|
582
|
-
}
|
|
583
|
-
if (kb.matches(data, "tui.editor.cursorWordRight")) {
|
|
597
|
+
else if (keybindings.matches(data, "tui.editor.cursorWordRight"))
|
|
584
598
|
this.moveWordForwards();
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
599
|
+
else
|
|
600
|
+
return false;
|
|
601
|
+
return true;
|
|
602
|
+
}
|
|
603
|
+
isLineBreakInput(data, keybindings) {
|
|
604
|
+
return (keybindings.matches(data, "tui.input.newLine") ||
|
|
589
605
|
(data.charCodeAt(0) === 10 && data.length > 1) ||
|
|
590
606
|
data === "\x1b\r" ||
|
|
591
607
|
data === "\x1b[13;2~" ||
|
|
592
608
|
(data.length > 1 && data.includes("\x1b") && data.includes("\r")) ||
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
609
|
+
data === "\n");
|
|
610
|
+
}
|
|
611
|
+
handleLineBreakInput(data, keybindings) {
|
|
612
|
+
if (!this.isLineBreakInput(data, keybindings))
|
|
613
|
+
return false;
|
|
614
|
+
if (this.shouldSubmitOnBackslashEnter(data, keybindings)) {
|
|
615
|
+
this.handleBackspace();
|
|
616
|
+
this.submitValue();
|
|
617
|
+
}
|
|
618
|
+
else {
|
|
599
619
|
this.addNewLine();
|
|
600
|
-
return;
|
|
601
620
|
}
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
621
|
+
return true;
|
|
622
|
+
}
|
|
623
|
+
handleSubmitInput(data, keybindings) {
|
|
624
|
+
if (!keybindings.matches(data, "tui.input.submit"))
|
|
625
|
+
return false;
|
|
626
|
+
if (this.disableSubmit)
|
|
627
|
+
return true;
|
|
628
|
+
const currentLine = this.state.lines[this.state.cursorLine] || "";
|
|
629
|
+
if (this.state.cursorCol > 0 && currentLine[this.state.cursorCol - 1] === "\\") {
|
|
630
|
+
this.handleBackspace();
|
|
631
|
+
this.addNewLine();
|
|
632
|
+
}
|
|
633
|
+
else {
|
|
614
634
|
this.submitValue();
|
|
615
|
-
return;
|
|
616
635
|
}
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
else if (this.historyIndex > -1 && this.isOnFirstVisualLine()) {
|
|
636
|
+
return true;
|
|
637
|
+
}
|
|
638
|
+
handleVerticalNavigation(data, keybindings) {
|
|
639
|
+
if (keybindings.matches(data, "tui.editor.cursorUp")) {
|
|
640
|
+
if (this.isEditorEmpty() || (this.historyIndex > -1 && this.isOnFirstVisualLine()))
|
|
623
641
|
this.navigateHistory(-1);
|
|
624
|
-
|
|
625
|
-
else if (this.isOnFirstVisualLine()) {
|
|
626
|
-
// Already at top - jump to start of line
|
|
642
|
+
else if (this.isOnFirstVisualLine())
|
|
627
643
|
this.moveToLineStart();
|
|
628
|
-
|
|
629
|
-
else {
|
|
644
|
+
else
|
|
630
645
|
this.moveCursor(-1, 0);
|
|
631
|
-
|
|
632
|
-
return;
|
|
633
|
-
}
|
|
634
|
-
if (kb.matches(data, "tui.editor.cursorDown")) {
|
|
635
|
-
if (this.historyIndex > -1 && this.isOnLastVisualLine()) {
|
|
636
|
-
this.navigateHistory(1);
|
|
637
|
-
}
|
|
638
|
-
else if (this.isOnLastVisualLine()) {
|
|
639
|
-
// Already at bottom - jump to end of line
|
|
640
|
-
this.moveToLineEnd();
|
|
641
|
-
}
|
|
642
|
-
else {
|
|
643
|
-
this.moveCursor(1, 0);
|
|
644
|
-
}
|
|
645
|
-
return;
|
|
646
|
+
return true;
|
|
646
647
|
}
|
|
647
|
-
if (
|
|
648
|
+
if (!keybindings.matches(data, "tui.editor.cursorDown"))
|
|
649
|
+
return false;
|
|
650
|
+
if (this.historyIndex > -1 && this.isOnLastVisualLine())
|
|
651
|
+
this.navigateHistory(1);
|
|
652
|
+
else if (this.isOnLastVisualLine())
|
|
653
|
+
this.moveToLineEnd();
|
|
654
|
+
else
|
|
655
|
+
this.moveCursor(1, 0);
|
|
656
|
+
return true;
|
|
657
|
+
}
|
|
658
|
+
handleHorizontalNavigation(data, keybindings) {
|
|
659
|
+
if (keybindings.matches(data, "tui.editor.cursorRight"))
|
|
648
660
|
this.moveCursor(0, 1);
|
|
649
|
-
|
|
650
|
-
}
|
|
651
|
-
if (kb.matches(data, "tui.editor.cursorLeft")) {
|
|
661
|
+
else if (keybindings.matches(data, "tui.editor.cursorLeft"))
|
|
652
662
|
this.moveCursor(0, -1);
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
663
|
+
else
|
|
664
|
+
return false;
|
|
665
|
+
return true;
|
|
666
|
+
}
|
|
667
|
+
handlePageAndJumpInput(data, keybindings) {
|
|
668
|
+
if (keybindings.matches(data, "tui.editor.pageUp"))
|
|
657
669
|
this.pageScroll(-1);
|
|
658
|
-
|
|
659
|
-
}
|
|
660
|
-
if (kb.matches(data, "tui.editor.pageDown")) {
|
|
670
|
+
else if (keybindings.matches(data, "tui.editor.pageDown"))
|
|
661
671
|
this.pageScroll(1);
|
|
662
|
-
|
|
663
|
-
}
|
|
664
|
-
// Character jump mode triggers
|
|
665
|
-
if (kb.matches(data, "tui.editor.jumpForward")) {
|
|
672
|
+
else if (keybindings.matches(data, "tui.editor.jumpForward"))
|
|
666
673
|
this.jumpMode = "forward";
|
|
667
|
-
|
|
668
|
-
}
|
|
669
|
-
if (kb.matches(data, "tui.editor.jumpBackward")) {
|
|
674
|
+
else if (keybindings.matches(data, "tui.editor.jumpBackward"))
|
|
670
675
|
this.jumpMode = "backward";
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
676
|
+
else
|
|
677
|
+
return false;
|
|
678
|
+
return true;
|
|
679
|
+
}
|
|
680
|
+
handlePrintableInput(data) {
|
|
674
681
|
if (matchesKey(data, "shift+space")) {
|
|
675
682
|
this.insertCharacter(" ");
|
|
676
683
|
return;
|
|
677
684
|
}
|
|
678
685
|
const printable = decodePrintableKey(data);
|
|
679
|
-
if (printable !== undefined)
|
|
686
|
+
if (printable !== undefined)
|
|
680
687
|
this.insertCharacter(printable);
|
|
681
|
-
|
|
682
|
-
}
|
|
683
|
-
// Regular characters
|
|
684
|
-
if (data.charCodeAt(0) >= 32) {
|
|
688
|
+
else if (data.charCodeAt(0) >= 32)
|
|
685
689
|
this.insertCharacter(data);
|
|
690
|
+
}
|
|
691
|
+
handleInput(data) {
|
|
692
|
+
const keybindings = getKeybindings();
|
|
693
|
+
if (this.handleJumpModeInput(data, keybindings))
|
|
694
|
+
return;
|
|
695
|
+
const paste = this.handleBracketedPasteInput(data);
|
|
696
|
+
if (paste.consumed)
|
|
697
|
+
return;
|
|
698
|
+
data = paste.data;
|
|
699
|
+
if (this.handleGlobalEditorInput(data, keybindings))
|
|
700
|
+
return;
|
|
701
|
+
if (this.handleAutocompleteInput(data, keybindings))
|
|
702
|
+
return;
|
|
703
|
+
if (this.handleTabInput(data, keybindings))
|
|
704
|
+
return;
|
|
705
|
+
if (this.handleDeletionInput(data, keybindings))
|
|
706
|
+
return;
|
|
707
|
+
if (this.handleKillRingInput(data, keybindings))
|
|
708
|
+
return;
|
|
709
|
+
if (this.handleHistoryInput(data, keybindings))
|
|
710
|
+
return;
|
|
711
|
+
if (this.handleLineAndWordMovement(data, keybindings))
|
|
712
|
+
return;
|
|
713
|
+
if (this.handleLineBreakInput(data, keybindings))
|
|
714
|
+
return;
|
|
715
|
+
if (this.handleSubmitInput(data, keybindings))
|
|
716
|
+
return;
|
|
717
|
+
if (this.handleVerticalNavigation(data, keybindings))
|
|
718
|
+
return;
|
|
719
|
+
if (this.handleHorizontalNavigation(data, keybindings))
|
|
720
|
+
return;
|
|
721
|
+
if (this.handlePageAndJumpInput(data, keybindings))
|
|
722
|
+
return;
|
|
723
|
+
this.handlePrintableInput(data);
|
|
724
|
+
}
|
|
725
|
+
getWrappedChunkCursorPosition(chunk, isLastChunk, isCurrentLine) {
|
|
726
|
+
if (!isCurrentLine)
|
|
727
|
+
return undefined;
|
|
728
|
+
const cursorPosition = this.state.cursorCol;
|
|
729
|
+
if (isLastChunk) {
|
|
730
|
+
return cursorPosition >= chunk.startIndex ? cursorPosition - chunk.startIndex : undefined;
|
|
686
731
|
}
|
|
732
|
+
if (cursorPosition < chunk.startIndex || cursorPosition >= chunk.endIndex)
|
|
733
|
+
return undefined;
|
|
734
|
+
return Math.min(cursorPosition - chunk.startIndex, chunk.text.length);
|
|
687
735
|
}
|
|
688
|
-
|
|
736
|
+
layoutWrappedLine(line, contentWidth, isCurrentLine) {
|
|
737
|
+
const chunks = wordWrapLine(line, contentWidth, [...this.segment(line, "grapheme")]);
|
|
689
738
|
const layoutLines = [];
|
|
690
|
-
|
|
691
|
-
|
|
739
|
+
for (let index = 0; index < chunks.length; index++) {
|
|
740
|
+
const chunk = chunks[index];
|
|
741
|
+
if (!chunk)
|
|
742
|
+
continue;
|
|
743
|
+
const cursorPos = this.getWrappedChunkCursorPosition(chunk, index === chunks.length - 1, isCurrentLine);
|
|
692
744
|
layoutLines.push({
|
|
693
|
-
text:
|
|
694
|
-
hasCursor:
|
|
695
|
-
cursorPos:
|
|
745
|
+
text: chunk.text,
|
|
746
|
+
hasCursor: cursorPos !== undefined,
|
|
747
|
+
...(cursorPos === undefined ? {} : { cursorPos }),
|
|
696
748
|
});
|
|
697
|
-
return layoutLines;
|
|
698
749
|
}
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
}
|
|
720
|
-
else {
|
|
721
|
-
// Line needs wrapping - use word-aware wrapping
|
|
722
|
-
const chunks = wordWrapLine(line, contentWidth, [...this.segment(line, "grapheme")]);
|
|
723
|
-
for (let chunkIndex = 0; chunkIndex < chunks.length; chunkIndex++) {
|
|
724
|
-
const chunk = chunks[chunkIndex];
|
|
725
|
-
if (!chunk)
|
|
726
|
-
continue;
|
|
727
|
-
const cursorPos = this.state.cursorCol;
|
|
728
|
-
const isLastChunk = chunkIndex === chunks.length - 1;
|
|
729
|
-
// Determine if cursor is in this chunk
|
|
730
|
-
// For word-wrapped chunks, we need to handle the case where
|
|
731
|
-
// cursor might be in trimmed whitespace at end of chunk
|
|
732
|
-
let hasCursorInChunk = false;
|
|
733
|
-
let adjustedCursorPos = 0;
|
|
734
|
-
if (isCurrentLine) {
|
|
735
|
-
if (isLastChunk) {
|
|
736
|
-
// Last chunk: cursor belongs here if >= startIndex
|
|
737
|
-
hasCursorInChunk = cursorPos >= chunk.startIndex;
|
|
738
|
-
adjustedCursorPos = cursorPos - chunk.startIndex;
|
|
739
|
-
}
|
|
740
|
-
else {
|
|
741
|
-
// Non-last chunk: cursor belongs here if in range [startIndex, endIndex)
|
|
742
|
-
// But we need to handle the visual position in the trimmed text
|
|
743
|
-
hasCursorInChunk = cursorPos >= chunk.startIndex && cursorPos < chunk.endIndex;
|
|
744
|
-
if (hasCursorInChunk) {
|
|
745
|
-
adjustedCursorPos = cursorPos - chunk.startIndex;
|
|
746
|
-
// Clamp to text length (in case cursor was in trimmed whitespace)
|
|
747
|
-
if (adjustedCursorPos > chunk.text.length) {
|
|
748
|
-
adjustedCursorPos = chunk.text.length;
|
|
749
|
-
}
|
|
750
|
-
}
|
|
751
|
-
}
|
|
752
|
-
}
|
|
753
|
-
if (hasCursorInChunk) {
|
|
754
|
-
layoutLines.push({
|
|
755
|
-
text: chunk.text,
|
|
756
|
-
hasCursor: true,
|
|
757
|
-
cursorPos: adjustedCursorPos,
|
|
758
|
-
});
|
|
759
|
-
}
|
|
760
|
-
else {
|
|
761
|
-
layoutLines.push({
|
|
762
|
-
text: chunk.text,
|
|
763
|
-
hasCursor: false,
|
|
764
|
-
});
|
|
765
|
-
}
|
|
766
|
-
}
|
|
767
|
-
}
|
|
750
|
+
return layoutLines;
|
|
751
|
+
}
|
|
752
|
+
layoutLogicalLine(line, contentWidth, isCurrentLine) {
|
|
753
|
+
if (visibleWidth(line) > contentWidth)
|
|
754
|
+
return this.layoutWrappedLine(line, contentWidth, isCurrentLine);
|
|
755
|
+
return [
|
|
756
|
+
{
|
|
757
|
+
text: line,
|
|
758
|
+
hasCursor: isCurrentLine,
|
|
759
|
+
...(isCurrentLine ? { cursorPos: this.state.cursorCol } : {}),
|
|
760
|
+
},
|
|
761
|
+
];
|
|
762
|
+
}
|
|
763
|
+
layoutText(contentWidth) {
|
|
764
|
+
if (this.state.lines.length === 0 || (this.state.lines.length === 1 && this.state.lines[0] === "")) {
|
|
765
|
+
return [{ text: "", hasCursor: true, cursorPos: 0 }];
|
|
766
|
+
}
|
|
767
|
+
const layoutLines = [];
|
|
768
|
+
for (let index = 0; index < this.state.lines.length; index++) {
|
|
769
|
+
layoutLines.push(...this.layoutLogicalLine(this.state.lines[index] || "", contentWidth, index === this.state.cursorLine));
|
|
768
770
|
}
|
|
769
771
|
return layoutLines;
|
|
770
772
|
}
|
|
@@ -801,6 +803,8 @@ export class Editor {
|
|
|
801
803
|
if (this.getText() !== normalized) {
|
|
802
804
|
this.pushUndoSnapshot();
|
|
803
805
|
}
|
|
806
|
+
this.pastes.clear();
|
|
807
|
+
this.pasteCounter = 0;
|
|
804
808
|
this.setTextInternal(normalized);
|
|
805
809
|
}
|
|
806
810
|
/**
|
|
@@ -865,6 +869,30 @@ export class Editor {
|
|
|
865
869
|
this.onChange(this.getText());
|
|
866
870
|
}
|
|
867
871
|
}
|
|
872
|
+
refreshAutocompleteAfterCharacterInsertion(char) {
|
|
873
|
+
if (this.autocompleteState) {
|
|
874
|
+
this.updateAutocomplete();
|
|
875
|
+
return;
|
|
876
|
+
}
|
|
877
|
+
if (char === "/" && this.isAtStartOfMessage()) {
|
|
878
|
+
this.tryTriggerAutocomplete();
|
|
879
|
+
return;
|
|
880
|
+
}
|
|
881
|
+
const currentLine = this.state.lines[this.state.cursorLine] || "";
|
|
882
|
+
const textBeforeCursor = currentLine.slice(0, this.state.cursorCol);
|
|
883
|
+
if (char === "@" || char === "#") {
|
|
884
|
+
const charBeforeSymbol = textBeforeCursor[textBeforeCursor.length - 2];
|
|
885
|
+
if (textBeforeCursor.length === 1 || charBeforeSymbol === " " || charBeforeSymbol === "\t") {
|
|
886
|
+
this.tryTriggerAutocomplete();
|
|
887
|
+
}
|
|
888
|
+
return;
|
|
889
|
+
}
|
|
890
|
+
if (!/[a-zA-Z0-9.\-_]/.test(char))
|
|
891
|
+
return;
|
|
892
|
+
if (this.isInSlashCommandContext(textBeforeCursor) || textBeforeCursor.match(/(?:^|[\s])[@#][^\s]*$/)) {
|
|
893
|
+
this.tryTriggerAutocomplete();
|
|
894
|
+
}
|
|
895
|
+
}
|
|
868
896
|
// All the editor methods from before...
|
|
869
897
|
insertCharacter(char, skipUndoCoalescing) {
|
|
870
898
|
this.historyIndex = -1; // Exit history browsing mode
|
|
@@ -887,38 +915,7 @@ export class Editor {
|
|
|
887
915
|
if (this.onChange) {
|
|
888
916
|
this.onChange(this.getText());
|
|
889
917
|
}
|
|
890
|
-
|
|
891
|
-
if (!this.autocompleteState) {
|
|
892
|
-
// Auto-trigger for "/" at the start of a line (slash commands)
|
|
893
|
-
if (char === "/" && this.isAtStartOfMessage()) {
|
|
894
|
-
this.tryTriggerAutocomplete();
|
|
895
|
-
}
|
|
896
|
-
// Auto-trigger for symbol-based completion like @ or # at token boundaries
|
|
897
|
-
else if (char === "@" || char === "#") {
|
|
898
|
-
const currentLine = this.state.lines[this.state.cursorLine] || "";
|
|
899
|
-
const textBeforeCursor = currentLine.slice(0, this.state.cursorCol);
|
|
900
|
-
const charBeforeSymbol = textBeforeCursor[textBeforeCursor.length - 2];
|
|
901
|
-
if (textBeforeCursor.length === 1 || charBeforeSymbol === " " || charBeforeSymbol === "\t") {
|
|
902
|
-
this.tryTriggerAutocomplete();
|
|
903
|
-
}
|
|
904
|
-
}
|
|
905
|
-
// Also auto-trigger when typing letters in a slash command or symbol completion context
|
|
906
|
-
else if (/[a-zA-Z0-9.\-_]/.test(char)) {
|
|
907
|
-
const currentLine = this.state.lines[this.state.cursorLine] || "";
|
|
908
|
-
const textBeforeCursor = currentLine.slice(0, this.state.cursorCol);
|
|
909
|
-
// Check if we're in a slash command (with or without space for arguments)
|
|
910
|
-
if (this.isInSlashCommandContext(textBeforeCursor)) {
|
|
911
|
-
this.tryTriggerAutocomplete();
|
|
912
|
-
}
|
|
913
|
-
// Check if we're in a symbol-based completion context like @ or #
|
|
914
|
-
else if (textBeforeCursor.match(/(?:^|[\s])[@#][^\s]*$/)) {
|
|
915
|
-
this.tryTriggerAutocomplete();
|
|
916
|
-
}
|
|
917
|
-
}
|
|
918
|
-
}
|
|
919
|
-
else {
|
|
920
|
-
this.updateAutocomplete();
|
|
921
|
-
}
|
|
918
|
+
this.refreshAutocompleteAfterCharacterInsertion(char);
|
|
922
919
|
}
|
|
923
920
|
handlePaste(pastedText) {
|
|
924
921
|
this.cancelAutocomplete();
|
|
@@ -1023,22 +1020,53 @@ export class Editor {
|
|
|
1023
1020
|
if (this.onSubmit)
|
|
1024
1021
|
this.onSubmit(result);
|
|
1025
1022
|
}
|
|
1023
|
+
deleteGraphemeBeforeCursor() {
|
|
1024
|
+
const line = this.state.lines[this.state.cursorLine] || "";
|
|
1025
|
+
const beforeCursor = line.slice(0, this.state.cursorCol);
|
|
1026
|
+
const graphemes = [...this.segment(beforeCursor, "grapheme")];
|
|
1027
|
+
const lastGrapheme = graphemes[graphemes.length - 1];
|
|
1028
|
+
const graphemeLength = lastGrapheme ? lastGrapheme.segment.length : 1;
|
|
1029
|
+
const pastedSegment = lastGrapheme ? PASTE_MARKER_SINGLE.exec(lastGrapheme.segment) : null;
|
|
1030
|
+
if (!pastedSegment) {
|
|
1031
|
+
const before = line.slice(0, this.state.cursorCol - graphemeLength);
|
|
1032
|
+
const after = line.slice(this.state.cursorCol);
|
|
1033
|
+
this.state.lines[this.state.cursorLine] = before + after;
|
|
1034
|
+
this.setCursorCol(this.state.cursorCol - graphemeLength);
|
|
1035
|
+
return;
|
|
1036
|
+
}
|
|
1037
|
+
const targetId = Number(pastedSegment[1]);
|
|
1038
|
+
const markerStart = this.state.cursorCol - graphemeLength;
|
|
1039
|
+
const beforeMarker = line.slice(0, markerStart);
|
|
1040
|
+
const afterMarker = line.slice(this.state.cursorCol);
|
|
1041
|
+
this.pastes.delete(targetId);
|
|
1042
|
+
this.pasteCounter--;
|
|
1043
|
+
// Shift registry entries down in ascending id order, independent
|
|
1044
|
+
// of marker order in the text ([paste #3] becomes [paste #2] when
|
|
1045
|
+
// [paste #1] is removed).
|
|
1046
|
+
const higherIds = [...this.pastes.keys()].filter((id) => id > targetId).sort((a, b) => a - b);
|
|
1047
|
+
for (const id of higherIds) {
|
|
1048
|
+
this.pastes.set(id - 1, this.pastes.get(id));
|
|
1049
|
+
this.pastes.delete(id);
|
|
1050
|
+
}
|
|
1051
|
+
const renumberMarkers = (text) => text.replace(PASTE_MARKER_REGEX, (fullMatch, idGroup, suffixGroup) => {
|
|
1052
|
+
const id = Number(idGroup);
|
|
1053
|
+
if (id <= targetId)
|
|
1054
|
+
return fullMatch;
|
|
1055
|
+
return `[paste #${id - 1}${suffixGroup}]`;
|
|
1056
|
+
});
|
|
1057
|
+
// Delete the target before renumbering. Renumber the text on each side
|
|
1058
|
+
// separately so an earlier #10 -> #9 change cannot stale cursorCol.
|
|
1059
|
+
const before = renumberMarkers(beforeMarker);
|
|
1060
|
+
const after = renumberMarkers(afterMarker);
|
|
1061
|
+
this.state.lines = this.state.lines.map((line, index) => index === this.state.cursorLine ? before + after : renumberMarkers(line));
|
|
1062
|
+
this.setCursorCol(before.length);
|
|
1063
|
+
}
|
|
1026
1064
|
handleBackspace() {
|
|
1027
1065
|
this.historyIndex = -1; // Exit history browsing mode
|
|
1028
1066
|
this.lastAction = null;
|
|
1029
1067
|
if (this.state.cursorCol > 0) {
|
|
1030
1068
|
this.pushUndoSnapshot();
|
|
1031
|
-
|
|
1032
|
-
const line = this.state.lines[this.state.cursorLine] || "";
|
|
1033
|
-
const beforeCursor = line.slice(0, this.state.cursorCol);
|
|
1034
|
-
// Find the last grapheme in the text before cursor
|
|
1035
|
-
const graphemes = [...this.segment(beforeCursor, "grapheme")];
|
|
1036
|
-
const lastGrapheme = graphemes[graphemes.length - 1];
|
|
1037
|
-
const graphemeLength = lastGrapheme ? lastGrapheme.segment.length : 1;
|
|
1038
|
-
const before = line.slice(0, this.state.cursorCol - graphemeLength);
|
|
1039
|
-
const after = line.slice(this.state.cursorCol);
|
|
1040
|
-
this.state.lines[this.state.cursorLine] = before + after;
|
|
1041
|
-
this.setCursorCol(this.state.cursorCol - graphemeLength);
|
|
1069
|
+
this.deleteGraphemeBeforeCursor();
|
|
1042
1070
|
}
|
|
1043
1071
|
else if (this.state.cursorLine > 0) {
|
|
1044
1072
|
this.pushUndoSnapshot();
|
|
@@ -1084,73 +1112,70 @@ export class Editor {
|
|
|
1084
1112
|
* Move cursor to a target visual line, applying sticky column logic.
|
|
1085
1113
|
* Shared by moveCursor() and pageScroll().
|
|
1086
1114
|
*/
|
|
1115
|
+
resolveCurrentVisualColumn(visualLines, currentVL) {
|
|
1116
|
+
if (this.snappedFromCursorCol === null)
|
|
1117
|
+
return this.state.cursorCol - currentVL.startCol;
|
|
1118
|
+
const visualLineIndex = this.findVisualLineAt(visualLines, currentVL.logicalLine, this.snappedFromCursorCol);
|
|
1119
|
+
return this.snappedFromCursorCol - visualLines[visualLineIndex].startCol;
|
|
1120
|
+
}
|
|
1121
|
+
getVisualSegmentMaxColumn(visualLines, visualLineIndex, visualLine) {
|
|
1122
|
+
const isLastSegment = visualLineIndex === visualLines.length - 1 ||
|
|
1123
|
+
visualLines[visualLineIndex + 1]?.logicalLine !== visualLine.logicalLine;
|
|
1124
|
+
return isLastSegment ? visualLine.length : Math.max(0, visualLine.length - 1);
|
|
1125
|
+
}
|
|
1126
|
+
findAtomicSegmentAtCursor(logicalLine) {
|
|
1127
|
+
for (const segment of this.segment(logicalLine, "grapheme")) {
|
|
1128
|
+
if (segment.index > this.state.cursorCol)
|
|
1129
|
+
return undefined;
|
|
1130
|
+
if (segment.segment.length > 1 && this.state.cursorCol < segment.index + segment.segment.length)
|
|
1131
|
+
return segment;
|
|
1132
|
+
}
|
|
1133
|
+
return undefined;
|
|
1134
|
+
}
|
|
1135
|
+
findVisualLineAfterSegment(visualLines, targetVisualLine, segmentEnd) {
|
|
1136
|
+
const target = visualLines[targetVisualLine];
|
|
1137
|
+
if (!target)
|
|
1138
|
+
return undefined;
|
|
1139
|
+
let next = targetVisualLine + 1;
|
|
1140
|
+
while (next < visualLines.length &&
|
|
1141
|
+
visualLines[next].logicalLine === target.logicalLine &&
|
|
1142
|
+
visualLines[next].startCol < segmentEnd) {
|
|
1143
|
+
next++;
|
|
1144
|
+
}
|
|
1145
|
+
return next < visualLines.length ? next : undefined;
|
|
1146
|
+
}
|
|
1147
|
+
snapCursorToAtomicSegment(visualLines, currentVisualLine, targetVisualLine, logicalLine) {
|
|
1148
|
+
const segment = this.findAtomicSegmentAtCursor(logicalLine);
|
|
1149
|
+
if (!segment) {
|
|
1150
|
+
this.snappedFromCursorCol = null;
|
|
1151
|
+
return;
|
|
1152
|
+
}
|
|
1153
|
+
const target = visualLines[targetVisualLine];
|
|
1154
|
+
const isContinuation = target ? segment.index < target.startCol : false;
|
|
1155
|
+
if (isContinuation && targetVisualLine > currentVisualLine) {
|
|
1156
|
+
const next = this.findVisualLineAfterSegment(visualLines, targetVisualLine, segment.index + segment.segment.length);
|
|
1157
|
+
if (next !== undefined) {
|
|
1158
|
+
this.moveToVisualLine(visualLines, currentVisualLine, next);
|
|
1159
|
+
return;
|
|
1160
|
+
}
|
|
1161
|
+
}
|
|
1162
|
+
this.snappedFromCursorCol = this.state.cursorCol;
|
|
1163
|
+
this.state.cursorCol = segment.index;
|
|
1164
|
+
}
|
|
1087
1165
|
moveToVisualLine(visualLines, currentVisualLine, targetVisualLine) {
|
|
1088
1166
|
const currentVL = visualLines[currentVisualLine];
|
|
1089
1167
|
const targetVL = visualLines[targetVisualLine];
|
|
1090
1168
|
if (!(currentVL && targetVL))
|
|
1091
1169
|
return;
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
let currentVisualCol;
|
|
1096
|
-
if (this.snappedFromCursorCol !== null) {
|
|
1097
|
-
const vlIndex = this.findVisualLineAt(visualLines, currentVL.logicalLine, this.snappedFromCursorCol);
|
|
1098
|
-
currentVisualCol = this.snappedFromCursorCol - visualLines[vlIndex].startCol;
|
|
1099
|
-
}
|
|
1100
|
-
else {
|
|
1101
|
-
currentVisualCol = this.state.cursorCol - currentVL.startCol;
|
|
1102
|
-
}
|
|
1103
|
-
// For non-last segments, clamp to length-1 to stay within the segment
|
|
1104
|
-
const isLastSourceSegment = currentVisualLine === visualLines.length - 1 ||
|
|
1105
|
-
visualLines[currentVisualLine + 1]?.logicalLine !== currentVL.logicalLine;
|
|
1106
|
-
const sourceMaxVisualCol = isLastSourceSegment ? currentVL.length : Math.max(0, currentVL.length - 1);
|
|
1107
|
-
const isLastTargetSegment = targetVisualLine === visualLines.length - 1 ||
|
|
1108
|
-
visualLines[targetVisualLine + 1]?.logicalLine !== targetVL.logicalLine;
|
|
1109
|
-
const targetMaxVisualCol = isLastTargetSegment ? targetVL.length : Math.max(0, targetVL.length - 1);
|
|
1170
|
+
const currentVisualCol = this.resolveCurrentVisualColumn(visualLines, currentVL);
|
|
1171
|
+
const sourceMaxVisualCol = this.getVisualSegmentMaxColumn(visualLines, currentVisualLine, currentVL);
|
|
1172
|
+
const targetMaxVisualCol = this.getVisualSegmentMaxColumn(visualLines, targetVisualLine, targetVL);
|
|
1110
1173
|
const moveToVisualCol = this.computeVerticalMoveColumn(currentVisualCol, sourceMaxVisualCol, targetMaxVisualCol);
|
|
1111
|
-
// Set cursor position
|
|
1112
1174
|
this.state.cursorLine = targetVL.logicalLine;
|
|
1113
1175
|
const targetCol = targetVL.startCol + moveToVisualCol;
|
|
1114
1176
|
const logicalLine = this.state.lines[targetVL.logicalLine] || "";
|
|
1115
1177
|
this.state.cursorCol = Math.min(targetCol, logicalLine.length);
|
|
1116
|
-
|
|
1117
|
-
// so the cursor never lands in the middle of a multi-grapheme unit.
|
|
1118
|
-
// Single-grapheme segments don't need snapping.
|
|
1119
|
-
const segments = [...this.segment(logicalLine, "grapheme")];
|
|
1120
|
-
for (const seg of segments) {
|
|
1121
|
-
if (seg.index > this.state.cursorCol)
|
|
1122
|
-
break;
|
|
1123
|
-
if (seg.segment.length <= 1)
|
|
1124
|
-
continue;
|
|
1125
|
-
if (this.state.cursorCol < seg.index + seg.segment.length) {
|
|
1126
|
-
const isContinuation = seg.index < targetVL.startCol;
|
|
1127
|
-
const isMovingDown = targetVisualLine > currentVisualLine;
|
|
1128
|
-
if (isContinuation && isMovingDown) {
|
|
1129
|
-
// The segment started on a previous visual line, and we
|
|
1130
|
-
// already visited it on the way down. Skip all remaining
|
|
1131
|
-
// continuation VLs and land on the first VL past it.
|
|
1132
|
-
const segEnd = seg.index + seg.segment.length;
|
|
1133
|
-
let next = targetVisualLine + 1;
|
|
1134
|
-
while (next < visualLines.length &&
|
|
1135
|
-
visualLines[next].logicalLine === targetVL.logicalLine &&
|
|
1136
|
-
visualLines[next].startCol < segEnd) {
|
|
1137
|
-
next++;
|
|
1138
|
-
}
|
|
1139
|
-
if (next < visualLines.length) {
|
|
1140
|
-
this.moveToVisualLine(visualLines, currentVisualLine, next);
|
|
1141
|
-
return;
|
|
1142
|
-
}
|
|
1143
|
-
}
|
|
1144
|
-
// Snap to the start of the segment so it gets highlighted.
|
|
1145
|
-
// Store the pre-snap position so the next vertical move can
|
|
1146
|
-
// resolve it to the correct visual column.
|
|
1147
|
-
this.snappedFromCursorCol = this.state.cursorCol;
|
|
1148
|
-
this.state.cursorCol = seg.index;
|
|
1149
|
-
return;
|
|
1150
|
-
}
|
|
1151
|
-
}
|
|
1152
|
-
// No snap occurred – we moved out of the atomic segment.
|
|
1153
|
-
this.snappedFromCursorCol = null;
|
|
1178
|
+
this.snapCursorToAtomicSegment(visualLines, currentVisualLine, targetVisualLine, logicalLine);
|
|
1154
1179
|
}
|
|
1155
1180
|
/**
|
|
1156
1181
|
* Compute the target visual column for vertical cursor movement.
|
|
@@ -1427,55 +1452,58 @@ export class Editor {
|
|
|
1427
1452
|
findCurrentVisualLine(visualLines) {
|
|
1428
1453
|
return this.findVisualLineAt(visualLines, this.state.cursorLine, this.state.cursorCol);
|
|
1429
1454
|
}
|
|
1455
|
+
moveCursorVertically(visualLines, currentVisualLine, deltaLine) {
|
|
1456
|
+
if (deltaLine === 0)
|
|
1457
|
+
return;
|
|
1458
|
+
const targetVisualLine = currentVisualLine + deltaLine;
|
|
1459
|
+
if (targetVisualLine >= 0 && targetVisualLine < visualLines.length) {
|
|
1460
|
+
this.moveToVisualLine(visualLines, currentVisualLine, targetVisualLine);
|
|
1461
|
+
}
|
|
1462
|
+
}
|
|
1463
|
+
moveCursorRight(visualLines, currentVisualLine) {
|
|
1464
|
+
const currentLine = this.state.lines[this.state.cursorLine] || "";
|
|
1465
|
+
if (this.state.cursorCol < currentLine.length) {
|
|
1466
|
+
const afterCursor = currentLine.slice(this.state.cursorCol);
|
|
1467
|
+
const firstGrapheme = [...this.segment(afterCursor, "grapheme")][0];
|
|
1468
|
+
this.setCursorCol(this.state.cursorCol + (firstGrapheme ? firstGrapheme.segment.length : 1));
|
|
1469
|
+
return;
|
|
1470
|
+
}
|
|
1471
|
+
if (this.state.cursorLine < this.state.lines.length - 1) {
|
|
1472
|
+
this.state.cursorLine++;
|
|
1473
|
+
this.setCursorCol(0);
|
|
1474
|
+
return;
|
|
1475
|
+
}
|
|
1476
|
+
const currentVL = visualLines[currentVisualLine];
|
|
1477
|
+
if (currentVL)
|
|
1478
|
+
this.preferredVisualCol = this.state.cursorCol - currentVL.startCol;
|
|
1479
|
+
}
|
|
1480
|
+
moveCursorLeft() {
|
|
1481
|
+
const currentLine = this.state.lines[this.state.cursorLine] || "";
|
|
1482
|
+
if (this.state.cursorCol > 0) {
|
|
1483
|
+
const beforeCursor = currentLine.slice(0, this.state.cursorCol);
|
|
1484
|
+
const graphemes = [...this.segment(beforeCursor, "grapheme")];
|
|
1485
|
+
const lastGrapheme = graphemes[graphemes.length - 1];
|
|
1486
|
+
this.setCursorCol(this.state.cursorCol - (lastGrapheme ? lastGrapheme.segment.length : 1));
|
|
1487
|
+
return;
|
|
1488
|
+
}
|
|
1489
|
+
if (this.state.cursorLine > 0) {
|
|
1490
|
+
this.state.cursorLine--;
|
|
1491
|
+
const previousLine = this.state.lines[this.state.cursorLine] || "";
|
|
1492
|
+
this.setCursorCol(previousLine.length);
|
|
1493
|
+
}
|
|
1494
|
+
}
|
|
1495
|
+
moveCursorHorizontally(visualLines, currentVisualLine, deltaCol) {
|
|
1496
|
+
if (deltaCol > 0)
|
|
1497
|
+
this.moveCursorRight(visualLines, currentVisualLine);
|
|
1498
|
+
else if (deltaCol < 0)
|
|
1499
|
+
this.moveCursorLeft();
|
|
1500
|
+
}
|
|
1430
1501
|
moveCursor(deltaLine, deltaCol) {
|
|
1431
1502
|
this.lastAction = null;
|
|
1432
1503
|
const visualLines = this.buildVisualLineMap(this.lastWidth);
|
|
1433
1504
|
const currentVisualLine = this.findCurrentVisualLine(visualLines);
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
if (targetVisualLine >= 0 && targetVisualLine < visualLines.length) {
|
|
1437
|
-
this.moveToVisualLine(visualLines, currentVisualLine, targetVisualLine);
|
|
1438
|
-
}
|
|
1439
|
-
}
|
|
1440
|
-
if (deltaCol !== 0) {
|
|
1441
|
-
const currentLine = this.state.lines[this.state.cursorLine] || "";
|
|
1442
|
-
if (deltaCol > 0) {
|
|
1443
|
-
// Moving right - move by one grapheme (handles emojis, combining characters, etc.)
|
|
1444
|
-
if (this.state.cursorCol < currentLine.length) {
|
|
1445
|
-
const afterCursor = currentLine.slice(this.state.cursorCol);
|
|
1446
|
-
const graphemes = [...this.segment(afterCursor, "grapheme")];
|
|
1447
|
-
const firstGrapheme = graphemes[0];
|
|
1448
|
-
this.setCursorCol(this.state.cursorCol + (firstGrapheme ? firstGrapheme.segment.length : 1));
|
|
1449
|
-
}
|
|
1450
|
-
else if (this.state.cursorLine < this.state.lines.length - 1) {
|
|
1451
|
-
// Wrap to start of next logical line
|
|
1452
|
-
this.state.cursorLine++;
|
|
1453
|
-
this.setCursorCol(0);
|
|
1454
|
-
}
|
|
1455
|
-
else {
|
|
1456
|
-
// At end of last line - can't move, but set preferredVisualCol for up/down navigation
|
|
1457
|
-
const currentVL = visualLines[currentVisualLine];
|
|
1458
|
-
if (currentVL) {
|
|
1459
|
-
this.preferredVisualCol = this.state.cursorCol - currentVL.startCol;
|
|
1460
|
-
}
|
|
1461
|
-
}
|
|
1462
|
-
}
|
|
1463
|
-
else {
|
|
1464
|
-
// Moving left - move by one grapheme (handles emojis, combining characters, etc.)
|
|
1465
|
-
if (this.state.cursorCol > 0) {
|
|
1466
|
-
const beforeCursor = currentLine.slice(0, this.state.cursorCol);
|
|
1467
|
-
const graphemes = [...this.segment(beforeCursor, "grapheme")];
|
|
1468
|
-
const lastGrapheme = graphemes[graphemes.length - 1];
|
|
1469
|
-
this.setCursorCol(this.state.cursorCol - (lastGrapheme ? lastGrapheme.segment.length : 1));
|
|
1470
|
-
}
|
|
1471
|
-
else if (this.state.cursorLine > 0) {
|
|
1472
|
-
// Wrap to end of previous logical line
|
|
1473
|
-
this.state.cursorLine--;
|
|
1474
|
-
const prevLine = this.state.lines[this.state.cursorLine] || "";
|
|
1475
|
-
this.setCursorCol(prevLine.length);
|
|
1476
|
-
}
|
|
1477
|
-
}
|
|
1478
|
-
}
|
|
1505
|
+
this.moveCursorVertically(visualLines, currentVisualLine, deltaLine);
|
|
1506
|
+
this.moveCursorHorizontally(visualLines, currentVisualLine, deltaCol);
|
|
1479
1507
|
}
|
|
1480
1508
|
/**
|
|
1481
1509
|
* Scroll by a page (direction: -1 for up, 1 for down).
|
|
@@ -1609,14 +1637,16 @@ export class Editor {
|
|
|
1609
1637
|
}
|
|
1610
1638
|
}
|
|
1611
1639
|
pushUndoSnapshot() {
|
|
1612
|
-
this.undoStack.push(this.state);
|
|
1640
|
+
this.undoStack.push({ state: this.state, pastes: this.pastes, pasteCounter: this.pasteCounter });
|
|
1613
1641
|
}
|
|
1614
1642
|
undo() {
|
|
1615
1643
|
this.historyIndex = -1; // Exit history browsing mode
|
|
1616
1644
|
const snapshot = this.undoStack.pop();
|
|
1617
1645
|
if (!snapshot)
|
|
1618
1646
|
return;
|
|
1619
|
-
Object.assign(this.state, snapshot);
|
|
1647
|
+
Object.assign(this.state, snapshot.state);
|
|
1648
|
+
this.pastes = snapshot.pastes;
|
|
1649
|
+
this.pasteCounter = snapshot.pasteCounter;
|
|
1620
1650
|
this.lastAction = null;
|
|
1621
1651
|
this.preferredVisualCol = null;
|
|
1622
1652
|
if (this.onChange) {
|