@linxiraos/pi-tui 1.1.13 → 1.1.15
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/CHANGELOG.md +2 -37
- package/dist/types/components/editor.d.ts +30 -0
- package/dist/types/mouse.d.ts +5 -5
- package/dist/types/terminal.d.ts +14 -0
- package/dist/types/tui.d.ts +22 -0
- package/dist/types/vim.d.ts +94 -0
- package/package.json +3 -3
- package/src/components/editor.ts +492 -22
- package/src/mouse.ts +5 -5
- package/src/terminal.ts +42 -0
- package/src/tui.ts +106 -17
- package/src/vim.ts +760 -0
package/src/components/editor.ts
CHANGED
|
@@ -26,6 +26,15 @@ import {
|
|
|
26
26
|
truncateToWidth,
|
|
27
27
|
visibleWidth,
|
|
28
28
|
} from "../utils";
|
|
29
|
+
import {
|
|
30
|
+
lastGraphemeStart,
|
|
31
|
+
nextGraphemeStart,
|
|
32
|
+
type VimCommand,
|
|
33
|
+
type VimMode,
|
|
34
|
+
type VimPosition,
|
|
35
|
+
VimState,
|
|
36
|
+
visualRange,
|
|
37
|
+
} from "../vim";
|
|
29
38
|
import {
|
|
30
39
|
borderlessComposerStyle,
|
|
31
40
|
type ComposerChromeContext,
|
|
@@ -370,6 +379,19 @@ function isPlainTextRun(data: string): boolean {
|
|
|
370
379
|
return true;
|
|
371
380
|
}
|
|
372
381
|
|
|
382
|
+
/** Named keys Vim's Normal/Visual modes reinterpret as motions instead of letting them edit. */
|
|
383
|
+
const VIM_NAV_KEYS: Record<string, string | undefined> = {
|
|
384
|
+
up: "k",
|
|
385
|
+
down: "j",
|
|
386
|
+
left: "h",
|
|
387
|
+
right: "l",
|
|
388
|
+
home: "0",
|
|
389
|
+
end: "$",
|
|
390
|
+
backspace: "h",
|
|
391
|
+
delete: "x",
|
|
392
|
+
space: "l",
|
|
393
|
+
};
|
|
394
|
+
|
|
373
395
|
const DEFAULT_PAGE_SCROLL_LINES = 10;
|
|
374
396
|
|
|
375
397
|
const MAX_UNDO_STACK = 100;
|
|
@@ -388,6 +410,10 @@ interface LayoutLine {
|
|
|
388
410
|
sourceStartCol: number;
|
|
389
411
|
hasCursor: boolean;
|
|
390
412
|
cursorPos?: number;
|
|
413
|
+
/** Logical buffer line this row came from, and the offset of `text[0]` within it. Populated
|
|
414
|
+
* only while a Vim visual selection is active, to map the selection span onto wrapped rows. */
|
|
415
|
+
logicalLine?: number;
|
|
416
|
+
startIndex?: number;
|
|
391
417
|
}
|
|
392
418
|
|
|
393
419
|
/** Per-line measurement carried across renders: exact visible width plus
|
|
@@ -421,6 +447,16 @@ interface HistoryStorage {
|
|
|
421
447
|
getRecent(limit: number): HistoryEntry[];
|
|
422
448
|
}
|
|
423
449
|
|
|
450
|
+
interface LocalHistoryEntry {
|
|
451
|
+
text: string;
|
|
452
|
+
draft?: {
|
|
453
|
+
pastes: Map<number, string>;
|
|
454
|
+
atoms: Map<string, string>;
|
|
455
|
+
pasteCounter: number;
|
|
456
|
+
restore?: () => void;
|
|
457
|
+
};
|
|
458
|
+
}
|
|
459
|
+
|
|
424
460
|
/** A synchronous replacement immediately before the editor cursor. */
|
|
425
461
|
export interface EditorInlineReplacement {
|
|
426
462
|
/** UTF-16 code units to remove immediately before the cursor. */
|
|
@@ -522,6 +558,15 @@ export class Editor implements Component, Focusable {
|
|
|
522
558
|
// Character jump mode
|
|
523
559
|
#jumpMode: "forward" | "backward" | null = null;
|
|
524
560
|
|
|
561
|
+
/** Vim-style modal editing (opt-in, see the `tui.vimMode` setting). `null` when disabled, in
|
|
562
|
+
* which case every code path below behaves exactly as it did before the mode existed. */
|
|
563
|
+
#vim: VimState | null = null;
|
|
564
|
+
/** Called with the selected text when Visual mode yanks, so hosts can reach the system
|
|
565
|
+
* clipboard — `packages/tui` deliberately has no clipboard dependency of its own. */
|
|
566
|
+
onYank?: (text: string) => void;
|
|
567
|
+
/** Fired when the modal state changes, so hosts can restyle their chrome (border, status). */
|
|
568
|
+
onVimModeChange?: (mode: VimMode) => void;
|
|
569
|
+
|
|
525
570
|
// Preferred visual column for vertical cursor movement (sticky column)
|
|
526
571
|
#preferredVisualCol: number | null = null;
|
|
527
572
|
|
|
@@ -569,9 +614,11 @@ export class Editor implements Component, Focusable {
|
|
|
569
614
|
#pasteHandler = new BracketedPasteHandler();
|
|
570
615
|
|
|
571
616
|
// Prompt history for up/down navigation
|
|
572
|
-
#history:
|
|
617
|
+
#history: LocalHistoryEntry[] = [];
|
|
573
618
|
#historyIndex: number = -1; // -1 = not browsing, 0 = most recent, 1 = older, etc.
|
|
574
619
|
#historyStorage?: HistoryStorage;
|
|
620
|
+
// Recalled payloads outlive browsing when an edit resets #historyIndex.
|
|
621
|
+
#historyDraftActive = false;
|
|
575
622
|
|
|
576
623
|
// Undo stack for editor state changes
|
|
577
624
|
#undoStack: EditorState[] = [];
|
|
@@ -727,6 +774,48 @@ export class Editor implements Component, Focusable {
|
|
|
727
774
|
this.#imeSafeCursorLayout = enabled;
|
|
728
775
|
}
|
|
729
776
|
|
|
777
|
+
/** Enable Vim-style modal editing. Toggling always drops back to Insert mode so the editor is
|
|
778
|
+
* never left in a state where ordinary typing does nothing. */
|
|
779
|
+
setVimMode(enabled: boolean): void {
|
|
780
|
+
if (enabled === (this.#vim !== null)) return;
|
|
781
|
+
this.#vim = enabled ? new VimState() : null;
|
|
782
|
+
if (this.#vim) this.#vim.mode = "insert";
|
|
783
|
+
this.invalidate();
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
/** Current modal state; always `"insert"` when Vim mode is off. */
|
|
787
|
+
get vimMode(): VimMode {
|
|
788
|
+
return this.#vim?.mode ?? "insert";
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
/** True when modal editing is active, regardless of which mode is current. */
|
|
792
|
+
get vimEnabled(): boolean {
|
|
793
|
+
return this.#vim !== null;
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
/** Half-typed Vim command (`"2d"`, `"g"`), or `""` when nothing is pending. */
|
|
797
|
+
get vimPending(): string {
|
|
798
|
+
return this.#vim?.pendingText ?? "";
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
/** Lines spanned by the active Visual selection; 0 outside Visual modes. */
|
|
802
|
+
get vimSelectedLines(): number {
|
|
803
|
+
const selection = this.#vimSelection();
|
|
804
|
+
return selection === null ? 0 : selection.to.line - selection.from.line + 1;
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
/**
|
|
808
|
+
* Whether Escape belongs to the editor right now rather than to the app.
|
|
809
|
+
*
|
|
810
|
+
* Hosts bind Escape to interrupt/clear; in Vim mode it first has to mean "leave Insert mode" and
|
|
811
|
+
* "cancel a half-typed operator". Only a quiet Normal mode gives Escape back to the app.
|
|
812
|
+
*/
|
|
813
|
+
vimConsumesEscape(): boolean {
|
|
814
|
+
const vim = this.#vim;
|
|
815
|
+
if (vim === null) return false;
|
|
816
|
+
return vim.mode !== "normal" || vim.pending;
|
|
817
|
+
}
|
|
818
|
+
|
|
730
819
|
getUseTerminalCursor(): boolean {
|
|
731
820
|
return this.#useTerminalCursor;
|
|
732
821
|
}
|
|
@@ -764,7 +853,7 @@ export class Editor implements Component, Focusable {
|
|
|
764
853
|
setHistoryStorage(storage: HistoryStorage): void {
|
|
765
854
|
this.#historyStorage = storage;
|
|
766
855
|
const recent = storage.getRecent(100);
|
|
767
|
-
this.#history = recent.map(entry => entry.prompt);
|
|
856
|
+
this.#history = recent.map(entry => ({ text: entry.prompt }));
|
|
768
857
|
this.#historyIndex = -1;
|
|
769
858
|
}
|
|
770
859
|
|
|
@@ -783,13 +872,49 @@ export class Editor implements Component, Focusable {
|
|
|
783
872
|
});
|
|
784
873
|
}
|
|
785
874
|
|
|
786
|
-
// Don't add consecutive duplicates
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
875
|
+
// Don't add consecutive submitted duplicates; a draft owns separate state.
|
|
876
|
+
const previous = this.#history[0];
|
|
877
|
+
if (previous?.text === trimmed && !previous.draft) return;
|
|
878
|
+
this.#pushHistory({ text: trimmed });
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
/** Retain the current draft for local recall only, never persistent history. */
|
|
882
|
+
rememberDraft(restore?: () => void): void {
|
|
883
|
+
const text = this.getText();
|
|
884
|
+
if (!text.trim()) return;
|
|
885
|
+
const pastes = new Map<number, string>();
|
|
886
|
+
for (const match of text.matchAll(/\[Paste #(\d+)(?:, (?:\+\d+ lines|\d+ chars))?\]/g)) {
|
|
887
|
+
const id = Number(match[1]);
|
|
888
|
+
const value = this.#pastes.get(id);
|
|
889
|
+
if (value !== undefined) pastes.set(id, value);
|
|
890
|
+
}
|
|
891
|
+
this.#pushHistory({
|
|
892
|
+
text,
|
|
893
|
+
draft: {
|
|
894
|
+
pastes,
|
|
895
|
+
atoms: new Map([...this.#atoms].filter(([label]) => text.includes(label))),
|
|
896
|
+
pasteCounter: this.#pasteCounter,
|
|
897
|
+
restore,
|
|
898
|
+
},
|
|
899
|
+
});
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
/** Release the current draft's expansion payloads without touching history. */
|
|
903
|
+
clearPasteState(): void {
|
|
904
|
+
this.#pastes.clear();
|
|
905
|
+
this.#pasteCounter = 0;
|
|
906
|
+
this.#atoms.clear();
|
|
907
|
+
this.#historyDraftActive = false;
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
/** Restore host-owned draft state before history text triggers onChange. */
|
|
911
|
+
restoreHistoryState(restore?: () => void): void {
|
|
912
|
+
restore?.();
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
#pushHistory(entry: LocalHistoryEntry): void {
|
|
916
|
+
this.#history.unshift(entry);
|
|
917
|
+
if (this.#history.length > 100) this.#history.pop();
|
|
793
918
|
}
|
|
794
919
|
|
|
795
920
|
#isEditorEmpty(): boolean {
|
|
@@ -814,13 +939,16 @@ export class Editor implements Component, Focusable {
|
|
|
814
939
|
const newIndex = this.#historyIndex - direction; // Up(-1) increases index, Down(1) decreases
|
|
815
940
|
if (newIndex < -1 || newIndex >= this.#history.length) return;
|
|
816
941
|
this.#historyIndex = newIndex;
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
this.#
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
this
|
|
942
|
+
const entry = this.#history[this.#historyIndex];
|
|
943
|
+
if (entry?.draft || this.#historyDraftActive) {
|
|
944
|
+
this.#pastes = new Map(entry?.draft?.pastes);
|
|
945
|
+
this.#atoms = new Map(entry?.draft?.atoms);
|
|
946
|
+
this.#pasteCounter = entry?.draft?.pasteCounter ?? 0;
|
|
947
|
+
this.restoreHistoryState(entry?.draft?.restore);
|
|
948
|
+
this.#historyDraftActive = entry?.draft !== undefined;
|
|
823
949
|
}
|
|
950
|
+
const cursorAnchor: HistoryCursorAnchor = direction === -1 ? "start" : "end";
|
|
951
|
+
this.#setTextInternal(entry?.text ?? "", cursorAnchor);
|
|
824
952
|
}
|
|
825
953
|
/** Internal setText that doesn't reset history state - used by navigateHistory */
|
|
826
954
|
#setTextInternal(text: string, cursorAnchor: HistoryCursorAnchor = "end"): void {
|
|
@@ -949,7 +1077,25 @@ export class Editor implements Component, Focusable {
|
|
|
949
1077
|
);
|
|
950
1078
|
}
|
|
951
1079
|
|
|
1080
|
+
/**
|
|
1081
|
+
* SGR wrapper for the cursor cell drawn *over* a grapheme.
|
|
1082
|
+
*
|
|
1083
|
+
* Vim's block-vs-bar distinction has to survive in a cell grid, so Insert mode underlines the
|
|
1084
|
+
* cell instead of reversing it: both occupy exactly one column, which keeps every width
|
|
1085
|
+
* calculation below untouched, and terminals render SGR 4 far more consistently than a
|
|
1086
|
+
* half-cell bar glyph. Non-Vim editors keep the reverse-video block they always had.
|
|
1087
|
+
*/
|
|
1088
|
+
#cursorCell(text: string): string {
|
|
1089
|
+
const insertShape = this.#vim !== null && this.#vim.mode === "insert";
|
|
1090
|
+
return insertShape ? `\x1b[4m${text}\x1b[0m` : `\x1b[7m${text}\x1b[0m`;
|
|
1091
|
+
}
|
|
1092
|
+
|
|
952
1093
|
#getStyledInputCursor(): { text: string; width: number } {
|
|
1094
|
+
// Normal/Visual rest *on* a grapheme, so past end-of-line they need a full block to look
|
|
1095
|
+
// like Vim; the thin bar glyph stays the Insert/non-Vim caret.
|
|
1096
|
+
if (this.#vim !== null && this.#vim.mode !== "insert") {
|
|
1097
|
+
return { text: "\x1b[7m \x1b[0m", width: 1 };
|
|
1098
|
+
}
|
|
953
1099
|
const cursorChar = this.#theme.symbols.inputCursor;
|
|
954
1100
|
// Keep the software cursor steady. Ghostty/cmux can leave visual
|
|
955
1101
|
// afterimages for SGR blink cells during rapid input-row repaints.
|
|
@@ -967,7 +1113,7 @@ export class Editor implements Component, Focusable {
|
|
|
967
1113
|
const lastGraphemeWidth = lastGrapheme ? visibleWidth(lastGrapheme) : 0;
|
|
968
1114
|
const builtInCursor = this.#getStyledInputCursor();
|
|
969
1115
|
const fallbackReplacement = lastGrapheme
|
|
970
|
-
? { text:
|
|
1116
|
+
? { text: this.#cursorCell(lastGrapheme), width: lastGraphemeWidth }
|
|
971
1117
|
: builtInCursor;
|
|
972
1118
|
const clampReplacement = (candidate: { text: string; width: number }): { text: string; width: number } => {
|
|
973
1119
|
let text = sliceByColumn(candidate.text, 0, maxWidth, true);
|
|
@@ -1113,6 +1259,10 @@ export class Editor implements Component, Focusable {
|
|
|
1113
1259
|
const inlineHint = this.#getInlineHint();
|
|
1114
1260
|
const hintStyle = this.#theme.hintStyle ?? ((t: string) => `\x1b[2m${t}\x1b[0m`);
|
|
1115
1261
|
|
|
1262
|
+
// Active Vim visual selection, if any. The cursor always sits inside it, so selected rows
|
|
1263
|
+
// skip the normal cursor-glyph branches: the reverse-video span already marks the spot.
|
|
1264
|
+
const vimSelection = this.#vimSelection();
|
|
1265
|
+
|
|
1116
1266
|
for (let visibleIndex = 0; visibleIndex < visibleLayoutLines.length; visibleIndex++) {
|
|
1117
1267
|
const layoutLine = visibleLayoutLines[visibleIndex]!;
|
|
1118
1268
|
let displayText = layoutLine.text;
|
|
@@ -1150,7 +1300,7 @@ export class Editor implements Component, Focusable {
|
|
|
1150
1300
|
const promptGlyphWidth = visibleWidth(promptGlyph);
|
|
1151
1301
|
const remainingCursorWidth = Math.max(0, zeroWidthCursorBudget - promptGlyphWidth);
|
|
1152
1302
|
if (remainingCursorWidth === 0) {
|
|
1153
|
-
result.push(
|
|
1303
|
+
result.push(`${this.#cursorCell(promptGlyph)}${marker}`);
|
|
1154
1304
|
} else {
|
|
1155
1305
|
const widthLimitedCursor = this.#renderEndOfLineCursorAtWidthLimit(
|
|
1156
1306
|
"",
|
|
@@ -1177,7 +1327,26 @@ export class Editor implements Component, Focusable {
|
|
|
1177
1327
|
continue;
|
|
1178
1328
|
}
|
|
1179
1329
|
|
|
1180
|
-
|
|
1330
|
+
const selectionSpan =
|
|
1331
|
+
vimSelection === null
|
|
1332
|
+
? null
|
|
1333
|
+
: this.#selectionSpanFor(
|
|
1334
|
+
layoutLine,
|
|
1335
|
+
vimSelection,
|
|
1336
|
+
layoutLines[this.#scrollOffset + visibleIndex + 1]?.logicalLine !== layoutLine.logicalLine,
|
|
1337
|
+
);
|
|
1338
|
+
|
|
1339
|
+
if (selectionSpan !== null) {
|
|
1340
|
+
displayText = this.#renderSelectedLine(
|
|
1341
|
+
displayText,
|
|
1342
|
+
selectionSpan,
|
|
1343
|
+
hasCursor ? layoutLine.cursorPos : undefined,
|
|
1344
|
+
marker,
|
|
1345
|
+
decorationContext,
|
|
1346
|
+
);
|
|
1347
|
+
decorated = true;
|
|
1348
|
+
if (selectionSpan.trailingNewline) displayWidth += 1;
|
|
1349
|
+
} else if (hasCursor && this.#useTerminalCursor) {
|
|
1181
1350
|
if (marker) {
|
|
1182
1351
|
const before = displayText.slice(0, layoutLine.cursorPos);
|
|
1183
1352
|
const after = displayText.slice(layoutLine.cursorPos);
|
|
@@ -1208,7 +1377,7 @@ export class Editor implements Component, Focusable {
|
|
|
1208
1377
|
const afterGraphemes = [...segmenter.segment(after)];
|
|
1209
1378
|
const firstGrapheme = afterGraphemes[0]?.segment || "";
|
|
1210
1379
|
const restAfter = after.slice(firstGrapheme.length);
|
|
1211
|
-
const cursor =
|
|
1380
|
+
const cursor = this.#cursorCell(firstGrapheme);
|
|
1212
1381
|
// Decorate the plain text on each side of the cursor glyph. The reverse-video
|
|
1213
1382
|
// reset (\x1b[0m) ends in "m" (a word char), so a boundary match on restAfter
|
|
1214
1383
|
// would fail in the whole-line fallback below — decorate the segments here.
|
|
@@ -1379,6 +1548,13 @@ export class Editor implements Component, Focusable {
|
|
|
1379
1548
|
return;
|
|
1380
1549
|
}
|
|
1381
1550
|
|
|
1551
|
+
// Vim modal editing. Placed after paste handling so bracketed pastes still land as text, and
|
|
1552
|
+
// before the bulk fast path below because in Normal mode a multi-grapheme run is a sequence
|
|
1553
|
+
// of commands, not something to insert.
|
|
1554
|
+
if (this.#vim !== null && this.#handleVimInput(data, canonical)) {
|
|
1555
|
+
return;
|
|
1556
|
+
}
|
|
1557
|
+
|
|
1382
1558
|
// Bulk printable fast path: a multi-scalar run of plain text (paste
|
|
1383
1559
|
// remainder, batched stdin) parses to no key, so no binding probe or
|
|
1384
1560
|
// special-key branch below can consume it — it always falls through to
|
|
@@ -1787,6 +1963,292 @@ export class Editor implements Component, Focusable {
|
|
|
1787
1963
|
}
|
|
1788
1964
|
}
|
|
1789
1965
|
|
|
1966
|
+
/**
|
|
1967
|
+
* Route one input chunk through the Vim state machine. Returns true when it was consumed.
|
|
1968
|
+
*
|
|
1969
|
+
* Anything the state machine declines — control chords, Enter, Tab — falls through to the
|
|
1970
|
+
* regular dispatch below, so app-level bindings keep working in Normal mode.
|
|
1971
|
+
*/
|
|
1972
|
+
#handleVimInput(data: string, canonical: string | undefined): boolean {
|
|
1973
|
+
const vim = this.#vim;
|
|
1974
|
+
if (vim === null) return false;
|
|
1975
|
+
|
|
1976
|
+
// Escape is the one key Vim owns in every mode: Insert → Normal, Visual → Normal, and
|
|
1977
|
+
// cancelling a half-typed operator. A quiet Normal mode hands it back to the host.
|
|
1978
|
+
// An open autocomplete popup still gets the first Escape though — dismissing it is what
|
|
1979
|
+
// the user means, and a second Escape then switches modes.
|
|
1980
|
+
if (canonical === "escape") {
|
|
1981
|
+
return this.isShowingAutocomplete() ? false : this.#runVimKey("escape", vim);
|
|
1982
|
+
}
|
|
1983
|
+
if (vim.mode === "insert") return false;
|
|
1984
|
+
|
|
1985
|
+
const mapped = canonical === undefined ? undefined : VIM_NAV_KEYS[canonical];
|
|
1986
|
+
if (mapped !== undefined) return this.#runVimKey(mapped, vim);
|
|
1987
|
+
|
|
1988
|
+
// Control chords carry no printable text and stay with the host.
|
|
1989
|
+
const printable = extractPrintableText(data);
|
|
1990
|
+
if (!printable) return false;
|
|
1991
|
+
|
|
1992
|
+
// Batched stdin can deliver several keystrokes at once, so replay the run one grapheme at a
|
|
1993
|
+
// time. A command that drops out of Normal mode part-way (`iabc`) turns the rest of the run
|
|
1994
|
+
// back into literal text rather than swallowing it.
|
|
1995
|
+
for (const seg of segmenter.segment(printable)) {
|
|
1996
|
+
if (this.#runVimKey(seg.segment, vim)) continue;
|
|
1997
|
+
this.#insertCharacter(printable.slice(seg.index));
|
|
1998
|
+
return true;
|
|
1999
|
+
}
|
|
2000
|
+
return true;
|
|
2001
|
+
}
|
|
2002
|
+
|
|
2003
|
+
#runVimKey(key: string, vim: VimState): boolean {
|
|
2004
|
+
const before = vim.mode;
|
|
2005
|
+
const pendingBefore = vim.pendingText;
|
|
2006
|
+
const selectedLinesBefore = this.vimSelectedLines;
|
|
2007
|
+
const commands = vim.handleKey(key, this.#state);
|
|
2008
|
+
if (commands === null) return false;
|
|
2009
|
+
this.#applyVimCommands(commands);
|
|
2010
|
+
// Pending and selection size are mode chrome too: hosts echo `2d` and the Visual line count
|
|
2011
|
+
// beside the mode name, and extending a selection changes neither the mode nor the pending
|
|
2012
|
+
// command — so all three have to be compared or the indicator goes stale mid-selection.
|
|
2013
|
+
if (vim.mode !== before || vim.pendingText !== pendingBefore || this.vimSelectedLines !== selectedLinesBefore) {
|
|
2014
|
+
this.onVimModeChange?.(vim.mode);
|
|
2015
|
+
}
|
|
2016
|
+
return true;
|
|
2017
|
+
}
|
|
2018
|
+
|
|
2019
|
+
#applyVimCommands(commands: readonly VimCommand[]): void {
|
|
2020
|
+
for (const command of commands) {
|
|
2021
|
+
switch (command.kind) {
|
|
2022
|
+
case "move":
|
|
2023
|
+
this.#moveVimCursor(command.to);
|
|
2024
|
+
break;
|
|
2025
|
+
case "mode":
|
|
2026
|
+
this.#resetKillSequence();
|
|
2027
|
+
this.#preferredVisualCol = null;
|
|
2028
|
+
break;
|
|
2029
|
+
case "yank": {
|
|
2030
|
+
const body = this.#sliceRange(command.from, command.to, command.linewise);
|
|
2031
|
+
// The trailing newline is what marks a register linewise, so `p` puts it back as
|
|
2032
|
+
// whole lines rather than splicing it mid-line.
|
|
2033
|
+
const text = command.linewise ? `${body}\n` : body;
|
|
2034
|
+
if (text) {
|
|
2035
|
+
this.#killRing.push(text, { prepend: false });
|
|
2036
|
+
this.onYank?.(text);
|
|
2037
|
+
}
|
|
2038
|
+
break;
|
|
2039
|
+
}
|
|
2040
|
+
case "delete":
|
|
2041
|
+
this.#deleteVimRange(command.from, command.to, command.linewise);
|
|
2042
|
+
break;
|
|
2043
|
+
case "openLine":
|
|
2044
|
+
this.#openVimLine(command.below);
|
|
2045
|
+
break;
|
|
2046
|
+
case "paste":
|
|
2047
|
+
this.#pasteVimRegister(command.after, command.count);
|
|
2048
|
+
break;
|
|
2049
|
+
case "undo":
|
|
2050
|
+
this.#applyUndo();
|
|
2051
|
+
break;
|
|
2052
|
+
}
|
|
2053
|
+
}
|
|
2054
|
+
this.#clampVimCursor();
|
|
2055
|
+
this.invalidate();
|
|
2056
|
+
}
|
|
2057
|
+
|
|
2058
|
+
#moveVimCursor(to: VimPosition): void {
|
|
2059
|
+
this.#state.cursorLine = Math.max(0, Math.min(to.line, this.#state.lines.length - 1));
|
|
2060
|
+
const line = this.#state.lines[this.#state.cursorLine] ?? "";
|
|
2061
|
+
this.#setCursorCol(Math.max(0, Math.min(to.col, line.length)));
|
|
2062
|
+
}
|
|
2063
|
+
|
|
2064
|
+
/** Normal mode rests the cursor *on* a grapheme; Insert and Visual may sit one past the end. */
|
|
2065
|
+
#clampVimCursor(): void {
|
|
2066
|
+
if (this.#vim?.mode !== "normal") return;
|
|
2067
|
+
const line = this.#state.lines[this.#state.cursorLine] ?? "";
|
|
2068
|
+
if (this.#state.cursorCol > lastGraphemeStart(line)) {
|
|
2069
|
+
this.#state.cursorCol = lastGraphemeStart(line);
|
|
2070
|
+
}
|
|
2071
|
+
}
|
|
2072
|
+
|
|
2073
|
+
/** Text covered by a half-open `[from, to)` buffer range. Linewise ranges take whole lines. */
|
|
2074
|
+
#sliceRange(from: VimPosition, to: VimPosition, linewise: boolean): string {
|
|
2075
|
+
const lines = this.#state.lines;
|
|
2076
|
+
if (linewise) {
|
|
2077
|
+
return lines.slice(from.line, Math.min(to.line, lines.length - 1) + 1).join("\n");
|
|
2078
|
+
}
|
|
2079
|
+
if (from.line === to.line) {
|
|
2080
|
+
return (lines[from.line] ?? "").slice(from.col, to.col);
|
|
2081
|
+
}
|
|
2082
|
+
const parts = [(lines[from.line] ?? "").slice(from.col)];
|
|
2083
|
+
for (let i = from.line + 1; i < to.line; i++) parts.push(lines[i] ?? "");
|
|
2084
|
+
parts.push((lines[to.line] ?? "").slice(0, to.col));
|
|
2085
|
+
return parts.join("\n");
|
|
2086
|
+
}
|
|
2087
|
+
|
|
2088
|
+
#deleteVimRange(from: VimPosition, to: VimPosition, linewise: boolean): void {
|
|
2089
|
+
const lines = this.#state.lines;
|
|
2090
|
+
if (linewise) {
|
|
2091
|
+
const last = Math.min(to.line, lines.length - 1);
|
|
2092
|
+
const removed = this.#sliceRange(from, to, true);
|
|
2093
|
+
if (!removed && from.line === last && lines.length === 1) return;
|
|
2094
|
+
this.#recordUndoState();
|
|
2095
|
+
this.#killRing.push(`${removed}\n`, { prepend: false });
|
|
2096
|
+
lines.splice(from.line, last - from.line + 1);
|
|
2097
|
+
if (lines.length === 0) lines.push("");
|
|
2098
|
+
this.#state.cursorLine = Math.min(from.line, lines.length - 1);
|
|
2099
|
+
this.#setCursorCol(0);
|
|
2100
|
+
this.#afterVimEdit();
|
|
2101
|
+
return;
|
|
2102
|
+
}
|
|
2103
|
+
|
|
2104
|
+
// A range that cuts through an atomic placeholder (`[Image #1, 800x600]`) swallows the whole
|
|
2105
|
+
// token instead of leaving a corrupt fragment — the same rule backspace follows.
|
|
2106
|
+
let start = from;
|
|
2107
|
+
let end = to;
|
|
2108
|
+
if (start.line === end.line) {
|
|
2109
|
+
const line = lines[start.line] ?? "";
|
|
2110
|
+
const expanded = this.#expandRangeOverAtomicTokens(line, start.col, end.col);
|
|
2111
|
+
start = { line: start.line, col: expanded.start };
|
|
2112
|
+
end = { line: end.line, col: expanded.end };
|
|
2113
|
+
} else {
|
|
2114
|
+
const startLine = lines[start.line] ?? "";
|
|
2115
|
+
const startToken = this.#atomicTokenAt(startLine, start.col);
|
|
2116
|
+
if (startToken !== undefined) start = { line: start.line, col: startToken.start };
|
|
2117
|
+
const endLine = lines[end.line] ?? "";
|
|
2118
|
+
if (end.col > 0) {
|
|
2119
|
+
const endToken = this.#atomicTokenAt(endLine, end.col - 1);
|
|
2120
|
+
if (endToken !== undefined && endToken.end > end.col) end = { line: end.line, col: endToken.end };
|
|
2121
|
+
}
|
|
2122
|
+
}
|
|
2123
|
+
|
|
2124
|
+
const removed = this.#sliceRange(start, end, false);
|
|
2125
|
+
if (!removed) return;
|
|
2126
|
+
this.#recordUndoState();
|
|
2127
|
+
this.#killRing.push(removed, { prepend: false });
|
|
2128
|
+
const head = (lines[start.line] ?? "").slice(0, start.col);
|
|
2129
|
+
const tail = (lines[Math.min(end.line, lines.length - 1)] ?? "").slice(end.col);
|
|
2130
|
+
lines.splice(start.line, Math.min(end.line, lines.length - 1) - start.line + 1, head + tail);
|
|
2131
|
+
this.#state.cursorLine = start.line;
|
|
2132
|
+
this.#setCursorCol(start.col);
|
|
2133
|
+
this.#afterVimEdit();
|
|
2134
|
+
}
|
|
2135
|
+
|
|
2136
|
+
#openVimLine(below: boolean): void {
|
|
2137
|
+
this.#recordUndoState();
|
|
2138
|
+
const at = below ? this.#state.cursorLine + 1 : this.#state.cursorLine;
|
|
2139
|
+
this.#state.lines.splice(at, 0, "");
|
|
2140
|
+
this.#state.cursorLine = at;
|
|
2141
|
+
this.#setCursorCol(0);
|
|
2142
|
+
this.#afterVimEdit();
|
|
2143
|
+
}
|
|
2144
|
+
|
|
2145
|
+
#pasteVimRegister(after: boolean, count: number): void {
|
|
2146
|
+
const entry = this.#killRing.peek();
|
|
2147
|
+
if (!entry) return;
|
|
2148
|
+
this.#recordUndoState();
|
|
2149
|
+
const linewise = entry.endsWith("\n");
|
|
2150
|
+
if (linewise) {
|
|
2151
|
+
const body = entry.slice(0, -1).split("\n");
|
|
2152
|
+
const at = after ? this.#state.cursorLine + 1 : this.#state.cursorLine;
|
|
2153
|
+
const payload: string[] = [];
|
|
2154
|
+
for (let i = 0; i < count; i++) payload.push(...body);
|
|
2155
|
+
this.#state.lines.splice(at, 0, ...payload);
|
|
2156
|
+
this.#state.cursorLine = at;
|
|
2157
|
+
this.#setCursorCol(0);
|
|
2158
|
+
} else {
|
|
2159
|
+
const line = this.#state.lines[this.#state.cursorLine] ?? "";
|
|
2160
|
+
const at = after ? nextGraphemeStart(line, this.#state.cursorCol) : this.#state.cursorCol;
|
|
2161
|
+
const payload = entry.repeat(count);
|
|
2162
|
+
this.#state.lines[this.#state.cursorLine] = line.slice(0, at) + payload + line.slice(at);
|
|
2163
|
+
this.#setCursorCol(at + payload.length - 1);
|
|
2164
|
+
}
|
|
2165
|
+
this.#afterVimEdit();
|
|
2166
|
+
}
|
|
2167
|
+
|
|
2168
|
+
#afterVimEdit(): void {
|
|
2169
|
+
this.#historyIndex = -1;
|
|
2170
|
+
this.#resetKillSequence();
|
|
2171
|
+
this.onChange?.(this.getText());
|
|
2172
|
+
}
|
|
2173
|
+
|
|
2174
|
+
/**
|
|
2175
|
+
* Buffer span highlighted by the active Visual selection, or null when there is none.
|
|
2176
|
+
* Exposed to the render path only; `to` is exclusive.
|
|
2177
|
+
*/
|
|
2178
|
+
#vimSelection(): { from: VimPosition; to: VimPosition; linewise: boolean } | null {
|
|
2179
|
+
const vim = this.#vim;
|
|
2180
|
+
if (vim === null || !vim.visual || vim.anchor === null) return null;
|
|
2181
|
+
const linewise = vim.mode === "visual-line";
|
|
2182
|
+
const { from, to } = visualRange(this.#state, vim.anchor, linewise);
|
|
2183
|
+
return { from, to, linewise };
|
|
2184
|
+
}
|
|
2185
|
+
|
|
2186
|
+
/**
|
|
2187
|
+
* Map the active selection onto one layout row, as offsets into that row's `text`.
|
|
2188
|
+
* Returns null when the row is outside the selection.
|
|
2189
|
+
*/
|
|
2190
|
+
#selectionSpanFor(
|
|
2191
|
+
layoutLine: LayoutLine,
|
|
2192
|
+
selection: { from: VimPosition; to: VimPosition; linewise: boolean },
|
|
2193
|
+
isLastRowOfLine: boolean,
|
|
2194
|
+
): { start: number; end: number; trailingNewline: boolean } | null {
|
|
2195
|
+
const logical = layoutLine.logicalLine;
|
|
2196
|
+
if (logical === undefined || logical < selection.from.line || logical > selection.to.line) return null;
|
|
2197
|
+
|
|
2198
|
+
const rowStart = layoutLine.startIndex ?? 0;
|
|
2199
|
+
const rowEnd = rowStart + layoutLine.text.length;
|
|
2200
|
+
const lineStart = logical === selection.from.line ? selection.from.col : 0;
|
|
2201
|
+
const lineEnd = logical === selection.to.line ? selection.to.col : (this.#state.lines[logical] ?? "").length;
|
|
2202
|
+
const start = Math.max(lineStart, rowStart);
|
|
2203
|
+
const end = Math.min(lineEnd, rowEnd);
|
|
2204
|
+
// Vim highlights the newline itself when the selection runs on into the next line.
|
|
2205
|
+
const trailingNewline = isLastRowOfLine && logical < selection.to.line;
|
|
2206
|
+
if (end <= start && !trailingNewline) return null;
|
|
2207
|
+
return { start: start - rowStart, end: Math.max(start, end) - rowStart, trailingNewline };
|
|
2208
|
+
}
|
|
2209
|
+
|
|
2210
|
+
/**
|
|
2211
|
+
* Reverse-video the selected span of one row while keeping the cursor marker at its exact
|
|
2212
|
+
* offset. Unselected fragments are decorated individually, the same way the cursor branch
|
|
2213
|
+
* splits `#decorate` around the cursor glyph.
|
|
2214
|
+
*/
|
|
2215
|
+
#renderSelectedLine(
|
|
2216
|
+
text: string,
|
|
2217
|
+
span: { start: number; end: number; trailingNewline: boolean },
|
|
2218
|
+
cursorPos: number | undefined,
|
|
2219
|
+
marker: string,
|
|
2220
|
+
context: EditorTextDecorationContext,
|
|
2221
|
+
): string {
|
|
2222
|
+
const start = Math.max(0, Math.min(span.start, text.length));
|
|
2223
|
+
const end = Math.max(start, Math.min(span.end, text.length));
|
|
2224
|
+
// Only cut for the marker when there is one to emit: an unfocused editor would otherwise
|
|
2225
|
+
// split the highlight into two identical spans for nothing.
|
|
2226
|
+
const markerPos = !marker || cursorPos === undefined ? undefined : Math.max(0, Math.min(cursorPos, text.length));
|
|
2227
|
+
|
|
2228
|
+
const cuts = new Set<number>([0, start, end, text.length]);
|
|
2229
|
+
if (markerPos !== undefined) cuts.add(markerPos);
|
|
2230
|
+
const points = [...cuts].sort((left, right) => left - right);
|
|
2231
|
+
|
|
2232
|
+
let out = "";
|
|
2233
|
+
for (let i = 0; i < points.length - 1; i++) {
|
|
2234
|
+
const from = points[i]!;
|
|
2235
|
+
const to = points[i + 1]!;
|
|
2236
|
+
if (marker && from === markerPos) out += marker;
|
|
2237
|
+
const segment = text.slice(from, to);
|
|
2238
|
+
out +=
|
|
2239
|
+
from >= start && from < end
|
|
2240
|
+
? `\x1b[7m${segment}\x1b[27m`
|
|
2241
|
+
: this.#decorate(segment, {
|
|
2242
|
+
...context,
|
|
2243
|
+
startCol: context.startCol + from,
|
|
2244
|
+
endCol: context.startCol + to,
|
|
2245
|
+
});
|
|
2246
|
+
}
|
|
2247
|
+
if (marker && markerPos !== undefined && markerPos >= text.length) out += marker;
|
|
2248
|
+
if (span.trailingNewline) out += "\x1b[7m \x1b[27m";
|
|
2249
|
+
return out;
|
|
2250
|
+
}
|
|
2251
|
+
|
|
1790
2252
|
/** Cached per-line measurement: exact visible width now, wrap chunks on demand. */
|
|
1791
2253
|
#lineEntry(line: string, width: number): WrapEntry {
|
|
1792
2254
|
const epoch = getWidthConfigEpoch();
|
|
@@ -1824,6 +2286,8 @@ export class Editor implements Component, Focusable {
|
|
|
1824
2286
|
sourceStartCol: 0,
|
|
1825
2287
|
hasCursor: true,
|
|
1826
2288
|
cursorPos: 0,
|
|
2289
|
+
logicalLine: 0,
|
|
2290
|
+
startIndex: 0,
|
|
1827
2291
|
});
|
|
1828
2292
|
return layoutLines;
|
|
1829
2293
|
}
|
|
@@ -1844,6 +2308,8 @@ export class Editor implements Component, Focusable {
|
|
|
1844
2308
|
sourceStartCol: 0,
|
|
1845
2309
|
hasCursor: true,
|
|
1846
2310
|
cursorPos: this.#state.cursorCol,
|
|
2311
|
+
logicalLine: i,
|
|
2312
|
+
startIndex: 0,
|
|
1847
2313
|
});
|
|
1848
2314
|
} else {
|
|
1849
2315
|
layoutLines.push({
|
|
@@ -1852,6 +2318,8 @@ export class Editor implements Component, Focusable {
|
|
|
1852
2318
|
sourceLine: i,
|
|
1853
2319
|
sourceStartCol: 0,
|
|
1854
2320
|
hasCursor: false,
|
|
2321
|
+
logicalLine: i,
|
|
2322
|
+
startIndex: 0,
|
|
1855
2323
|
});
|
|
1856
2324
|
}
|
|
1857
2325
|
} else {
|
|
@@ -1896,6 +2364,8 @@ export class Editor implements Component, Focusable {
|
|
|
1896
2364
|
sourceStartCol: chunk.startIndex,
|
|
1897
2365
|
hasCursor: true,
|
|
1898
2366
|
cursorPos: adjustedCursorPos,
|
|
2367
|
+
logicalLine: i,
|
|
2368
|
+
startIndex: chunk.startIndex,
|
|
1899
2369
|
});
|
|
1900
2370
|
} else {
|
|
1901
2371
|
layoutLines.push({
|
|
@@ -1904,6 +2374,8 @@ export class Editor implements Component, Focusable {
|
|
|
1904
2374
|
sourceLine: i,
|
|
1905
2375
|
sourceStartCol: chunk.startIndex,
|
|
1906
2376
|
hasCursor: false,
|
|
2377
|
+
logicalLine: i,
|
|
2378
|
+
startIndex: chunk.startIndex,
|
|
1907
2379
|
});
|
|
1908
2380
|
}
|
|
1909
2381
|
}
|
|
@@ -2449,9 +2921,7 @@ export class Editor implements Component, Focusable {
|
|
|
2449
2921
|
const result = this.#expandPasteMarkers(this.#state.lines.join("\n")).trim();
|
|
2450
2922
|
|
|
2451
2923
|
this.#state = { lines: [""], cursorLine: 0, cursorCol: 0 };
|
|
2452
|
-
this
|
|
2453
|
-
this.#pasteCounter = 0;
|
|
2454
|
-
this.#atoms.clear();
|
|
2924
|
+
this.clearPasteState();
|
|
2455
2925
|
this.#historyIndex = -1;
|
|
2456
2926
|
this.#scrollOffset = 0;
|
|
2457
2927
|
this.#undoStack.length = 0;
|
package/src/mouse.ts
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* SGR mouse report parsing (`\x1b[<button;col;rowM` / `…m`).
|
|
3
3
|
*
|
|
4
|
-
* Mouse tracking is enabled
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* the frame paints from screen row 0, hence
|
|
8
|
-
* 0-based for direct indexing into rendered lines.
|
|
4
|
+
* Mouse tracking is enabled while a fullscreen overlay holds the alternate
|
|
5
|
+
* screen (see tui.ts MOUSE_TRACKING_ON), or — opt-in via `tui.mouse` — on the
|
|
6
|
+
* normal buffer whenever no overlay is visible. Consumers hit-test
|
|
7
|
+
* against their own rendered frame: the frame paints from screen row 0, hence
|
|
8
|
+
* `row`/`col` are exposed 0-based for direct indexing into rendered lines.
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
11
|
/** A decoded SGR mouse report. */
|