Skip to content

Commit 986b67c

Browse files
committed
add text objects
1 parent a297d2d commit 986b67c

2 files changed

Lines changed: 242 additions & 4 deletions

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ opencode plugin opencode-vim@latest --global
2121
| `h`, `j`, `k`, `l`, `w`, `b`, `e`, `$`, `0` | Move through the prompt |
2222
| `x`, `d`, `c`, `y`, `p`, `u`, `<C-r>` | Edit, yank, paste, undo, redo |
2323
| `v`, `V` | Visual and visual-line selection |
24-
| Counts and text objects | Examples: `3w`, `diw`, `ci"` |
24+
| Counts and text objects | Examples: `3w`, `diw`, `ci"`, `yiq`, `dip`, `yib` |
2525
| Registers, marks, macros | Vim-style prompt-local state |
2626
| `<CR>` in normal mode | Submit the prompt |
2727
| `/vim` | Toggle Vim mode on or off |

src/modules/vim/vimee.ts

Lines changed: 241 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import type { KeyEvent } from "@opentui/core"
2-
import { TextBuffer, createInitialContext, createKeybindMap, parseKeySequence, processKeystroke } from "@vimee/core"
3-
import type { CursorPosition, KeybindDefinition, KeybindMap, ValidKeySequence, VimAction as VimeeAction, VimContext, VimMode as VimeeMode } from "@vimee/core"
2+
import { TextBuffer, createInitialContext, createKeybindMap, parseKeySequence, processKeystroke, resetContext } from "@vimee/core"
3+
import type { CursorPosition, KeybindDefinition, KeybindMap, MotionRange, Operator, ValidKeySequence, VimAction as VimeeAction, VimContext, VimMode as VimeeMode } from "@vimee/core"
44
import type { PromptContext } from "../../prompt/types"
55
import { focusedInput, setInput, type EditBufferLike } from "./actions"
66
import type { VimConfig } from "./config"
@@ -38,7 +38,7 @@ export function createVimeeAdapter(state: VimState, config: VimConfig, log: VimL
3838
const offset = clamp(input?.cursorOffset ?? text.length, 0, text.length)
3939
const map = mapForHostText(text, input)
4040
const cursor = hostPosition(map, offset)
41-
const vimeeKey = keyForVimee(event, key)
41+
let vimeeKey = keyForVimee(event, key)
4242
if (!vimeeKey) return false
4343

4444
if (state.mode() === "normal" && key === "<CR>") {
@@ -70,6 +70,9 @@ export function createVimeeAdapter(state: VimState, config: VimConfig, log: VimL
7070
const hostEnd = vimeeKey === "e" ? endMotionOffset(map.hostText, offset, vim.count || 1) : undefined
7171
const shouldFlashYank = shouldFlashYankFor(vimeeKey)
7272
const visualYankRange = visualYankRangeFor(map)
73+
vimeeKey = textObjectAlias(vimeeKey, vim) ?? vimeeKey
74+
const textObjectHandled = handleTextObject(vimeeKey, ctx, map)
75+
if (textObjectHandled !== undefined) return textObjectHandled
7376
const result = processKeystroke(vimeeKey, vim, buffer, event.ctrl, false, keybinds)
7477
vim = result.newCtx
7578
if (hostEnd !== undefined && result.actions.every((action) => action.type === "cursor-move") && hostEnd > hostOffset(map, vim.cursor, "previous")) vim = { ...vim, cursor: hostPosition(map, hostEnd) }
@@ -230,6 +233,34 @@ export function createVimeeAdapter(state: VimState, config: VimConfig, log: VimL
230233
return actions
231234
}
232235

236+
function handleTextObject(key: string, ctx: PromptContext, map: PromptMap) {
237+
if (vim.phase !== "text-object-pending" || !vim.textObjectModifier) return undefined
238+
const range = resolvePromptTextObject(vim.textObjectModifier, key, vim.cursor, buffer)
239+
if (!range) return undefined
240+
241+
const flashRange = vim.operator === "y" ? motionHostRange(map, range) : undefined
242+
243+
if (!vim.operator) {
244+
vim = { ...resetContext(vim), visualAnchor: range.start, cursor: range.end }
245+
applyActions([{ type: "cursor-move", position: range.end }], ctx, map)
246+
state.setPending("")
247+
updateTimeout(ctx)
248+
log("vimee.textobject", { key, mode: vim.mode, phase: vim.phase, cursor: vim.cursor, actions: ["cursor-move"] })
249+
return true
250+
}
251+
if (!textObjectOperator(vim.operator)) return undefined
252+
253+
const result = executeTextObject(vim.operator, range, buffer, vim)
254+
vim = result.context
255+
applyActions(result.actions, ctx, map)
256+
if (flashRange) flashYank(ctx, activeMap, { type: "yank", text: result.yankedText }, flashRange)
257+
syncMode(state, vim.mode)
258+
state.setPending("")
259+
updateTimeout(ctx)
260+
log("vimee.textobject", { key, mode: vim.mode, phase: vim.phase, cursor: vim.cursor, actions: result.actions.map((action) => action.type) })
261+
return true
262+
}
263+
233264
function updateTimeout(ctx: PromptContext) {
234265
if (timer) clearTimeout(timer)
235266
timer = undefined
@@ -370,6 +401,13 @@ function yankedTextRange(map: PromptMap, cursor: CursorPosition, text: string):
370401
return vimOffsetRange(map, start, start + text.length - 1)
371402
}
372403

404+
function motionHostRange(map: PromptMap, range: MotionRange): HostRange | undefined {
405+
if (range.linewise) return visualLineRange(map, range.start, range.end)
406+
const start = vimOffsetFromPosition(map.vimText, range.start)
407+
const end = vimOffsetFromPosition(map.vimText, range.end)
408+
return vimOffsetRange(map, start, end)
409+
}
410+
373411
function vimOffsetRange(map: PromptMap, left: number, right: number): HostRange | undefined {
374412
const start = hostFromVimOffset(map, Math.min(left, right), "next")
375413
const end = hostFromVimOffset(map, Math.max(left, right), "previous")
@@ -572,6 +610,206 @@ function consumesKey(key: string, actions: VimeeAction[], ctx: VimContext, keybi
572610
return ctx.mode !== "insert" || key === "Escape"
573611
}
574612

613+
function textObjectAlias(key: string, ctx: VimContext) {
614+
if (ctx.phase !== "text-object-pending") return undefined
615+
if (key === "b") return "("
616+
if (key === "B") return "{"
617+
return undefined
618+
}
619+
620+
function textObjectOperator(operator: Operator): operator is "y" | "d" | "c" {
621+
return operator === "y" || operator === "d" || operator === "c"
622+
}
623+
624+
function resolvePromptTextObject(modifier: "i" | "a", key: string, cursor: CursorPosition, buffer: TextBuffer): MotionRange | null {
625+
if (key === "q") return quoteRange(modifier, cursor, buffer)
626+
if (key !== "p") return null
627+
return paragraphRange(modifier, cursor, buffer)
628+
}
629+
630+
function quoteRange(modifier: "i" | "a", cursor: CursorPosition, buffer: TextBuffer): MotionRange | null {
631+
const pair = quoteObjectPair(cursor, buffer)
632+
if (!pair) return null
633+
const start = modifier === "i" ? pair.open + 1 : pair.open
634+
const end = modifier === "i" ? pair.close - 1 : pair.close
635+
return {
636+
start: { line: cursor.line, col: start },
637+
end: { line: cursor.line, col: Math.max(start, end) },
638+
linewise: false,
639+
inclusive: true,
640+
}
641+
}
642+
643+
function paragraphRange(modifier: "i" | "a", cursor: CursorPosition, buffer: TextBuffer): MotionRange | null {
644+
const count = buffer.getLineCount()
645+
if (count === 0) return null
646+
647+
let start = clamp(cursor.line, 0, count - 1)
648+
while (start < count && blankLine(buffer.getLine(start))) start++
649+
if (start >= count) {
650+
start = clamp(cursor.line, 0, count - 1)
651+
while (start >= 0 && blankLine(buffer.getLine(start))) start--
652+
}
653+
if (start < 0 || start >= count) return null
654+
655+
let end = start
656+
while (start > 0 && !blankLine(buffer.getLine(start - 1))) start--
657+
while (end < count - 1 && !blankLine(buffer.getLine(end + 1))) end++
658+
659+
if (modifier === "a") {
660+
if (end < count - 1 && blankLine(buffer.getLine(end + 1))) {
661+
end++
662+
while (end < count - 1 && blankLine(buffer.getLine(end + 1))) end++
663+
} else {
664+
while (start > 0 && blankLine(buffer.getLine(start - 1))) start--
665+
}
666+
}
667+
668+
return {
669+
start: { line: start, col: 0 },
670+
end: { line: end, col: Math.max(0, buffer.getLineLength(end) - 1) },
671+
linewise: true,
672+
inclusive: true,
673+
}
674+
}
675+
676+
function quoteObjectPair(cursor: CursorPosition, buffer: TextBuffer) {
677+
let best: { open: number; close: number; distance: number } | undefined
678+
for (const quote of ['"', "'", "`"] as const) {
679+
const pair = quotePair(cursor, buffer, quote)
680+
if (!pair) continue
681+
if (!best || pair.distance < best.distance) best = pair
682+
}
683+
return best
684+
}
685+
686+
function quotePair(cursor: CursorPosition, buffer: TextBuffer, quote: string) {
687+
const line = buffer.getLine(cursor.line)
688+
let open = -1
689+
let close = -1
690+
let inQuote = false
691+
let quoteStart = -1
692+
693+
for (let index = 0; index < line.length; index++) {
694+
if (line[index] !== quote || escaped(line, index)) continue
695+
if (!inQuote) {
696+
quoteStart = index
697+
inQuote = true
698+
continue
699+
}
700+
if (cursor.col >= quoteStart && cursor.col <= index) {
701+
return { open: quoteStart, close: index, distance: 0 }
702+
}
703+
inQuote = false
704+
}
705+
706+
for (let index = cursor.col; index < line.length; index++) {
707+
if (line[index] !== quote || escaped(line, index)) continue
708+
if (open === -1) open = index
709+
else {
710+
close = index
711+
break
712+
}
713+
}
714+
if (open !== -1 && close !== -1) return { open, close, distance: open - cursor.col }
715+
716+
close = -1
717+
open = -1
718+
for (let index = cursor.col; index >= 0; index--) {
719+
if (line[index] !== quote || escaped(line, index)) continue
720+
if (close === -1) close = index
721+
else {
722+
open = index
723+
break
724+
}
725+
}
726+
if (open !== -1 && close !== -1) return { open, close, distance: cursor.col - close }
727+
return undefined
728+
}
729+
730+
function executeTextObject(operator: Operator, range: MotionRange, buffer: TextBuffer, ctx: VimContext) {
731+
buffer.saveUndoPoint(ctx.cursor)
732+
const result = range.linewise ? executeLinewiseTextObject(operator, range, buffer) : executeCharwiseTextObject(operator, range, buffer)
733+
const registers = ctx.selectedRegister ? { ...ctx.registers, [ctx.selectedRegister]: result.yankedText } : ctx.registers
734+
const context = {
735+
...resetContext(ctx),
736+
mode: result.mode,
737+
cursor: result.cursor,
738+
register: result.yankedText,
739+
registers,
740+
statusMessage: result.statusMessage,
741+
}
742+
const actions: VimeeAction[] = [{ type: "yank", text: result.yankedText }, ...result.actions, { type: "mode-change", mode: result.mode }, { type: "cursor-move", position: result.cursor }]
743+
return { actions, context, yankedText: result.yankedText }
744+
}
745+
746+
function executeLinewiseTextObject(operator: Operator, range: MotionRange, buffer: TextBuffer) {
747+
const startLine = Math.min(range.start.line, range.end.line)
748+
const endLine = Math.max(range.start.line, range.end.line)
749+
const lineCount = endLine - startLine + 1
750+
const yankedText = buffer.getLines().slice(startLine, endLine + 1).join("\n") + "\n"
751+
if (operator === "y") {
752+
return { actions: [] as VimeeAction[], cursor: { line: startLine, col: 0 }, mode: "normal" as VimeeMode, yankedText, statusMessage: lineCount >= 2 ? `${lineCount} lines yanked` : "" }
753+
}
754+
755+
buffer.deleteLines(startLine, lineCount)
756+
if (buffer.getLineCount() === 0) buffer.insertLine(0, "")
757+
const line = Math.min(startLine, buffer.getLineCount() - 1)
758+
if (operator === "c") buffer.insertLine(line, "")
759+
return {
760+
actions: [{ type: "content-change", content: buffer.getContent() }] as VimeeAction[],
761+
cursor: { line, col: 0 },
762+
mode: (operator === "c" ? "insert" : "normal") as VimeeMode,
763+
yankedText,
764+
statusMessage: lineCount >= 2 ? `${lineCount} fewer lines` : "",
765+
}
766+
}
767+
768+
function executeCharwiseTextObject(operator: Operator, range: MotionRange, buffer: TextBuffer) {
769+
const ordered = orderedRange(range)
770+
const endCol = range.inclusive ? ordered.end.col + 1 : ordered.end.col
771+
const yankedText = textInRange(buffer, ordered.start, { line: ordered.end.line, col: endCol })
772+
if (operator === "y") {
773+
return { actions: [] as VimeeAction[], cursor: ordered.start, mode: "normal" as VimeeMode, yankedText, statusMessage: yankedText.split("\n").length >= 2 ? `${yankedText.split("\n").length} lines yanked` : "" }
774+
}
775+
776+
const linesBefore = buffer.getLineCount()
777+
buffer.deleteRange(ordered.start.line, ordered.start.col, ordered.end.line, endCol)
778+
const linesRemoved = linesBefore - buffer.getLineCount()
779+
return {
780+
actions: [{ type: "content-change", content: buffer.getContent() }] as VimeeAction[],
781+
cursor: { line: ordered.start.line, col: operator === "c" ? ordered.start.col : Math.min(ordered.start.col, Math.max(0, buffer.getLineLength(ordered.start.line) - 1)) },
782+
mode: (operator === "c" ? "insert" : "normal") as VimeeMode,
783+
yankedText,
784+
statusMessage: linesRemoved >= 2 ? `${linesRemoved} fewer lines` : "",
785+
}
786+
}
787+
788+
function orderedRange(range: MotionRange) {
789+
if (range.start.line > range.end.line || (range.start.line === range.end.line && range.start.col > range.end.col)) {
790+
return { start: range.end, end: range.start }
791+
}
792+
return { start: range.start, end: range.end }
793+
}
794+
795+
function textInRange(buffer: TextBuffer, start: CursorPosition, end: CursorPosition) {
796+
if (start.line === end.line) return buffer.getLine(start.line).slice(start.col, end.col)
797+
const lines = [buffer.getLine(start.line).slice(start.col)]
798+
for (let line = start.line + 1; line < end.line; line++) lines.push(buffer.getLine(line))
799+
lines.push(buffer.getLine(end.line).slice(0, end.col))
800+
return lines.join("\n")
801+
}
802+
803+
function blankLine(line: string) {
804+
return line.trim().length === 0
805+
}
806+
807+
function escaped(line: string, index: number) {
808+
let slashCount = 0
809+
for (let cursor = index - 1; cursor >= 0 && line[cursor] === "\\"; cursor--) slashCount++
810+
return slashCount % 2 === 1
811+
}
812+
575813
function clamp(value: number, min: number, max: number) {
576814
return Math.max(min, Math.min(max, value))
577815
}

0 commit comments

Comments
 (0)