Знакомая картина. Пишешь на автомате сложный однострочник в терминале или строчку кода в любимой IDE, нажимаешь клавишу – и с тоской наблюдаешь, как вместо команды на экране появляется россыпь кракозябр вроде git ghjvjnt или docker compose гз (примеры – для примера). Шелл ожидаемо выдает ошибку, пальцы по привычке зажимают Ctrl+Backspace или долбят клавишу удаления, а рабочий настрой разбивается о банальный язык ввода.

Хуже этого может быть только поведение так называемых «умных помощников», которые вроде бы призваны эту боль лечить. Начинаешь набивать пароль в консоли – утилита радостно лезет переводить скрытые символы. Объявляешь в коде переменную user_name – программа бодро режет ее по нижнему подчеркиванию и превращает хвост в кириллицу. Запускаешь терминальную утилиту со сложными ключами – переключатель решает проявить инициативу прямо посреди флагов сборки. В итоге ловишь себя на том, что тратишь больше сил на борьбу с самодеятельностью программы, чем на саму опечатку, и в приступе раздражения просто выгружаешь ее из трея (или вносишь в исключения). Прекрасно, круг замкнулся.

Многие могут сказать, что современные программы уже достаточно умные, чтобы не совершать подобных ошибок. Но ведь и я человек не простой... Если бы разработчики таких программ узнали что я иногда набираю, то, думаю, они бы впали в депрессию.

Казалось бы, тема автоматического переключения раскладки исследована вдоль и поперек еще в нулевых. Есть ветеран Punto Switcher, доживающий свой век под крылом Яндекса, есть минималистичная Caramba Switcher от того же автора, есть комбайны вроде EveryLang с переводчиками и распознаванием текста, есть узкоспециализированные Aml Maple и Key Switcher, а для любителей открытого кода – Mahou.

Сразу оговорюсь: всё нижеследующее – исключительно субъективный личный опыт автора. Это не попытка навесить ярлыки или составить «окончательный рейтинг», а результат долгого поиска, проб, ошибок и многомесячных попыток жить с каждой из этих программ в реальной повседневной работе. И у каждого, кто ежедневно пишет код, работает в терминале или набирает много текста или специфических комбинаций символов, к этому набору накапливается свой счет:

  • Классический Punto Switcher годами не получает развития, оброс лишним багажом и с завидным упорством ломает ввод команд в консолях и средах разработки.

  • Современные альтернативы вроде Caramba и EveryLang планомерно ушли в коммерческие Pro-версии по подписке, оставив в бесплатном режиме урезанные возможности, закрытые бинарники и сетевую активность. Cетевая активность закрытых программ заставляет задуматься о потенциальных рисках – сегодня утилита просто стучится за обновлениями, а завтра, под давлением всё более жестких требований российского законодательства и регуляторов к контролю за трафиком, разработчиков закрытого софта вполне могут обязать собирать расширенную телеметрию – вплоть до проверки сетевого окружения машины, активных маршрутов или наличия VPN. От закрытого бинарника, имеющего доступ ко всему клавиатурному вводу, в таких условиях всегда ждешь подвоха.

  • Проекты с открытым исходным кодом вроде Mahou вызывают куда больше доверия к безопасности, но на практике их комбайн возможностей избыточен: 90% наворотов остаются невостребованными, а сам проект требует долгой ручной дрессировки горячих клавиш либо замирает на старых версиях Windows.

В остатке каждый решает эту проблему сам – кто-то годами тренирует мышечную память и переучивается на экзотические сочетания клавиш, кто-то держит в трее связку самописных скриптов на AutoHotkey, кто-то просто мирится с чужими багами. Но в конце концов каждый пишущий человек так или иначе к какому-то решению приходит.

Для меня этим решением стал проверенный инженерный принцип: сделать ровно тот набор возможностей, который нужен тебе самому в ежедневной работе, и иметь на руках прозрачный код, который можно в любой момент расширить, подправить или доработать под свои привычки. А ведь простор для экспериментов здесь практически бесконечный – можно бесконечно шлифовать эвристики распознавания опечаток, подбирать веса биграмм, прикручивать контекст фразы или тестировать новые пороги переключения.

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

  • Ну чисто поржать - лобовой словарный поиск по полным базам. Взять словарь Зализняка на сотни тысяч словоформ и искать полное совпадение. Естественно, идея была отброшена еще на этапе скачивания словаря.

  • Идея чистого частотного анализа n-грамм без правил и контекста, попытка опираться только на вероятности слогов без жестких фильтров. Привела к неконтролируемому количеству ложных срабатываний на коротких словах (1–3 буквы) и техническом коде (id, as, in, to). Любую специфическую часть кода или шелл-скрипта программа чаще всего переводила в кириллицу.

  • Машинное обучение и мини-нейросети (ML-классификаторы) – модно, современно, но в контексте системного перехватчика абсолютно нежизнеспособно. Нейросетевая модель непредсказуема в пограничных ситуациях, требует внешних рантаймов (что ставит крест на компактном бинарнике) и, главное, не укладывается в жесткий бюджет времени отклика хука в доли микросекунды. Это не поржать, но улыбнуться точно.

  • Попытки опрашивать активное окно через GetKeyboardLayout в современных ОС часто возвращают погоду на Марсе. Вкладки браузеров, Electron-приложения и удаленные рабочие столы живут своей жизнью, поэтому логика переключения должна была опираться на физические скан-коды и реальный ввод, а не на системные флаги.

В итоге трёхлетняя эволюция привела к гибридной модели – жесткий линейный конвейер отсева технических токенов, ядро частых слов, скользящее окно n-грамм, учет структуры гласных/согласных и скользящий контекст последних набранных слов. Скорее всего – ничего нового, но путь к этому решению был интересный.

Кроме того, в собственный инструмент можно без оглядки на чужие гайдлайны встроить те мелочи, которых не хватает в чужих программах. Например, ненавязчивую звуковую индикацию – тихие щелчки при вводе в русской раскладке (чтобы боковым зрением и на слух сразу чувствовать, на каком языке сейчас печатаешь) и мягкий звук самого переключения, дающий понять, в какую именно сторону сменился язык.

Но дьявол, как обычно в Win32, прячется в системных внутренностях. Стоит зазеваться в низкоуровневом хуке – и у пользователя начнет заикаться ввод во всей системе. Забудешь снять хук перед эмуляцией – получишь вечную рекурсию или залипшие клавиши. Попробуешь опросить чужое окно через COM прямо из цикла обработки сообщений – и намертво встанешь вместе с зависшим блокнотом.

Коротко пробегусь, как устроены архитектура и алгоритмы проекта на Delphi/Win32 – с двумя потоками, очередью повтора ввода, защитой от зацикливания, скорингом n-грамм и легковесным стеммингом, умещающимися в единый монолитный бинарник без внешних библиотек.

Чтобы не превращать кодовую базу в хаос, вся логика приложения разделена на четыре автономных слоя:

  • Перехват и железо (Ghbdtn.Hook, Ghbdtn.KeyState, Ghbdtn.Layout) – низкоуровневые хуки клавиатуры и мыши Windows (WH_KEYBOARD_LL, WH_MOUSE_LL), отслеживание модификаторов без веры опросам ОС, распознавание жестов (двойной Shift), аппаратная модель клавиш и корректная перекодировка с сохранением регистра.

  • Интеллектуальное ядро (Ghbdtn.Analyzer, Ghbdtn.Heuristics, Ghbdtn.Learning, Ghbdtn.Phrase) – шестиступенчатый конвейер принятия решений, расчет n-грамм и гласных, легковесный морфологический стемминг (отсечение окончаний без тяжелых баз), контекст фразы из пяти последних слов и адаптивное обучение.

  • Диспетчеризация и исполнение (Ghbdtn.InputQueue, Ghbdtn.InputWorker, Ghbdtn.Fixer) – потокобезопасная очередь задач, фоновый рабочий поток для потенциально блокирующих вызовов и сборка неделимого пакета SendInput, исключающая мерцание текста и зацикливание хука.

  • Интерфейс и системное окружение (Ghbdtn.App, Ghbdtn.CaretHint, Ghbdtn.Password, Ghbdtn.Sound) – главное окно-оркестратор с иконкой в трее, плавающий полупрозрачный индикатор раскладки у каретки, звуковая обратная связь и защита парольных полей через UI Automation. С отдельным окном настроек я особо не заморачивался. Как быстрое и практичное кустарное решение вынес все основные переключатели прямо в контекстное меню трея.

Контекстное меню трея – это и есть панель управления программой:

Значок в трее показывает текущую раскладку. Он рисуется кодом под размер системного значка, поэтому остаётся чётким при любом масштабировании, а оранжевая плашка с чёрными буквами читается и на светлой, и на тёмной панели задач (здесь сплагиатил внешний вид у карамбы):

При смене раскладки у каретки на мгновение появляется плашка с названием языка. В момент появления она светло-жёлтая и чуть крупнее, затем плавно сжимается и меняет цвет на оранжевый, после чего исчезает. Вспышку и анимацию размера можно отключить в меню:

Когда решение принято по заметной причине, рядом с названием языка выводится короткая пометка – «обучение», «словарь» или «не трогать». По ней сразу видно, почему программа поступила именно так:

При реализации первого же слоя разработчик неизбежно упирается в главное правило Win32 – низкоуровневый хук WH_KEYBOARD_LL обязан работать очень быстро. Если процедура хука задерживает управление хотя бы на пару десятков миллисекунд, Windows считает хук зависшим и молча выкидывает его из цепочки вызовов. А пока хук думает, пользователь пальцами ощущает задержку клавиатуры.

Именно поэтому логика разделена на два изолированных контура:

  1. Главный поток (UI и хук) – здесь крутится стандартный цикл сообщений Windows. Он ловит скан-коды и виртуальные клавиши, отслеживает двойные нажатия Shift, быстро накапливает буквы в TInputBuffer и на границе слова (пробел, Enter, точка) прогоняет эвристику. Вся эта математика занимает доли микросекунды, потому что таблицы биграмм и триграмм уже лежат в оперативной памяти в виде плоских массивов.

  2. Рабочий поток (TInputWorker) – фоновый TThread, который спокойно спит на системном объекте синхронизации TEvent. Он просыпается только тогда, когда в потокобезопасную очередь TInputQueue падает задача: заменить слово, подменить выделенный текст через буфер обмена или записать настройки на диск.

Такое разделение полностью развязывает руки. Рабочий поток может безбоязненно опрашивать COM-интерфейсы UI Automation или делать повторные попытки открытия занятого буфера обмена (OpenClipboardWithRetry). Хук клавиатуры при этом не простаивает.

Однако быстрая реакция хука сама по себе не решает проблему скоростной печати. Представьте "типичную" ситуацию – человек набирает текст со скоростью 400 знаков в минуту (я так не умею, я этого боюсь – можно сболтнуть что-нибудь лишнее), пальцы отбивают ghbdtn , анализатор мгновенно распознает ошибку и отправляет команду рабочему потоку, но за время подготовки эмуляции пользователь уже успевает нажать первую букву следующего слова. В классических переключателях эмулированные клавиши Backspace стирают не старую ошибку, а свежее начало нового предложения.

Чтобы исключить подобную рассинхронизацию, в TInputQueue предусмотрен буфер перехвата (FReplay):

  • Как только анализатор отправляет команду на подмену, очередь переходит в состояние ожидания исполнения (FReplacementPending = True).

  • Все нажатия клавиш, которые пользователь совершает, пока рабочий поток готовит и шлет пакет SendInput, хук временно перехватывает и сохраняет в буфер FReplay.

  • Как только SendInput завершил вставку исправленного слова, рабочий поток достает накопившиеся клавиши из FReplay и досылает их в окно в строго исходном порядке. Ни одна буква не теряется и не встает поперек паровоза.

Но прежде чем слово вообще может быть заменено, его нужно правильно интерпретировать на физическом уровне. Программа не пытается угадывать раскладку по системным словарям ОС. Она опирается на физическую модель клавиатуры. Низкоуровневый хук видит аппаратный скан-код и виртуальный код Windows (VK-код вроде $51 для физической кнопки Q). Сама буква извлекается из фиксированной таблицы соответствия ЙЦУКЕН и QWERTY: q привязана к й, w – к ц, и так далее по всему полю, включая [/х, ;/ж, '/э, ,/б, ./ю, `/ё. Для верхнего регистра есть парная таблица: Shift+; в английской раскладке даёт :, а в русской – Ж.

Цифры 0–9 в таблицу букв не входят. Без Shift это сами цифры, а с Shift результат зависит от раскладки (на английской 6 – это ^, на русской – :). Минус и плюс на обеих раскладках дают -/_ и =/+.

Перекодировка одного символа  \pi(c)– это поиск его позиции в строке одного алфавита и чтение символа с той же позиции в парной строке:

\pi(c) = \begin{cases} R_\ell[i], & c = E_\ell[i] \\ E_\ell[i], & c = R_\ell[i] \\ R_u[i], & c = E_u[i] \\ E_u[i], & c = R_u[i] \\ c, & \text{иначе} \end{cases}

Здесь E_\ell и R_\ell – строчные латинские и русские строки, E_u и R_u – прописные. Символ, отсутствующий в таблицах, остаётся как есть. Для целой строки перекодировка \Pi выполняется поэлементно:

\Pi(s_1 \ldots s_n) = \pi(s_1) \ldots \pi(s_n).

Для букв операция взаимно обратна: \Pi(\Pi(s)) = s. Регистр сохраняется: Ghbdtn превращается в Привет, а повторный проход возвращает Ghbdtn.

Пока пользователь нажимает буквы, они просто накапливаются в TInputBuffer. Решение о переключении принимается строго на границе слова. Границей считаются пробел, Enter, Tab и знаки, не являющиеся буквами в текущей раскладке. И здесь кроется целый пласт тонких краевых эффектов:

  1. Одна клавиша – буква и знак, на английской раскладке клавиши , и . печатают запятую и точку (границы слова). Но на русской раскладке те же самые клавиши печатают буквы б и ю! Если всегда считать их границей, набор ghbdtn, оборвется и никогда не превратится в приветб. Решение – клавиша завершает слово, но при переводе на русский , и . дописываются к тексту как буквы б и ю, а их оригинальный ввод в приложение не пропускается.

  2. Буквы ж, э, х, ъ: клавиши ;, ', [, ] на русской раскладке являются полноценными буквами. Их нельзя считать границей набора, иначе слово nfr;t развалится на куски вместо того, чтобы стать также.

  3. Подчеркивания и равенства – клавиши - и = завершают слово, но их варианты с Shift (_ и +) словом поглощаются. Благодаря этому user_name и a=b остаются едиными токенами и не ломаются.

  4. Буква диска C: – одна латинская буква с двоеточием принудительно игнорируется, чтобы системные пути не улетали в кириллицу.

Когда слово перешагнуло границу, с него снимаются внешние краевые знаки, и оно поступает в шестиступенчатый конвейер принятия решений модуля Ghbdtn.Analyzer. Проверки идут в строгом порядке, и первая же сработавшая немедленно завершает анализ:

  1. Запрет обучения (block) – если пользователь в этом приложении ранее отменил исправление этого слова (или стёр его Backspace'ом целиком), оно остаётся как есть.

  2. Защита токенов («не трогать») – абсолютный иммунитет для технического текста:

    • пути файлов и веб-ссылки (C:\, /usr/bin, https://);

    • адреса электронной почты (user@mail);

    • переменные кода и параметры (user_name, max_value, a=b, host:port);

    • смешанные строки с цифрами (win32, x86) и смесь кириллицы с латиницей;

    • аббревиатуры из 2–4 заглавных букв (API, HTTP, SQL).

  3. Принудительное обучение (force) – если пользователь трижды принудительно переворачивал слово вручную через двойной Shift, оно перекодируется безусловно.

  4. Служебные короткие формы: набор латиницей одиночных служебных форм (b -> и, yt -> не, gj -> по, c -> с и ещё около двадцати вариантов) переключается сразу по таблице. Для 1–2 букв эвристикам не за что зацепиться, поэтому здесь работает прямой словарь.

  5. Проверка длины – если после очистки в слове меньше двух символов, оно остаётся как есть (одиночные английские a и i не трогаются).

  6. Оценка и порог – только если слово прошло предыдущие пять фильтров, запускается математический скоринг.

В коде движка анализатора Ghbdtn.Analyzer.pas этот конвейер выражен максимально прямолинейно:

function AnalyzeWord(const AWord: string; ACurrentLang: TLangKind;
  AMode: THeuristicMode; const APhrase: TPhraseContext;
  const ANeverSwitch, APreferSwitch: TLearningLookup): TAnalysisResult;
var
  CleanW, LowerWord, Converted, LowerConverted: string;
  OriginalScore, ConvertedScore, Margin, KnownKey: Integer;
  SourceLayout, OtherLayout: TLangKind;
  TypedParts, OtherParts: TScoreParts;
begin
  Result.NeedsSwitch := False;
  Result.TargetLang := lkUnknown;

  CleanW := CleanWordString(AWord);
  LowerWord := CleanW.ToLower;

  // 1. Черный список (обучение "не переключать")
  if Assigned(ANeverSwitch) and ANeverSwitch(LowerWord) then Exit;

  // 2. Защищенные токены (URL, пути, почта, camelCase, цифры)
  if IsProtectedToken(CleanW) then Exit;

  // 3. Белый список (обучение "всегда переворачивать")
  if Assigned(APreferSwitch) and APreferSwitch(LowerWord) then
  begin
    Result.NeedsSwitch := True;
    Result.ConvertedText := ConvertStringSwap(AWord);
    Result.TargetLang := InvertLang(TypedLayout(LowerWord, ACurrentLang));
    Exit;
  end;

  // 4. Короткие служебные формы (b -> и, yt -> не, gj -> по)
  if IsShortLayoutWord(LowerWord) and (ACurrentLang <> lkRU) then
  begin
    Result.NeedsSwitch := True;
    Result.TargetLang := lkRU;
    Result.ConvertedText := ConvertStringSwap(AWord);
    Exit;
  end;

  // 5. Одиночные символы дальше не пускаем
  if Length(CleanW) < 2 then Exit;

  // 6. Математический скоринг n-грамм и эвристик
  Converted := ConvertStringSwap(AWord);
  LowerConverted := ConvertStringSwap(CleanW).ToLower;
  SourceLayout := TypedLayout(LowerWord, ACurrentLang);
  OtherLayout := InvertLang(SourceLayout);

  if SourceLayout = lkEN then
  begin
    TypedParts := ExplainEnglish(LowerWord);
    OtherParts := ExplainRussian(LowerConverted);
    if IsKnownEnToRuKey(LowerWord) then KnownKey := KnownLayoutWordScore;
  end
  else
  begin
    TypedParts := ExplainRussian(LowerWord);
    OtherParts := ExplainEnglish(LowerConverted);
  end;

  OriginalScore := TypedParts.Total + PhraseScoreBias(APhrase, SourceLayout);
  ConvertedScore := OtherParts.Total + KnownKey + PhraseScoreBias(APhrase, OtherLayout);
  Margin := SwitchMargin(AMode, ACurrentLang <> lkUnknown);

  if ConvertedScore - OriginalScore >= Margin then
  begin
    Result.NeedsSwitch := True;
    Result.TargetLang := OtherLayout;
    Result.ConvertedText := Converted;
  end;
end;

Как видно, здесь нет тяжелых деревьев решений и рекурсий. Всё выстроено линейно – от мгновенных проверок фильтров к расчету баллов правдоподобия.

Для набранного слова w и его зеркальной копии \Pi(w) параллельно вычисляется балл правдоподобия S(w):

S(w) = N(w) + D(w) + V(w) + H(w) + P(w) + R(w).

Балл складывается из суммы положительных и штрафных языковых маркеров:

Слагаемое

Обозначение

Баллы

Практический смысл

Знакомые n-граммы

N(w)

+1 за биграмму, +2 за триграмму

Типичные слоги живого языка

Словарь частых слов

D(w)

+10, иначе 0

Полное попадание в словарь ядра

Наличие гласных

V(w)

+2, либо +1 для y

Обязательная основа естественных слов

Форма слова

H(w)

-2 или -4

Невозможные буквы в начале (русские ь, ъ, ы)

Запрещенные пары

P(w)

-4, иначе 0

Невозможные сочетания (ьь, щк, ыь)

Ряд согласных

R(w)

-3, иначе 0

Четыре согласные буквы подряд

Никакой словарь не способен вместить все фамилии, сленговые выражения и специфические термины. Чтобы безошибочно определять язык незнакомых слов, текст разбивается на n-граммы – цепочки соседних букв:

  • Биграммы (n = 2): слово «привет» раскладывается на 5 пар: пр, ри, ив, ве, ет.

  • Триграммы (n = 3): тройки того же слова: при, рив, иве, вет.

Формула скользящим окном подсчитывает совпадения с таблицами частых сочетаний:

N(w) = \sum_{i=1}^{|w|-1} [w_{i..i+1} \in B] + 2 \sum_{i=1}^{|w|-2} [w_{i..i+2} \in T].

В модуле Ghbdtn.Heuristics.pas этот подсчет выполняется обычной функцией со скользящим окном, где проверка вхождения делается двоичным поиском по отсортированным массивам:

function ScoreNGrams(const S: string;
  const ABigrams, ATrigrams: TArray<string>): Integer;
var
  I: Integer;
begin
  Result := 0;
  // Скользящее окно биграмм (+1 балл)
  for I := 1 to Length(S) - 1 do
    if ContainsSorted(ABigrams, Copy(S, I, 2)) then
      Inc(Result);

  // Скользящее окно триграмм (+2 балла)
  for I := 1 to Length(S) - 2 do
    if ContainsSorted(ATrigrams, Copy(S, I, 3)) then
      Inc(Result, 2);
end;

Когда пользователь нажимает ghbdtn, срезы gh, hb, bd, dt, tn с точки зрения английского языка дают нулевой результат (N = 0). Но стоит перевести символы в кириллицу, как мы получаем привет с пятью русскими биграммами и тремя триграммами. Языковая принадлежность становится очевидной даже без обращения к словарю.

С гласными буквами логика также просто. Их отсутствие – практически гарантированный признак чужой раскладки (как в ghbdtn). Поэтому гласные аеёиоуыэюя и aeiou сразу приносят +2 балла.

Однако с английской буквой y обнаружился тонкий подвох – в английском она часто выступает гласной (city, my), но на русской клавиатуре клавиша y отвечает за согласную н. Если бы анализатор начислял за нее два балла, любой русский набор со слогом на н выглядел бы похожим на английское слово. Поэтому y дает всего +1 балл и только при условии, что в слове уже найдена хотя бы одна типичная английская n-грамма.

Для самых популярных русских слов, набранных латиницей (ghbdtn -> привет, xnj -> что), предусмотрен дополнительный бонус известных ключей:

K(w) = \begin{cases} 8, & L \text{ – английская и } w \text{ – известный ключ} \\ 0, & \text{иначе.} \end{cases}

Сумма +8 от ключа, +10 от русского словаря и плюсов за n-граммы создает уверенный перевес в пользу переключения.

При финальном сравнении набранного и альтернативного вариантов решающую роль играет контекст набранной фразы B(\varphi):

\begin{aligned} S_{\mathrm{typed}} &= S_L(w) + B(\varphi, L), \\ S_{\mathrm{other}} &= S_{\bar L}(\Pi(w)) + K(w) + B(\varphi, \bar L). \end{aligned}

Контекст \varphi сохраняет языки пяти предшествующих слов в текущем окне. Если последние 2–3 слова вводились на языке L, он получает фору до +3 баллов:

B(\varphi, L) = \begin{cases} 0, & h(L) < 2 \\ h(L), & h(L) = 2 \\ 3, & h(L) \ge 3. \end{cases}

Слово переворачивается только тогда, когда альтернативный язык выигрывает с явным отрывом, превышающим защитный порог M:

S_{\mathrm{other}} - S_{\mathrm{typed}} \ge M.

Величина порога M зависит от выбранного профиля чувствительности:

  • Обычный режим M = 4. Контекст фразы (максимум +3) не способен спровоцировать переключение сам по себе, но уверенно разрешает сомнения в коротких неоднозначных словах вроде jryj (окно).

  • Осторожный режим M = 7. Программа вмешивается только при прямом словарном совпадении или значительном преимуществе по слогам.

  • Агрессивный режим M = 2. Срабатывание происходит даже при минимальном перевесе.

  • Раскладка окна неизвестна фиксированный порог M = 6.

Приведу пример. Слово ghbdtn:

  1. Набранная латиница ghbdtn. Знакомых n-грамм нет (N = 0), в словаре нет (D = 0), гласных нет (V = 0), штраф формы H = -2, шесть согласных подряд R = -3. Итог S_{\mathrm{en}} = -5.

  2. Русская перекодировка привет. n-граммы дают N = 11, словарь D = 10, две гласные V = 2. Итог S_{\mathrm{ru}} = 23.

  3. Плюс бонус ключа K = 8. Итоговая разница:

S_{\mathrm{other}} - S_{\mathrm{typed}} = (23 + 8) - (-5) = 36 \ge 4.

Разница в 36 баллов с запасом перекрывает порог, и слово переключается.

Есть еще один маленький каверзный случай. Иногда поведение для конкретной программы по умолчанию не устраивает. Для этого в Ghbdtn заложен механизм адаптивного обучения. У каждого процесса в системе есть свой локальный профиль, где для каждого слова накапливаются два счетчика – запрет r и предпочтение p (в диапазоне от 0 до 12).

  • Запрет вступает в силу при r \ge 3 и r > p.

  • Принудительный перевод срабатывает при p \ge 3 и p > r.

Одиночная случайная манипуляция не сбивает программу с толку. Чтобы закрепить новое поведение, действие требуется подтвердить трижды.

При этом хранить тяжелые многомегабайтные морфологические словари ради утилиты переключения – нельзя. Вместо этого в Ghbdtn реализован алгоритм отсечения окончаний (легковесный стемминг):

  1. Список окончаний по убыванию длины – от трехбуквенных (ами, ого, ему) до однобуквенных (а, я, ы, ь). Сортировка по длине защищает слово машинами от среза только лишь одного -и на конце.

  2. Зеркальный список для латиницы – при инициализации строится точная проекция окончаний в раскладке QWERTY (ами -> fvb, ый -> sq). Алгоритм с одинаковой точностью нормализует как русские словоформы, так и ошибочную латиницу.

  3. Ограничение длины основы \ge 4 – окончание снимается только тогда, когда в корне остается не менее 4 символов. У слова машины отсекается -ы (остается корень машин длиной 5). А у слова коты окончание трогать нельзя. Иначе останется кот длиной 3. Это правило предотвращает ложное склеивание коротких корней.

  4. Также используется словарь супплетивных(!) семейств. Для форм с чередованием корней (идти/шёл, человек/люди, ребёнок/дети) используется компактная таблица связей.

В результате отмена переключения слова машины автоматически распространяется на формы машина, машиной и их клавиатурные проекции vfibyf / vfibys.

Отказ пользователя распознается по нажатиям Backspace. Если после автозамены человек нажал Backspace один или два раза – скорее всего, это обычное исправление опечатки в хвосте слова, и счетчики обучения не затрагиваются. Признаком явного отказа служит соотношение:

(e \ge n) \lor \bigl( (e \ge 3) \land (2e \ge n) \bigr).

Отказ фиксируется только тогда, когда стерто всё слово целиком (e \ge n) либо стерта хотя бы половина достаточно длинного слова (2e \ge n при e \ge 3).

Когда алгоритм принял окончательное решение о замене, наступает этап физической подмены. Архитектура текстовых полей Windows устроена так, что подменить введенный текст можно единственным путем - стереть старые символы и напечатать поверх новые. Если делать это посимвольно в цикле через keybd_event (ну кто так сейчас делает? Эээхххх...), буквы будут поочередно исчезать и появляться на глазах у пользователя с мерцанием и дерганьем каретки.

В модуле Ghbdtn.Fixer эта операция сведена к отправке единого пакета через системный вызов SendInput:

  1. В памяти формируется непрерывный массив записей TInput.

  2. В начало пачки укладываются нажатия и отпускания VK_BACK ровно по количеству символов исходного набора.

  3. Следом размещаются новые символы с флагом KEYEVENTF_UNICODE.

  4. В хвост дописывается завершающий разделитель (пробел или Enter), если он не был поглощен самим словом.

  5. Массив за один вызов SendInput передается подсистеме ввода Windows.

  6. Активному окну направляется сообщение WM_INPUTLANGCHANGEREQUEST для синхронизации языка в системе.

Приложение-получатель обрабатывает весь пакет за один проход очереди сообщений, поэтому на экране старое слово исчезает, а новое появляется сразу и без визуального шума.

Естественно, при эмуляции клавиатуры возникает проблема – глобальный хук видит сгенерированные виртуальные нажатия точно так же, как если бы по клавишам барабанил живой человек. Без специальной защиты программа моментально уйдет в бесконечную рекурсию. Чтобы исключить автопожирание, действуют две жесткие меры:

  • Каждое синтезированное событие помечается уникальной сигнатурой GHBDTN_INPUT_SIGNATURE в поле dwExtraInfo. Процедура хука считывает эту метку и передает управление дальше по цепочке.

  • На время выполнения вызова SendInput хук временно отключается (UnhookWindowsHookEx) и восстанавливается сразу после его завершения. Это исключает залипание клавиш-модификаторов и паразитные срабатывания.

Отдельное внимание – безопасность при вводе паролей. Перехватывать или переводить символы в секретных полях – нельзя. Проверка таких полей выстроена в два этапа:

  • Стандартные элементы управления Win32 проверяются системным запросом EM_GETPASSWORDCHAR.

  • Современные интерфейсы (браузеры, Electron, WPF) опрашиваются через подсистему UI Automation по атрибуту UIA_IsPasswordPropertyId.

Чтобы COM-вызовы к сторонним процессам не замораживали поток перехвата, проверка выполняется в асинхронном режиме с кэшированием статуса для активного окна с фокусом. Как только фокус оказывается в парольном поле, внутренний буфер накопления символов принудительно обнуляется.

Вся архитектура Ghbdtn подчинена одной цели – быть незаметной и предсказуемой. Программа не пытается казаться умнее пользователя, не лезет в сеть (думал сделать обновление с GitHub, но потом заключил в {$IFDEF}, по умолчанию отключено), не расходует десятки мегабайт на тяжелые среды выполнения и не нагружает процессор. Win32 API, легковесные n-граммы и монолитная компиляция в машинный код дают то, ради чего всё затевалось – мгновенный отклик за доли микросекунды, стабильную работу без утечек памяти и полный контроль над собственным рабочим инструментом.

Собранную версию (.exe) я не выкладываю и не распространяю. Программа представляет собой низкоуровневый клавиатурный перехватчик и в современных реалиях запускать чужие готовые бинарники с такими системными привилегиями – сомнительная идея с точки зрения безопасности. Намного спокойнее и честнее собрать утилиту самостоятельно из исходного кода.

Для сборки понадобится среда Embarcadero Delphi / RAD Studio 13 Florence (целевая платформа – Win64). Проект собирается штатными средствами без каких-либо сторонних пакетов и компонентов. Достаточно открыть Ghbdtn.dproj в IDE либо запустить пакетный скрипт build.bat Release Win64, который скомпилирует исполняемый файл в папку bin\Ghbdtn.exe. Модульные тесты собираются и прогоняются скриптом tests\build-tests.bat на базе встроенного фреймворка DUnitX.

Полный исходный код проекта, тесты и алгоритмическая документация открыты на GitHub: github.com/Laex/Ghbdtn.