TLDR
Этот туториал описывает часть функционала плагина «Langmapper.nvim», ссылка на него будет в конце статьи. Для остальных, кто хочет настроить Neovim для работы с русской или другой раскладкой, описаны необходимые шаги и приведён упрощенный код.
Проблемы
Neovim получает значение, а не код клавиши, что делает его зависимым от текущей раскладки;
Решение с переключением раскладки при выходе из режима вставки ограничивает работу текстом на русском: будут недоступны операторы
f,F,t,T,r,R
и поиск для русских символов.Функциональность опции
langmap
не учитывает перевод пользовательских привязок клавиш.
Задачи
Научить Neovim понимать команды, введенные на русской раскладке;
Автоматически перевести пользовательские привязки клавиш;
Перевести встроенные привязки, последовательности Ctrl+ и привязки от плагинов;
Одинаково обрабатывать нажатие физических клавиш, независимо от текущей раскладки, при дублирующихся символах.
Настройка vim.opt.langmap
Опция langmap
переводит введенные символы на противоположные на основе карты сопоставлений.
Указывать сопоставления можно двумя способами:
Попарно, где каждая пара символов разделена запятой (
ЙQ,ЦW
);Набором символов
во что;откудa
, конкатенированных точкой запятой. Если наборов несколько, то они разделяются запятой.
Я буду использовать второй вариант, так как он кажется мне более читабельным.
В примере используется раскладка «RussianWin» на MacOS.
local function escape(str)
-- Эти символы должны быть экранированы, если встречаются в langmap
local escape_chars = [[;,."|\]]
return vim.fn.escape(str, escape_chars)
end
-- Наборы символов, введенных с зажатым шифтом
local en_shift = [[~QWERTYUIOP{}ASDFGHJKL:"ZXCVBNM<>]]
local ru_shift = [[ËЙЦУКЕНГШЩЗХЪФЫВАПРОЛДЖЭЯЧСМИТЬБЮ]]
-- Наборы символов, введенных как есть
-- Здесь я не добавляю ',.' и 'бю', чтобы впоследствии не было рекурсивного вызова комманды
local en = [[`qwertyuiop[]asdfghjkl;'zxcvbnm]]
local ru = [[ёйцукенгшщзхъфывапролджэячсмить]]
vim.opt.langmap = vim.fn.join({
-- ; - разделитель, который не нужно экранировать
-- |
escape(ru_shift) .. ';' .. escape(en_shift),
escape(ru) .. ';' .. escape(en),
}, ',')
Теперь операторы и текстовые объекты работают при введении русских символов, но привязки клавиш по-прежнему понимают только английский алфавит и последовательности Ctrl+ не работают.
Обертка над vim.keymap.set
Очевидный способ — повторно регистрировать каждый маппинг для всех раскладок:
local map = vim.keymap.set
map('n', '<Leader>q', ':qa')
map('n', '<Leader>й', ':qa')
Это сильно засоряет конфиг и неудобно в сопровождении.
Другой способ, это создать обертку над функцией vim.keymap.set
, которая будет автоматически устанавливать маппинги для каждой раскладки.
Сложность в том, чтобы корректно обработать зоопарк возможных обозначений клавиш Neovim, например:
<Leader>q
нужно перевести в<Leader>й
;<M-Q>
нужно перевести в<M-Й>
, сохранив регистр;<C-Q>
нужно перевести в<C-й>
, оставивC
английской, и приведяЙ
в нижний регистр, потому что<C-Q>
и<C-q>
для Neovim равнозначны, а<C-Й>
и<C-й>
- нет;<S-Tab>
нужно оставить как есть;Маппинги, содержащие
<Plug>
,<Sid>
и<Cnr>
вообще не нужно трогать;<Localleader>q
нужно перевести вжй
(еслиmaplocalleader = ';'
).
Не буду приводить код функции translate_keycode()
, так как он достаточно объемный из-за вариативности обозначений, и легко реализуется самостоятельно: понадобятся две строки с русской и английской раскладками и метод vim.fn.tr(str, from, to)
.
Реализацию можно посмотреть в репозитории плагина.
local map = function(mode, lhs, rhs, opts)
-- Регистрация оригинального маппинга
vim.keymap.set(mode, lhs, rhs, opts)
local tr_lhs = tranlate_keycode(lhs)
-- Регистрация переведенного маппинга
vim.keymap.set(mode, tr_lhs, rhs, opts)
end
-- Теперь по одному вызову будет регистрация <leader>q и <leader>й
map('n', '<Leader>q', ':qa')
Применение этой обертки решает проблему с работой пользовательских привязок клавиш на обеих раскладках, но дефолтные маппинги, хоткеи от плагинов и последовательности Ctrl+ все еще не работают с русскими буквами.
Авто перевод зарегистрированных привязок
API Neovim предоставляет два метода, которые возвращают список установленных маппингов:
vim.api.nvim_get_keymap(mode) -- Для глобальных сопоставлений
vim.api.nvim_buf_get_keymap(mode) -- Для локальных сопоставлений
Каждый маппинг из списка выглядит примерно так:
{
buffer = 0,
expr = 0,
lhs = "gx", -- left-hand-side
lhsraw = "gx",
lnum = 82,
mode = "n",
noremap = 0,
nowait = 0,
rhs = "<Plug>NetrwBrowseX", -- right-hand-side
script = 0,
sid = 13,
silent = 0
}
Теперь можно обойти эти массивы для каждого режима и зарегистрировать переведенный маппинг.
В этот раз регистрация будет проходить с помощью vim.api.nvim_feedkeys
- это позволит корректно обрабатывать привязки клавиш, которые ожидают текстовые объекты. Например, распространенный маппинг gc
для комментирования ожидает третий символ, для определения текстового объекта, который нужно закомментировать. Если перевести и зарегистрировать его напрямую, то маппинг не будет работать, но если имитировать ввод gc
при нажатии пс
, то маппинг сработает так, как ожидалось.
Так же, это позволит взять только три поля из каждого словаря и не приводить каждый из них к контракту параметра opts
в vim.keymap.set
.
Здесь будет лучше использовать vim.api.nvim_set_keymap
и vim.api.nvim_buf_set_keymap
, чтобы не допустить ошибки при регистрации глобальных и локальных маппингов. vim.keymap.set
так же использует эти функции для маппинга.
Особого внимания требует поле mode
, так как оно может быть не однобуквенным, а состоять из склейки обозначений режимов. Поэтому его нужно разбить на массив символов.
Авто маппинг глобальных сопоставлений нужен только один раз, поэтому его нужно вызвать в самом конце init.lua
:
-- init.lua
local function global_automapping()
-- Обычно нужны только эти режимы для перевода
-- Несмотря на то, что 'v' содержит в себе 'x' и 's',
-- их нужно указать отдельно
local allowed_modes = { 'n', 's', 'x', 'v' }
local mappings = {}
for _, mode in ipairs(allowed_modes) do
local maps = vim.api.nvim_get_keymap(mode)
for _, map in ipairs(maps) do
local lhs, desc, modes = map.lhs, map.desc, vim.split(map.mode, '')
table.insert(mappings, { lhs = lhs, desc = desc, mode = modes })
end
end
for _, map in ipairs(mappings) do
local lhs = translate_keycode(map.lhs)
for _, mode in ipairs(map.mode) do
-- Проверка, что переведенный маппинг не поторяет оригинальный маппинг
-- и что он еще не был зарегистрирован
if not (map.lhs == lhs or has_map(lhs, mode, mappings)) then
local opts = {
callback = function()
local repl = vim.api.nvim_replace_termcodes(map.lhs, true, true, true)
-- 'm' здесь означет, что нужно использовать
-- remap при вводе символов
vim.api.nvim_feedkeys(repl, 'm', true)
end,
desc = map.desc .. '(translated)',
}
vim.api.nvim_set_keymap(mode, lhs, '', opts)
end
end
end
end
global_automapping()
С переводом локальных привязок для каждого буфера сложнее. Нужно зарегистрировать колбэк на события BufWinEnter
и LspAttach
, чтобы выполнять перевод после того, когда локальные привязки установлены:
-- Функция сокращена, т.к. повторяет 'global_automapping'
-- показаны только элементы, требующие изменений
local function local_automapping(bufnr)
-- ... code
for _, mode in ipairs(allowed_modes) do
local maps = vim.api.nvim_buf_get_keymap(bufnr, mode)
-- ... code
end
for _, map in ipairs(mappings) do
-- ... code
for _, mode in ipairs(map.mode) do
if not (map.lhs == lhs or has_map(lhs, mode, mappings)) then
-- .. code
vim.api.nvim_buf_set_keymap(bufnr, mode, lhs, '', opts)
end
end
end
end
vim.api.nvim_create_autocmd({ 'BufWinEnter', 'LspAttach' }, {
callback = function(data)
vim.schedule(function()
if vim.api.nvim_buf_is_loaded(data.buf) then
local_automapping(data.buf)
end
end)
end,
})
Глобальные и локальные маппинги переведены:
Привязки Neovim по умолчанию (например,
gx
),Привязки, созданные из вим-скрпта,
И привязки от плагинов без ленивой загрузки.
О том, как перевести ленивые маппинги, будет ниже.
Перевод и регистрация последовательностей Ctrl+
Дефолтные маппинги для ctrl, так же как и остальные встроенные команды, не отображаются в результатах функции vim.api.nvim_get_keymap
. Значит, нельзя удобно проверить, привязан ли какой-нибудь функционал к конкретной ctrl+ последовательности. (Конечно, можно парсить help
или получать предложения автодополнения по ctrl, но это сильно замедлит выполнение кода)
Решением может быть перевод всех возможных последовательностей ctrl для каждого режима с помощью nvim_feedkeys
.
В случае, если какой-либо функционал может быть выполнен при нажатии последовательности, то он будет выполнен. В противном случае просто ничего не произойдет.
-- Обратите внимание, что в отличие от langmap, здесь присутствуют все символы раскладок,
-- даже те, которые дублируют друг-друга.
-- Исключение: ряд цифр, который при переводе принесет больше неудобств, чем пользы
local ru = [[ËЙЦУКЕНГШЩЗХЪ/ФЫВАПРОЛДЖЭЯЧСМИТЬБЮ,ёйцукенгшщзхъфывапролджэячсмитьбю.]]
local en = [[~QWERTYUIOP{}|ASDFGHJKL:"ZXCVBNM<>?`qwertyuiop[]asdfghjkl;'zxcvbnm,./]]
local function map_translated_ctrls()
-- Маппинг Ctlr+ регистронезависимый, поэтому убираем заглавные буквы
local en_list = vim.split(en:gsub('%u', ''), '')
local modes = { 'n', 'o', 'i', 'c', 't', 'v' }
for _, char in ipairs(en_list) do
local keycode = '<C-' .. char .. '>'
local tr_char = vim.fn.tr(char, en, ru)
local tr_keycode = '<C-' .. tr_char .. '>'
-- Предотвращаем рекурсию, если символ содержится в обеих раскладках
if not en:find(tr_char, 1, true) then
local term_keycodes = vim.api.nvim_replace_termcodes(keycode, true, true, true)
vim.keymap.set(modes, tr_keycode, function()
vim.api.nvim_feedkeys(term_keycodes, 'm', true)
end)
end
end
end
map_translated_ctrls()
Теперь все Ctrl+ работают на обоих языках.
Вариативная обработка дублирующихся символов
На раскладке «RussianWin» на месте английских символов /
, ?
и |
расположены .
, ,
и /
.
local ru = [[ËЙЦУКЕНГШЩЗХЪ/ФЫВАПРОЛДЖЭЯЧСМИТЬБЮ,ёйцукенгшщзхъфывапролджэячсмитьбю.]]
local en = [[~QWERTYUIOP{}|ASDFGHJKL:"ZXCVBNM<>?`qwertyuiop[]asdfghjkl;'zxcvbnm,./]]
Эти символы очень важны при работе в Neovim в нормальном режиме. При этом, их нельзя переопределить в langmap
, потому что все эти символы встречаются в обеих раскладках и использование их в langmap
приведет к цикличности вызовов. И если бю:,.
может сработать, то ,.;?/
не сработает.
Так же, задачей является то, то независимо от текущего метода ввода, при нажатии одних и тех же физических клавиш ожидается одна и та же функциональность. Поэтому потребуется проверять текущую раскладку при их нажатии.
На Mac и Windows проверить текущий метод ввода из терминала можно с помощью утилиты im-select, на Linux - xkb-switch.
Функция для определения текущей раскладки будет выглядеть так:
local function get_current_layout_id()
local cmd = 'im-select'
if vim.fn.executable(cmd) then
local output = vim.split(vim.trim(vim.fn.system(cmd)), '\n')
return output[#output] -- Выведет com.apple.keylayout.RussianWin для русской раскладки
-- и com.apple.keylayout.ABC для английской
end
end
Теперь нужно пройтись по карте сопоставлений раскладок (строки en
и ru
) и выявить символы, которые нужно обработать. Эти символы отвечают таким условиям:
Не содержатся в
langmap
, а значит должны быть обработаны;Не равны друг-другу, потому что нет смысла менять поведение одинаковых символов;
Всего будет найдено пять символов: б
, ю
, .
, ,
и /
.
Теперь их нужно разделить на две категории: которые должны учитывать текущую раскладку и остальные.
б
и ю
не требуют проверку метода ввода, поэтому их параллельные значения могут быть переданы через nvim_feedkeys
без дополнительных условий.
Для остальных символов требуется проверка раскладки. Можно представить вариации этих символов так:
local variants = {
[','] = { on_en = ',', on_ru = '?' },
['.'] = { on_en = '.', on_ru = '/' },
['/'] = { on_en = '/', on_ru = '|' },
}
Код функции будет таким:
local function set_missing()
local en_list = vim.split(en, '')
for i, char in ipairs(en_list) do
local char = en_list[i]
local tr_char = vim.fn.tr(char, en, ru)
if not (char == tr_char or langmap_contains(char, tr_char)) then
-- Если символ не дублирующийся, например 'б' и 'ю'
if not en:find(tr_char, 1, true) then
vim.keymap.set('n', tr_char, function()
vim.api.nvim_feedkeys(char, 'n', true)
-- | - здесь нужно использовать noremap
end)
else -- Символ дублируется, например ',', '.' и т.д.
vim.keymap.set('n', tr_char, function()
if get_current_layout_id() == 'com.apple.keylayout.RussianWin' then
vim.api.nvim_feedkeys(char, 'n', true)
else
vim.api.nvim_feedkeys(tr_char, 'n', true)
end
end)
end
end
end
end
set_missing()
На данном этапе команды, введенные с русской раскладкой, работают аналогично командам, введенным с английской.
Финальный штрих — это обработка маппингов, зарегистрированных плагинами с ленивой загрузкой.
«Взлом» vim.api.nvim_set_keymap
Можно глобально обернуть vim.api.nvim_set_keymap
для автоматического перевода абсолютно всех маппингов, которые зарегистрированы с помощью этой функции. Она так же используется внутри vim.keymap.set
.
Для того чтобы были переведены все привязки (пользовательские и от плагинов), переназначение этой функции нужно расположить до загрузки плагинов и файла со своими привязками. vim.api.nvim_buf_set_keymap
тоже нужно переназначить — это происходит аналогичным образом.
Нужно учитывать, что <leader>
и <localleader>
должны быть назначены до переопределения.
local function hack_nvim_set_keymap(mode, lhs, rhs, opts)
opts = opts or {}
-- Регистрация оригинального маппинга
vim.api.nvim_set_keymap(mode, lhs, rhs, opts)
-- В большинстве случаев не нужно переводить команды режима вставки
local disable_modes = { 'i' }
if not vim.tbl_contains(disable_modes, mode) then
local tr_lhs = translate_keycode(lhs)
opts.desc = opts.desc .. '(translate)'
if tr_lhs ~= lhs then
vim.api.nvim_set_keymap(mode, tr_lhs, rhs, opts)
end
end
end
vim.api.nvim_set_keymap = hack_nvim_set_keymap
Теперь все привязки клавиш, назначенные в lua-файлах, будут автоматически переведены.
Итоги
Все поставленные задачи были решены. Теперь в подавляющем большинстве случаев будет без разницы, какой метод ввода используется на данный момент.
Исключением является, когда от пользователя ожидается ввод через vim.fn.input()
. Например, это используется в плагинах вроде surround
и windows picker
.
Заключение
Весь вышеуказанный код оформлен в плагин Langmapper.nvim, который, помимо прочего, учитывает работу с удалением маппингов, предоставляет утилиты для точечной настройки ваших привязок и его можно настроить для работы не только с русской раскладкой. Все, кому будет полезна такая функциональность, добро пожаловать на тест.