Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions src/modules/vim/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import type { SnippetController } from "../snippets/types"
import { applyVimCursorStyle, focusedInput } from "./actions"
import type { VimConfig } from "./config"
import { createVimConfig } from "./config"
import { displayToChar, displayWidth } from "./map"
import { keyNotation } from "./keys"
import { createVimLog } from "./log"
import type { VimLog } from "./log"
Expand Down Expand Up @@ -135,7 +136,7 @@ function preparePassThroughKey(ctx: PromptContext, key: string, mode: string) {
if (mode !== "normal" || key !== "<CR>") return false
const input = focusedInput(ctx)
if (!input?.plainText || input.cursorOffset === undefined) return false
input.cursorOffset = Math.min(input.cursorOffset + 1, input.plainText.length)
input.cursorOffset = Math.min(input.cursorOffset + 1, displayWidth(input.plainText))
return true
}

Expand Down Expand Up @@ -178,8 +179,8 @@ function isPromptFocused(ctx: PromptContext) {
function isNativeCompletionToken(ctx: PromptContext) {
const text = ctx.prompt()?.current.input ?? focusedInput(ctx)?.plainText ?? ""
const input = focusedInput(ctx)
const offset = Math.max(0, input?.cursorOffset ?? text.length)
const beforeCursor = text.slice(0, Math.min(offset + 1, text.length))
const charIdx = displayToChar(text, Math.max(0, input?.cursorOffset ?? 0))
const beforeCursor = text.slice(0, Math.min(charIdx + 1, text.length))
return /^\/\S*$/.test(beforeCursor) || /(?:^|\s)@\S*$/.test(beforeCursor)
}

Expand Down
67 changes: 61 additions & 6 deletions src/modules/vim/map.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,59 @@
import type { CursorPosition } from "@vimee/core"
import type { EditBufferLike } from "./actions"

const WIDE_RANGES: [number, number][] = [
[0x1100, 0x115F], // Hangul Jamo
[0x2329, 0x232A],
[0x2E80, 0x303E], // CJK Radicals, Kangxi, CJK Symbols
[0x3040, 0x33BF], // Hiragana, Katakana, Bopomofo, etc.
[0x3400, 0x4DBF], // CJK Extension A
[0x4E00, 0xA4CF], // CJK Unified Ideographs, Yi
[0xA960, 0xA97F], // Hangul Jamo Extended-A
[0xAC00, 0xD7AF], // Hangul Syllables
[0xD7B0, 0xD7FF], // Hangul Jamo Extended-B
[0xF900, 0xFAFF], // CJK Compatibility Ideographs
[0xFE10, 0xFE19], // Vertical Forms
[0xFE30, 0xFE6F], // CJK Compatibility Forms
[0xFF01, 0xFF60], // Fullwidth Forms
[0xFFE0, 0xFFE6],
[0x1B000, 0x1B0FF], // Kana Supplement
[0x1B100, 0x1B12F], // Kana Extended-A
[0x20000, 0x2FFFF], // CJK Extension B-F
[0x30000, 0x3FFFF], // CJK Extension G-H
]

export function charDisplayWidth(char: string): number {
const code = char.charCodeAt(0)
if (code < 0x7F) return 1
for (const [start, end] of WIDE_RANGES) {
if (code >= start && code <= end) return 2
}
return 1
}

export function charToDisplay(text: string, charIndex: number): number {
let width = 0
const len = Math.min(charIndex, text.length)
for (let i = 0; i < len; i++) {
width += charDisplayWidth(text[i])
}
return width
}

export function displayToChar(text: string, displayOffset: number): number {
let width = 0
for (let i = 0; i < text.length; i++) {
const w = charDisplayWidth(text[i])
if (width + w > displayOffset) return i
width += w
}
return text.length
}

export function displayWidth(text: string): number {
return charToDisplay(text, text.length)
}

export type PromptMap = {
hostText: string
vimText: string
Expand Down Expand Up @@ -37,12 +90,14 @@ function preserveSynthetic(vimOffset: number, prefix: number, suffix: number, vi
return vimOffset !== prefix - 1 && vimOffset !== vimLength - suffix
}

export function hostPosition(map: PromptMap, hostOffset: number): CursorPosition {
return positionFromOffset(map.vimText, map.hostToVim[clamp(hostOffset, 0, map.hostText.length)] ?? 0)
export function hostPosition(map: PromptMap, hostDisplayOffset: number): CursorPosition {
const charIdx = displayToChar(map.hostText, hostDisplayOffset)
return positionFromOffset(map.vimText, map.hostToVim[clamp(charIdx, 0, map.hostText.length)] ?? 0)
}

export function hostOffset(map: PromptMap, position: CursorPosition, bias: "previous" | "next" = "next") {
return hostOffsetFromVimOffset(map, offsetFromPosition(map.vimText, position), bias)
const charIdx = hostOffsetFromVimOffset(map, offsetFromPosition(map.vimText, position), bias)
return charToDisplay(map.hostText, charIdx)
}

function buildPromptMap(hostText: string, wraps: number[]): PromptMap {
Expand Down Expand Up @@ -99,14 +154,14 @@ function visualWrapOffsets(input: EditBufferLike, text: string) {
const wraps: number[] = []
let previousRow: number | undefined

for (let offset = 0; offset <= text.length; offset++) {
input.cursorOffset = offset
for (let charIdx = 0; charIdx <= text.length; charIdx++) {
input.cursorOffset = charToDisplay(text, charIdx)
const row = input.visualCursor?.visualRow
if (row === undefined) {
wraps.length = 0
break
}
if (previousRow !== undefined && row > previousRow && text[offset - 1] !== "\n") wraps.push(offset)
if (previousRow !== undefined && row > previousRow && text[charIdx - 1] !== "\n") wraps.push(charIdx)
previousRow = row
}

Expand Down
64 changes: 39 additions & 25 deletions src/modules/vim/vimee.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import type { PromptContext } from "../../prompt/types"
import { focusedInput, setInput, type EditBufferLike } from "./actions"
import type { VimConfig } from "./config"
import type { VimLog } from "./log"
import { createPromptMap, derivePromptMap, hostOffset, hostPosition, type PromptMap } from "./map"
import { charToDisplay, displayToChar, displayWidth, createPromptMap, derivePromptMap, hostOffset, hostPosition, type PromptMap } from "./map"
import type { createVimState } from "./state"

type VimState = ReturnType<typeof createVimState>
Expand Down Expand Up @@ -61,10 +61,12 @@ export function createVimeeAdapter(state: VimState, config: VimConfig, log: VimL

const input = focusedInput(ctx)
const text = input?.plainText ?? ref.current.input
const offset = clamp(input?.cursorOffset ?? text.length, 0, text.length)
const dw = displayWidth(text)
const displayOff = clamp(input?.cursorOffset ?? dw, 0, dw)
const charOff = displayToChar(text, displayOff)

const map = mapForHostText(text, input)
const cursor = hostPosition(map, offset)
const cursor = hostPosition(map, displayOff)

const wasPending = keybinds?.isPending() ?? false
const pendingBefore = pendingInsert
Expand All @@ -77,7 +79,7 @@ export function createVimeeAdapter(state: VimState, config: VimConfig, log: VimL
return true
}

const hostEnd = vimeeKey === "e" ? endMotionOffset(map.hostText, offset, vim.count || 1) : undefined
const hostEnd = vimeeKey === "e" ? endMotionOffset(map.hostText, charOff, vim.count || 1) : undefined
const shouldFlashYank = shouldFlashYankFor(vimeeKey)
const visualYankRange = visualYankRangeFor(map)
vimeeKey = textObjectAlias(vimeeKey, vim) ?? vimeeKey
Expand All @@ -90,8 +92,9 @@ export function createVimeeAdapter(state: VimState, config: VimConfig, log: VimL
const content = result.actions.find((action) => action.type === "content-change")?.content
if (vimeeKey === "x" && content !== undefined) {
const next = nextMap(map, content)
const target = clamp(offset, 0, Math.max(0, next.hostText.length - 1))
if (offset < map.hostText.length - 1 && map.hostText[offset + 1] !== "\n" && hostOffset(next, vim.cursor, "previous") < target) {
const nextDW = displayWidth(next.hostText)
const target = clamp(displayOff, 0, Math.max(0, nextDW - 1))
if (charOff < map.hostText.length - 1 && map.hostText[charOff + 1] !== "\n" && hostOffset(next, vim.cursor, "previous") < target) {
vim = { ...vim, cursor: hostPosition(next, target) }
clampFinalCursor = false
}
Expand All @@ -100,7 +103,7 @@ export function createVimeeAdapter(state: VimState, config: VimConfig, log: VimL
if (shouldFlashYank) flashYank(ctx, activeMap, yankAction(result.actions), visualYankRange)
syncMode(state, vim.mode)
const keybindPending = keybinds?.isPending() ?? false
if (wasPending && !keybindPending && pendingBefore && state.mode() === "insert") flushPendingInsert(ctx, pendingBefore, offset)
if (wasPending && !keybindPending && pendingBefore && state.mode() === "insert") flushPendingInsert(ctx, pendingBefore, charOff)
pendingInsert = keybindPending && state.mode() === "insert" ? plainPending(vim.statusMessage) : ""
state.setPending(pendingDisplay(vim, keybindPending))
updateTimeout(ctx)
Expand Down Expand Up @@ -237,9 +240,10 @@ export function createVimeeAdapter(state: VimState, config: VimConfig, log: VimL
const ref = ctx.prompt()
const input = focusedInput(ctx)
const text = input?.plainText ?? ref?.current.input ?? ""
const offset = clamp(input?.cursorOffset ?? text.length, 0, text.length)

if (input && text.length > 0) {
const dw = displayWidth(text)
const offset = clamp(input?.cursorOffset ?? dw, 0, dw)
input.cursorOffset = Math.max(0, offset - 1)
clampNormalCursor(input)
}
Expand Down Expand Up @@ -329,18 +333,19 @@ export function createVimeeAdapter(state: VimState, config: VimConfig, log: VimL
state.setPending("")
}

function flushPendingInsert(ctx: PromptContext, value: string, offset?: number) {
function flushPendingInsert(ctx: PromptContext, value: string, charOffset?: number) {
if (!value || state.mode() !== "insert") return
const ref = ctx.prompt()
if (!ref) return
const input = focusedInput(ctx)
const text = input?.plainText ?? ref.current.input
const insertAt = clamp(offset ?? input?.cursorOffset ?? text.length, 0, text.length)
const currentOffset = input?.cursorOffset ?? insertAt
const next = text.slice(0, insertAt) + value + text.slice(insertAt)
const currentDisplayOff = input?.cursorOffset ?? 0
const currentCharOff = displayToChar(text, currentDisplayOff)
const insertAtChar = charOffset ?? currentCharOff
const next = text.slice(0, insertAtChar) + value + text.slice(insertAtChar)
setInput(ref, next)
const nextOffset = currentOffset >= insertAt ? currentOffset + value.length : currentOffset
if (input) input.cursorOffset = nextOffset
const nextCharOff = currentCharOff >= insertAtChar ? currentCharOff + value.length : currentCharOff
if (input) input.cursorOffset = displayWidth(next.slice(0, nextCharOff))
}

function cursorOffset(map: PromptMap, position: CursorPosition) {
Expand All @@ -357,7 +362,7 @@ export function createVimeeAdapter(state: VimState, config: VimConfig, log: VimL
if (!input?.gotoVisualLineEnd) return false
clearVisualSelection(input)
input.gotoVisualLineEnd()
vim = { ...vim, cursor: hostPosition(map, input.cursorOffset ?? map.hostText.length), mode: "insert", phase: "idle", count: 0, operator: null, statusMessage: "-- INSERT --" }
vim = { ...vim, cursor: hostPosition(map, input.cursorOffset ?? displayWidth(map.hostText)), mode: "insert", phase: "idle", count: 0, operator: null, statusMessage: "-- INSERT --" }
nativeInsertUndoSaved = false
syncMode(state, "insert")
return true
Expand Down Expand Up @@ -457,8 +462,9 @@ function vimOffsetRange(map: PromptMap, left: number, right: number): HostRange

function hostRange(map: PromptMap, left: number, right: number): HostRange | undefined {
if (!map.hostText) return undefined
const start = clamp(Math.min(left, right), 0, Math.max(0, map.hostText.length - 1))
const end = clamp(Math.max(left, right), 0, Math.max(0, map.hostText.length - 1))
const dw = displayWidth(map.hostText)
const start = clamp(Math.min(left, right), 0, Math.max(0, dw - 1))
const end = clamp(Math.max(left, right), 0, Math.max(0, dw - 1))
return { start, end }
}

Expand Down Expand Up @@ -521,32 +527,40 @@ function vimOffsetFromPosition(text: string, position: CursorPosition) {

function hostFromVimOffset(map: PromptMap, offset: number, bias: "previous" | "next") {
const current = clamp(offset, 0, map.vimText.length)
if (current === map.vimText.length) return map.hostText.length
if (current === map.vimText.length) return displayWidth(map.hostText)

const host = map.vimToHost[current]
if (host !== undefined) return host
if (host !== undefined) return charToDisplay(map.hostText, host)

if (bias === "previous") {
for (let previous = current - 1; previous >= 0; previous--) {
const previousHost = map.vimToHost[previous]
if (previousHost !== undefined) return previousHost
if (previousHost !== undefined) return charToDisplay(map.hostText, previousHost)
}
}

for (let next = current + 1; next < map.vimToHost.length; next++) {
const nextHost = map.vimToHost[next]
if (nextHost !== undefined) return nextHost
if (nextHost !== undefined) return charToDisplay(map.hostText, nextHost)
}
return map.hostText.length
return displayWidth(map.hostText)
}

function clampNormalCursor(input: EditBufferLike) {
const cursor = input.visualCursor
const eol = input.editorView?.getVisualEOL?.()
const offset = input.cursorOffset
if (!cursor || !eol || offset === undefined) return
const text = input.plainText
if (!cursor || offset === undefined || text === undefined) return
if (cursor.visualCol === 0) return
if (cursor.visualRow === eol.visualRow && (cursor.offset === eol.offset || offset === eol.offset)) input.cursorOffset = Math.max(0, offset - 1)
const dw = displayWidth(text)
if (offset >= dw) {
input.cursorOffset = Math.max(0, dw - 1)
return
}
const charIdx = displayToChar(text, offset)
if (charIdx < text.length && text[charIdx] === '\n') {
input.cursorOffset = Math.max(0, offset - 1)
}
}

function endMotionOffset(text: string, offset: number, count: number) {
Expand Down