*UNDER CONSTRUCTION*

vis-korea-ime 포팅하기

created:
updated:
<a>

https://codeberg.org/ywbird/vis-korea-ime[↗]

최근 Emacs랑 이거저거 editor 사용해 보고 있는데, vis에 한글 입력이 없더라구요.
vis 또한 플러그인으로 Lua를 사용하길래, kiyoon 님의 Kiyoon/Korean-IME.nvim[↗] (이 또한 lifthrasiir/hangeul.vim[↗] 의 Lua 버전 재작성)을 vis로 포팅했습니다.

구현 과정에서 neovim과 vis의 keymap 구현 방식의 차이 때문에 좀 고생했습니다.

noremap의 부재

먼저 neovim의 keymap 설정 방식은 아래와 같습니다.

vim.keymap.set("i", "<space>", function()
    finish_2s()
    return "<space>"
end, { noremap = true, silent = true, expr = true, desc = "..." })

해석해 보자면,

  • "i" : INSERT 모드에서
  • "<space>" : <space> 키를 누르면
  • finish_2s() : 해당 함수를 실행하고
  • return "<space>" : <space> 를 키 입력한다.
  • noremap = true : 제귀 키 입력을 방지하고,
  • silent = true : 입력(<space>)의 결과를 숨긴다.
  • expr = true : 함수의 반환 값을 입력으로 사용한다.
    (이게 없다면 <space> 는 키 입력으로 처리되지 않음)

반면, vis에서는 아래와 같습니다.

vis:map(vis.modes.INSERT, " ", function()
    finish_2s()
    vis:insert(" ")
end, "...")

해석해 보자면,

  • vis.modes.INSERT : INSERT 모드에서
  • " " : <space> 키를 누르면
  • finish_2s() : 해당 함수를 실행하고
  • vis:insert(" ") : <space> 를 커서 위치에 삽입한다.

vis에서도 vis:feedkeys(string) 를 이용해서 키 입력으로 처리할 수 있습니다.
하지만, a 키 할당에 vis:feedkeys("a") 같이 해버리면, vis에서는 noremap 이 없어서 제귀 입력이 발생해서 C stack overflow 오류가 일어나게 됩니다.

여기서 문제가 발생합니다.

기존 Kiyoon/Korean-IME.nvim 의 한글 입력/삭제 방식에 있는데, 아래와 같이 한글 자모를 설정한 상태로 이전 글자를 지우고, 새로운 글자를 입력하는 방식입니다.

문제는, 한 자모를 지우기 위해 <Backspace> 를 눌렀을 때, 이전 글자를 Backspace 로 지우고 새로운 글자를 입력하는 과정에서 입력된 <Backspace> 가 제귀가 되어버리는 것입니다.

한글 입력

자모 상태

{ init = "ㅂ", med = "ㅗ", fin = "None" }

입력 area

보█
  ^cursor

이때 자판에서 d(ㅇ) 을 입력하면 입력된 d(ㅇ) 가 무시됨과 동시에 다음과 같은 키 입력이 예약됩니다.

<Backspace>봉
한글 삭제

자모 상태

{ init = "ㅂ", med = "ㅗ", fin = "ㅇ" }

입력 area

봉█
  ^cursor

이때 자판에서 <Backspace> 을 입력하면 입력된 <Backspace> 가 무시됨과 동시에 다음과 같은 키 입력이 예약됩니다.

<Backspace>보

위의 방식은 noremap 이 있는 neovim에서는 잘 작동하지만, noremap 이 없는 vis에서는 재귀를 일으키게 됩니다.

해결 방법

결국 찾아낸 해결 방법은, 원본과 같이 입력 문자열이 아닌 입력 배열을 사용하고, <Backspace> 대신 파일을 직접 수정하는 방식입니다.

예를 들어 원래 abcd<Backspace>한글 로 저장되던 키 입력을 아래와 같이 저장합니다. 그리고 -1<Backspace> 로 생각합니다.

{ "a", "b", "c", "d", -1, "한", "글" }

아래는 처음 생각한 <Backspace> 의 대안입니다. 현재 커서 위치에서 한 byte를 지우고, 커서를 한 byte 앞으로 움직이는 방식입니다.

sim_backspace() 구현
local function sim_backspace()
    local win = vis.win
    local pos = win.selection.pos
    if pos == 0 then
        return
    end
    win.file:delete(pos-1, 1)
    win.selection.pos = pos - 1
end

하지만 여기서도 문제가 발생했는데, file:delete(pos, len)[↗] 은 UTF-8 문자 단위가 아니라, 바이트 단위로 작동한다는 것입니다.

한글 문자 뒤에서 위의 sim_backspace() 를 실행하면, 옳지 않은 UTF-8 문자열이 생성되게 됩니다.

UTF-8의 인코딩 규칙을 보면,

  • 1 byte 사용 시, MSB(most significant bit)0 을 할당
  • 2 bytes 이상 사용 시, 첫 byte의 앞부분에 0 으로 끝나는 사용할 바이트 수를 명시
    2 bytes 사용 시 110, 3 bytes 사용시 1110, etc.
    나머지 byte에는 앞부분에 연속적 byte임을 알리는 비트 10 을 할당
  • 나머지 비트들에 데이터 저장

따라서, 어떤 byte에 대해, 그 UTF-8 문자의 시작을 아래의 방식으로 찾을 수 있습니다.

  • MSB가 0 이라면 1 byte짜리 문자
  • Byte의 앞부분이 10 이라면 110, 1110, etc. 가 나올 때까지 한 byte 앞으로 가서 탐색

위의 방식으로 구현된 새로운 sim_backspace() 와 입력 배열을 사용하여 배열의 아이템이 문자의 경우 삽입, -1 의 경우 sim_backspace() 를 실행하는 함수를 작성했습니다.

sim_backspace() 구현
local function utf8_prev_len(file, pos)
    if pos == 0 then
        return 0
    end

    local i = pos - 1

    while true do
        local b = file:content(i, 1):byte()

        -- continuation byte (10xxxxxx)?
        if b >= 0x80 and b < 0xC0 then
            i = i - 1
        else
            break
        end
    end

    return pos - i
end

local function sim_backspace()
    local win = vis.win
    local pos = win.selection.pos

    if pos == 0 then
        return
    end

    local len = utf8_prev_len(win.file, pos)

    win.file:delete(pos - len, len)
    win.selection.pos = pos - len
end
sim_keys(keys) 구현
local function sim_keys(keys)
    for _,k in ipairs(keys) do
        if k == -1 then
            sim_backspace()
            refresh()
        else
            vis:insert(k)
        end
    end
end

Note

...
            sim_backspace()
            refresh() -- ?
        else
...

Neovim에서는 CursorMovedI 를 통해 커서가 움직이는 것을 감지할 수 있지만, vis에서는 대응되는 event가 없어서, UI_DRAW event가 발생할때, 저장된 커서 위치가 변화하면 CursorMovedI 와 동일하게 처리한다.
하지만, 현재 구현으로 <Backspace>봉 과 같이 글자는 변하지만 커서는 움직이지 않는 상황에서 직접 refresh() 를 실행시켜 주었다.

마지막으로, vis에는 없는 vim의 CursorMovedI, InsertEnter, InsertLeave 이벤트를 구현해주었습니다.

CursorMovedI, InsertEnter, InsertLeave 이벤트 구현
local last_pos
local last_mode = vis.mode
vis.events.subscribe(vis.events.UI_DRAW, function()
    local win = vis.win

    if win ~= nil then
        local pos = win.selection.pos

        if last_pos ~= pos then
            last_pos = pos

            if vis.mode == vis.modes.INSERT then -- CursorMovedI
                refresh()
            end
        end
    end

    if last_mode == vis.modes.INSERT and vis.mode ~= vis.modes.INSERT then -- InsertLeave
        finish(true)
    elseif last_mode ~= vis.modes.INSERT and vis.mode == vis.modes.INSERT then -- InsertEnter
        begin(true)
    end
    last_mode = vis.mode
end)

알려진 문제

해결됨: vis-korea-ime 포팅하기 P2(다음 아티클)

문제가 있다면.. vis에서는 입력 모드 (INSERT, NORMAL, etc.)를 명령 입력 모달에서도 적용하는지라, 자꾸 :w 대신 :ㅈ 을 입력해 버린다든가 하는 마이너한(아님) 문제가 있습니다…. ㅎㅎ
해결하려고 vis API를 뒤져보고 있습니다만…. 아직은 잘 모르겠네요….

해결 방식?

하나 생각해 낸 방식은 입력 영역이 Window[↗] 로 구분되기 때문에, 아래와 같이 window의 테이블 아이디를 받아와 window마다 모드를 저장하는 방식입니다.

M.mode = {}

local function get_win_mode()
    if M.mode[tostring(vis.win)] == nil then
        M.mode[tostring(vis.win)] = "en"
    end
    return M.mode[tostring(vis.win)]
end

local function set_win_mode(mode)
    M.mode[tostring(vis.win)] = mode
end

하지만 문제는, 명령 입력 영역은 : 를 누를 때마다 새로 생성되기 때문에, 계속해서 테이블이 커지는 것이 문제입니다.

주기적으로 현재 window를 제외한 M.mode 테이블을 리셋한다면, 한 번에 여러 window가 열려있을 때 나머지가 리셋되는 문제가 생깁니다.

Log

  • (2026-07-04) CursorMovedI, InsertEnter, InsertLeave 구현 수정 (94bf397 - cdedab3)
  • (2026-07-05) Window 마다 IM 격리 (d9379950)

Webmentions(?)

  • This test verifies that you accept a Webmention request that contains a valid source and target URL. To pass this test, your Webmention endpoint must return either HTTP 200, 201 or 202 along with the appropriate headers.

    If your endpoint returns HTTP 201, then it MUST also return a Location header. If it returns HTTP 200 or 202, then it MUST NOT include a Location header.

Q. Why isn't my webmention not appearing?

A. I deploy this site not so often and this site is static site. So don't expect your webmention to appear instantly. Thanks for the webmention and sorry for the inconvenience.
last deployed: see footer