opencode-vim 0.0.19 → 0.0.21
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 +70 -18
- package/docs/configuration.md +13 -3
- package/docs/keymap-actions.md +213 -0
- package/package.json +18 -16
- package/src/modules/vim/actions.ts +32 -7
- package/src/modules/vim/edit.test.ts +51 -0
- package/src/modules/vim/edit.ts +33 -0
- package/src/modules/vim/graphemes.ts +38 -0
- package/src/modules/vim/index.tsx +15 -27
- package/src/modules/vim/keys.ts +5 -1
- package/src/modules/vim/map.test.ts +45 -0
- package/src/modules/vim/map.ts +62 -29
- package/src/modules/vim/state.test.ts +18 -0
- package/src/modules/vim/state.ts +7 -1
- package/src/modules/vim/view.tsx +2 -0
- package/src/modules/vim/vimee.test.ts +211 -0
- package/src/modules/vim/vimee.ts +372 -82
- package/tui.tsx +281 -1
- package/view.tsx +46 -0
- package/tui.json.example +0 -37
|
@@ -7,6 +7,7 @@ import type { SnippetController } from "../snippets/types"
|
|
|
7
7
|
import { applyVimCursorStyle, focusedInput } from "./actions"
|
|
8
8
|
import type { VimConfig } from "./config"
|
|
9
9
|
import { createVimConfig } from "./config"
|
|
10
|
+
import { displayToChar } from "./map"
|
|
10
11
|
import { keyNotation } from "./keys"
|
|
11
12
|
import { createVimLog } from "./log"
|
|
12
13
|
import type { VimLog } from "./log"
|
|
@@ -38,17 +39,9 @@ export function createVimModule(options?: unknown, enabled: Accessor<boolean> =
|
|
|
38
39
|
function VimKeyboard(props: { ctx: PromptContext; config: VimConfig; state: ReturnType<typeof createVimState>; snippets?: SnippetController; enabled: Accessor<boolean>; log: VimLog }) {
|
|
39
40
|
let cursorStyleMode = ""
|
|
40
41
|
const vimee = createVimeeAdapter(props.state, props.config, props.log)
|
|
41
|
-
const preparedEvents = new WeakSet<KeyEvent>()
|
|
42
42
|
props.log("keyboard.mount", { kind: props.ctx.kind })
|
|
43
43
|
|
|
44
44
|
const cursorStyleTimer = setInterval(syncCursorStyle, 50)
|
|
45
|
-
const offKeyIntercept = props.ctx.api.keymap.intercept("key", ({ event }) => {
|
|
46
|
-
if (!canHandleKeys(props)) return
|
|
47
|
-
const key = keyNotation(event)
|
|
48
|
-
if (!key || !passThroughKey(event, key, props.state.mode())) return
|
|
49
|
-
if (preparePassThroughKey(props.ctx, key, props.state.mode())) preparedEvents.add(event)
|
|
50
|
-
}, { priority: 100 })
|
|
51
|
-
|
|
52
45
|
const offKeyboard = props.ctx.api.keymap.intercept("key", ({ event }) => {
|
|
53
46
|
props.log("keyboard.event", {
|
|
54
47
|
name: event.name,
|
|
@@ -72,10 +65,7 @@ function VimKeyboard(props: { ctx: PromptContext; config: VimConfig; state: Retu
|
|
|
72
65
|
return
|
|
73
66
|
}
|
|
74
67
|
|
|
75
|
-
if (passThroughKey(event, key, props.state.mode()))
|
|
76
|
-
if (!preparedEvents.delete(event)) preparePassThroughKey(props.ctx, key, props.state.mode())
|
|
77
|
-
return
|
|
78
|
-
}
|
|
68
|
+
if (passThroughKey(event, key, props.state.mode())) return
|
|
79
69
|
|
|
80
70
|
if (key === "<Esc>" && props.state.mode() === "normal" && !props.state.pending()) {
|
|
81
71
|
props.log("keyboard.fallthrough", { key, mode: props.state.mode() })
|
|
@@ -88,10 +78,17 @@ function VimKeyboard(props: { ctx: PromptContext; config: VimConfig; state: Retu
|
|
|
88
78
|
return
|
|
89
79
|
}
|
|
90
80
|
|
|
91
|
-
const
|
|
81
|
+
const modeBefore = props.state.mode()
|
|
82
|
+
let consumed = false
|
|
83
|
+
try {
|
|
84
|
+
consumed = vimee.handle(event, key, props.ctx)
|
|
85
|
+
} finally {
|
|
86
|
+
if (consumed || props.state.mode() !== modeBefore) {
|
|
87
|
+
event.preventDefault()
|
|
88
|
+
event.stopPropagation()
|
|
89
|
+
}
|
|
90
|
+
}
|
|
92
91
|
if (consumed) {
|
|
93
|
-
event.preventDefault()
|
|
94
|
-
event.stopPropagation()
|
|
95
92
|
syncCursorStyle(true)
|
|
96
93
|
props.ctx.requestRender()
|
|
97
94
|
}
|
|
@@ -99,7 +96,6 @@ function VimKeyboard(props: { ctx: PromptContext; config: VimConfig; state: Retu
|
|
|
99
96
|
|
|
100
97
|
onCleanup(() => {
|
|
101
98
|
props.log("keyboard.cleanup", { kind: props.ctx.kind })
|
|
102
|
-
offKeyIntercept()
|
|
103
99
|
offKeyboard()
|
|
104
100
|
vimee.cleanup()
|
|
105
101
|
clearInterval(cursorStyleTimer)
|
|
@@ -128,15 +124,7 @@ function readablePending(sequence: string) {
|
|
|
128
124
|
|
|
129
125
|
function passThroughKey(event: KeyEvent, key: string, mode: string) {
|
|
130
126
|
if (mode !== "normal") return false
|
|
131
|
-
return event.super === true || isArrowKey(key) || key === "<
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
function preparePassThroughKey(ctx: PromptContext, key: string, mode: string) {
|
|
135
|
-
if (mode !== "normal" || key !== "<CR>") return false
|
|
136
|
-
const input = focusedInput(ctx)
|
|
137
|
-
if (!input?.plainText || input.cursorOffset === undefined) return false
|
|
138
|
-
input.cursorOffset = Math.min(input.cursorOffset + 1, input.plainText.length)
|
|
139
|
-
return true
|
|
127
|
+
return event.super === true || isArrowKey(key) || key === "<C-c>"
|
|
140
128
|
}
|
|
141
129
|
|
|
142
130
|
function sendNavigationKey(event: KeyEvent, ctx: PromptContext, key: string, mode: string, snippets?: SnippetController) {
|
|
@@ -178,8 +166,8 @@ function isPromptFocused(ctx: PromptContext) {
|
|
|
178
166
|
function isNativeCompletionToken(ctx: PromptContext) {
|
|
179
167
|
const text = ctx.prompt()?.current.input ?? focusedInput(ctx)?.plainText ?? ""
|
|
180
168
|
const input = focusedInput(ctx)
|
|
181
|
-
const
|
|
182
|
-
const beforeCursor = text.slice(0, Math.min(
|
|
169
|
+
const charIdx = displayToChar(text, Math.max(0, input?.cursorOffset ?? 0))
|
|
170
|
+
const beforeCursor = text.slice(0, Math.min(charIdx + 1, text.length))
|
|
183
171
|
return /^\/\S*$/.test(beforeCursor) || /(?:^|\s)@\S*$/.test(beforeCursor)
|
|
184
172
|
}
|
|
185
173
|
|
package/src/modules/vim/keys.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import type { KeyEvent } from "@opentui/core"
|
|
2
2
|
|
|
3
|
+
const graphemes = new Intl.Segmenter(undefined, { granularity: "grapheme" })
|
|
4
|
+
|
|
3
5
|
const NAMED_KEYS: Record<string, string> = {
|
|
4
6
|
escape: "<Esc>",
|
|
5
7
|
esc: "<Esc>",
|
|
@@ -15,6 +17,8 @@ const NAMED_KEYS: Record<string, string> = {
|
|
|
15
17
|
right: "<Right>",
|
|
16
18
|
home: "<Home>",
|
|
17
19
|
end: "<End>",
|
|
20
|
+
pageup: "<PageUp>",
|
|
21
|
+
pagedown: "<PageDown>",
|
|
18
22
|
}
|
|
19
23
|
|
|
20
24
|
export function keyNotation(event: KeyEvent) {
|
|
@@ -23,7 +27,7 @@ export function keyNotation(event: KeyEvent) {
|
|
|
23
27
|
|
|
24
28
|
if (event.ctrl) return `<C-${ctrlKey(name)}>`
|
|
25
29
|
if (event.meta) return `<M-${name}>`
|
|
26
|
-
if (name.length === 1) return event.shift ? name.toUpperCase() : name
|
|
30
|
+
if ([...graphemes.segment(name)].length === 1) return event.shift ? name.toUpperCase() : name
|
|
27
31
|
return NAMED_KEYS[name] ?? `<${name}>`
|
|
28
32
|
}
|
|
29
33
|
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test"
|
|
2
|
+
import { charToDisplay, displayToChar, displayWidth } from "./map"
|
|
3
|
+
|
|
4
|
+
describe("vim display offsets", () => {
|
|
5
|
+
test("maps ASCII and CJK offsets", () => {
|
|
6
|
+
const text = "a中b"
|
|
7
|
+
|
|
8
|
+
expect(displayWidth(text)).toBe(4)
|
|
9
|
+
expect(charToDisplay(text, 2)).toBe(3)
|
|
10
|
+
expect(displayToChar(text, 2)).toBe(1)
|
|
11
|
+
expect(displayToChar(text, 3)).toBe(2)
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
test("does not split supplementary characters", () => {
|
|
15
|
+
const text = "𠀀x"
|
|
16
|
+
|
|
17
|
+
expect(displayWidth(text)).toBe(3)
|
|
18
|
+
expect(charToDisplay(text, 1)).toBe(0)
|
|
19
|
+
expect(charToDisplay(text, 2)).toBe(2)
|
|
20
|
+
expect(displayToChar(text, 1)).toBe(0)
|
|
21
|
+
expect(displayToChar(text, 2)).toBe(2)
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
test("does not split grapheme clusters", () => {
|
|
25
|
+
const emoji = "👨👩👧👦"
|
|
26
|
+
const accent = "e\u0301"
|
|
27
|
+
|
|
28
|
+
expect(displayWidth(emoji)).toBe(2)
|
|
29
|
+
expect(charToDisplay(emoji, 1)).toBe(0)
|
|
30
|
+
expect(displayToChar(emoji, 1)).toBe(0)
|
|
31
|
+
expect(displayToChar(emoji, 2)).toBe(emoji.length)
|
|
32
|
+
expect(displayWidth(accent)).toBe(1)
|
|
33
|
+
expect(charToDisplay(accent, 1)).toBe(0)
|
|
34
|
+
expect(displayToChar(accent, 1)).toBe(accent.length)
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
test("counts newlines as textarea offsets", () => {
|
|
38
|
+
const text = "中\nx"
|
|
39
|
+
|
|
40
|
+
expect(displayWidth(text)).toBe(4)
|
|
41
|
+
expect(displayToChar(text, 2)).toBe(1)
|
|
42
|
+
expect(displayToChar(text, 3)).toBe(2)
|
|
43
|
+
expect(charToDisplay(text, 2)).toBe(3)
|
|
44
|
+
})
|
|
45
|
+
})
|
package/src/modules/vim/map.ts
CHANGED
|
@@ -1,15 +1,50 @@
|
|
|
1
1
|
import type { CursorPosition } from "@vimee/core"
|
|
2
2
|
import type { EditBufferLike } from "./actions"
|
|
3
|
+
import { createGraphemeCodec, type GraphemeCodec } from "./graphemes"
|
|
4
|
+
|
|
5
|
+
const graphemes = new Intl.Segmenter(undefined, { granularity: "grapheme" })
|
|
6
|
+
|
|
7
|
+
function graphemeWidth(value: string) {
|
|
8
|
+
// Textarea offsets count newlines as one position; Bun.stringWidth counts them as zero.
|
|
9
|
+
// OpenTUI's edit buffer gives tabs a fixed two-column width by default.
|
|
10
|
+
if (value === "\t") return 2
|
|
11
|
+
return value === "\n" ? 1 : Bun.stringWidth(value)
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function charToDisplay(text: string, charIndex: number): number {
|
|
15
|
+
let width = 0
|
|
16
|
+
const limit = Math.max(0, Math.min(charIndex, text.length))
|
|
17
|
+
for (const part of graphemes.segment(text)) {
|
|
18
|
+
if (part.index + part.segment.length > limit) break
|
|
19
|
+
width += graphemeWidth(part.segment)
|
|
20
|
+
}
|
|
21
|
+
return width
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function displayToChar(text: string, displayOffset: number): number {
|
|
25
|
+
let width = 0
|
|
26
|
+
for (const part of graphemes.segment(text)) {
|
|
27
|
+
const next = width + graphemeWidth(part.segment)
|
|
28
|
+
if (next > displayOffset) return part.index
|
|
29
|
+
width = next
|
|
30
|
+
}
|
|
31
|
+
return text.length
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function displayWidth(text: string): number {
|
|
35
|
+
return charToDisplay(text, text.length)
|
|
36
|
+
}
|
|
3
37
|
|
|
4
38
|
export type PromptMap = {
|
|
39
|
+
codec: GraphemeCodec
|
|
5
40
|
hostText: string
|
|
6
41
|
vimText: string
|
|
7
42
|
hostToVim: number[]
|
|
8
43
|
vimToHost: Array<number | undefined>
|
|
9
44
|
}
|
|
10
45
|
|
|
11
|
-
export function createPromptMap(hostText: string, input?: EditBufferLike): PromptMap {
|
|
12
|
-
return buildPromptMap(hostText, input ? visualWrapOffsets(input, hostText) : [])
|
|
46
|
+
export function createPromptMap(hostText: string, input?: EditBufferLike, codec = createGraphemeCodec()): PromptMap {
|
|
47
|
+
return buildPromptMap(hostText, input ? visualWrapOffsets(input, hostText) : [], codec)
|
|
13
48
|
}
|
|
14
49
|
|
|
15
50
|
export function derivePromptMap(map: PromptMap, vimText: string): PromptMap {
|
|
@@ -26,10 +61,10 @@ export function derivePromptMap(map: PromptMap, vimText: string): PromptMap {
|
|
|
26
61
|
continue
|
|
27
62
|
}
|
|
28
63
|
|
|
29
|
-
hostText += vimText[vimOffset]
|
|
64
|
+
hostText += map.codec.decode(vimText[vimOffset])
|
|
30
65
|
}
|
|
31
66
|
|
|
32
|
-
return buildPromptMapFromSynthetic(hostText, vimText, synthetic)
|
|
67
|
+
return buildPromptMapFromSynthetic(hostText, vimText, synthetic, map.codec)
|
|
33
68
|
}
|
|
34
69
|
|
|
35
70
|
function preserveSynthetic(vimOffset: number, prefix: number, suffix: number, vimLength: number, inserted: string) {
|
|
@@ -37,38 +72,40 @@ function preserveSynthetic(vimOffset: number, prefix: number, suffix: number, vi
|
|
|
37
72
|
return vimOffset !== prefix - 1 && vimOffset !== vimLength - suffix
|
|
38
73
|
}
|
|
39
74
|
|
|
40
|
-
export function hostPosition(map: PromptMap,
|
|
41
|
-
|
|
75
|
+
export function hostPosition(map: PromptMap, hostDisplayOffset: number): CursorPosition {
|
|
76
|
+
const charIdx = displayToChar(map.hostText, hostDisplayOffset)
|
|
77
|
+
return positionFromOffset(map.vimText, map.hostToVim[clamp(charIdx, 0, map.hostText.length)] ?? 0)
|
|
42
78
|
}
|
|
43
79
|
|
|
44
80
|
export function hostOffset(map: PromptMap, position: CursorPosition, bias: "previous" | "next" = "next") {
|
|
45
|
-
|
|
81
|
+
const charIdx = hostOffsetFromVimOffset(map, offsetFromPosition(map.vimText, position), bias)
|
|
82
|
+
return charToDisplay(map.hostText, charIdx)
|
|
46
83
|
}
|
|
47
84
|
|
|
48
|
-
function buildPromptMap(hostText: string, wraps: number[]): PromptMap {
|
|
85
|
+
function buildPromptMap(hostText: string, wraps: number[], codec: GraphemeCodec): PromptMap {
|
|
49
86
|
const hostToVim: number[] = []
|
|
50
87
|
const vimToHost: Array<number | undefined> = []
|
|
51
88
|
const wrapOffsets = new Set(wraps.filter((offset) => offset > 0 && offset < hostText.length && hostText[offset - 1] !== "\n"))
|
|
52
89
|
let vimText = ""
|
|
53
90
|
let vimOffset = 0
|
|
54
91
|
|
|
55
|
-
for (
|
|
92
|
+
for (const { index: hostOffset, segment } of graphemes.segment(hostText)) {
|
|
56
93
|
if (wrapOffsets.has(hostOffset)) {
|
|
57
94
|
vimText += "\n"
|
|
58
95
|
vimToHost[vimOffset] = undefined
|
|
59
96
|
vimOffset++
|
|
60
97
|
}
|
|
61
|
-
hostToVim[
|
|
62
|
-
vimText +=
|
|
98
|
+
for (let index = hostOffset; index < hostOffset + segment.length; index++) hostToVim[index] = vimOffset
|
|
99
|
+
vimText += codec.encode(segment)
|
|
63
100
|
vimToHost[vimOffset] = hostOffset
|
|
64
101
|
vimOffset++
|
|
65
102
|
}
|
|
66
103
|
|
|
67
104
|
hostToVim[hostText.length] = vimOffset
|
|
68
|
-
return { hostText, vimText, hostToVim, vimToHost }
|
|
105
|
+
return { hostText, vimText, hostToVim, vimToHost, codec }
|
|
69
106
|
}
|
|
70
107
|
|
|
71
|
-
function buildPromptMapFromSynthetic(hostText: string, vimText: string, synthetic: Set<number
|
|
108
|
+
function buildPromptMapFromSynthetic(hostText: string, vimText: string, synthetic: Set<number>, codec: GraphemeCodec): PromptMap {
|
|
72
109
|
const hostToVim: number[] = []
|
|
73
110
|
const vimToHost: Array<number | undefined> = []
|
|
74
111
|
let hostOffset = 0
|
|
@@ -79,13 +116,13 @@ function buildPromptMapFromSynthetic(hostText: string, vimText: string, syntheti
|
|
|
79
116
|
continue
|
|
80
117
|
}
|
|
81
118
|
|
|
82
|
-
hostToVim[hostOffset] = vimOffset
|
|
83
119
|
vimToHost[vimOffset] = hostOffset
|
|
84
|
-
|
|
120
|
+
const length = codec.decode(vimText[vimOffset]).length
|
|
121
|
+
for (let index = 0; index < length; index++) hostToVim[hostOffset++] = vimOffset
|
|
85
122
|
}
|
|
86
123
|
|
|
87
124
|
hostToVim[hostText.length] = vimText.length
|
|
88
|
-
return { hostText, vimText, hostToVim, vimToHost }
|
|
125
|
+
return { hostText, vimText, hostToVim, vimToHost, codec }
|
|
89
126
|
}
|
|
90
127
|
|
|
91
128
|
function previousOffset(map: PromptMap, vimOffset: number, vimLength: number, prefix: number, suffix: number) {
|
|
@@ -95,22 +132,18 @@ function previousOffset(map: PromptMap, vimOffset: number, vimLength: number, pr
|
|
|
95
132
|
}
|
|
96
133
|
|
|
97
134
|
function visualWrapOffsets(input: EditBufferLike, text: string) {
|
|
98
|
-
const original = input.cursorOffset
|
|
99
135
|
const wraps: number[] = []
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
136
|
+
const lines = input.editorView?.getLogicalLineInfo?.()
|
|
137
|
+
if (!lines) return wraps
|
|
138
|
+
let line = 1
|
|
139
|
+
let offset = 0
|
|
140
|
+
for (const part of graphemes.segment(text)) {
|
|
141
|
+
while (line < lines.lineStartCols.length && lines.lineStartCols[line] <= offset) {
|
|
142
|
+
if (lines.lineWraps[line] > 0) wraps.push(part.index)
|
|
143
|
+
line++
|
|
108
144
|
}
|
|
109
|
-
|
|
110
|
-
previousRow = row
|
|
145
|
+
offset += graphemeWidth(part.segment)
|
|
111
146
|
}
|
|
112
|
-
|
|
113
|
-
input.cursorOffset = original
|
|
114
147
|
return wraps
|
|
115
148
|
}
|
|
116
149
|
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test"
|
|
2
|
+
import { createVimState } from "./state"
|
|
3
|
+
|
|
4
|
+
describe("vim state", () => {
|
|
5
|
+
test("isolates listener errors", () => {
|
|
6
|
+
const events: string[] = []
|
|
7
|
+
const state = createVimState("normal", (event) => events.push(event))
|
|
8
|
+
state.subscribe(() => {
|
|
9
|
+
throw new Error("stale renderable")
|
|
10
|
+
})
|
|
11
|
+
state.subscribe(() => events.push("listener.complete"))
|
|
12
|
+
|
|
13
|
+
expect(() => state.setMode("insert")).not.toThrow()
|
|
14
|
+
expect(state.mode()).toBe("insert")
|
|
15
|
+
expect(events).toContain("state.listener.error")
|
|
16
|
+
expect(events).toContain("listener.complete")
|
|
17
|
+
})
|
|
18
|
+
})
|
package/src/modules/vim/state.ts
CHANGED
|
@@ -33,6 +33,12 @@ export function createVimState(defaultMode: VimMode, log: VimLog = () => {}) {
|
|
|
33
33
|
}
|
|
34
34
|
|
|
35
35
|
function notify() {
|
|
36
|
-
for (const listener of listeners)
|
|
36
|
+
for (const listener of listeners) {
|
|
37
|
+
try {
|
|
38
|
+
listener()
|
|
39
|
+
} catch (error) {
|
|
40
|
+
log("state.listener.error", { error: error instanceof Error ? error.message : String(error) })
|
|
41
|
+
}
|
|
42
|
+
}
|
|
37
43
|
}
|
|
38
44
|
}
|
package/src/modules/vim/view.tsx
CHANGED
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test"
|
|
2
|
+
import type { KeyEvent } from "@opentui/core"
|
|
3
|
+
import type { PromptContext } from "./actions"
|
|
4
|
+
import { createVimConfig } from "./config"
|
|
5
|
+
import type { VimLog } from "./log"
|
|
6
|
+
import { createVimState, type VimMode } from "./state"
|
|
7
|
+
import { createVimeeAdapter } from "./vimee"
|
|
8
|
+
|
|
9
|
+
describe("vim command keymaps", () => {
|
|
10
|
+
test("does not restore stale cursor state after a normal command", () => {
|
|
11
|
+
const fixture = createFixture("normal", "command:test.run")
|
|
12
|
+
fixture.dispatch = () => {
|
|
13
|
+
fixture.input.plainText = "updated"
|
|
14
|
+
fixture.input.cursorOffset = 7
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
expect(fixture.handle()).toBe(true)
|
|
18
|
+
expect(fixture.commands).toEqual(["test.run"])
|
|
19
|
+
expect(fixture.input.cursorOffset).toBe(7)
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
test("uses the host history boundary and restores display width", () => {
|
|
23
|
+
const fixture = createFixture("normal", "command:prompt.history.next", "中")
|
|
24
|
+
let boundary = -1
|
|
25
|
+
fixture.dispatch = () => {
|
|
26
|
+
boundary = fixture.input.cursorOffset
|
|
27
|
+
fixture.input.plainText = "中中"
|
|
28
|
+
fixture.input.cursorOffset = 2
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
expect(fixture.handle()).toBe(true)
|
|
32
|
+
expect(boundary).toBe(1)
|
|
33
|
+
expect(fixture.input.cursorOffset).toBe(4)
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
test("dispatches commands in insert mode", () => {
|
|
37
|
+
const fixture = createFixture("insert", "command:test.run")
|
|
38
|
+
|
|
39
|
+
expect(fixture.handle()).toBe(true)
|
|
40
|
+
expect(fixture.commands).toEqual(["test.run"])
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
test("rejects an empty command name", () => {
|
|
44
|
+
const logs: Array<[string, unknown]> = []
|
|
45
|
+
createFixture("normal", "command: ", "", (event, data) => logs.push([event, data]))
|
|
46
|
+
|
|
47
|
+
expect(logs.some(([event]) => event === "vimee.keymap.invalid")).toBe(true)
|
|
48
|
+
})
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
describe("vim enter keymaps", () => {
|
|
52
|
+
test("submits unmapped Enter in normal mode without moving the cursor", () => {
|
|
53
|
+
const fixture = createFixture("normal", undefined, "中")
|
|
54
|
+
fixture.input.cursorOffset = 2
|
|
55
|
+
|
|
56
|
+
expect(fixture.handle("<CR>")).toBe(true)
|
|
57
|
+
expect(fixture.submissions).toHaveLength(1)
|
|
58
|
+
expect(fixture.input.cursorOffset).toBe(2)
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
test("passes unmapped Enter through in insert mode", () => {
|
|
62
|
+
const fixture = createFixture("insert", undefined)
|
|
63
|
+
|
|
64
|
+
expect(fixture.handle("<CR>")).toBe(false)
|
|
65
|
+
expect(fixture.submissions).toHaveLength(0)
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
test("does not submit a matched mapping with no immediate actions", () => {
|
|
69
|
+
const fixture = createFixture("normal", "d", "text", () => {}, "<CR>")
|
|
70
|
+
|
|
71
|
+
expect(fixture.handle("<CR>")).toBe(true)
|
|
72
|
+
expect(fixture.submissions).toHaveLength(0)
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
test("runs an Enter command mapping", () => {
|
|
76
|
+
const fixture = createFixture("normal", "command:test.run", "", () => {}, "<CR>")
|
|
77
|
+
|
|
78
|
+
expect(fixture.handle("<CR>")).toBe(true)
|
|
79
|
+
expect(fixture.commands).toEqual(["test.run"])
|
|
80
|
+
expect(fixture.submissions).toHaveLength(0)
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
test("applies mode and submit mappings", () => {
|
|
84
|
+
const normal = createFixture("normal", "insert", "", () => {}, "<CR>")
|
|
85
|
+
const insert = createFixture("insert", "submit", "", () => {}, "<CR>")
|
|
86
|
+
|
|
87
|
+
expect(normal.handle("<CR>")).toBe(true)
|
|
88
|
+
expect(normal.handle("a")).toBe(false)
|
|
89
|
+
expect(insert.handle("<CR>")).toBe(true)
|
|
90
|
+
expect(insert.submissions).toHaveLength(1)
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
test("allows Enter to finish a multi-key mapping", () => {
|
|
94
|
+
const fixture = createFixture("normal", "command:test.run", "", () => {}, "g<CR>")
|
|
95
|
+
|
|
96
|
+
expect(fixture.handle("g")).toBe(true)
|
|
97
|
+
expect(fixture.handle("<CR>")).toBe(true)
|
|
98
|
+
expect(fixture.commands).toEqual(["test.run"])
|
|
99
|
+
expect(fixture.submissions).toHaveLength(0)
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
test("rejects mappings that defer Enter", () => {
|
|
103
|
+
const logs: Array<[string, unknown]> = []
|
|
104
|
+
const fixture = createFixture("normal", "command:test.run", "", (event, data) => logs.push([event, data]), "<CR>x")
|
|
105
|
+
|
|
106
|
+
expect(fixture.handle("<CR>")).toBe(true)
|
|
107
|
+
expect(fixture.submissions).toHaveLength(1)
|
|
108
|
+
expect(logs.some(([event]) => event === "vimee.keymap.invalid")).toBe(true)
|
|
109
|
+
})
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
describe("vim prompt history", () => {
|
|
113
|
+
test("starts from an empty prompt and continues through history", () => {
|
|
114
|
+
const fixture = createFixture("normal", undefined, "")
|
|
115
|
+
const entries = ["newest", "older", ""]
|
|
116
|
+
fixture.dispatch = (command) => {
|
|
117
|
+
fixture.input.plainText = entries.shift() ?? ""
|
|
118
|
+
fixture.input.cursorOffset = command === "prompt.history.previous" ? 0 : fixture.input.plainText.length
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
expect(fixture.handle("k")).toBe(true)
|
|
122
|
+
expect(fixture.handle("k")).toBe(true)
|
|
123
|
+
expect(fixture.handle("j")).toBe(true)
|
|
124
|
+
expect(fixture.commands).toEqual([
|
|
125
|
+
"prompt.history.previous",
|
|
126
|
+
"prompt.history.previous",
|
|
127
|
+
"prompt.history.next",
|
|
128
|
+
])
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
test("keeps normal movement for a nonempty prompt", () => {
|
|
132
|
+
const fixture = createFixture("normal", undefined, "text")
|
|
133
|
+
|
|
134
|
+
expect(fixture.handle("k")).toBe(true)
|
|
135
|
+
expect(fixture.handle("j")).toBe(true)
|
|
136
|
+
expect(fixture.commands).toEqual([])
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
test("stops history navigation after another normal key", () => {
|
|
140
|
+
const fixture = createFixture("normal", undefined, "")
|
|
141
|
+
fixture.dispatch = () => {
|
|
142
|
+
fixture.input.plainText = "recalled"
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
expect(fixture.handle("k")).toBe(true)
|
|
146
|
+
expect(fixture.handle("h")).toBe(true)
|
|
147
|
+
expect(fixture.handle("k")).toBe(true)
|
|
148
|
+
expect(fixture.commands).toEqual(["prompt.history.previous"])
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
test("prefers configured keymaps", () => {
|
|
152
|
+
const fixture = createFixture("normal", "command:test.run", "", () => {}, "k")
|
|
153
|
+
|
|
154
|
+
expect(fixture.handle()).toBe(true)
|
|
155
|
+
expect(fixture.commands).toEqual(["test.run"])
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
test("completes a pending keymap before history navigation", () => {
|
|
159
|
+
const fixture = createFixture("normal", "command:test.run", "", () => {}, "gk")
|
|
160
|
+
|
|
161
|
+
expect(fixture.handle("g")).toBe(true)
|
|
162
|
+
expect(fixture.handle("k")).toBe(true)
|
|
163
|
+
expect(fixture.commands).toEqual(["test.run"])
|
|
164
|
+
})
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
function createFixture(mode: VimMode, action: string | undefined, text = "text", log: VimLog = () => {}, mappedKey = "Q") {
|
|
168
|
+
const input = {
|
|
169
|
+
plainText: text,
|
|
170
|
+
cursorOffset: 0,
|
|
171
|
+
visualCursor: { visualRow: 0, visualCol: 0, offset: 0 },
|
|
172
|
+
moveCursorLeft: () => false,
|
|
173
|
+
}
|
|
174
|
+
const commands: string[] = []
|
|
175
|
+
const submissions: true[] = []
|
|
176
|
+
const prompt = {
|
|
177
|
+
current: { input: text, mode: "normal", parts: [] },
|
|
178
|
+
set() {},
|
|
179
|
+
submit() {
|
|
180
|
+
submissions.push(true)
|
|
181
|
+
},
|
|
182
|
+
blur() {},
|
|
183
|
+
}
|
|
184
|
+
const fixture = {
|
|
185
|
+
input,
|
|
186
|
+
commands,
|
|
187
|
+
submissions,
|
|
188
|
+
dispatch: (_command: string) => {},
|
|
189
|
+
handle: (key = mappedKey) => adapter.handle({ name: key } as KeyEvent, key, ctx),
|
|
190
|
+
}
|
|
191
|
+
const ctx = {
|
|
192
|
+
api: {
|
|
193
|
+
renderer: { currentFocusedRenderable: input },
|
|
194
|
+
keymap: {
|
|
195
|
+
dispatchCommand(command: string) {
|
|
196
|
+
commands.push(command)
|
|
197
|
+
fixture.dispatch(command)
|
|
198
|
+
return { ok: true as const }
|
|
199
|
+
},
|
|
200
|
+
},
|
|
201
|
+
},
|
|
202
|
+
prompt: () => prompt,
|
|
203
|
+
requestRender() {},
|
|
204
|
+
} as unknown as PromptContext
|
|
205
|
+
const config = createVimConfig({
|
|
206
|
+
defaultMode: mode,
|
|
207
|
+
keymaps: action ? { [mode]: { [mappedKey]: action } } : undefined,
|
|
208
|
+
})
|
|
209
|
+
const adapter = createVimeeAdapter(createVimState(mode), config, log)
|
|
210
|
+
return fixture
|
|
211
|
+
}
|