@oh-my-pi/pi-tui 11.2.3 → 11.3.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/package.json +3 -3
- package/src/autocomplete.ts +14 -2
- package/src/components/box.ts +11 -1
- package/src/components/editor.ts +166 -127
- package/src/components/input.ts +38 -8
- package/src/components/markdown.ts +6 -8
- package/src/components/settings-list.ts +2 -2
- package/src/components/text.ts +6 -4
- package/src/index.ts +1 -1
- package/src/terminal.ts +47 -6
- package/src/tui.ts +214 -226
- package/src/utils.ts +7 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@oh-my-pi/pi-tui",
|
|
3
|
-
"version": "11.
|
|
3
|
+
"version": "11.3.0",
|
|
4
4
|
"description": "Terminal User Interface library with differential rendering for efficient text-based applications",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/index.ts",
|
|
@@ -47,8 +47,8 @@
|
|
|
47
47
|
"bun": ">=1.3.7"
|
|
48
48
|
},
|
|
49
49
|
"dependencies": {
|
|
50
|
-
"@oh-my-pi/pi-natives": "11.
|
|
51
|
-
"@oh-my-pi/pi-utils": "11.
|
|
50
|
+
"@oh-my-pi/pi-natives": "11.3.0",
|
|
51
|
+
"@oh-my-pi/pi-utils": "11.3.0",
|
|
52
52
|
"@types/mime-types": "^3.0.1",
|
|
53
53
|
"chalk": "^5.6.2",
|
|
54
54
|
"marked": "^17.0.1",
|
package/src/autocomplete.ts
CHANGED
|
@@ -519,6 +519,10 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider {
|
|
|
519
519
|
if (!entry.name.toLowerCase().startsWith(searchPrefix.toLowerCase())) {
|
|
520
520
|
continue;
|
|
521
521
|
}
|
|
522
|
+
// Skip .git directory
|
|
523
|
+
if (entry.name === ".git") {
|
|
524
|
+
continue;
|
|
525
|
+
}
|
|
522
526
|
|
|
523
527
|
// Check if entry is a directory (or a symlink pointing to a directory)
|
|
524
528
|
let isDirectory = entry.isDirectory();
|
|
@@ -627,11 +631,19 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider {
|
|
|
627
631
|
query,
|
|
628
632
|
path: this.basePath,
|
|
629
633
|
maxResults: 100,
|
|
630
|
-
hidden:
|
|
634
|
+
hidden: true,
|
|
631
635
|
gitignore: true,
|
|
632
636
|
});
|
|
633
637
|
|
|
634
|
-
const
|
|
638
|
+
const filteredMatches = result.matches.filter(entry => {
|
|
639
|
+
const p = entry.path.endsWith("/") ? entry.path.slice(0, -1) : entry.path;
|
|
640
|
+
const normalized = p.replaceAll("\\", "/");
|
|
641
|
+
// Exclude the `.git` directory (including when the path ends with `/.git`).
|
|
642
|
+
// Must only match a full path segment to avoid excluding `.gitignore`, `foo.git`, etc.
|
|
643
|
+
return !/(^|\/)\.git(\/|$)/.test(normalized);
|
|
644
|
+
});
|
|
645
|
+
|
|
646
|
+
const scoredEntries = filteredMatches
|
|
635
647
|
.map(entry => ({
|
|
636
648
|
path: entry.path,
|
|
637
649
|
isDirectory: entry.isDirectory,
|
package/src/components/box.ts
CHANGED
|
@@ -51,8 +51,18 @@ export class Box implements Component {
|
|
|
51
51
|
this.cached = undefined;
|
|
52
52
|
}
|
|
53
53
|
|
|
54
|
+
private static _tmp = new Uint32Array(2);
|
|
54
55
|
private computeCacheKey(width: number, childLines: string[], bgSample: string | undefined): bigint {
|
|
55
|
-
|
|
56
|
+
Box._tmp[0] = width;
|
|
57
|
+
Box._tmp[1] = childLines.length;
|
|
58
|
+
let h = Bun.hash.xxHash64(Box._tmp);
|
|
59
|
+
for (const line of childLines) {
|
|
60
|
+
Box._tmp[0] = line.length;
|
|
61
|
+
h = Bun.hash.xxHash64(Box._tmp, h);
|
|
62
|
+
h = Bun.hash.xxHash64(line, h);
|
|
63
|
+
}
|
|
64
|
+
h = Bun.hash.xxHash64(bgSample ?? "", h);
|
|
65
|
+
return h;
|
|
56
66
|
}
|
|
57
67
|
|
|
58
68
|
private matchCache(cacheKey: bigint): boolean {
|
package/src/components/editor.ts
CHANGED
|
@@ -306,6 +306,9 @@ export class Editor implements Component, Focusable {
|
|
|
306
306
|
// Character jump mode
|
|
307
307
|
private jumpMode: "forward" | "backward" | null = null;
|
|
308
308
|
|
|
309
|
+
// Preferred visual column for vertical cursor movement (sticky column)
|
|
310
|
+
private preferredVisualCol: number | null = null;
|
|
311
|
+
|
|
309
312
|
// Border color (can be changed dynamically)
|
|
310
313
|
public borderColor: (str: string) => string;
|
|
311
314
|
|
|
@@ -455,7 +458,7 @@ export class Editor implements Component, Focusable {
|
|
|
455
458
|
const lines = text.replace(/\r\n/g, "\n").replace(/\r/g, "\n").split("\n");
|
|
456
459
|
this.state.lines = lines.length === 0 ? [""] : lines;
|
|
457
460
|
this.state.cursorLine = this.state.lines.length - 1;
|
|
458
|
-
this.
|
|
461
|
+
this.setCursorCol(this.state.lines[this.state.cursorLine]?.length || 0);
|
|
459
462
|
|
|
460
463
|
if (this.onChange) {
|
|
461
464
|
this.onChange(this.getText());
|
|
@@ -616,42 +619,6 @@ export class Editor implements Component, Focusable {
|
|
|
616
619
|
return result;
|
|
617
620
|
}
|
|
618
621
|
|
|
619
|
-
getCursorPosition(width: number): { row: number; col: number } | null {
|
|
620
|
-
if (!this.useTerminalCursor) return null;
|
|
621
|
-
|
|
622
|
-
const paddingX = this.getEditorPaddingX();
|
|
623
|
-
const borderWidth = paddingX + 1;
|
|
624
|
-
const layoutWidth = this.getLayoutWidth(width, paddingX);
|
|
625
|
-
if (layoutWidth <= 0) return null;
|
|
626
|
-
|
|
627
|
-
const layoutLines = this.layoutText(layoutWidth);
|
|
628
|
-
const visibleContentHeight = this.getVisibleContentHeight(layoutLines.length);
|
|
629
|
-
this.updateScrollOffset(layoutWidth, layoutLines, visibleContentHeight);
|
|
630
|
-
|
|
631
|
-
for (let i = 0; i < layoutLines.length; i++) {
|
|
632
|
-
if (i < this.scrollOffset || i >= this.scrollOffset + visibleContentHeight) continue;
|
|
633
|
-
const layoutLine = layoutLines[i];
|
|
634
|
-
if (!layoutLine || !layoutLine.hasCursor || layoutLine.cursorPos === undefined) continue;
|
|
635
|
-
|
|
636
|
-
const lineWidth = visibleWidth(layoutLine.text);
|
|
637
|
-
const isCursorAtLineEnd = layoutLine.cursorPos === layoutLine.text.length;
|
|
638
|
-
|
|
639
|
-
if (isCursorAtLineEnd && lineWidth >= layoutWidth && layoutLine.text.length > 0) {
|
|
640
|
-
const graphemes = [...segmenter.segment(layoutLine.text)];
|
|
641
|
-
const lastGrapheme = graphemes[graphemes.length - 1]?.segment || "";
|
|
642
|
-
const lastWidth = visibleWidth(lastGrapheme) || 1;
|
|
643
|
-
const colOffset = borderWidth + Math.max(0, lineWidth - lastWidth);
|
|
644
|
-
return { row: 1 + i - this.scrollOffset, col: colOffset };
|
|
645
|
-
}
|
|
646
|
-
|
|
647
|
-
const before = layoutLine.text.slice(0, layoutLine.cursorPos);
|
|
648
|
-
const colOffset = borderWidth + visibleWidth(before);
|
|
649
|
-
return { row: 1 + i - this.scrollOffset, col: colOffset };
|
|
650
|
-
}
|
|
651
|
-
|
|
652
|
-
return null;
|
|
653
|
-
}
|
|
654
|
-
|
|
655
622
|
handleInput(data: string): void {
|
|
656
623
|
const kb = getEditorKeybindings();
|
|
657
624
|
|
|
@@ -768,7 +735,7 @@ export class Editor implements Component, Focusable {
|
|
|
768
735
|
|
|
769
736
|
this.state.lines = result.lines;
|
|
770
737
|
this.state.cursorLine = result.cursorLine;
|
|
771
|
-
this.
|
|
738
|
+
this.setCursorCol(result.cursorCol);
|
|
772
739
|
|
|
773
740
|
this.cancelAutocomplete();
|
|
774
741
|
|
|
@@ -803,7 +770,7 @@ export class Editor implements Component, Focusable {
|
|
|
803
770
|
|
|
804
771
|
this.state.lines = result.lines;
|
|
805
772
|
this.state.cursorLine = result.cursorLine;
|
|
806
|
-
this.
|
|
773
|
+
this.setCursorCol(result.cursorCol);
|
|
807
774
|
}
|
|
808
775
|
this.cancelAutocomplete();
|
|
809
776
|
}
|
|
@@ -823,7 +790,7 @@ export class Editor implements Component, Focusable {
|
|
|
823
790
|
|
|
824
791
|
this.state.lines = result.lines;
|
|
825
792
|
this.state.cursorLine = result.cursorLine;
|
|
826
|
-
this.
|
|
793
|
+
this.setCursorCol(result.cursorCol);
|
|
827
794
|
|
|
828
795
|
this.cancelAutocomplete();
|
|
829
796
|
|
|
@@ -885,7 +852,7 @@ export class Editor implements Component, Focusable {
|
|
|
885
852
|
this.addNewLine();
|
|
886
853
|
}
|
|
887
854
|
}
|
|
888
|
-
// New line
|
|
855
|
+
// New line
|
|
889
856
|
else if (
|
|
890
857
|
(data.charCodeAt(0) === 10 && data.length > 1) || // Ctrl+Enter with modifiers
|
|
891
858
|
data === "\x1b[13;5u" || // Ctrl+Enter (Kitty protocol)
|
|
@@ -896,7 +863,11 @@ export class Editor implements Component, Focusable {
|
|
|
896
863
|
(data.length > 1 && data.includes("\x1b") && data.includes("\r")) ||
|
|
897
864
|
(data === "\n" && data.length === 1) // Shift+Enter from iTerm2 mapping
|
|
898
865
|
) {
|
|
899
|
-
|
|
866
|
+
if (this.shouldSubmitOnBackslashEnter(data, kb)) {
|
|
867
|
+
this.handleBackspace();
|
|
868
|
+
this.submitValue();
|
|
869
|
+
return;
|
|
870
|
+
}
|
|
900
871
|
this.addNewLine();
|
|
901
872
|
}
|
|
902
873
|
// Plain Enter - submit (handles both legacy \r and Kitty protocol with lock bits)
|
|
@@ -906,46 +877,7 @@ export class Editor implements Component, Focusable {
|
|
|
906
877
|
return;
|
|
907
878
|
}
|
|
908
879
|
|
|
909
|
-
|
|
910
|
-
// If char before cursor is \, delete it and insert newline instead of submitting.
|
|
911
|
-
const currentLine = this.state.lines[this.state.cursorLine] || "";
|
|
912
|
-
if (this.state.cursorCol > 0 && currentLine[this.state.cursorCol - 1] === "\\") {
|
|
913
|
-
this.handleBackspace();
|
|
914
|
-
this.addNewLine();
|
|
915
|
-
return;
|
|
916
|
-
}
|
|
917
|
-
|
|
918
|
-
this.resetKillSequence();
|
|
919
|
-
|
|
920
|
-
// Get text and substitute paste markers with actual content
|
|
921
|
-
let result = this.state.lines.join("\n").trim();
|
|
922
|
-
|
|
923
|
-
// Replace all [paste #N +xxx lines] or [paste #N xxx chars] markers with actual paste content
|
|
924
|
-
for (const [pasteId, pasteContent] of this.pastes) {
|
|
925
|
-
// Match formats: [paste #N], [paste #N +xxx lines], or [paste #N xxx chars]
|
|
926
|
-
const markerRegex = new RegExp(`\\[paste #${pasteId}( (\\+\\d+ lines|\\d+ chars))?\\]`, "g");
|
|
927
|
-
result = result.replace(markerRegex, pasteContent);
|
|
928
|
-
}
|
|
929
|
-
|
|
930
|
-
// Reset editor and clear pastes
|
|
931
|
-
this.state = {
|
|
932
|
-
lines: [""],
|
|
933
|
-
cursorLine: 0,
|
|
934
|
-
cursorCol: 0,
|
|
935
|
-
};
|
|
936
|
-
this.clearUndoStack();
|
|
937
|
-
this.pastes.clear();
|
|
938
|
-
this.pasteCounter = 0;
|
|
939
|
-
this.historyIndex = -1; // Exit history browsing mode
|
|
940
|
-
|
|
941
|
-
// Notify that editor is now empty
|
|
942
|
-
if (this.onChange) {
|
|
943
|
-
this.onChange("");
|
|
944
|
-
}
|
|
945
|
-
|
|
946
|
-
if (this.onSubmit) {
|
|
947
|
-
this.onSubmit(result);
|
|
948
|
-
}
|
|
880
|
+
this.submitValue();
|
|
949
881
|
}
|
|
950
882
|
// Backspace (including Shift+Backspace)
|
|
951
883
|
else if (matchesKey(data, "backspace") || matchesKey(data, "shift+backspace")) {
|
|
@@ -1155,7 +1087,7 @@ export class Editor implements Component, Focusable {
|
|
|
1155
1087
|
const after = line.slice(this.state.cursorCol);
|
|
1156
1088
|
|
|
1157
1089
|
this.state.lines[this.state.cursorLine] = before + text + after;
|
|
1158
|
-
this.state.cursorCol
|
|
1090
|
+
this.setCursorCol(this.state.cursorCol + text.length);
|
|
1159
1091
|
|
|
1160
1092
|
if (this.onChange) {
|
|
1161
1093
|
this.onChange(this.getText());
|
|
@@ -1174,7 +1106,7 @@ export class Editor implements Component, Focusable {
|
|
|
1174
1106
|
const after = line.slice(this.state.cursorCol);
|
|
1175
1107
|
|
|
1176
1108
|
this.state.lines[this.state.cursorLine] = before + char + after;
|
|
1177
|
-
this.state.cursorCol
|
|
1109
|
+
this.setCursorCol(this.state.cursorCol + char.length);
|
|
1178
1110
|
|
|
1179
1111
|
if (this.onChange) {
|
|
1180
1112
|
this.onChange(this.getText());
|
|
@@ -1292,13 +1224,44 @@ export class Editor implements Component, Focusable {
|
|
|
1292
1224
|
|
|
1293
1225
|
// Move cursor to start of new line
|
|
1294
1226
|
this.state.cursorLine++;
|
|
1295
|
-
this.
|
|
1227
|
+
this.setCursorCol(0);
|
|
1296
1228
|
|
|
1297
1229
|
if (this.onChange) {
|
|
1298
1230
|
this.onChange(this.getText());
|
|
1299
1231
|
}
|
|
1300
1232
|
}
|
|
1301
1233
|
|
|
1234
|
+
private shouldSubmitOnBackslashEnter(data: string, kb: ReturnType<typeof getEditorKeybindings>): boolean {
|
|
1235
|
+
if (this.disableSubmit) return false;
|
|
1236
|
+
if (!matchesKey(data, "enter")) return false;
|
|
1237
|
+
const submitKeys = kb.getKeys("submit");
|
|
1238
|
+
const hasShiftEnter = submitKeys.includes("shift+enter") || submitKeys.includes("shift+return");
|
|
1239
|
+
if (!hasShiftEnter) return false;
|
|
1240
|
+
|
|
1241
|
+
const currentLine = this.state.lines[this.state.cursorLine] || "";
|
|
1242
|
+
return this.state.cursorCol > 0 && currentLine[this.state.cursorCol - 1] === "\\";
|
|
1243
|
+
}
|
|
1244
|
+
|
|
1245
|
+
private submitValue(): void {
|
|
1246
|
+
this.resetKillSequence();
|
|
1247
|
+
|
|
1248
|
+
let result = this.state.lines.join("\n").trim();
|
|
1249
|
+
for (const [pasteId, pasteContent] of this.pastes) {
|
|
1250
|
+
const markerRegex = new RegExp(`\\[paste #${pasteId}( (\\+\\d+ lines|\\d+ chars))?\\]`, "g");
|
|
1251
|
+
result = result.replace(markerRegex, pasteContent);
|
|
1252
|
+
}
|
|
1253
|
+
|
|
1254
|
+
this.state = { lines: [""], cursorLine: 0, cursorCol: 0 };
|
|
1255
|
+
this.pastes.clear();
|
|
1256
|
+
this.pasteCounter = 0;
|
|
1257
|
+
this.historyIndex = -1;
|
|
1258
|
+
this.scrollOffset = 0;
|
|
1259
|
+
this.undoStack.length = 0;
|
|
1260
|
+
|
|
1261
|
+
if (this.onChange) this.onChange("");
|
|
1262
|
+
if (this.onSubmit) this.onSubmit(result);
|
|
1263
|
+
}
|
|
1264
|
+
|
|
1302
1265
|
private handleBackspace(): void {
|
|
1303
1266
|
this.historyIndex = -1; // Exit history browsing mode
|
|
1304
1267
|
this.resetKillSequence();
|
|
@@ -1318,7 +1281,7 @@ export class Editor implements Component, Focusable {
|
|
|
1318
1281
|
const after = line.slice(this.state.cursorCol);
|
|
1319
1282
|
|
|
1320
1283
|
this.state.lines[this.state.cursorLine] = before + after;
|
|
1321
|
-
this.state.cursorCol
|
|
1284
|
+
this.setCursorCol(this.state.cursorCol - graphemeLength);
|
|
1322
1285
|
} else if (this.state.cursorLine > 0) {
|
|
1323
1286
|
// Merge with previous line
|
|
1324
1287
|
const currentLine = this.state.lines[this.state.cursorLine] || "";
|
|
@@ -1328,7 +1291,7 @@ export class Editor implements Component, Focusable {
|
|
|
1328
1291
|
this.state.lines.splice(this.state.cursorLine, 1);
|
|
1329
1292
|
|
|
1330
1293
|
this.state.cursorLine--;
|
|
1331
|
-
this.
|
|
1294
|
+
this.setCursorCol(previousLine.length);
|
|
1332
1295
|
}
|
|
1333
1296
|
|
|
1334
1297
|
if (this.onChange) {
|
|
@@ -1353,15 +1316,96 @@ export class Editor implements Component, Focusable {
|
|
|
1353
1316
|
}
|
|
1354
1317
|
}
|
|
1355
1318
|
|
|
1319
|
+
/**
|
|
1320
|
+
* Set cursor column and clear preferredVisualCol.
|
|
1321
|
+
* Use this for all non-vertical cursor movements to reset sticky column behavior.
|
|
1322
|
+
*/
|
|
1323
|
+
private setCursorCol(col: number): void {
|
|
1324
|
+
this.state.cursorCol = col;
|
|
1325
|
+
this.preferredVisualCol = null;
|
|
1326
|
+
}
|
|
1327
|
+
|
|
1328
|
+
/**
|
|
1329
|
+
* Move cursor to a target visual line, applying sticky column logic.
|
|
1330
|
+
* Shared by moveCursor() and pageScroll().
|
|
1331
|
+
*/
|
|
1332
|
+
private moveToVisualLine(
|
|
1333
|
+
visualLines: Array<{ logicalLine: number; startCol: number; length: number }>,
|
|
1334
|
+
currentVisualLine: number,
|
|
1335
|
+
targetVisualLine: number,
|
|
1336
|
+
): void {
|
|
1337
|
+
const currentVL = visualLines[currentVisualLine];
|
|
1338
|
+
const targetVL = visualLines[targetVisualLine];
|
|
1339
|
+
|
|
1340
|
+
if (currentVL && targetVL) {
|
|
1341
|
+
const currentVisualCol = this.state.cursorCol - currentVL.startCol;
|
|
1342
|
+
|
|
1343
|
+
// For non-last segments, clamp to length-1 to stay within the segment
|
|
1344
|
+
const isLastSourceSegment =
|
|
1345
|
+
currentVisualLine === visualLines.length - 1 ||
|
|
1346
|
+
visualLines[currentVisualLine + 1]?.logicalLine !== currentVL.logicalLine;
|
|
1347
|
+
const sourceMaxVisualCol = isLastSourceSegment ? currentVL.length : Math.max(0, currentVL.length - 1);
|
|
1348
|
+
|
|
1349
|
+
const isLastTargetSegment =
|
|
1350
|
+
targetVisualLine === visualLines.length - 1 ||
|
|
1351
|
+
visualLines[targetVisualLine + 1]?.logicalLine !== targetVL.logicalLine;
|
|
1352
|
+
const targetMaxVisualCol = isLastTargetSegment ? targetVL.length : Math.max(0, targetVL.length - 1);
|
|
1353
|
+
|
|
1354
|
+
const moveToVisualCol = this.computeVerticalMoveColumn(
|
|
1355
|
+
currentVisualCol,
|
|
1356
|
+
sourceMaxVisualCol,
|
|
1357
|
+
targetMaxVisualCol,
|
|
1358
|
+
);
|
|
1359
|
+
|
|
1360
|
+
// Set cursor position
|
|
1361
|
+
this.state.cursorLine = targetVL.logicalLine;
|
|
1362
|
+
const targetCol = targetVL.startCol + moveToVisualCol;
|
|
1363
|
+
const logicalLine = this.state.lines[targetVL.logicalLine] || "";
|
|
1364
|
+
this.state.cursorCol = Math.min(targetCol, logicalLine.length);
|
|
1365
|
+
}
|
|
1366
|
+
}
|
|
1367
|
+
|
|
1368
|
+
/**
|
|
1369
|
+
* Compute the target visual column for vertical cursor movement.
|
|
1370
|
+
* Implements the sticky column decision table.
|
|
1371
|
+
*/
|
|
1372
|
+
private computeVerticalMoveColumn(
|
|
1373
|
+
currentVisualCol: number,
|
|
1374
|
+
sourceMaxVisualCol: number,
|
|
1375
|
+
targetMaxVisualCol: number,
|
|
1376
|
+
): number {
|
|
1377
|
+
const hasPreferred = this.preferredVisualCol !== null;
|
|
1378
|
+
const cursorInMiddle = currentVisualCol < sourceMaxVisualCol;
|
|
1379
|
+
const targetTooShort = targetMaxVisualCol < currentVisualCol;
|
|
1380
|
+
|
|
1381
|
+
if (!hasPreferred || cursorInMiddle) {
|
|
1382
|
+
if (targetTooShort) {
|
|
1383
|
+
this.preferredVisualCol = currentVisualCol;
|
|
1384
|
+
return targetMaxVisualCol;
|
|
1385
|
+
}
|
|
1386
|
+
this.preferredVisualCol = null;
|
|
1387
|
+
return currentVisualCol;
|
|
1388
|
+
}
|
|
1389
|
+
|
|
1390
|
+
const targetCantFitPreferred = targetMaxVisualCol < this.preferredVisualCol!;
|
|
1391
|
+
if (targetTooShort || targetCantFitPreferred) {
|
|
1392
|
+
return targetMaxVisualCol;
|
|
1393
|
+
}
|
|
1394
|
+
|
|
1395
|
+
const result = this.preferredVisualCol!;
|
|
1396
|
+
this.preferredVisualCol = null;
|
|
1397
|
+
return result;
|
|
1398
|
+
}
|
|
1399
|
+
|
|
1356
1400
|
private moveToLineStart(): void {
|
|
1357
1401
|
this.resetKillSequence();
|
|
1358
|
-
this.
|
|
1402
|
+
this.setCursorCol(0);
|
|
1359
1403
|
}
|
|
1360
1404
|
|
|
1361
1405
|
private moveToLineEnd(): void {
|
|
1362
1406
|
this.resetKillSequence();
|
|
1363
1407
|
const currentLine = this.state.lines[this.state.cursorLine] || "";
|
|
1364
|
-
this.
|
|
1408
|
+
this.setCursorCol(currentLine.length);
|
|
1365
1409
|
}
|
|
1366
1410
|
|
|
1367
1411
|
private resetKillSequence(): void {
|
|
@@ -1412,6 +1456,7 @@ export class Editor implements Component, Focusable {
|
|
|
1412
1456
|
|
|
1413
1457
|
this.historyIndex = -1;
|
|
1414
1458
|
this.resetKillSequence();
|
|
1459
|
+
this.preferredVisualCol = null;
|
|
1415
1460
|
this.state = {
|
|
1416
1461
|
lines: [...snapshot.lines],
|
|
1417
1462
|
cursorLine: snapshot.cursorLine,
|
|
@@ -1462,7 +1507,7 @@ export class Editor implements Component, Focusable {
|
|
|
1462
1507
|
const before = line.slice(0, this.state.cursorCol);
|
|
1463
1508
|
const after = line.slice(this.state.cursorCol);
|
|
1464
1509
|
this.state.lines[this.state.cursorLine] = before + normalized + after;
|
|
1465
|
-
this.state.cursorCol
|
|
1510
|
+
this.setCursorCol(this.state.cursorCol + normalized.length);
|
|
1466
1511
|
} else {
|
|
1467
1512
|
const currentLine = this.state.lines[this.state.cursorLine] || "";
|
|
1468
1513
|
const beforeCursor = currentLine.slice(0, this.state.cursorCol);
|
|
@@ -1485,7 +1530,7 @@ export class Editor implements Component, Focusable {
|
|
|
1485
1530
|
|
|
1486
1531
|
this.state.lines = newLines;
|
|
1487
1532
|
this.state.cursorLine += lines.length - 1;
|
|
1488
|
-
this.
|
|
1533
|
+
this.setCursorCol((lines[lines.length - 1] || "").length);
|
|
1489
1534
|
}
|
|
1490
1535
|
|
|
1491
1536
|
if (this.onChange) {
|
|
@@ -1509,7 +1554,7 @@ export class Editor implements Component, Focusable {
|
|
|
1509
1554
|
// Delete from start of line up to cursor
|
|
1510
1555
|
deletedText = currentLine.slice(0, this.state.cursorCol);
|
|
1511
1556
|
this.state.lines[this.state.cursorLine] = currentLine.slice(this.state.cursorCol);
|
|
1512
|
-
this.
|
|
1557
|
+
this.setCursorCol(0);
|
|
1513
1558
|
} else if (this.state.cursorLine > 0) {
|
|
1514
1559
|
// At start of line - merge with previous line
|
|
1515
1560
|
deletedText = "\n";
|
|
@@ -1517,7 +1562,7 @@ export class Editor implements Component, Focusable {
|
|
|
1517
1562
|
this.state.lines[this.state.cursorLine - 1] = previousLine + currentLine;
|
|
1518
1563
|
this.state.lines.splice(this.state.cursorLine, 1);
|
|
1519
1564
|
this.state.cursorLine--;
|
|
1520
|
-
this.
|
|
1565
|
+
this.setCursorCol(previousLine.length);
|
|
1521
1566
|
}
|
|
1522
1567
|
|
|
1523
1568
|
this.recordKill(deletedText, "backward");
|
|
@@ -1567,18 +1612,18 @@ export class Editor implements Component, Focusable {
|
|
|
1567
1612
|
this.state.lines[this.state.cursorLine - 1] = previousLine + currentLine;
|
|
1568
1613
|
this.state.lines.splice(this.state.cursorLine, 1);
|
|
1569
1614
|
this.state.cursorLine--;
|
|
1570
|
-
this.
|
|
1615
|
+
this.setCursorCol(previousLine.length);
|
|
1571
1616
|
}
|
|
1572
1617
|
} else {
|
|
1573
1618
|
const oldCursorCol = this.state.cursorCol;
|
|
1574
1619
|
this.moveWordBackwards();
|
|
1575
1620
|
const deleteFrom = this.state.cursorCol;
|
|
1576
|
-
this.
|
|
1621
|
+
this.setCursorCol(oldCursorCol);
|
|
1577
1622
|
|
|
1578
1623
|
const deletedText = currentLine.slice(deleteFrom, oldCursorCol);
|
|
1579
1624
|
this.state.lines[this.state.cursorLine] =
|
|
1580
1625
|
currentLine.slice(0, deleteFrom) + currentLine.slice(this.state.cursorCol);
|
|
1581
|
-
this.
|
|
1626
|
+
this.setCursorCol(deleteFrom);
|
|
1582
1627
|
this.recordKill(deletedText, "backward");
|
|
1583
1628
|
}
|
|
1584
1629
|
|
|
@@ -1604,7 +1649,7 @@ export class Editor implements Component, Focusable {
|
|
|
1604
1649
|
const oldCursorCol = this.state.cursorCol;
|
|
1605
1650
|
this.moveWordForwards();
|
|
1606
1651
|
const deleteTo = this.state.cursorCol;
|
|
1607
|
-
this.
|
|
1652
|
+
this.setCursorCol(oldCursorCol);
|
|
1608
1653
|
|
|
1609
1654
|
const deletedText = currentLine.slice(oldCursorCol, deleteTo);
|
|
1610
1655
|
this.state.lines[this.state.cursorLine] = currentLine.slice(0, oldCursorCol) + currentLine.slice(deleteTo);
|
|
@@ -1723,29 +1768,14 @@ export class Editor implements Component, Focusable {
|
|
|
1723
1768
|
|
|
1724
1769
|
private moveCursor(deltaLine: number, deltaCol: number): void {
|
|
1725
1770
|
this.resetKillSequence();
|
|
1726
|
-
const
|
|
1771
|
+
const visualLines = this.buildVisualLineMap(this.lastLayoutWidth);
|
|
1772
|
+
const currentVisualLine = this.findCurrentVisualLine(visualLines);
|
|
1727
1773
|
|
|
1728
1774
|
if (deltaLine !== 0) {
|
|
1729
|
-
// Build visual line map for navigation
|
|
1730
|
-
const visualLines = this.buildVisualLineMap(contentWidth);
|
|
1731
|
-
const currentVisualLine = this.findCurrentVisualLine(visualLines);
|
|
1732
|
-
|
|
1733
|
-
// Calculate column position within current visual line
|
|
1734
|
-
const currentVL = visualLines[currentVisualLine];
|
|
1735
|
-
const visualCol = currentVL ? this.state.cursorCol - currentVL.startCol : 0;
|
|
1736
|
-
|
|
1737
|
-
// Move to target visual line
|
|
1738
1775
|
const targetVisualLine = currentVisualLine + deltaLine;
|
|
1739
1776
|
|
|
1740
1777
|
if (targetVisualLine >= 0 && targetVisualLine < visualLines.length) {
|
|
1741
|
-
|
|
1742
|
-
if (targetVL) {
|
|
1743
|
-
this.state.cursorLine = targetVL.logicalLine;
|
|
1744
|
-
// Try to maintain visual column position, clamped to line length
|
|
1745
|
-
const targetCol = targetVL.startCol + Math.min(visualCol, targetVL.length);
|
|
1746
|
-
const logicalLine = this.state.lines[targetVL.logicalLine] || "";
|
|
1747
|
-
this.state.cursorCol = Math.min(targetCol, logicalLine.length);
|
|
1748
|
-
}
|
|
1778
|
+
this.moveToVisualLine(visualLines, currentVisualLine, targetVisualLine);
|
|
1749
1779
|
}
|
|
1750
1780
|
}
|
|
1751
1781
|
|
|
@@ -1758,11 +1788,17 @@ export class Editor implements Component, Focusable {
|
|
|
1758
1788
|
const afterCursor = currentLine.slice(this.state.cursorCol);
|
|
1759
1789
|
const graphemes = [...segmenter.segment(afterCursor)];
|
|
1760
1790
|
const firstGrapheme = graphemes[0];
|
|
1761
|
-
this.state.cursorCol
|
|
1791
|
+
this.setCursorCol(this.state.cursorCol + (firstGrapheme ? firstGrapheme.segment.length : 1));
|
|
1762
1792
|
} else if (this.state.cursorLine < this.state.lines.length - 1) {
|
|
1763
1793
|
// Wrap to start of next logical line
|
|
1764
1794
|
this.state.cursorLine++;
|
|
1765
|
-
this.
|
|
1795
|
+
this.setCursorCol(0);
|
|
1796
|
+
} else {
|
|
1797
|
+
// At end of last line - can't move, but set preferredVisualCol for up/down navigation
|
|
1798
|
+
const currentVL = visualLines[currentVisualLine];
|
|
1799
|
+
if (currentVL) {
|
|
1800
|
+
this.preferredVisualCol = this.state.cursorCol - currentVL.startCol;
|
|
1801
|
+
}
|
|
1766
1802
|
}
|
|
1767
1803
|
} else {
|
|
1768
1804
|
// Moving left - move by one grapheme (handles emojis, combining characters, etc.)
|
|
@@ -1770,12 +1806,12 @@ export class Editor implements Component, Focusable {
|
|
|
1770
1806
|
const beforeCursor = currentLine.slice(0, this.state.cursorCol);
|
|
1771
1807
|
const graphemes = [...segmenter.segment(beforeCursor)];
|
|
1772
1808
|
const lastGrapheme = graphemes[graphemes.length - 1];
|
|
1773
|
-
this.state.cursorCol
|
|
1809
|
+
this.setCursorCol(this.state.cursorCol - (lastGrapheme ? lastGrapheme.segment.length : 1));
|
|
1774
1810
|
} else if (this.state.cursorLine > 0) {
|
|
1775
1811
|
// Wrap to end of previous logical line
|
|
1776
1812
|
this.state.cursorLine--;
|
|
1777
1813
|
const prevLine = this.state.lines[this.state.cursorLine] || "";
|
|
1778
|
-
this.
|
|
1814
|
+
this.setCursorCol(prevLine.length);
|
|
1779
1815
|
}
|
|
1780
1816
|
}
|
|
1781
1817
|
}
|
|
@@ -1789,7 +1825,7 @@ export class Editor implements Component, Focusable {
|
|
|
1789
1825
|
if (this.state.cursorLine > 0) {
|
|
1790
1826
|
this.state.cursorLine--;
|
|
1791
1827
|
const prevLine = this.state.lines[this.state.cursorLine] || "";
|
|
1792
|
-
this.
|
|
1828
|
+
this.setCursorCol(prevLine.length);
|
|
1793
1829
|
}
|
|
1794
1830
|
return;
|
|
1795
1831
|
}
|
|
@@ -1822,7 +1858,7 @@ export class Editor implements Component, Focusable {
|
|
|
1822
1858
|
}
|
|
1823
1859
|
}
|
|
1824
1860
|
|
|
1825
|
-
this.
|
|
1861
|
+
this.setCursorCol(newCol);
|
|
1826
1862
|
}
|
|
1827
1863
|
|
|
1828
1864
|
/**
|
|
@@ -1852,7 +1888,7 @@ export class Editor implements Component, Focusable {
|
|
|
1852
1888
|
|
|
1853
1889
|
if (idx !== -1) {
|
|
1854
1890
|
this.state.cursorLine = lineIdx;
|
|
1855
|
-
this.
|
|
1891
|
+
this.setCursorCol(idx);
|
|
1856
1892
|
return;
|
|
1857
1893
|
}
|
|
1858
1894
|
}
|
|
@@ -1866,7 +1902,7 @@ export class Editor implements Component, Focusable {
|
|
|
1866
1902
|
if (this.state.cursorCol >= currentLine.length) {
|
|
1867
1903
|
if (this.state.cursorLine < this.state.lines.length - 1) {
|
|
1868
1904
|
this.state.cursorLine++;
|
|
1869
|
-
this.
|
|
1905
|
+
this.setCursorCol(0);
|
|
1870
1906
|
}
|
|
1871
1907
|
return;
|
|
1872
1908
|
}
|
|
@@ -1875,10 +1911,11 @@ export class Editor implements Component, Focusable {
|
|
|
1875
1911
|
const segments = segmenter.segment(textAfterCursor);
|
|
1876
1912
|
const iterator = segments[Symbol.iterator]();
|
|
1877
1913
|
let next = iterator.next();
|
|
1914
|
+
let newCol = this.state.cursorCol;
|
|
1878
1915
|
|
|
1879
1916
|
// Skip leading whitespace
|
|
1880
1917
|
while (!next.done && isWhitespaceChar(next.value.segment)) {
|
|
1881
|
-
|
|
1918
|
+
newCol += next.value.segment.length;
|
|
1882
1919
|
next = iterator.next();
|
|
1883
1920
|
}
|
|
1884
1921
|
|
|
@@ -1887,17 +1924,19 @@ export class Editor implements Component, Focusable {
|
|
|
1887
1924
|
if (isPunctuationChar(firstGrapheme)) {
|
|
1888
1925
|
// Skip punctuation run
|
|
1889
1926
|
while (!next.done && isPunctuationChar(next.value.segment)) {
|
|
1890
|
-
|
|
1927
|
+
newCol += next.value.segment.length;
|
|
1891
1928
|
next = iterator.next();
|
|
1892
1929
|
}
|
|
1893
1930
|
} else {
|
|
1894
1931
|
// Skip word run
|
|
1895
1932
|
while (!next.done && !isWhitespaceChar(next.value.segment) && !isPunctuationChar(next.value.segment)) {
|
|
1896
|
-
|
|
1933
|
+
newCol += next.value.segment.length;
|
|
1897
1934
|
next = iterator.next();
|
|
1898
1935
|
}
|
|
1899
1936
|
}
|
|
1900
1937
|
}
|
|
1938
|
+
|
|
1939
|
+
this.setCursorCol(newCol);
|
|
1901
1940
|
}
|
|
1902
1941
|
|
|
1903
1942
|
// Helper method to check if cursor is at start of message (for slash command detection)
|
|
@@ -2000,7 +2039,7 @@ https://github.com/EsotericSoftware/spine-runtimes/actions/runs/19536643416/job/
|
|
|
2000
2039
|
|
|
2001
2040
|
this.state.lines = result.lines;
|
|
2002
2041
|
this.state.cursorLine = result.cursorLine;
|
|
2003
|
-
this.
|
|
2042
|
+
this.setCursorCol(result.cursorCol);
|
|
2004
2043
|
|
|
2005
2044
|
if (this.onChange) {
|
|
2006
2045
|
this.onChange(this.getText());
|