Skip to content

Commit 33f1617

Browse files
authored
feat: add e and E vim motions (#67)
feat(vim): add e and E vim motions * works both on input box and visual mode * supports `v`, `c`, `d` and `y` operators
1 parent e1c8be2 commit 33f1617

3 files changed

Lines changed: 442 additions & 6 deletions

File tree

packages/opencode/src/cli/cmd/tui/component/vim/vim-handler.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
deleteUnderCursor,
1414
deleteWord,
1515
deleteWordBackward,
16+
deleteWordEnd,
1617
findChar,
1718
findCharInLine,
1819
firstNonWhitespace,
@@ -47,6 +48,8 @@ import {
4748
yankLineSpan,
4849
yankSelection,
4950
yankWord,
51+
yankWordEnd,
52+
yankWordEndSpan,
5053
yankWordSpan,
5154
} from "./vim-motions"
5255

@@ -353,6 +356,18 @@ export function createVimHandler(input: {
353356
return true
354357
}
355358

359+
if ((key === "e" || key === "E") && !hasModifier(event)) {
360+
const big = key === "E" || !!event.shift
361+
begin(() => {
362+
const reg = deleteWordEnd(input.textarea(), big)
363+
if (reg) setRegister(reg)
364+
input.state.clearPending()
365+
input.state.setMode("insert")
366+
})
367+
event.preventDefault()
368+
return true
369+
}
370+
356371
if (hasModifier(event)) {
357372
input.state.clearPending()
358373
return false
@@ -392,6 +407,17 @@ export function createVimHandler(input: {
392407
return true
393408
}
394409

410+
if ((key === "e" || key === "E") && !hasModifier(event)) {
411+
const big = key === "E" || !!event.shift
412+
edit(() => {
413+
const reg = deleteWordEnd(input.textarea(), big)
414+
if (reg) setRegister(reg)
415+
input.state.clearPending()
416+
})
417+
event.preventDefault()
418+
return true
419+
}
420+
395421
if (hasModifier(event)) {
396422
input.state.clearPending()
397423
return false
@@ -421,6 +447,17 @@ export function createVimHandler(input: {
421447
return true
422448
}
423449

450+
if ((key === "e" || key === "E") && !hasModifier(event)) {
451+
const big = key === "E" || !!event.shift
452+
const span = yankWordEndSpan(input.textarea(), big)
453+
const reg = yankWordEnd(input.textarea(), big)
454+
if (reg) setRegister(reg, true)
455+
if (span && span.end > span.start) input.flash?.(span)
456+
input.state.clearPending()
457+
event.preventDefault()
458+
return true
459+
}
460+
424461
if (hasModifier(event)) {
425462
input.state.clearPending()
426463
return false

packages/opencode/src/cli/cmd/tui/component/vim/vim-motions.ts

Lines changed: 41 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -117,20 +117,29 @@ export function prevWordStart(text: string, offset: number, big: boolean) {
117117
return pos
118118
}
119119

120+
function wordClass(char: string, big: boolean): "blank" | "word" | "punct" {
121+
if (!isBigWord(char)) return "blank"
122+
if (big || isWord(char)) return "word"
123+
return "punct"
124+
}
125+
120126
export function wordEnd(text: string, offset: number, big: boolean) {
121127
if (text.length === 0) return 0
122-
const match = big ? isBigWord : isWord
123128
let pos = offset
124129
if (pos >= text.length) pos = text.length - 1
125130

126-
if (match(text[pos]) && (pos + 1 >= text.length || !match(text[pos + 1]))) {
131+
const startClass = wordClass(text[pos], big)
132+
const atRunEnd =
133+
startClass === "blank" || pos + 1 >= text.length || wordClass(text[pos + 1], big) !== startClass
134+
135+
if (atRunEnd) {
127136
pos++
137+
while (pos < text.length && wordClass(text[pos], big) === "blank") pos++
138+
if (pos >= text.length) return text.length - 1
128139
}
129140

130-
while (pos < text.length && !match(text[pos])) pos++
131-
if (pos >= text.length) return text.length - 1
132-
133-
while (pos + 1 < text.length && match(text[pos + 1])) pos++
141+
const target = wordClass(text[pos], big)
142+
while (pos + 1 < text.length && wordClass(text[pos + 1], big) === target) pos++
134143
return pos
135144
}
136145

@@ -312,6 +321,17 @@ export function deleteWordBackward(textarea: TextareaRenderable): VimRegister {
312321
return { text: yanked, linewise: false }
313322
}
314323

324+
export function deleteWordEnd(textarea: TextareaRenderable, big = false): VimRegister {
325+
const text = textarea.plainText
326+
const startOffset = textarea.cursorOffset
327+
if (startOffset >= text.length) return null
328+
const endOffset = wordEnd(text, startOffset, big) + 1
329+
if (endOffset <= startOffset) return null
330+
const yanked = text.slice(startOffset, endOffset)
331+
deleteOffsets(textarea, startOffset, endOffset)
332+
return { text: yanked, linewise: false }
333+
}
334+
315335
export function deleteLine(textarea: TextareaRenderable): VimRegister {
316336
const text = textarea.plainText
317337
if (!text.length) return null
@@ -445,6 +465,21 @@ export function yankWordSpan(textarea: TextareaRenderable): VimSpan | null {
445465
return { start, end }
446466
}
447467

468+
export function yankWordEnd(textarea: TextareaRenderable, big = false): VimRegister {
469+
const span = yankWordEndSpan(textarea, big)
470+
if (!span) return null
471+
return { text: textarea.plainText.slice(span.start, span.end), linewise: false }
472+
}
473+
474+
export function yankWordEndSpan(textarea: TextareaRenderable, big = false): VimSpan | null {
475+
const text = textarea.plainText
476+
const start = textarea.cursorOffset
477+
if (start >= text.length) return null
478+
const end = wordEnd(text, start, big) + 1
479+
if (end <= start) return null
480+
return { start, end }
481+
}
482+
448483
export function pasteAfter(textarea: TextareaRenderable, reg: VimRegister) {
449484
if (!reg) return
450485
if (reg.linewise) {

0 commit comments

Comments
 (0)