|
1 | 1 | 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" |
4 | 4 | import type { PromptContext } from "../../prompt/types" |
5 | 5 | import { focusedInput, setInput, type EditBufferLike } from "./actions" |
6 | 6 | import type { VimConfig } from "./config" |
@@ -38,7 +38,7 @@ export function createVimeeAdapter(state: VimState, config: VimConfig, log: VimL |
38 | 38 | const offset = clamp(input?.cursorOffset ?? text.length, 0, text.length) |
39 | 39 | const map = mapForHostText(text, input) |
40 | 40 | const cursor = hostPosition(map, offset) |
41 | | - const vimeeKey = keyForVimee(event, key) |
| 41 | + let vimeeKey = keyForVimee(event, key) |
42 | 42 | if (!vimeeKey) return false |
43 | 43 |
|
44 | 44 | if (state.mode() === "normal" && key === "<CR>") { |
@@ -70,6 +70,9 @@ export function createVimeeAdapter(state: VimState, config: VimConfig, log: VimL |
70 | 70 | const hostEnd = vimeeKey === "e" ? endMotionOffset(map.hostText, offset, vim.count || 1) : undefined |
71 | 71 | const shouldFlashYank = shouldFlashYankFor(vimeeKey) |
72 | 72 | const visualYankRange = visualYankRangeFor(map) |
| 73 | + vimeeKey = textObjectAlias(vimeeKey, vim) ?? vimeeKey |
| 74 | + const textObjectHandled = handleTextObject(vimeeKey, ctx, map) |
| 75 | + if (textObjectHandled !== undefined) return textObjectHandled |
73 | 76 | const result = processKeystroke(vimeeKey, vim, buffer, event.ctrl, false, keybinds) |
74 | 77 | vim = result.newCtx |
75 | 78 | 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 |
230 | 233 | return actions |
231 | 234 | } |
232 | 235 |
|
| 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 | + |
233 | 264 | function updateTimeout(ctx: PromptContext) { |
234 | 265 | if (timer) clearTimeout(timer) |
235 | 266 | timer = undefined |
@@ -370,6 +401,13 @@ function yankedTextRange(map: PromptMap, cursor: CursorPosition, text: string): |
370 | 401 | return vimOffsetRange(map, start, start + text.length - 1) |
371 | 402 | } |
372 | 403 |
|
| 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 | + |
373 | 411 | function vimOffsetRange(map: PromptMap, left: number, right: number): HostRange | undefined { |
374 | 412 | const start = hostFromVimOffset(map, Math.min(left, right), "next") |
375 | 413 | const end = hostFromVimOffset(map, Math.max(left, right), "previous") |
@@ -572,6 +610,206 @@ function consumesKey(key: string, actions: VimeeAction[], ctx: VimContext, keybi |
572 | 610 | return ctx.mode !== "insert" || key === "Escape" |
573 | 611 | } |
574 | 612 |
|
| 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 | + |
575 | 813 | function clamp(value: number, min: number, max: number) { |
576 | 814 | return Math.max(min, Math.min(max, value)) |
577 | 815 | } |
0 commit comments