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

Комментарии (3)


  1. bozman
    02.04.2023 08:05
    +1

    Это же просто праздник какой-то! Сейчас использую встроенный вимовский маппер, но с системнеой раскладкой, конечно, повеселее. Плюс по какой-то причине вим в "своей" раскладке не видит заглавную "Б", вводит < вместо нее.


  1. artemisia_borealis
    02.04.2023 08:05

    Ещё здесь не рассмотрена раскладка Russian (typewriter), там будут тонкости.
    А для английского помимо Qwerty очень важны Colemak, Walkman и Dvorak.


    Количество таблиц будет 8.


    1. Wansmer Автор
      02.04.2023 08:05

      Целью статьи было не охватить все возможные раскладки, а показать принцип, как можно с ними работать.

      Также, в плагине можно добавить больше одной раскладки, но нужно будет самостоятельно следить за возможными конфликтами.

      Какие тонкости будут с Russian (typewriter), которые не предусматривает предложенная логика?