Как подружить Neovim c русской раскладкой

от автора

TLDR

Этот туториал описывает часть функционала плагина «Langmapper.nvim», ссылка на него будет в конце статьи. Для остальных, кто хочет настроить Neovim для работы с русской или другой раскладкой, описаны необходимые шаги и приведён упрощенный код.

Проблемы

  1. Neovim получает значение, а не код клавиши, что делает его зависимым от текущей раскладки;

  2. Решение с переключением раскладки при выходе из режима вставки ограничивает работу текстом на русском: будут недоступны операторы f,F,t,T,r,R и поиск для русских символов.

  3. Функциональность опции langmap не учитывает перевод пользовательских привязок клавиш.

Задачи

  • Научить Neovim понимать команды, введенные на русской раскладке;

  • Автоматически перевести пользовательские привязки клавиш;

  • Перевести встроенные привязки, последовательности Ctrl+ и привязки от плагинов;

  • Одинаково обрабатывать нажатие физических клавиш, независимо от текущей раскладки, при дублирующихся символах.

Настройка vim.opt.langmap

Опция langmap переводит введенные символы на противоположные на основе карты сопоставлений.

Указывать сопоставления можно двумя способами:

  1. Попарно, где каждая пара символов разделена запятой (ЙQ,ЦW);

  2. Набором символов во что;откуд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, например:

  1. <Leader>q нужно перевести в <Leader>й;

  2. <M-Q> нужно перевести в <M-Й>, сохранив регистр;

  3. <C-Q> нужно перевести в <C-й>, оставив C английской, и приведя Й в нижний регистр, потому что <C-Q> и <C-q> для Neovim равнозначны, а <C-Й> и <C-й> — нет;

  4. <S-Tab> нужно оставить как есть;

  5. Маппинги, содержащие <Plug>, <Sid> и <Cnr> вообще не нужно трогать;

  6. <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) и выявить символы, которые нужно обработать. Эти символы отвечают таким условиям:

  1. Не содержатся в langmap, а значит должны быть обработаны;

  2. Не равны друг-другу, потому что нет смысла менять поведение одинаковых символов;

Всего будет найдено пять символов: б, ю, ., , и /.

Теперь их нужно разделить на две категории: которые должны учитывать текущую раскладку и остальные.

б и ю не требуют проверку метода ввода, поэтому их параллельные значения могут быть переданы через 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, который, помимо прочего, учитывает работу с удалением маппингов, предоставляет утилиты для точечной настройки ваших привязок и его можно настроить для работы не только с русской раскладкой. Все, кому будет полезна такая функциональность, добро пожаловать на тест.


ссылка на оригинал статьи https://habr.com/ru/articles/726400/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *