opencode-vim 0.0.22 → 0.0.23
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 +17 -66
- package/assets/vim-in-motion-dialogs.gif +0 -0
- package/docs/configuration.md +50 -400
- package/docs/keymap-actions.md +60 -188
- package/docs/vim-behavior.md +93 -0
- package/package.json +1 -1
- package/src/clipboard.tsx +50 -0
- package/src/modules/vim/actions.ts +1 -4
- package/src/modules/vim/config.ts +12 -0
- package/src/modules/vim/map.ts +13 -121
- package/src/modules/vim/vimee.ts +228 -163
- package/src/session.tsx +291 -0
- package/src/transcript-items.tsx +168 -0
- package/src/transcript.tsx +179 -0
- package/tui.tsx +73 -8
- package/view.tsx +1 -1
package/src/modules/vim/vimee.ts
CHANGED
|
@@ -1,30 +1,35 @@
|
|
|
1
1
|
import type { KeyEvent } from "@opentui/core"
|
|
2
|
-
import { TextBuffer, createInitialContext, createKeybindMap, parseKeySequence, processKeystroke, resetContext, resolveMotion } from "@vimee/core"
|
|
2
|
+
import { TextBuffer, createInitialContext, createKeybindMap, executeOperatorOnRange, parseKeySequence, processKeystroke, resetContext, resolveMotion } from "@vimee/core"
|
|
3
3
|
import type { CursorPosition, KeybindDefinition, KeybindMap, MotionRange, Operator, ValidKeySequence, VimAction as VimeeAction, VimContext, VimMode as VimeeMode } from "@vimee/core"
|
|
4
4
|
import { focusedInput, setInput, type EditBufferLike, type PromptContext } from "./actions"
|
|
5
5
|
import type { VimConfig } from "./config"
|
|
6
6
|
import type { VimLog } from "./log"
|
|
7
7
|
import { createGraphemeCodec } from "./graphemes"
|
|
8
|
-
import { charToDisplay, displayToChar, displayWidth, createPromptMap,
|
|
8
|
+
import { charToDisplay, displayToChar, displayWidth, createPromptMap, hostFromVimOffset, hostOffset, hostPosition, type PromptMap } from "./map"
|
|
9
9
|
import type { createVimState } from "./state"
|
|
10
10
|
|
|
11
11
|
type VimState = ReturnType<typeof createVimState>
|
|
12
|
-
type HostAction = VimeeAction | { type: "submit" } | { type: "command"; command: string }
|
|
12
|
+
type HostAction = VimeeAction & { register?: string } | { type: "submit" } | { type: "command"; command: string }
|
|
13
13
|
type HostKeybindAction = "normal" | "submit" | "command"
|
|
14
14
|
type HostKeybindDefinition = KeybindDefinition & { hostAction?: HostKeybindAction; command?: string }
|
|
15
15
|
type HostRange = { start: number; end: number }
|
|
16
16
|
|
|
17
|
-
const YANK_FLASH_MS = 250
|
|
17
|
+
export const YANK_FLASH_MS = 250
|
|
18
18
|
|
|
19
19
|
export type VimeeAdapter = ReturnType<typeof createVimeeAdapter>
|
|
20
20
|
|
|
21
|
-
|
|
21
|
+
type AdapterOptions = {
|
|
22
|
+
readOnly?: boolean
|
|
23
|
+
onYank?: (text: string) => void
|
|
24
|
+
readClipboard?: () => Promise<string>
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function createVimeeAdapter(state: VimState, config: VimConfig, log: VimLog, options: AdapterOptions = {}) {
|
|
22
28
|
const codec = createGraphemeCodec()
|
|
23
29
|
let buffer = new TextBuffer("")
|
|
24
|
-
let activeMap = createPromptMap("",
|
|
25
|
-
const maps = new Map([[activeMap.vimText, activeMap]])
|
|
30
|
+
let activeMap = createPromptMap("", codec)
|
|
26
31
|
let vim = createInitialContext({ line: 0, col: 0 })
|
|
27
|
-
const keybinds = createKeybinds(config, log)
|
|
32
|
+
const keybinds = options.readOnly ? undefined : createKeybinds(config, log)
|
|
28
33
|
let timer: ReturnType<typeof setTimeout> | undefined
|
|
29
34
|
let yankTimer: ReturnType<typeof setTimeout> | undefined
|
|
30
35
|
let yankFlashActive = false
|
|
@@ -34,19 +39,20 @@ export function createVimeeAdapter(state: VimState, config: VimConfig, log: VimL
|
|
|
34
39
|
let nativeInsertUndoSaved = false
|
|
35
40
|
let historyText: string | undefined
|
|
36
41
|
let activeInput: EditBufferLike | undefined
|
|
37
|
-
let
|
|
38
|
-
let
|
|
42
|
+
let preferredColumn: number | undefined
|
|
43
|
+
let preferredScreen = false
|
|
44
|
+
let generation = 0
|
|
39
45
|
const defaultHistoryKeys = {
|
|
40
46
|
k: !hasNormalKeyPrefix(config, "k"),
|
|
41
47
|
j: !hasNormalKeyPrefix(config, "j"),
|
|
42
48
|
}
|
|
43
|
-
const appendMapped = hasNormalKeyPrefix(config, "A")
|
|
44
49
|
|
|
45
50
|
return {
|
|
46
51
|
attach,
|
|
47
52
|
suspend,
|
|
53
|
+
setRegister(text: string) { vim = { ...vim, register: codec.encode(text) } },
|
|
48
54
|
isPending: () => vim.phase !== "idle" || vim.count > 0 || !!keybinds?.isPending(),
|
|
49
|
-
handle(event: KeyEvent, key: string, ctx: PromptContext) {
|
|
55
|
+
handle(event: KeyEvent, key: string, ctx: PromptContext): boolean | Promise<boolean> {
|
|
50
56
|
const ref = ctx.prompt()
|
|
51
57
|
if (!ref) return false
|
|
52
58
|
const input = focusedInput(ctx)
|
|
@@ -54,6 +60,11 @@ export function createVimeeAdapter(state: VimState, config: VimConfig, log: VimL
|
|
|
54
60
|
|
|
55
61
|
let vimeeKey = keyForVimee(event, key)
|
|
56
62
|
if (!vimeeKey) return false
|
|
63
|
+
if (options.readOnly && !readOnlyKey(vimeeKey, event.ctrl, vim)) {
|
|
64
|
+
vim = resetContext(vim)
|
|
65
|
+
state.setPending("")
|
|
66
|
+
return true
|
|
67
|
+
}
|
|
57
68
|
vimeeKey = codec.encode(vimeeKey)
|
|
58
69
|
|
|
59
70
|
if (state.mode() === "insert") {
|
|
@@ -73,7 +84,7 @@ export function createVimeeAdapter(state: VimState, config: VimConfig, log: VimL
|
|
|
73
84
|
const displayOff = clamp(input?.cursorOffset ?? dw, 0, dw)
|
|
74
85
|
const charOff = displayToChar(text, displayOff)
|
|
75
86
|
|
|
76
|
-
const canBrowseHistory = vim.phase === "idle" && vim.count === 0 && !keybinds?.isPending()
|
|
87
|
+
const canBrowseHistory = !options.readOnly && vim.phase === "idle" && vim.count === 0 && !keybinds?.isPending()
|
|
77
88
|
const historyCommand = canBrowseHistory ? defaultHistoryCommand(vimeeKey, text, historyText, defaultHistoryKeys) : undefined
|
|
78
89
|
if (historyCommand) {
|
|
79
90
|
const result = dispatchCommand(historyCommand, ctx)
|
|
@@ -85,21 +96,13 @@ export function createVimeeAdapter(state: VimState, config: VimConfig, log: VimL
|
|
|
85
96
|
}
|
|
86
97
|
historyText = undefined
|
|
87
98
|
|
|
88
|
-
const map = mapForHostText(text
|
|
99
|
+
const map = mapForHostText(text)
|
|
89
100
|
const cursor = hostPosition(map, displayOff)
|
|
90
101
|
|
|
91
102
|
const wasPending = keybinds?.isPending() ?? false
|
|
92
103
|
const pendingBefore = pendingInsert
|
|
93
104
|
sync(map, cursor)
|
|
94
105
|
|
|
95
|
-
if (vimeeKey === "A" && vim.mode === "normal" && vim.phase === "idle" && !wasPending && !appendMapped && appendVisualLine(input, map)) {
|
|
96
|
-
state.setPending("")
|
|
97
|
-
updateTimeout(ctx)
|
|
98
|
-
log("vimee.key", { key, vimeeKey, mode: vim.mode, phase: vim.phase, cursor: vim.cursor, actions: ["mode-change"] })
|
|
99
|
-
return true
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
const hostEnd = vimeeKey === "e" && vim.phase === "idle" ? charToDisplay(map.hostText, endMotionOffset(map.hostText, charOff, vim.count || 1)) : undefined
|
|
103
106
|
const shouldFlashYank = shouldFlashYankFor(vimeeKey)
|
|
104
107
|
const visualYankRange = visualYankRangeFor(map)
|
|
105
108
|
vimeeKey = textObjectAlias(vimeeKey, vim) ?? vimeeKey
|
|
@@ -121,43 +124,60 @@ export function createVimeeAdapter(state: VimState, config: VimConfig, log: VimL
|
|
|
121
124
|
return true
|
|
122
125
|
}
|
|
123
126
|
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
127
|
+
const commandKey = vimeeKey
|
|
128
|
+
const definition = resolved?.status === "matched" ? resolved.definition : undefined
|
|
129
|
+
if (options.readClipboard && needsClipboard(commandKey, event.ctrl, definition)) {
|
|
130
|
+
const before = generation
|
|
131
|
+
return options.readClipboard().then((text) => {
|
|
132
|
+
// A focus change, mouse move or external edit cancels a pending put.
|
|
133
|
+
if (before !== generation || input?.isDestroyed || input?.plainText !== map.hostText || input?.cursorOffset !== displayOff) return false
|
|
134
|
+
vim = { ...vim, register: codec.encode(text) }
|
|
135
|
+
return finish()
|
|
136
|
+
})
|
|
130
137
|
}
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
vim
|
|
142
|
-
|
|
138
|
+
return finish()
|
|
139
|
+
|
|
140
|
+
function finish() {
|
|
141
|
+
let result
|
|
142
|
+
if (resolved?.status === "matched") {
|
|
143
|
+
const actions = applyKeybind(resolved.definition, ctx, map)
|
|
144
|
+
result = { newCtx: vim, actions }
|
|
145
|
+
} else {
|
|
146
|
+
// Match LazyVim's uncounted j/k and arrow mappings in normal/visual mode.
|
|
147
|
+
// Apply this at the input boundary so custom mapping sequences stay literal.
|
|
148
|
+
if (!event.ctrl && vim.phase === "idle" && vim.count === 0 && (commandKey === "j" || commandKey === "k" || commandKey === "ArrowDown" || commandKey === "ArrowUp")) {
|
|
149
|
+
vim = processKey("g").newCtx
|
|
150
|
+
}
|
|
151
|
+
result = processKey(commandKey, event.ctrl)
|
|
143
152
|
}
|
|
153
|
+
vim = result.newCtx
|
|
154
|
+
const actions = result.actions as HostAction[]
|
|
155
|
+
if (resolved?.status !== "matched") applyActions(actions, ctx, map)
|
|
156
|
+
if (shouldFlashYank) flashYank(ctx, activeMap, yankAction(actions), visualYankRange)
|
|
157
|
+
syncMode(state, vim.mode)
|
|
158
|
+
|
|
159
|
+
const keybindPending = keybinds?.isPending() ?? false
|
|
160
|
+
if (wasPending && !keybindPending && pendingBefore && state.mode() === "insert") flushPendingInsert(ctx, pendingBefore, charOff)
|
|
161
|
+
pendingInsert = keybindPending && state.mode() === "insert" ? plainPending(vim.statusMessage) : ""
|
|
162
|
+
state.setPending(pendingDisplay(vim, keybindPending))
|
|
163
|
+
updateTimeout(ctx)
|
|
164
|
+
log("vimee.key", { key, vimeeKey, mode: vim.mode, phase: vim.phase, cursor: vim.cursor, actions: actions.map((action) => action.type) })
|
|
165
|
+
return consumesKey(commandKey, actions, vim, keybindPending)
|
|
144
166
|
}
|
|
145
|
-
applyActions(actions, ctx, map, clampFinalCursor)
|
|
146
|
-
if (shouldFlashYank) flashYank(ctx, activeMap, yankAction(actions), visualYankRange)
|
|
147
|
-
syncMode(state, vim.mode)
|
|
148
|
-
|
|
149
|
-
const keybindPending = keybinds?.isPending() ?? false
|
|
150
|
-
if (wasPending && !keybindPending && pendingBefore && state.mode() === "insert") flushPendingInsert(ctx, pendingBefore, charOff)
|
|
151
|
-
pendingInsert = keybindPending && state.mode() === "insert" ? plainPending(vim.statusMessage) : ""
|
|
152
|
-
state.setPending(pendingDisplay(vim, keybindPending))
|
|
153
|
-
updateTimeout(ctx)
|
|
154
|
-
log("vimee.key", { key, vimeeKey, mode: vim.mode, phase: vim.phase, cursor: vim.cursor, actions: actions.map((action) => action.type) })
|
|
155
|
-
return consumesKey(vimeeKey, actions, vim, keybindPending)
|
|
156
|
-
},
|
|
157
|
-
cleanup() {
|
|
158
|
-
suspend()
|
|
159
|
-
maps.clear()
|
|
160
167
|
},
|
|
168
|
+
cleanup: suspend,
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function needsClipboard(key: string, ctrl: boolean, definition?: KeybindDefinition) {
|
|
172
|
+
if (definition) {
|
|
173
|
+
if ("execute" in definition) return false
|
|
174
|
+
return parseKeySequence(definition.keys).some((token) => ["p", "P", ".", "@"].includes(token))
|
|
175
|
+
}
|
|
176
|
+
if (ctrl) return false
|
|
177
|
+
if (vim.phase === "macro-execute-pending") return true
|
|
178
|
+
if (vim.phase !== "idle" || vim.selectedRegister) return false
|
|
179
|
+
if (key === "p" || key === "P") return true
|
|
180
|
+
return key === "." && vim.lastChange.some((token) => ["p", "P", "@"].includes(token))
|
|
161
181
|
}
|
|
162
182
|
|
|
163
183
|
function attach(ctx: PromptContext) {
|
|
@@ -165,17 +185,15 @@ export function createVimeeAdapter(state: VimState, config: VimConfig, log: VimL
|
|
|
165
185
|
if (activeInput === input) return
|
|
166
186
|
suspend()
|
|
167
187
|
activeInput = input
|
|
168
|
-
activeMap = createPromptMap(input?.plainText ?? ctx.prompt()?.current.input ?? "",
|
|
169
|
-
maps.clear()
|
|
170
|
-
rememberMap(activeMap)
|
|
188
|
+
activeMap = createPromptMap(input?.plainText ?? ctx.prompt()?.current.input ?? "", codec)
|
|
171
189
|
buffer = new TextBuffer(activeMap.vimText)
|
|
172
190
|
nativeInsertUndoSaved = false
|
|
173
191
|
vim = { ...resetContext(vim), cursor: hostPosition(activeMap, input?.cursorOffset ?? 0), mode: state.mode() }
|
|
174
|
-
mapWidth = input?.width
|
|
175
|
-
mapWrapMode = input?.wrapMode
|
|
176
192
|
}
|
|
177
193
|
|
|
178
194
|
function suspend() {
|
|
195
|
+
generation++
|
|
196
|
+
preferredColumn = undefined
|
|
179
197
|
if (pendingContext) cancelPendingInsert(pendingContext)
|
|
180
198
|
if (timer) clearTimeout(timer)
|
|
181
199
|
timer = undefined
|
|
@@ -190,30 +208,24 @@ export function createVimeeAdapter(state: VimState, config: VimConfig, log: VimL
|
|
|
190
208
|
}
|
|
191
209
|
|
|
192
210
|
function sync(map: PromptMap, cursor: CursorPosition) {
|
|
211
|
+
if (cursor.line !== vim.cursor.line || cursor.col !== vim.cursor.col) preferredColumn = undefined
|
|
193
212
|
if (buffer.getContent() !== map.vimText) buffer.replaceContent(map.vimText)
|
|
194
213
|
vim = { ...vim, cursor, mode: state.mode() }
|
|
195
214
|
}
|
|
196
215
|
|
|
197
|
-
function mapForHostText(text: string
|
|
198
|
-
|
|
199
|
-
if (
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
mapWidth = input?.width
|
|
203
|
-
mapWrapMode = input?.wrapMode
|
|
204
|
-
if (textChanged && state.mode() !== "insert") maps.clear()
|
|
205
|
-
rememberMap(activeMap)
|
|
206
|
-
if (textChanged && state.mode() === "insert") {
|
|
216
|
+
function mapForHostText(text: string) {
|
|
217
|
+
if (activeMap.hostText === text) return activeMap
|
|
218
|
+
if (state.mode() === "insert") recordNativeChange(text)
|
|
219
|
+
activeMap = createPromptMap(text, codec)
|
|
220
|
+
if (state.mode() === "insert") {
|
|
207
221
|
if (!nativeInsertUndoSaved) {
|
|
208
222
|
buffer.saveUndoPoint(vim.cursor)
|
|
209
223
|
nativeInsertUndoSaved = true
|
|
210
224
|
}
|
|
211
225
|
buffer.replaceContent(activeMap.vimText)
|
|
212
|
-
} else
|
|
226
|
+
} else {
|
|
213
227
|
buffer = new TextBuffer(activeMap.vimText)
|
|
214
228
|
nativeInsertUndoSaved = false
|
|
215
|
-
} else {
|
|
216
|
-
buffer.replaceContent(activeMap.vimText)
|
|
217
229
|
}
|
|
218
230
|
return activeMap
|
|
219
231
|
}
|
|
@@ -234,19 +246,7 @@ export function createVimeeAdapter(state: VimState, config: VimConfig, log: VimL
|
|
|
234
246
|
vim = { ...vim, pendingChange: keys }
|
|
235
247
|
}
|
|
236
248
|
|
|
237
|
-
function
|
|
238
|
-
maps.set(map.vimText, map)
|
|
239
|
-
}
|
|
240
|
-
|
|
241
|
-
function nextMap(map: PromptMap, vimText: string) {
|
|
242
|
-
const known = maps.get(vimText)
|
|
243
|
-
if (known) return known
|
|
244
|
-
const next = derivePromptMap(map, vimText)
|
|
245
|
-
rememberMap(next)
|
|
246
|
-
return next
|
|
247
|
-
}
|
|
248
|
-
|
|
249
|
-
function applyActions(actions: HostAction[], ctx: PromptContext, map: PromptMap, clampFinalCursor = true) {
|
|
249
|
+
function applyActions(actions: HostAction[], ctx: PromptContext, map: PromptMap) {
|
|
250
250
|
const ref = ctx.prompt()
|
|
251
251
|
const input = focusedInput(ctx)
|
|
252
252
|
let currentMap = map
|
|
@@ -255,8 +255,7 @@ export function createVimeeAdapter(state: VimState, config: VimConfig, log: VimL
|
|
|
255
255
|
for (const action of actions) {
|
|
256
256
|
switch (action.type) {
|
|
257
257
|
case "content-change":
|
|
258
|
-
|
|
259
|
-
currentMap = nextMap(currentMap, action.content)
|
|
258
|
+
currentMap = createPromptMap(codec.decode(action.content), codec)
|
|
260
259
|
activeMap = currentMap
|
|
261
260
|
setInput(ref, currentMap.hostText)
|
|
262
261
|
buffer.replaceContent(action.content)
|
|
@@ -264,6 +263,9 @@ export function createVimeeAdapter(state: VimState, config: VimConfig, log: VimL
|
|
|
264
263
|
case "cursor-move":
|
|
265
264
|
setCursor(input, currentMap, action.position)
|
|
266
265
|
break
|
|
266
|
+
case "yank":
|
|
267
|
+
if (!action.register) options.onYank?.(codec.decode(action.text))
|
|
268
|
+
break
|
|
267
269
|
case "mode-change":
|
|
268
270
|
nativeInsertUndoSaved = action.mode === "insert" && actions.some((item) => item.type === "content-change")
|
|
269
271
|
if (action.mode === "insert") {
|
|
@@ -284,7 +286,7 @@ export function createVimeeAdapter(state: VimState, config: VimConfig, log: VimL
|
|
|
284
286
|
}
|
|
285
287
|
}
|
|
286
288
|
|
|
287
|
-
setCursor(input, currentMap, vim.cursor
|
|
289
|
+
setCursor(input, currentMap, vim.cursor)
|
|
288
290
|
syncVisualSelection(input, currentMap, ctx)
|
|
289
291
|
}
|
|
290
292
|
|
|
@@ -351,7 +353,7 @@ export function createVimeeAdapter(state: VimState, config: VimConfig, log: VimL
|
|
|
351
353
|
const ref = ctx.prompt()
|
|
352
354
|
const input = focusedInput(ctx)
|
|
353
355
|
const text = input?.plainText ?? ref?.current.input ?? ""
|
|
354
|
-
const map = mapForHostText(text
|
|
356
|
+
const map = mapForHostText(text)
|
|
355
357
|
|
|
356
358
|
if (input && text.length > 0) {
|
|
357
359
|
const dw = displayWidth(text)
|
|
@@ -377,10 +379,11 @@ export function createVimeeAdapter(state: VimState, config: VimConfig, log: VimL
|
|
|
377
379
|
return undefined
|
|
378
380
|
}
|
|
379
381
|
|
|
380
|
-
function applyKeybind(definition: KeybindDefinition, map: PromptMap) {
|
|
382
|
+
function applyKeybind(definition: KeybindDefinition, ctx: PromptContext, map: PromptMap) {
|
|
381
383
|
if ("execute" in definition) {
|
|
382
384
|
const actions = definition.execute(vim, buffer) as HostAction[]
|
|
383
|
-
vim = { ...vim, cursor: hostPosition(map,
|
|
385
|
+
vim = { ...vim, cursor: hostPosition(map, hostOffset(map, vim.cursor)) }
|
|
386
|
+
applyActions(actions, ctx, map)
|
|
384
387
|
return actions
|
|
385
388
|
}
|
|
386
389
|
|
|
@@ -388,12 +391,37 @@ export function createVimeeAdapter(state: VimState, config: VimConfig, log: VimL
|
|
|
388
391
|
for (const token of parseKeySequence(definition.keys)) {
|
|
389
392
|
const result = processKey(codec.encode(keyToken(token)), tokenCtrl(token))
|
|
390
393
|
vim = result.newCtx
|
|
394
|
+
// Keep the host layout current for screen motions later in the mapping.
|
|
395
|
+
applyActions(result.actions, ctx, activeMap)
|
|
391
396
|
actions = [...actions, ...(result.actions as HostAction[])]
|
|
392
397
|
}
|
|
393
398
|
return actions
|
|
394
399
|
}
|
|
395
400
|
|
|
396
401
|
function processKey(key: string, ctrl = false): { newCtx: VimContext; actions: HostAction[] } {
|
|
402
|
+
const register = vim.selectedRegister
|
|
403
|
+
const moving = !ctrl && vim.mode !== "insert" && (vim.phase === "idle" || vim.phase === "operator-pending" || vim.phase === "g-pending")
|
|
404
|
+
const vertical = moving && (key === "j" || key === "k" || key === "ArrowDown" || key === "ArrowUp")
|
|
405
|
+
const lineEnd = moving && key === "$" && vim.phase === "idle"
|
|
406
|
+
const result = processKeyInner(key, ctrl)
|
|
407
|
+
if (register) {
|
|
408
|
+
for (const action of result.actions) {
|
|
409
|
+
if (action.type === "yank") action.register = register
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
if (result.actions.some((action) => action.type === "content-change" || action.type === "mode-change")) preferredColumn = undefined
|
|
413
|
+
else if (!vertical && result.actions.some((action) => action.type === "cursor-move")) preferredColumn = lineEnd ? Infinity : undefined
|
|
414
|
+
return result
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
function processKeyInner(key: string, ctrl: boolean): { newCtx: VimContext; actions: HostAction[] } {
|
|
418
|
+
let range = ctrl ? undefined : lineMotion(key)
|
|
419
|
+
if (range && vim.operator && (key === "j" || key === "k" || key === "ArrowDown" || key === "ArrowUp") && range.start.line === range.end.line && range.start.col === range.end.col) {
|
|
420
|
+
return { newCtx: { ...resetContext(vim), pendingChange: [] }, actions: [] }
|
|
421
|
+
}
|
|
422
|
+
if (range && !vim.operator) {
|
|
423
|
+
return { newCtx: { ...resetContext(vim), cursor: range.end }, actions: [{ type: "cursor-move", position: range.end }] }
|
|
424
|
+
}
|
|
397
425
|
let wordRange: MotionRange | undefined
|
|
398
426
|
if (!ctrl && vim.mode !== "insert" && (key === "w" || key === "W") && (vim.phase === "idle" || vim.phase === "operator-pending")) {
|
|
399
427
|
wordRange = forwardWordRange(key, vim.cursor, buffer, vim.count || 1)
|
|
@@ -431,9 +459,8 @@ export function createVimeeAdapter(state: VimState, config: VimConfig, log: VimL
|
|
|
431
459
|
return { newCtx: { ...vim, lastChange: keys, pendingChange: [] }, actions }
|
|
432
460
|
}
|
|
433
461
|
|
|
434
|
-
let range: MotionRange | undefined
|
|
435
462
|
const operator = vim.operator
|
|
436
|
-
if (!ctrl && operator && textObjectOperator(operator)) {
|
|
463
|
+
if (!range && !ctrl && operator && textObjectOperator(operator)) {
|
|
437
464
|
if (vim.phase === "operator-pending") {
|
|
438
465
|
const count = vim.count || 1
|
|
439
466
|
if (key === "l" || key === "ArrowRight") {
|
|
@@ -458,6 +485,15 @@ export function createVimeeAdapter(state: VimState, config: VimConfig, log: VimL
|
|
|
458
485
|
}
|
|
459
486
|
}
|
|
460
487
|
if (range && operator) {
|
|
488
|
+
// Vim's exclusive motions ending at column zero stop before the newline.
|
|
489
|
+
// Starting in the first nonblank column makes that range linewise instead.
|
|
490
|
+
if (vim.phase === "g-pending" && !range.inclusive) {
|
|
491
|
+
const ordered = orderedRange(range)
|
|
492
|
+
if (ordered.end.line > ordered.start.line && ordered.end.col === 0) {
|
|
493
|
+
const linewise = ordered.start.col <= Math.max(0, buffer.getLine(ordered.start.line).search(/\S/))
|
|
494
|
+
range = { start: ordered.start, end: { line: ordered.end.line - 1, col: buffer.getLineLength(ordered.end.line - 1) }, linewise, inclusive: false }
|
|
495
|
+
}
|
|
496
|
+
}
|
|
461
497
|
const keys: string[] = []
|
|
462
498
|
if (vim.count) keys.push(...String(vim.count))
|
|
463
499
|
for (const token of vim.pendingChange) {
|
|
@@ -474,7 +510,73 @@ export function createVimeeAdapter(state: VimState, config: VimConfig, log: VimL
|
|
|
474
510
|
// The engine saves undo points for yanks too. Run read-only operations
|
|
475
511
|
// against a disposable buffer so they cannot alter the editing history.
|
|
476
512
|
const yanking = operator === "y" || (isVisualMode(vim.mode) && key === "y") || (vim.mode === "normal" && key === "Y")
|
|
477
|
-
|
|
513
|
+
const register = vim.selectedRegister ? vim.registers[vim.selectedRegister] ?? "" : vim.register
|
|
514
|
+
const emptyLinePaste = !ctrl && vim.mode === "normal" && vim.phase === "idle" && (key === "p" || key === "P")
|
|
515
|
+
&& buffer.getContent() === "" && register.endsWith("\n")
|
|
516
|
+
const result = processKeystroke(key, vim, yanking ? new TextBuffer(buffer.getContent()) : buffer, ctrl, false)
|
|
517
|
+
if (emptyLinePaste) {
|
|
518
|
+
// Linewise puts should fill an empty prompt, not keep its placeholder line.
|
|
519
|
+
// Remove just that line, keeping the engine's undo point and repeat keys.
|
|
520
|
+
buffer.deleteLines(key === "p" ? 0 : buffer.getLineCount() - 1, 1)
|
|
521
|
+
result.newCtx.cursor = { line: 0, col: 0 }
|
|
522
|
+
for (const action of result.actions) {
|
|
523
|
+
if (action.type === "content-change") action.content = buffer.getContent()
|
|
524
|
+
if (action.type === "cursor-move") action.position = result.newCtx.cursor
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
return result
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
function lineMotion(key: string): MotionRange | undefined {
|
|
531
|
+
if (vim.mode !== "normal" && !isVisualMode(vim.mode)) return
|
|
532
|
+
const screen = vim.phase === "g-pending"
|
|
533
|
+
if (!screen && vim.phase !== "idle" && vim.phase !== "operator-pending") return
|
|
534
|
+
const down = key === "j" || key === "ArrowDown"
|
|
535
|
+
const up = key === "k" || key === "ArrowUp"
|
|
536
|
+
if (!down && !up && !(screen && (key === "0" || key === "^" || key === "$"))) return
|
|
537
|
+
|
|
538
|
+
const map = buffer.getContent() === activeMap.vimText ? activeMap : createPromptMap(codec.decode(buffer.getContent()), codec)
|
|
539
|
+
const offset = hostOffset(map, vim.cursor)
|
|
540
|
+
const count = vim.count || 1
|
|
541
|
+
let start: number
|
|
542
|
+
let end: number
|
|
543
|
+
let column: number
|
|
544
|
+
if (screen) {
|
|
545
|
+
const lines = activeInput?.editorView?.getLogicalLineInfo?.()
|
|
546
|
+
if (!lines?.lineStartCols.length) return
|
|
547
|
+
let row = 0
|
|
548
|
+
while (row + 1 < lines.lineStartCols.length && lines.lineStartCols[row + 1] <= offset) row++
|
|
549
|
+
column = offset - lines.lineStartCols[row]
|
|
550
|
+
const delta = down ? count : up ? -count : key === "$" ? count - 1 : 0
|
|
551
|
+
const target = clamp(row + delta, 0, lines.lineStartCols.length - 1)
|
|
552
|
+
start = lines.lineStartCols[target]
|
|
553
|
+
end = start + Math.max(0, lines.lineWidthCols[target] - 1)
|
|
554
|
+
if ((down || up) && target === row) return { start: vim.cursor, end: vim.cursor, linewise: false, inclusive: false }
|
|
555
|
+
} else {
|
|
556
|
+
const lineStart = hostOffset(map, { line: vim.cursor.line, col: 0 })
|
|
557
|
+
column = offset - lineStart
|
|
558
|
+
const line = clamp(vim.cursor.line + (down ? count : -count), 0, buffer.getLineCount() - 1)
|
|
559
|
+
start = hostOffset(map, { line, col: 0 })
|
|
560
|
+
end = Math.max(start, hostOffset(map, { line, col: buffer.getLineLength(line) }) - 1)
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
let target: number
|
|
564
|
+
if (down || up) {
|
|
565
|
+
if (preferredColumn === undefined || (preferredScreen !== screen && preferredColumn !== Infinity)) preferredColumn = column
|
|
566
|
+
preferredScreen = screen
|
|
567
|
+
target = Math.min(start + preferredColumn, end)
|
|
568
|
+
} else if (key === "$") {
|
|
569
|
+
target = end
|
|
570
|
+
} else {
|
|
571
|
+
target = start
|
|
572
|
+
if (key === "^") {
|
|
573
|
+
const from = displayToChar(map.hostText, start)
|
|
574
|
+
const to = displayToChar(map.hostText, end + 1)
|
|
575
|
+
const first = map.hostText.slice(from, to).search(/\S/)
|
|
576
|
+
if (first >= 0) target = charToDisplay(map.hostText, from + first)
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
return { start: vim.cursor, end: hostPosition(map, target), linewise: !screen, inclusive: !screen || key === "$" }
|
|
478
580
|
}
|
|
479
581
|
|
|
480
582
|
function handleTextObject(key: string, ctx: PromptContext, map: PromptMap) {
|
|
@@ -555,24 +657,10 @@ export function createVimeeAdapter(state: VimState, config: VimConfig, log: VimL
|
|
|
555
657
|
if (input) input.cursorOffset = displayWidth(next.slice(0, nextCharOff))
|
|
556
658
|
}
|
|
557
659
|
|
|
558
|
-
function
|
|
559
|
-
return hostOffset(map, position, vim.mode === "insert" ? "next" : "previous")
|
|
560
|
-
}
|
|
561
|
-
|
|
562
|
-
function setCursor(input: EditBufferLike | undefined, map: PromptMap, position: CursorPosition, clampCursor = true) {
|
|
660
|
+
function setCursor(input: EditBufferLike | undefined, map: PromptMap, position: CursorPosition) {
|
|
563
661
|
if (!input) return
|
|
564
|
-
input.cursorOffset =
|
|
565
|
-
if (
|
|
566
|
-
}
|
|
567
|
-
|
|
568
|
-
function appendVisualLine(input: EditBufferLike | undefined, map: PromptMap) {
|
|
569
|
-
if (!input?.gotoVisualLineEnd) return false
|
|
570
|
-
clearVisualSelection(input)
|
|
571
|
-
input.gotoVisualLineEnd()
|
|
572
|
-
vim = { ...vim, cursor: hostPosition(map, input.cursorOffset ?? displayWidth(map.hostText)), mode: "insert", phase: "idle", count: 0, operator: null, pendingChange: ["A"], statusMessage: "-- INSERT --" }
|
|
573
|
-
nativeInsertUndoSaved = false
|
|
574
|
-
syncMode(state, "insert")
|
|
575
|
-
return true
|
|
662
|
+
input.cursorOffset = hostOffset(map, position)
|
|
663
|
+
if (vim.mode !== "insert") clampNormalCursor(input)
|
|
576
664
|
}
|
|
577
665
|
|
|
578
666
|
function syncVisualSelection(input: EditBufferLike | undefined, map: PromptMap, ctx: PromptContext) {
|
|
@@ -629,6 +717,15 @@ export function createVimeeAdapter(state: VimState, config: VimConfig, log: VimL
|
|
|
629
717
|
}
|
|
630
718
|
}
|
|
631
719
|
|
|
720
|
+
function readOnlyKey(key: string, ctrl: boolean, vim: VimContext) {
|
|
721
|
+
if (key === "Escape") return true
|
|
722
|
+
if (ctrl) return false
|
|
723
|
+
if (vim.phase === "char-pending" || vim.phase === "text-object-pending" || vim.phase === "register-pending") return true
|
|
724
|
+
if (vim.phase === "g-pending") return ["g", "j", "k", "0", "^", "$", "ArrowDown", "ArrowUp"].includes(key)
|
|
725
|
+
if ((vim.phase === "operator-pending" || isVisualMode(vim.mode)) && (key === "i" || key === "a")) return true
|
|
726
|
+
return /^[0-9hjklwWbBeE$^gGfFtT;,vVy%{}()"]$/.test(key) || ["ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown", "Home", "End"].includes(key)
|
|
727
|
+
}
|
|
728
|
+
|
|
632
729
|
function forwardWordRange(key: string, start: CursorPosition, buffer: TextBuffer, count: number): MotionRange {
|
|
633
730
|
let { line, col } = start
|
|
634
731
|
for (let index = 0; index < count; index++) {
|
|
@@ -654,16 +751,16 @@ function wordClass(char: string | undefined, big: boolean) {
|
|
|
654
751
|
}
|
|
655
752
|
|
|
656
753
|
function visualCharRange(map: PromptMap, anchor: CursorPosition, cursor: CursorPosition): HostRange | undefined {
|
|
657
|
-
const anchorOffset = hostOffset(map, anchor
|
|
658
|
-
const cursorOffset = hostOffset(map, cursor
|
|
754
|
+
const anchorOffset = hostOffset(map, anchor)
|
|
755
|
+
const cursorOffset = hostOffset(map, cursor)
|
|
659
756
|
return hostRange(map, anchorOffset, cursorOffset)
|
|
660
757
|
}
|
|
661
758
|
|
|
662
759
|
function visualLineRange(map: PromptMap, anchor: CursorPosition, cursor: CursorPosition): HostRange | undefined {
|
|
663
760
|
const startLine = Math.min(anchor.line, cursor.line)
|
|
664
761
|
const endLine = Math.max(anchor.line, cursor.line)
|
|
665
|
-
const start = hostOffset(map, { line: startLine, col: 0 }
|
|
666
|
-
const end = hostOffset(map, { line: endLine, col: vimLineLength(map.vimText, endLine) }
|
|
762
|
+
const start = hostOffset(map, { line: startLine, col: 0 })
|
|
763
|
+
const end = hostOffset(map, { line: endLine, col: vimLineLength(map.vimText, endLine) })
|
|
667
764
|
return hostRange(map, start, end)
|
|
668
765
|
}
|
|
669
766
|
|
|
@@ -686,8 +783,8 @@ function motionHostRange(map: PromptMap, range: MotionRange): HostRange | undefi
|
|
|
686
783
|
}
|
|
687
784
|
|
|
688
785
|
function vimOffsetRange(map: PromptMap, left: number, right: number): HostRange | undefined {
|
|
689
|
-
const start = hostFromVimOffset(map, Math.min(left, right)
|
|
690
|
-
const end = hostFromVimOffset(map, Math.max(left, right)
|
|
786
|
+
const start = hostFromVimOffset(map, Math.min(left, right))
|
|
787
|
+
const end = hostFromVimOffset(map, Math.max(left, right))
|
|
691
788
|
return hostRange(map, start, end)
|
|
692
789
|
}
|
|
693
790
|
|
|
@@ -756,27 +853,6 @@ function vimOffsetFromPosition(text: string, position: CursorPosition) {
|
|
|
756
853
|
return offset + clamp(position.col, 0, lines[line]?.length ?? 0)
|
|
757
854
|
}
|
|
758
855
|
|
|
759
|
-
function hostFromVimOffset(map: PromptMap, offset: number, bias: "previous" | "next") {
|
|
760
|
-
const current = clamp(offset, 0, map.vimText.length)
|
|
761
|
-
if (current === map.vimText.length) return displayWidth(map.hostText)
|
|
762
|
-
|
|
763
|
-
const host = map.vimToHost[current]
|
|
764
|
-
if (host !== undefined) return charToDisplay(map.hostText, host)
|
|
765
|
-
|
|
766
|
-
if (bias === "previous") {
|
|
767
|
-
for (let previous = current - 1; previous >= 0; previous--) {
|
|
768
|
-
const previousHost = map.vimToHost[previous]
|
|
769
|
-
if (previousHost !== undefined) return charToDisplay(map.hostText, previousHost)
|
|
770
|
-
}
|
|
771
|
-
}
|
|
772
|
-
|
|
773
|
-
for (let next = current + 1; next < map.vimToHost.length; next++) {
|
|
774
|
-
const nextHost = map.vimToHost[next]
|
|
775
|
-
if (nextHost !== undefined) return charToDisplay(map.hostText, nextHost)
|
|
776
|
-
}
|
|
777
|
-
return displayWidth(map.hostText)
|
|
778
|
-
}
|
|
779
|
-
|
|
780
856
|
function clampNormalCursor(input: EditBufferLike) {
|
|
781
857
|
const cursor = input.visualCursor
|
|
782
858
|
const offset = input.cursorOffset
|
|
@@ -825,27 +901,6 @@ function hasNormalKeyPrefix(config: VimConfig, key: string) {
|
|
|
825
901
|
return false
|
|
826
902
|
}
|
|
827
903
|
|
|
828
|
-
function endMotionOffset(text: string, offset: number, count: number) {
|
|
829
|
-
let index = offset
|
|
830
|
-
for (let step = 0; step < count; step++) {
|
|
831
|
-
index++
|
|
832
|
-
while (index < text.length && isWhitespace(text[index])) index++
|
|
833
|
-
const kind = wordKind(text[index])
|
|
834
|
-
if (!kind) return Math.max(0, text.length - 1)
|
|
835
|
-
while (wordKind(text[index + 1]) === kind) index++
|
|
836
|
-
}
|
|
837
|
-
return clamp(index, 0, Math.max(0, text.length - 1))
|
|
838
|
-
}
|
|
839
|
-
|
|
840
|
-
function isWhitespace(value: string | undefined) {
|
|
841
|
-
return value === " " || value === "\t" || value === "\n"
|
|
842
|
-
}
|
|
843
|
-
|
|
844
|
-
function wordKind(value: string | undefined) {
|
|
845
|
-
if (!value || isWhitespace(value)) return undefined
|
|
846
|
-
return /\w/.test(value) ? "word" : "punctuation"
|
|
847
|
-
}
|
|
848
|
-
|
|
849
904
|
function createKeybinds(config: VimConfig, log: VimLog): KeybindMap | undefined {
|
|
850
905
|
const map = createKeybindMap()
|
|
851
906
|
let count = 0
|
|
@@ -1060,7 +1115,16 @@ function quotePair(cursor: CursorPosition, buffer: TextBuffer, quote: string) {
|
|
|
1060
1115
|
|
|
1061
1116
|
function executeTextObject(operator: Operator, range: MotionRange, buffer: TextBuffer, ctx: VimContext) {
|
|
1062
1117
|
if (operator !== "y") buffer.saveUndoPoint(ctx.cursor)
|
|
1063
|
-
|
|
1118
|
+
if (operator === ">" || operator === "<") {
|
|
1119
|
+
const result = executeOperatorOnRange(operator, range, buffer, ctx.cursor, { style: ctx.indentStyle, width: ctx.indentWidth })
|
|
1120
|
+
const cursor = { line: result.newCursor.line, col: Math.max(0, buffer.getLine(result.newCursor.line).search(/\S/)) }
|
|
1121
|
+
return {
|
|
1122
|
+
actions: [...result.actions, { type: "cursor-move", position: cursor }] as VimeeAction[],
|
|
1123
|
+
context: { ...resetContext(ctx), cursor },
|
|
1124
|
+
yankedText: "",
|
|
1125
|
+
}
|
|
1126
|
+
}
|
|
1127
|
+
const result = range.linewise ? executeLinewiseTextObject(operator, range, buffer, ctx.cursor) : executeCharwiseTextObject(operator, range, buffer)
|
|
1064
1128
|
const registers = ctx.selectedRegister ? { ...ctx.registers, [ctx.selectedRegister]: result.yankedText } : ctx.registers
|
|
1065
1129
|
const context = {
|
|
1066
1130
|
...resetContext(ctx),
|
|
@@ -1074,13 +1138,14 @@ function executeTextObject(operator: Operator, range: MotionRange, buffer: TextB
|
|
|
1074
1138
|
return { actions, context, yankedText: result.yankedText }
|
|
1075
1139
|
}
|
|
1076
1140
|
|
|
1077
|
-
function executeLinewiseTextObject(operator: Operator, range: MotionRange, buffer: TextBuffer) {
|
|
1141
|
+
function executeLinewiseTextObject(operator: Operator, range: MotionRange, buffer: TextBuffer, cursor: CursorPosition) {
|
|
1078
1142
|
const startLine = Math.min(range.start.line, range.end.line)
|
|
1079
1143
|
const endLine = Math.max(range.start.line, range.end.line)
|
|
1080
1144
|
const lineCount = endLine - startLine + 1
|
|
1081
1145
|
const yankedText = buffer.getLines().slice(startLine, endLine + 1).join("\n") + "\n"
|
|
1082
1146
|
if (operator === "y") {
|
|
1083
|
-
|
|
1147
|
+
const col = Math.min(cursor.col, Math.max(0, buffer.getLineLength(startLine) - 1))
|
|
1148
|
+
return { actions: [] as VimeeAction[], cursor: { line: startLine, col }, mode: "normal" as VimeeMode, yankedText, statusMessage: lineCount >= 2 ? `${lineCount} lines yanked` : "" }
|
|
1084
1149
|
}
|
|
1085
1150
|
|
|
1086
1151
|
buffer.deleteLines(startLine, lineCount)
|