@thazhemadam/pi-vim 0.1.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/LICENSE +165 -0
- package/LICENSE.GPL +674 -0
- package/README.md +12 -0
- package/dist/editor.d.ts +67 -0
- package/dist/editor.js +359 -0
- package/dist/extension.d.ts +2 -0
- package/dist/extension.js +36 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +4 -0
- package/dist/keymap.d.ts +4 -0
- package/dist/keymap.js +13 -0
- package/dist/visual-selection.d.ts +6 -0
- package/dist/visual-selection.js +204 -0
- package/package.json +68 -0
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
import { CURSOR_MARKER, visibleWidth } from "@earendil-works/pi-tui";
|
|
2
|
+
const ESCAPE = "\x1b";
|
|
3
|
+
const REVERSE_VIDEO_CURSOR = new RegExp(`${ESCAPE}\\[7m([^${ESCAPE}]*)${ESCAPE}\\[0m`);
|
|
4
|
+
const ANSI_CSI_SEQUENCE = new RegExp(`^${ESCAPE}\\[[0-?]*[ -/]*[@-~]`);
|
|
5
|
+
const START_REVERSE_VIDEO = `${ESCAPE}[7m`;
|
|
6
|
+
const END_REVERSE_VIDEO = `${ESCAPE}[0m`;
|
|
7
|
+
const graphemeSegmenter = new Intl.Segmenter(undefined, {
|
|
8
|
+
granularity: "grapheme",
|
|
9
|
+
});
|
|
10
|
+
/** Remove Pi's fake block cursor when vim-pi uses a hardware cursor. */
|
|
11
|
+
export function removeReverseVideoCursor(lines) {
|
|
12
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
13
|
+
if (!REVERSE_VIDEO_CURSOR.test(lines[i])) {
|
|
14
|
+
continue;
|
|
15
|
+
}
|
|
16
|
+
lines[i] = lines[i].replace(REVERSE_VIDEO_CURSOR, "$1");
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
/** Overlay the active Visual selection on Pi's rendered editor rows. */
|
|
21
|
+
export function highlightVisualSelection(editor, renderedLines, selection, width, terminalRows) {
|
|
22
|
+
const layout = requirePiVisualLayout(editor, width, terminalRows);
|
|
23
|
+
const bufferLines = editor.getLines();
|
|
24
|
+
const segments = mapVisualLineSegments(bufferLines, layout.lines);
|
|
25
|
+
const ranges = visualSelectionRanges(bufferLines, selection, editor.getCursor());
|
|
26
|
+
for (const segment of segments) {
|
|
27
|
+
const range = ranges.get(segment.logicalLine);
|
|
28
|
+
if (!range) {
|
|
29
|
+
continue;
|
|
30
|
+
}
|
|
31
|
+
const visibleIndex = segment.visualLine - layout.scrollOffset;
|
|
32
|
+
if (visibleIndex < 0 || visibleIndex >= layout.visibleLineCount) {
|
|
33
|
+
continue;
|
|
34
|
+
}
|
|
35
|
+
const renderedIndex = visibleIndex + 1;
|
|
36
|
+
const renderedLine = renderedLines[renderedIndex];
|
|
37
|
+
if (renderedLine === undefined) {
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
const line = bufferLines[segment.logicalLine] ?? "";
|
|
41
|
+
const start = Math.max(range.start, segment.start);
|
|
42
|
+
const end = Math.min(range.end, segment.end);
|
|
43
|
+
let startColumn = layout.paddingX + visibleWidth(line.slice(segment.start, start));
|
|
44
|
+
let endColumn = startColumn + visibleWidth(line.slice(start, Math.max(start, end)));
|
|
45
|
+
// Linewise Visual mode includes an empty line's cursor cell.
|
|
46
|
+
if (selection.mode === "linewise" &&
|
|
47
|
+
segment.start === 0 &&
|
|
48
|
+
segment.end === 0) {
|
|
49
|
+
startColumn = layout.paddingX;
|
|
50
|
+
endColumn = startColumn + 1;
|
|
51
|
+
}
|
|
52
|
+
if (endColumn <= startColumn) {
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
renderedLines[renderedIndex] = highlightColumns(renderedLine, startColumn, endColumn);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Read the layout that Pi used for the preceding render.
|
|
60
|
+
*
|
|
61
|
+
* Pi does not expose its wrapped-line map publicly. Calling the private layout
|
|
62
|
+
* method keeps Visual highlighting aligned with Pi's exact wrapping algorithm.
|
|
63
|
+
* If a future Pi release changes those internals, omit the overlay rather than
|
|
64
|
+
* show a partial selection that disagrees with the operation's actual range.
|
|
65
|
+
*/
|
|
66
|
+
function requirePiVisualLayout(editor, width, terminalRows) {
|
|
67
|
+
const maxVisibleLines = Math.max(5, Math.floor(terminalRows * 0.3));
|
|
68
|
+
const maxPadding = Math.max(0, Math.floor((width - 1) / 2));
|
|
69
|
+
const paddingX = Math.min(editor.getPaddingX(), maxPadding);
|
|
70
|
+
const contentWidth = Math.max(1, width - paddingX * 2);
|
|
71
|
+
const layoutWidth = Math.max(1, contentWidth - (paddingX ? 0 : 1));
|
|
72
|
+
const internals = editor;
|
|
73
|
+
if (typeof internals.layoutText !== "function") {
|
|
74
|
+
throw incompatiblePiLayout();
|
|
75
|
+
}
|
|
76
|
+
let lines;
|
|
77
|
+
try {
|
|
78
|
+
lines = internals.layoutText.call(editor, layoutWidth);
|
|
79
|
+
}
|
|
80
|
+
catch (cause) {
|
|
81
|
+
throw incompatiblePiLayout(cause);
|
|
82
|
+
}
|
|
83
|
+
const scrollOffset = internals.scrollOffset;
|
|
84
|
+
if (!Array.isArray(lines) ||
|
|
85
|
+
!lines.every((line) => typeof line === "object" &&
|
|
86
|
+
line !== null &&
|
|
87
|
+
typeof line.text === "string") ||
|
|
88
|
+
!Number.isInteger(scrollOffset) ||
|
|
89
|
+
scrollOffset === undefined ||
|
|
90
|
+
scrollOffset < 0 ||
|
|
91
|
+
scrollOffset > lines.length) {
|
|
92
|
+
throw incompatiblePiLayout();
|
|
93
|
+
}
|
|
94
|
+
return {
|
|
95
|
+
lines,
|
|
96
|
+
scrollOffset,
|
|
97
|
+
visibleLineCount: Math.min(maxVisibleLines, Math.max(0, lines.length - scrollOffset)),
|
|
98
|
+
paddingX,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
/** Map Pi's visual layout rows back to ranges in the logical buffer. */
|
|
102
|
+
function mapVisualLineSegments(bufferLines, layoutLines) {
|
|
103
|
+
const segments = [];
|
|
104
|
+
let visualLine = 0;
|
|
105
|
+
for (let logicalLine = 0; logicalLine < bufferLines.length; logicalLine += 1) {
|
|
106
|
+
const line = bufferLines[logicalLine] ?? "";
|
|
107
|
+
let start = 0;
|
|
108
|
+
do {
|
|
109
|
+
const layoutLine = layoutLines[visualLine];
|
|
110
|
+
if (!layoutLine) {
|
|
111
|
+
throw incompatiblePiLayout();
|
|
112
|
+
}
|
|
113
|
+
const end = start + layoutLine.text.length;
|
|
114
|
+
if (line.slice(start, end) !== layoutLine.text) {
|
|
115
|
+
throw incompatiblePiLayout();
|
|
116
|
+
}
|
|
117
|
+
segments.push({ logicalLine, visualLine, start, end });
|
|
118
|
+
visualLine += 1;
|
|
119
|
+
start = end;
|
|
120
|
+
if (layoutLine.text.length === 0 && line.length > 0) {
|
|
121
|
+
throw incompatiblePiLayout();
|
|
122
|
+
}
|
|
123
|
+
} while (start < line.length);
|
|
124
|
+
}
|
|
125
|
+
if (visualLine !== layoutLines.length) {
|
|
126
|
+
throw incompatiblePiLayout();
|
|
127
|
+
}
|
|
128
|
+
return segments;
|
|
129
|
+
}
|
|
130
|
+
function incompatiblePiLayout(cause) {
|
|
131
|
+
return new Error("vim-pi cannot map this Pi editor's wrapped layout; install the supported Pi 0.79.8 release", cause === undefined ? undefined : { cause });
|
|
132
|
+
}
|
|
133
|
+
/** Convert an anchor and cursor into inclusive logical-line ranges. */
|
|
134
|
+
function visualSelectionRanges(lines, selection, active) {
|
|
135
|
+
const ranges = new Map();
|
|
136
|
+
const startLine = Math.min(selection.anchor.line, active.line);
|
|
137
|
+
const endLine = Math.max(selection.anchor.line, active.line);
|
|
138
|
+
if (selection.mode === "linewise") {
|
|
139
|
+
for (let line = startLine; line <= endLine; line += 1) {
|
|
140
|
+
ranges.set(line, {
|
|
141
|
+
start: 0,
|
|
142
|
+
end: Math.max(lines[line]?.length ?? 0, 1),
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
return ranges;
|
|
146
|
+
}
|
|
147
|
+
const forward = selection.anchor.line < active.line ||
|
|
148
|
+
(selection.anchor.line === active.line &&
|
|
149
|
+
selection.anchor.col <= active.col);
|
|
150
|
+
const start = forward ? selection.anchor : active;
|
|
151
|
+
const end = forward ? active : selection.anchor;
|
|
152
|
+
for (let line = start.line; line <= end.line; line += 1) {
|
|
153
|
+
const lineLength = lines[line]?.length ?? 0;
|
|
154
|
+
ranges.set(line, {
|
|
155
|
+
start: line === start.line ? start.col : 0,
|
|
156
|
+
end: line === end.line ? Math.min(end.col + 1, lineLength) : lineLength,
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
return ranges;
|
|
160
|
+
}
|
|
161
|
+
/** Wrap a visible-column span with reverse-video ANSI. */
|
|
162
|
+
function highlightColumns(line, startColumn, endColumn) {
|
|
163
|
+
if (endColumn <= startColumn) {
|
|
164
|
+
return line;
|
|
165
|
+
}
|
|
166
|
+
const start = rawIndexForColumn(line, startColumn);
|
|
167
|
+
const end = rawIndexForColumn(line, endColumn);
|
|
168
|
+
return `${line.slice(0, start)}${START_REVERSE_VIDEO}${line.slice(start, end)}${END_REVERSE_VIDEO}${line.slice(end)}`;
|
|
169
|
+
}
|
|
170
|
+
/** Return the raw string index for a visible column, ignoring control escapes. */
|
|
171
|
+
function rawIndexForColumn(line, column) {
|
|
172
|
+
let raw = 0;
|
|
173
|
+
let col = 0;
|
|
174
|
+
while (raw < line.length) {
|
|
175
|
+
if (line.startsWith(CURSOR_MARKER, raw)) {
|
|
176
|
+
raw += CURSOR_MARKER.length;
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
179
|
+
const control = ANSI_CSI_SEQUENCE.exec(line.slice(raw));
|
|
180
|
+
if (control) {
|
|
181
|
+
raw += control[0].length;
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
184
|
+
if (col >= column) {
|
|
185
|
+
break;
|
|
186
|
+
}
|
|
187
|
+
const nextControl = line.indexOf("\x1b", raw);
|
|
188
|
+
const plainText = line.slice(raw, nextControl === -1 ? line.length : nextControl);
|
|
189
|
+
const grapheme = graphemeSegmenter
|
|
190
|
+
.segment(plainText)[Symbol.iterator]()
|
|
191
|
+
.next().value?.segment;
|
|
192
|
+
if (!grapheme) {
|
|
193
|
+
raw += 1;
|
|
194
|
+
continue;
|
|
195
|
+
}
|
|
196
|
+
const graphemeWidth = visibleWidth(grapheme);
|
|
197
|
+
if (col + graphemeWidth > column) {
|
|
198
|
+
break;
|
|
199
|
+
}
|
|
200
|
+
raw += grapheme.length;
|
|
201
|
+
col += graphemeWidth;
|
|
202
|
+
}
|
|
203
|
+
return raw;
|
|
204
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@thazhemadam/pi-vim",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"license": "LGPL-3.0-only",
|
|
5
|
+
"description": "Vim-style modal editing integration for Pi.",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"engines": {
|
|
8
|
+
"node": ">=22.19.0"
|
|
9
|
+
},
|
|
10
|
+
"repository": {
|
|
11
|
+
"type": "git",
|
|
12
|
+
"url": "git+https://github.com/thazhemadam/vim-state.git",
|
|
13
|
+
"directory": "packages/integrations/pi-vim"
|
|
14
|
+
},
|
|
15
|
+
"homepage": "https://github.com/thazhemadam/vim-state/tree/main/packages/integrations/pi-vim#readme",
|
|
16
|
+
"bugs": {
|
|
17
|
+
"url": "https://github.com/thazhemadam/vim-state/issues"
|
|
18
|
+
},
|
|
19
|
+
"publishConfig": {
|
|
20
|
+
"access": "public"
|
|
21
|
+
},
|
|
22
|
+
"keywords": [
|
|
23
|
+
"pi-package",
|
|
24
|
+
"pi-extension",
|
|
25
|
+
"vim",
|
|
26
|
+
"modal-editing"
|
|
27
|
+
],
|
|
28
|
+
"files": [
|
|
29
|
+
"dist",
|
|
30
|
+
"LICENSE",
|
|
31
|
+
"LICENSE.GPL",
|
|
32
|
+
"README.md"
|
|
33
|
+
],
|
|
34
|
+
"main": "./dist/index.js",
|
|
35
|
+
"types": "./dist/index.d.ts",
|
|
36
|
+
"exports": {
|
|
37
|
+
".": {
|
|
38
|
+
"types": "./dist/index.d.ts",
|
|
39
|
+
"import": "./dist/index.js"
|
|
40
|
+
}
|
|
41
|
+
},
|
|
42
|
+
"pi": {
|
|
43
|
+
"extensions": [
|
|
44
|
+
"./dist/index.js"
|
|
45
|
+
]
|
|
46
|
+
},
|
|
47
|
+
"scripts": {
|
|
48
|
+
"build": "tsc -p tsconfig.build.json",
|
|
49
|
+
"check": "tsc --noEmit && oxlint",
|
|
50
|
+
"lint": "oxlint",
|
|
51
|
+
"lint:fix": "oxlint --fix",
|
|
52
|
+
"prepack": "npm run build",
|
|
53
|
+
"test": "tsx --test test/**/*.test.ts"
|
|
54
|
+
},
|
|
55
|
+
"peerDependencies": {
|
|
56
|
+
"@earendil-works/pi-coding-agent": ">=0.79.8 <=0.84.2",
|
|
57
|
+
"@earendil-works/pi-tui": ">=0.79.8 <=0.84.2"
|
|
58
|
+
},
|
|
59
|
+
"dependencies": {
|
|
60
|
+
"@thazhemadam/vim-state": "^0.1.0",
|
|
61
|
+
"xstate": "5.32.1"
|
|
62
|
+
},
|
|
63
|
+
"devDependencies": {
|
|
64
|
+
"oxlint": "^1.70.0",
|
|
65
|
+
"tsx": "^4.22.4",
|
|
66
|
+
"typescript": "^5.9.3"
|
|
67
|
+
}
|
|
68
|
+
}
|