П’ять днів, Carbon і трохи болю: як я робив macOS-утиліту для виправлення розкладки

Caramba Switcher або Punto Switcher знають майже усі. Мене ж зачепило інше: що насправді ховається за простим «виправити розкладку»? Виглядає як задачка на пів години, а виявляється, там і реверс розкладок через Carbon, і читання виділення через Accessibility, і ціла історія з тим, що довелося викинути. А порт на Windows вийшов окремим викликом: чи реально зібрати кросплатформну тулу, що використовує суто системні API кожної ОС, без Electron/Qt та інших абстракцій?

Вийшов reLayout: 5 днів, 155 комітів, 17 релізів, 12 мов інтерфейсу, ~3000 рядків Swift. Нижче зібрано найцікавіші розвилки, зокрема ті, де правильним ходом було не додати фічу, а прибрати її.

Проблема

Набрав фразу, не глянувши на індикатор, і отримав Ghbdsn замість Привіт або Руддщ замість Hello. Ще гірше, коли набрав повну фразу. Хочеться: виділити, натиснути гарячу клавішу, і текст перенабереться символ-у-символ у правильній розкладці, а системний input source перемкнеться. Натиснув клавішу ще раз протягом ~1,5 с — відкат.

Звучить як таблиця відповідностей йq, цw. Але так робити не можна, і це перший цікавий момент.

Рушій: чому не таблиця «символ → символ»

Найпростіше рішення: словник йq, цw. Для пари ЙЦУКЕНQWERTY він навіть працює: більшість Punto-клонів так і живуть, бо ua/ruen покриває майже всіх. Але це лише одна пара з багатьох. Та сама послідовність клавіш Q W E R T Y дає qwerty, йцукен, qwertz, azerty або навіть ',.pyf, залежно від активної розкладки (саме тому вони так і названі).

Захардкодиш йq — прив’яжешся до однієї пари й одного напрямку: для FRUS треба aq, для Dvorak 'q. Тримати окрему таблицю під кожну пару — це така собі ідея. Тому замість словника будемо обчислювати кожну клітинку на льоту:

Рушій не знає про мови, а питає в системи, що насправді встановлено. На macOS це Carbon: UCKeyTranslate працює лише в один бік (keycode + модифікатори → символ), тому зворотну карту символ → keycode будуємо один раз, проганяючи через нього всі клавіші (128 × кілька комбо-модифікаторів). Далі сама конвертація зводиться до прямого O(1) lookup у двох готових словниках. Так, Carbon у 2026-му: сучасної Swift-заміни на цьому рівні просто немає, і це правильний інструмент, а не борг (RegisterEventHotKey для глобальної клавіші теж звідти).

Увесь рушій лежить у платформонезалежному вигляді Core/Engine.swift, близько 200 рядків, без імпорту AppKit/Carbon/WinSDK. Його споживають macOS-застосунок, порт на Windows і тести.

Читання та запис виділення

Прочитати виділення з довільного застосунку непросто. Пріоритет за Accessibility (AXSelectedText напряму, не чіпаючи буфер обміну). Де AX недоступний, працює fallback на буфер. Спершу fallback вирізав виділення через Cmd+X — здавалося, ідеально: cut читає і прибирає текст одним кроком, і подвійний Cmd+C не будить DeepL та подібні «copy-twice» тулзи, що стежать за Cmd+C+C. Але cut має побічний ефект: «smart cut-and-paste» (Word і текстові в’юхи з smartInsertDelete) разом із вирізаним словом мовчки з’їдає сусідній пробіл — поза виділенням, ззовні цього не видно, — і виправлене слово приклеюється до попереднього.

Тож явне виділення тепер читається через Cmd+C: copy лишає виділення на місці, а синтетичний ввід заміняє рівно його; cut зостався лише для захоплення рядка без виділення (там виділення починається від початку рядка — з’їдати нічого). Один Cmd+C за retype — пари Cmd+C+C для DeepL так і не виникає. Фікси по дорозі: не затирати буфер на AX-шляху (бо дані користувача, то святе); не писати виділення у тимчасові файли в release (бо приватність); кешувати список розкладок, а не смикати Carbon на кожне натискання. Запис назад робиться через синтетичний ввід у стилі Caramba, а вже потім перемикання input source.

А якщо нічого не виділено? Тоді хапаємо рядок від каретки до його початку (Shift+Cmd+Left). Але такий рядок майже завжди змішаний: ліворуч уже правильний текст, а праворуч — той єдиний хвіст у чужій розкладці, який ти щойно набрав. Конвертувати весь рядок не можна — затреш правильне. Тому хвіст звужуємо: якірний скрипт визначає останню літеру рядка (бо думаєш ти саме про щойно набране), далі йдемо НАЗАД через літери того ж скрипта й нейтральні символи (цифри, пунктуація, пробіли) і зупиняємося на першій літері іншого скрипта.

Вікно — все, що після цієї літери. Якщо вікно почалося посеред слова (стоп-літера потяглася у змішаний токен), відрізаємо залишок слова до першого нелітерного знака. Решта — конвертується. привіт ghbdsn з активною uk дає привіт привіт, а не ghbdsn ghbdsn. Уся ця логіка — чиста функція над рядком Core/Engine.swift, тож має тести й безкоштовно дістається порту на Windows.

Розвилка: рахувати символ чи «відстрілити клавіші» заново

Type-back (typeUnicode у main.swift) шле події через keyboardSetUnicodeString: кладе готовий Unicode-символ прямо в подію, повз активну розкладку. Детерміновано, від розкладки отримувача не залежить. Збоку це схоже на те, ніби текст сам себе переписує.

Напрошується альтернатива: навіщо обчислювати символ, якщо є KeyStroke? Перемкнемо розкладку й відстрілимо назад keycode+mods, хай ОС сама народить символ (викидаємо strokeToChar). Але це крок до проблем:

  • Гонка з перемиканням. TISSelectInputSource асинхронний; відстрілиш keycode зарано, і він перекладеться під старою розкладкою — буде сміття. Спостерігач kTISNotifySelectedKeyboardInputSourceChanged каже лише, що «вибір змінився», а не те, що «отримувач готовий перекладати».
  • Переклад залежить від застосунку (Cocoa, Electron, термінал, VNC — по-різному); keyboardSetUnicodeString це обходить.
  • Dead keys під US-Intl/French запустять реальну композицію, це про символи з діакритикою; зараз kUCKeyTranslateNoDeadKeysBit резолвить їх у процесі.
  • Економії нуль: реверс char → KeyStroke усе одно потрібен; викидаємо лише дешевий lookup, а натомість маємо OS-round-trip з усіма ризиками вище, ще й undo ламається.

Тож я свідомо лишив варіант, де символ обчислюється заздалегідь і вже готовим вирушає в систему. Саме це й прибирає всі три класи багів одразу: без гонок із розкладкою, без сюрпризів від конкретного застосунку, без зайвої dead-key-композиції. «Переграти» фізичні клавіші заново виправдано лише там, де готовий символ не можна інжектити безпосередньо.

На Windows SendInput уміє KEYEVENTF_UNICODE, але застосунки, що читають ввід на рівні скан-кодів (ігри, частина RDP, легасі), цей прапор ігнорують. На Linux під X11 (XTEST) і Wayland чистого Unicode-інжекту взагалі немає — там керуєш keycode’ами, тож replay стає основним шляхом. На macOS keyboardSetUnicodeString знімає це питання.

Гарячі клавіші: тап-модифікатора і граблі

Дефолт: тап лівого Option (натиснув-відпустив). Граблі:

  • Mod-tap: якщо в циклі з’явився чужий модифікатор, тап не має спрацьовувати. Інакше Option+Control ловиться як «тап Option» і спонтанно перенабирає текст.
  • Тап-послідовність: гарячою клавішею стала довільна послідовність із N тапів, записувана в Settings; у Caramba ж можна Shift + Shift, але ж ми не гірші.
  • Focus-gate баг: поки записуєш гарячу клавішу, треба глушити і її, і автокоректор. Інакше коміт тап-послідовності заново зводить хоткей, поки поле запису ще у фокусі, спрацьовує retype, і синтетичний Cmd+X летить назад у поле. Фіча ламає себе через власне введення. Перша спроба глушила все, поки відкрите Settings, — але так клавіша вмирала й тоді, коли користувач просто відкрив налаштування й нічого не записував. Правильний обсяг — не вікно, а саме поле запису: гейт прив’язаний до фокусу рекордера, тож хоткей живий усюди, крім миті, коли його перенабивають. Гонка без лідера лікується звуженням, а не глушінням ширше.

Авто-коректор і чесний негативний результат

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

Найочевидніший — словник, як у Punto: немає слова в поточній мові → переверни розкладку. Біда в тому, що він сиплеться на довгому хвості: абревіатури, імена, команди, сленг. Короткий латинський токен легко виявляється валідним словом, і словник радо його «виправить»:

Словник оперує бінарним «є/немає у списку» і не відрізняє сміття від навмисного токена. Тому детектор я зробив імовірнісним: char-trigram модель (Core/Trigram.swift). Моделі генеруються офлайн із частотних списків слів (FrequencyWords Hermit Dave), побудованих на базі корпусу OpenSubtitles. Калібрування (calibrate.py) перебирає пороги θ_garbage / θ_margin, фіксуючи precision ≥ 99% (хибних ≤ 1%), і дивиться, який recall лишається:

параrecall @ precision ≥ 99%θ_garbageθ_marginFP
uk ↔ en99.2%—2.40.059/7427
ru ↔ en99.4%—2.60.049/7769
de ↔ en19.4%—3.41.874/7889
fr ↔ en66.8%—3.81.469/7058
es ↔ en3.6%—4.42.472/7216

Як читати таблицю: precision усюди зафіксований на ≥99%, тобто це і є умова sweep. Дивись стовпчик FP (хибних ≤1% у кожному рядку). Питання лише в тому, скільки помилок при цій планці ми реально ловимо, тобто recall. І ось він усе й вирішує: для cross-script ловимо майже всі (~99%), а для латиниці recall обвалюється й стрибає: es — 3.6%, de — 19%, fr — раптом 67%. Забагато слів валідні в обох розкладках; поріг, що ловить французьку, для німецької марний. Виходить режим, що для однієї мови працює, а для сусідньої тихо мовчить; такий випускати не можна. Тому автокоректор поїхав лише для cross-script (uk/ru ↔ en), типово вимкнений. Негативний результат — теж результат.

Плюс винятки для програм: preset-список терміналів/IDE став deny-list’ом з редагуванням.

Дистрибуція, це швидко, але є нюанси

Збірка-підпис-викладка зайняла, від сили, вечір, бо це переважно CI-boilerplate. Але два місця варті уваги:

  • Developer ID + нотаризація Apple + Іtaple, Sparkle для авто-апдейтів, Homebrew Cask з авто-bump тапа, усе в CI.
  • Окремі dev/release збірки: різні bundle id і імена .app, щоб macOS тримав окремі гранти Accessibility (TCC); інакше кожне збирання скидає дозвіл.

Версія береться з Git-тегу, реліз на GitHub Actions по пушу тегу.

Один рушій — дві ОС

Головний мотив узятися за Windows був спортивний: чи можна зробити кросплатформну тулу, що смикає суто системні API кожної ОС, без runtime-абстракцій. Одна мова (Swift), спільне ядро, а платформні шари б’ють у найнижчий рівень: macOS — Carbon/AppKit (UCKeyTranslate, RegisterEventHotKey, AX), Windows — голий WinAPI (ToUnicodeEx, SendInput, UIA). Відповідь: можна. (Є навіть ще більш кросплатформний шлях, якщо замість Swift узяти Rust, але «не сьогодні»).

Платформонезалежний Core/Engine.swift винесений у SwiftPM-модуль ReLayoutCore. Його споживають macOS-застосунок (macos/main.swift, близько 2000 рядків), порт на Windows (windows/, десь 900 рядків) і кросплатформні тести в CI на обох ОС. Правило: міняєш алгоритм, правиш Engine.swift і додаєш тест, і обидві платформи підхоплюють.

Індикатор розкладки, який було зібрано — і видалено

Очевидна фіча: при зміні розкладки міняти іконку в menu-bar (macOS) і треї (Windows) на копію системного індикатора (ENG/УКР). Я й сам колись тримав власний індикатор Punto/Caramba замість системного: у меню перемикача все було під рукою. Тож зробити такий і в reLayout здавалося природним. На Windows це вилилося в довгу возню з GDI: каша з неініціалізованої AND-маски, читабельність на світлому/темному таскбарі, storm із понад 900 викликів ToUnicodeEx кожні 400 мс (було переписано на дешевий LANGID), системний шрифт + нативне ім’я мови.

А далі найцікавіше. Трей-комірка квадратна, ~16px, а системний УКР у Windows — широка текст-смуга в таскбарі, а не іконка. Широкою трей-іконку зробити не можна; будь-який варіант — квадратне наближення, гірше за оригінал, що стоїть поруч із годинником. Windows вже малює індикатор розкладки сам, тож наш виявляється незграбним клоном. macOS теж показує її в нативному меню. Тобто, дубль на обох ОС, а на Windows ще й виглядає кострубато.

Рішенням було прибрати все зайве: індикатор і селектор розкладок із трея й меню, налаштування «tray icon», рядок «Layouts». Іконка тепер має бути завжди статичним логотипом. Потім чистка мертвого коду. Мінус фіча, мінус код, мінус 400-мс polling. Принцип той самий, що й колись із чужим перемикачем, від якого я відмовився: лишай те, що працює добре, а не дубль, зроблений абияк.

Але... Windows coming soon

Порт дійшов до робочого MVP: глобальна клавіша, clipboard, SendInput, перемикання розкладки, трей. Спільний рушій, як найважча логічна частина, вже працює. І все одно я його заморозив. Бо читання виділення на Windows іде лише через clipboard (деградація порівняно з Accessibility на macOS); не дороблені UIA-читання, Settings UI, релізне пакування/підпис у CI, storage-адаптер. А macOS-реліз був повністю готовий. Розтікатися на два напівфабрикати гірше ніж зашипити один.

У CI все чесно: job build-windows загейчений на workflow_dispatch, не на тег-реліз (з v1.2.9 релізи macOS-only). Це не «кинули», а «ядро готове, платформна інтеграція чекає черги».

Процес: 5 днів, AI-сесії, handoff-доки

Розробка відбувалася в сесіях у Claude Code. Щоб не втрачати контекст між сесіями, завів два файли: SNAPSHOT.md («де ми зараз», перезаписується) і HISTORY.md (append-only хроніка, комітиться). Саме з HISTORY.md і частково з JSONL сесій потім зібралася ця стаття.

Темп по днях:

Перший вечір дав робочий прототип і розуміння, що шанси є. Пік на другий день.

Підсумки

  • 5 днів, 155 комітів, 17 релізів, 12 мов, ~3000 рядків на Swift.
  • Технічний досвід: конвертація розкладок — це реверс через встановлені розкладки, а не через таблицю символів.
  • Продуктовий досвід: знати, що не випускати, важливіше, ніж впхати все: відрізали Latin↔Latin автокоректор і власний індикатор дубля, заморозили Windows до готовності.
  • Інженерний досвід: детермінований in-process шлях (обчислити символ + інжектити Unicode) б’є «чесний» replay клавіш, бо прибирає гонки, різнобій застосунків і dead-key by design.

Застосунок безкоштовний, код відкритий під ліцензією MIT: бери, форкай, роби свій порт для Windows/Linux. До речі, з Linux все ще цікавіше, бо там X11 vs Wayland.

Сайт: relayout.forfutdinov.com · GitHub: github.com/vladforfutdinov/reLayout

Підписуйтеся на Telegram-канал «DOU #tech», щоб не пропустити нові технічні статті

👍ПодобаєтьсяСподобалось7
До обраногоВ обраному0
LinkedIn
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter

Спробував на останньому релізі — взагалі не працює. Тобто працює взагалі неправильно.
Розкладки: укр. кастомна, англ, ісп.
мак ос 26.5.1
Баги:
— вводжу «руддщ», жму опшн-кей — нічого не відбувається. з «ghbdsn» — аналогічно
— включаю автопереклад — все ламається, слова самовільно перекладаються з правильних на криві (напр. той самий «привіт» -> «ghbdsn»), розкладка сама переключається. Коротче повний неадекват

Гляньте як варіант мій форк альтернативного розкладчика, який просто працює:
github.com/v-ivanyshyn/SwitchFix
Основна фішка — якщо ввів фразу на не тій розкладці, виділяємо її, переключаємо розкладку, і введена фраза автоматично виправляється на ту розкладку, на яку ми переключились, ніяких додаткових хоткеїв і складної логіки

дуже схоже на конфлікт двох працюючих одночасно конверторів

До речі, з Linux все ще цікавіше, бо там X11 vs Wayland.

Мало того, співпраця з Wayland в Gnome та з Wayland в KDE в контексті такої утілітки відрізняється.

Дуже дякую за статтю, не працював з мак на такому рівні як описано в статті, але коли перейшов з віндовс на лінукс найбільшим обмеженням для мене стали не «проблеми з Nvidia» (якщо хтось в темі — знає про що я), а якраз відсутність *-switcher`ів.

Тому прийшлось розроблювати свій. Основне завдання — керувати в автоматичному режимі зміною розкладки, основний тригер — пробіл, робота в терміналі обов’язкова. Судді (перемикати чи ні) — Hunspell (завичай використовується для перевірки орфографії) та моделька FastText від Facebook AI Research (по суті аналог способу використання триграм).

І все почалося з Gnome + Wayland. Досить незвично мати такого рівня обмеження, але спрацювала ідея розробити Gnome extension в якості проксі для керування розкладкою та отриманням поточної розкладки. Варіант не самий стабільний, але працюючий.

З KDE було простіше — посередник не потрібен навіть для Wayland, KDE надає прямий доступ до D-Bus-API.

Проєкт створювався як «для себе», але прочитавши статтю розумію що мабуть я не один такий, і проєкту є місце на github.

Ще б хтось зробив перемикання по Ctrl+Shift...

Сліпий десятипальцевий метод набору тексту. Шкода, на мобільному так ніззя

Що тільки не придумають, щоб не вчитися друкувати не дивлячись на клавіатуру =))

У мене два лаптопи з суттєво різними клавіатурами, навчитись сліпому набору для обох — нереально. А ще є десктоп, там третій варіант.
Якщо ви в змозі все робити з одною клавою — вам дуже пощастило.

І чим допоможе той сліпий набір, коли не перемкнув вчасно?

Підписатись на коментарі