diff --git a/src/modules/vim/index.tsx b/src/modules/vim/index.tsx index 7c83468..facd6e0 100644 --- a/src/modules/vim/index.tsx +++ b/src/modules/vim/index.tsx @@ -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" @@ -135,7 +136,7 @@ function preparePassThroughKey(ctx: PromptContext, key: string, mode: string) { if (mode !== "normal" || key !== "") 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 } @@ -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) } diff --git a/src/modules/vim/map.ts b/src/modules/vim/map.ts index c2ce67a..9a18772 100644 --- a/src/modules/vim/map.ts +++ b/src/modules/vim/map.ts @@ -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 @@ -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 { @@ -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 } diff --git a/src/modules/vim/vimee.ts b/src/modules/vim/vimee.ts index 3771029..3e45806 100644 --- a/src/modules/vim/vimee.ts +++ b/src/modules/vim/vimee.ts @@ -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 @@ -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 @@ -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 @@ -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 } @@ -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) @@ -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) } @@ -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) { @@ -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 @@ -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 } } @@ -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) {