Kutit: раскладка, которая помнит, где я печатал
Утилита для macOS на Hammerspoon: запоминает раскладку для каждого поля ввода и вкладки браузера и возвращает её, когда я туда возвращаюсь. Не читает ни одного введённого символа.
На этой странице
Я пишу на двух языках весь день: код и терминал на английском, рабочая переписка и заметки на русском, запросы к ИИ-ассистенту вперемешку. Раскладка переключается сотни раз в день, и каждый раз, когда я ошибаюсь, первые буквы слова улетают не туда.
Kutit (читается как «cut it») решает это так: запоминает раскладку для каждого поля ввода и каждой вкладки браузера и возвращает её, когда я снова туда попадаю. Чат в Telegram остаётся русским, поле поиска в IDE остаётся английским, вкладка с русским запросом остаётся русской после перехода по ссылке.
Главный принцип: ничего не читать из ввода
Утилита, которая следит за клавиатурой, по умолчанию вызывает подозрение. Поэтому правило заложено с первого дня и проверяется в коде, тестах и логах:
- Kutit не читает и не сохраняет ни одного введённого символа. Нажатие клавиши для него только сигнал «здесь печатали», без содержимого.
- Заголовки окон не используются как ключи и не пишутся в лог.
- В память и на диск попадают только ключи (приложение, номер окна, ключ поля или вкладки) и идентификатор раскладки.
- Ничего не уходит с машины.
Где я печатаю, Kutit узнаёт из дерева доступности macOS (Accessibility API). Это тот же интерфейс, которым пользуются экранные дикторы: он описывает структуру окна, а не то, что в него вводят.
Как это работает
Память по полю. Ключ строится как «приложение → окно → поле». Поле опознаётся по AXIdentifier, если он есть (заметка в Notes, адресная строка Safari), иначе по хвосту пути в дереве доступности. Набор текста или ручное переключение в поле учат его раскладке, возвращение в поле её восстанавливает.
Вкладки браузера. Каждая вкладка помнит свою раскладку при переходах по ссылкам и перестановке вкладок. Вкладку я нахожу в полосе вкладок окна по правилам, записанным как XPath-подобные строки. Ключ берётся из ChromeAXNodeId в Chrome и Vivaldi или сама кнопка вкладки в Safari. Внутри вкладки у адресной строки, поиска на панели и каждого поля страницы своя память.
Умолчания. Адресная строка браузера, Spotlight, Siri и поля паролей всегда получают раскладку по умолчанию и никогда не запоминаются. Поле или вкладка без истории тоже получают умолчание.
Переключение по 🌐. Клавиша Globe (Fn) в macOS включает раскладку с заметной паузой. Первым советом было переназначить её на Caps Lock. Оказалось, что это не нужно: короткое нажатие Globe можно перехватить напрямую. В системных настройках «Press 🌐 key to» ставится в «Do Nothing», а Kutit переключает раскладку сам, мгновенно.
Плашка. После переключения над полем ввода появляется небольшая плашка с языком. Если поля нет, плашка появляется по центру окна. Есть три стиля на выбор.
Сон и пробуждение. Память переживает перезапуск Hammerspoon. Когда в ней что-то меняется, Kutit ждёт минуту затишья и сохраняет её бинарным снапшотом, а при выходе сохраняет сразу. При старте память восстанавливается.
Перехват Globe
Globe для macOS модификатор, как Shift: отдельных событий нажатия и отпускания у неё нет, приходит только flagsChanged. Переключать раскладку нужно на короткое нажатие и отпускание без других клавиш между ними, чтобы fn+F-клавиши и fn+стрелки работали как раньше:
function t.onFlagsChanged(code, flags)
if not enabled or code ~= globKey then return end
if flags and flags[globFlag] then
armed, chord = true, false
elseif armed then
armed = false
if not chord and not (flags and (flags.cmd or flags.ctrl or flags.alt)) then
t.onKeyDown(code, flags)
t.onKeyUp(code)
end
end
end
function t.onOtherKeyDown()
if armed then chord = true end
end
Событие модификатора при этом не поглощается: иначе fn может «залипнуть» в системе.
Журнал: что пробовал и от чего отказался
Проект прожил неделю, и почти каждый день что-то из сделанного накануне уходило. Здесь собраны подходы, которые я применял, и причины, по которым от них отказался.
Ключ по заголовку окна. Первая версия запоминала раскладку по заголовку окна: транслитерированному, с LRU-лимитом на окно, в JSON-файле. Это работало, пока заголовок не менялся, а меняется он постоянно. Хуже другое: заголовок содержит то, что я читаю и пишу. Заголовки ушли из ключей, затем из логов, затем из кода вместе с модулем транслитерации.
Угадывание по словам. Была ветка, которая предсказывала раскладку по частоте слов в заголовке. Отказался по той же причине: чтобы угадывать по содержимому, его надо читать.
Путь в дереве доступности. Ключом поля стал хвост пути к нему в дереве: роли элементов и номера среди одноимённых соседей. Это не зависит от текста и стабильно, пока приложение не перестраивает интерфейс.
Адресная строка по форме дерева. Детектор адресной строки по структуре дерева давал разный ответ в зависимости от момента: Chrome строит дерево лениво, а у стартовой страницы Safari его нет вовсе. Его заменило простое правило: приложение зарегистрировано как обработчик https, поле лежит в панели инструментов, и между ними нет веб-страницы.
Таймер, который съел сборщик мусора. Если на hs.timer никто не ссылается, он собирается и не срабатывает. После этого Kutit тихо переставал реагировать на смену фокуса до перезагрузки конфигурации. Теперь тестовый «фейковый мир» держит таймеры слабыми ссылками и собирает мусор перед каждым шагом, и такая ошибка ловится сразу.
Держать дерево доступности включённым. Chromium выключает своё дерево, если к нему долго не обращаются, и после этого Kutit «слепнет» во вкладках. Идея включить AXManualAccessibility для всех приложений не сработала. Chrome этот атрибут не поддерживает. Альтернатива AXEnhancedUserInterface замедляет оконные менеджеры. А включённое дерево в каждом приложении стоит процессора и батареи. Осталось поддерживать дерево только у активного приложения.
Пережить перезагрузку системы. После перезагрузки macOS заново нумерует окна, браузер нумерует вкладки. Старая запись может попасть на чужое новое окно и выдать ему чужую раскладку. Поэтому снапшот привязан к моменту загрузки системы, и после перезагрузки память начинается с нуля.
Свежий Chrome молчит. В только что запущенном Chrome macOS иногда не присылает событие о смене раскладки. Kutit не узнавал о переключении на русский и возвращал умолчание. Теперь переключатель Globe сам сообщает, какую раскладку он включил.
Поле, которое пересоздаётся. Telegram пересоздаёт поле сообщения при каждой смене чата: старый элемент умирает, новый лежит по тому же пути. Память теперь привязана к ключу, а не к элементу. Командные палитры Chromium отдают фокус пункту списка, а не полю ввода, и Kutit смотрит на поле рядом со списком.
Как я это проверяю
Все модули, которым нужна система, получают её через внедрённое окружение, поэтому тесты идут на обычном lua без Hammerspoon:
- сценарии трекера и случайные последовательности «фокус, набор, переключение», которые сверяются с эталонной моделью;
- реальные деревья доступности Chrome, Vivaldi, Safari, Notes и VSCodium, снятые с живых приложений, только роли элементов;
- трассы живых сессий: Kutit записывает, что видел и что сделал, и такая трасса становится регрессионным тестом. В трассу, как и в память, попадают только ключи и раскладки.
Что дальше
Kutit работает у меня каждый день на Hammerspoon. Следующий шаг — нативное приложение на Swift: план расписан по фазам, Lua-версия остаётся эталоном, и порт должен вести себя так же на тех же записанных сценариях.