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_DRAWevent가 발생할때, 저장된 커서 위치가 변화하면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)
