В Windows пользователи давно оценили удобство инструментов вроде Punto Switcher - утилиты, которая исправляет текст, случайно набранный не в той раскладке. Достаточно выделить фразу и нажать горячую клавишу и текст транслитерируется из кириллицы в латиницу или наоборот. Это особенно полезно при написании кода, когда переключаешься между языками, но забываешь переключить саму раскладку клавиатуры.
В Linux аналогичных решений есть несколько, например, xneur (написан на C++, не обновлялся с 2016 года), loloswitcher (написан на C++, не обновлялся с 2018 года), xswitcher (написан на golang, не обновлялся с 2021 года). Большинство из них давно не обновляются и работают нестабильно на современных дистрибутивах, при этом требуют для запуска root права, что не безопасно и требует досконального изучения кода перед использованием. Обратите внимание, что все языки компилируемые. Нет простого решения - поправил что надо и запустил.
Так как Python славится своей универсальностью, была предпринята попытка написать свое легковесное, гибкое и полностью контролируемое решение переключения раскладки выделенного текста PySwitcher. Скрипт реализует простую идею - выделил текст не в той раскладке, нажал горячую клавишу - получил текст в нужной раскладке. Его можно легко настроить под себя, расширить функционал и использовать в повседневной работе.
Punto Switcher - отличный инструмент, но он существует только для Windows. На Linux его официальной версии нет, а сторонние попытки воссоздать его функционал зачастую не дотягивают до стабильности и гибкости. Кроме того, многие из них:
root для запуска.PySwitcher работает на X11 (и может быть адаптирован под Wayland с использованием wtype вместо xdotool), не требует прав суперпользователя во время работы и легко модифицируется.
pyperclipМожно было бы использовать готовые библиотеки, например, pyperclip - популярную обёртку для работы с буфером обмена. Однако в PySwitcher принято решение работать с утилитами напрямую: xclip и xdotool. Почему?
pyperclip - это обёртка над xclip, xsel и pbcopy. Зачем тащить целую библиотеку, если можно вызывать xclip напрямую?subprocess можно точно настроить поведение: выбор буфера (clipboard или primary), обработку ошибок, задержки.pyperclip перестанет поддерживаться, xclip и xdotool останутся стандартными утилитами в большинстве дистрибутивов.PySwitcher - это фоновый скрипт, который:
evdev);Также скрипт поддерживает:
notify-send;sudo apt install xclip xdotool python3-evdev libnotify-bin
На других дистрибутивах используйте соответствующий менеджер пакетов (например,
dnf,pacman,zypper).
inputЧтобы скрипт мог читать события с клавиатуры, нужно добавить текущего пользователя в группу input:
sudo usermod -aG input $USER
После этого перезапустите сеанс (выйдите и зайдите снова).
PySwitcherПодробно прокомментированный код скрипта pyswitcher.py - транслятор раскладки выделенного текста
#!/usr/bin/env python3 """ pyswitcher - автоматический транслитератор выделенного текста по горячей клавише. Основные возможности: - Переключение раскладки (латиница ↔ кириллица) по горячей клавише (по умолчанию CapsLock). - Изменение регистра выделенного текста: Ctrl+U - верхний, Ctrl+L - нижний. - Минимизация артефактов в системном буфере обмена: исходное содержимое CLIPBOARD всегда сохраняется и восстанавливается после операции (насколько это возможно). - Специальный обход для редактора Kate: вместо Ctrl+V используется удаление выделения и печать текста через xdotool type, чтобы избежать вставки в начало строки. Зависимости: - xclip (работа с X11 буфером обмена) - xdotool (эмуляция нажатий клавиш / печати) - python3-evdev (чтение /dev/input) """ import argparse import os import sys import time import subprocess import evdev # --- КОНФИГУРАЦИЯ КЛАВИШ --- TRIGGER_KEY = 'KEY_CAPSLOCK' # основная горячая клавиша: транслитерация TOGGLE_KEY = 'KEY_PAUSE' # вкл/выкл транслитерации CTRL_KEY = 'KEY_LEFTCTRL' # отслеживаем левый Ctrl (по желанию можно добавить правый) UPPER_KEY = 'KEY_U' # Ctrl + U => ВЕРХНИЙ РЕГИСТР LOWER_KEY = 'KEY_L' # Ctrl + L => нижний регистр USER_INPUT_GROUP = 'input' # группа для доступа к /dev/input XCLIP_SELECTION = 'clipboard' # работаем с CLIPBOARD (Ctrl+C / Ctrl+V) # --- ТАБЛИЦЫ ТРАНСЛИТЕРАЦИИ --- lat = "`qwertyuiop[]asdfghjkl;'zxcvbnm,./~QWERTYUIOP{}ASDFGHJKL:\"ZXCVBNM<>?#" rus = "ёйцукенгшщзхъфывапролджэячсмитьбю.ЁЙЦУКЕНГШЩЗХЪФЫВАПРОЛДЖЭЯЧСМИТЬБЮ,№" translit_dict = dict(zip(lat, rus)) # латиница => кириллица reverse_translit_dict = {v: k for k, v in translit_dict.items()} # кириллица => латиница def log(msg: str, debug: bool) -> None: """Простой логгер: печатает сообщение только в режиме debug.""" if debug: print(f"[LOG] {msg}") def notify(msg: str, debug: bool) -> None: """Показать уведомление через notify-send (если утилита доступна).""" try: subprocess.run( ['notify-send', 'PySwitcher', msg], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=False, ) except FileNotFoundError: log("notify-send не найден, уведомление пропущено.", debug) except Exception as e: log(f"Ошибка notify-send: {e}", debug) def check_permissions(debug: bool) -> bool: """ Проверяет, есть ли у текущего процесса доступ к /dev/input: - если запущено от root - считаем, что доступ есть; - иначе проверяем, состоит ли пользователь в группе USER_INPUT_GROUP. """ if not os.path.exists('/dev/input'): log("Ошибка: не найден /dev/input.", debug) return False if os.geteuid() == 0: log("Запуск от root - проверка группы input пропущена.", debug) return True try: import grp input_gid = grp.getgrnam(USER_INPUT_GROUP).gr_gid if input_gid in os.getgroups(): return True log( f"Пользователь не в группе '{USER_INPUT_GROUP}'. " f"Добавьте: sudo usermod -aG {USER_INPUT_GROUP} $USER", debug, ) log("После этого перезайдите в сессию или перезагрузитесь.", debug) return False except KeyError: log(f"Группа '{USER_INPUT_GROUP}' не найдена в системе.", debug) return False except Exception as e: log(f"Ошибка проверки прав: {e}", debug) return False def find_physical_keyboard(debug: bool): """ Находит физическую клавиатуру по её возможностям: - устройство не должно называться 'virtual'; - в capabilities должны быть коды клавиш и, как минимум, KEY_A и KEY_SPACE. """ try: devices = [evdev.InputDevice(path) for path in evdev.list_devices()] except Exception as e: log(f"Не удалось получить список устройств ввода: {e}", debug) return None for device in devices: try: if 'virtual' in device.name.lower(): continue caps = device.capabilities() if evdev.ecodes.EV_KEY not in caps: continue keys = caps[evdev.ecodes.EV_KEY] if evdev.ecodes.KEY_A in keys and evdev.ecodes.KEY_SPACE in keys: log(f"Используем клавиатуру: {device.path} ({device.name})", debug) return device except Exception as e: log(f"Ошибка при разборе устройства {device.path}: {e}", debug) return None def get_clipboard_text(debug: bool) -> str: """ Считывает текст из X11 selection (по умолчанию CLIPBOARD) с помощью xclip. В случае ошибок возвращает пустую строку и логирует проблему. """ try: text = subprocess.check_output( ['xclip', '-o', '-selection', XCLIP_SELECTION], stderr=subprocess.DEVNULL, text=True, ) return text except FileNotFoundError: log("xclip не найден. Установите: sudo apt install xclip", debug) return "" except subprocess.CalledProcessError: # xclip вернул ошибку (например, буфер пуст) - считаем, что текста нет return "" except Exception as e: log(f"Ошибка чтения буфера обмена: {e}", debug) return "" def set_clipboard_text(text: str, debug: bool) -> bool: """ Записывает текст в X11 selection (CLIPBOARD) через xclip. Возвращает True при успехе, False при ошибке. """ try: subprocess.run( ['xclip', '-i', '-selection', XCLIP_SELECTION], input=text, text=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=True, ) return True except FileNotFoundError: log("xclip не найден. Установите: sudo apt install xclip", debug) return False except subprocess.CalledProcessError as e: log(f"Ошибка записи в буфер обмена (xclip): {e}", debug) return False except Exception as e: log(f"Непредвиденная ошибка записи в буфер обмена: {e}", debug) return False def copy_selection(debug: bool) -> None: """ Просит активное окно скопировать выделенный текст (Ctrl+C). С учётом clearmodifiers, чтобы модификаторы не мешали. """ log("Пробуем скопировать выделение (Ctrl+C)...", debug) try: subprocess.run( ['xdotool', 'key', '--clearmodifiers', 'ctrl+c'], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=False, ) except FileNotFoundError: log("xdotool не найден. Установите: sudo apt install xdotool", debug) except Exception as e: log(f"Ошибка вызова xdotool (copy): {e}", debug) # Даём приложению время обновить буфер time.sleep(0.15) def paste_selection(debug: bool) -> None: """ Просит активное окно вставить текст (Ctrl+V). Используется только в "общем" случае (не для Kate). """ log("Пробуем вставить (Ctrl+V)...", debug) try: subprocess.run( ['xdotool', 'key', '--clearmodifiers', 'ctrl+v'], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=False, ) except FileNotFoundError: log("xdotool не найден. Установите: sudo apt install xdotool", debug) except Exception as e: log(f"Ошибка вызова xdotool (paste): {e}", debug) time.sleep(0.1) def delete_selection(debug: bool) -> None: """ Удаляет текущее выделение в активном окне. В большинстве редакторов (включая Kate) BackSpace при активном выделении удаляет весь фрагмент и оставляет курсор в начале удалённого участка. """ log("Удаляем выделение (BackSpace)...", debug) try: subprocess.run( ['xdotool', 'key', '--clearmodifiers', 'BackSpace'], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=False, ) except FileNotFoundError: log("xdotool не найден. Установите: sudo apt install xdotool", debug) except Exception as e: log(f"Ошибка вызова xdotool (delete_selection): {e}", debug) time.sleep(0.05) def type_text(text: str, debug: bool) -> None: """ Печатает указанный текст в активном окне через xdotool type. Используется в обход Ctrl+V для проблемных приложений (например, Kate). """ log(f"Печатаем текст через xdotool type (len={len(text)})", debug) try: subprocess.run( ['xdotool', 'type', '--clearmodifiers', '--delay', '0', '--', text], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=False, ) except FileNotFoundError: log("xdotool не найден. Установите: sudo apt install xdotool", debug) except Exception as e: log(f"Ошибка xdotool type: {e}", debug) def is_kate_active(debug: bool) -> bool: """ Определяет, является ли активным окном редактор Kate. Для этого используется xdotool и проверяется класс окна (window classname). """ try: out = subprocess.check_output( ['xdotool', 'getactivewindow', 'getwindowclassname'], stderr=subprocess.DEVNULL, text=True, ).strip().lower() log(f"Активный класс окна: {out}", debug) return 'kate' in out except FileNotFoundError: log("xdotool не найден - не можем определить активное окно.", debug) return False except subprocess.CalledProcessError: log("Не удалось получить активное окно через xdotool.", debug) return False except Exception as e: log(f"Ошибка при определении активного окна: {e}", debug) return False def is_suspicious_multiline(text: str, debug: bool) -> bool: """ Проверка на "подозрительно многострочный" текст. Цель - мягко ограничить обработку случаев, когда: - в буфер попадает целая строка/блок с переводом строки в конце (частый паттерн "копировать строку"); - текст содержит много переносов или очень длинный (абзацы, большие блоки кода). При этом небольшие многострочные фрагменты без перевода строки на конце считаются допустимыми. """ newline_count = text.count('\n') + text.count('\r') if newline_count == 0: return False # Если есть перенос в конце - очень похоже на копирование целой строки/блока if text.endswith('\n') or text.endswith('\r'): log( "Обнаружен перенос строки в конце текста - похоже, скопирована целая строка/блок.", debug, ) return True def process_selection(transform_func, debug: bool, action_name: str = "") -> bool: """ Общий обработчик выделенного текста. Логика: 1. Сохраняем исходное содержимое CLIPBOARD. 2. Отправляем Ctrl+C и читаем скопированный текст. 3. Проверяем: - есть ли что-то осмысленное (не пусто, не только пробелы); - не подозрительно ли многострочный текст; - изменился ли буфер после копирования; 4. Применяем transform_func. 5. Если текст изменился: - если активна Kate - удаляем выделение и печатаем трансформированный текст (delete + type); - иначе кладём текст в CLIPBOARD и делаем Ctrl+V. 6. В любом случае по завершении пытаемся восстановить исходное содержимое CLIPBOARD. """ original_clipboard = get_clipboard_text(debug) log(f"Буфер обмена ДО копирования: '{original_clipboard}'", debug) try: copy_selection(debug) copied = get_clipboard_text(debug) log(f"Буфер обмена ПОСЛЕ копирования: '{copied}'", debug) # Нет текста или только пробелы - считаем, что нормального выделения нет if not copied or copied.isspace(): log( "Буфер обмена пустой или содержит только пробельные символы после копирования. " "Скорее всего, ничего не выделено.", debug, ) return False # Мягкая проверка на "подозрительно многострочный" текст if is_suspicious_multiline(copied, debug): log("Текст выглядит как целая строка/большой блок - пропускаем.", debug) return False # Если буфер не изменился - считаем, что приложение не обновило CLIPBOARD if copied == original_clipboard: log( "Буфер обмена не изменился после копирования. " "Скорее всего, приложение не обновило буфер или нет выделения - пропускаем.", debug, ) return False # Применяем трансформацию try: transformed = transform_func(copied) except Exception as e: log(f"Ошибка в функции трансформации: {e}", debug) return False if transformed == copied: log("Текст не изменился после преобразования. Пропускаем.", debug) return False # Специальный обход для Kate: удаляем выделение и печатаем текст напрямую if is_kate_active(debug): log( "Обнаружено окно Kate - используем режим delete+type вместо Ctrl+V.", debug, ) delete_selection(debug) type_text(transformed, debug) log(f"{action_name} (Kate workaround): '{copied}' => '{transformed}'", debug) return True # Общий путь для остальных приложений: CLIPBOARD + Ctrl+V if not set_clipboard_text(transformed, debug): log("Не удалось записать преобразованный текст в буфер обмена.", debug) return False paste_selection(debug) log(f"{action_name}: '{copied}' => '{transformed}'", debug) return True finally: # В ЛЮБОМ случае пытаемся вернуть исходное содержимое CLIPBOARD if original_clipboard is not None: if set_clipboard_text(original_clipboard, debug): log("Буфер обмена восстановлен.", debug) else: log( "Не удалось восстановить исходный буфер обмена. " "Возможны артефакты в CLIPBOARD.", debug, ) enabled = True # глобальный флаг вкл/выкл транслитерации def main(debug: bool) -> None: """Основной цикл: слушает события клавиатуры и обрабатывает горячие клавиши.""" global enabled if not check_permissions(debug): sys.exit(1) keyboard = find_physical_keyboard(debug) if not keyboard: log("Не найдена подходящая физическая клавиатура.", debug) sys.exit(1) log(f"Готов к работе. Выделите текст и нажмите {TRIGGER_KEY}...", debug) try: ctrl_pressed = False for event in keyboard.read_loop(): if event.type != evdev.ecodes.EV_KEY: continue key_event = evdev.categorize(event) keycode = key_event.keycode keystate = key_event.keystate # Отслеживание состояния Ctrl if keycode == CTRL_KEY: ctrl_pressed = (keystate == key_event.key_down) continue # Вкл/выкл транслитерации if keycode == TOGGLE_KEY and keystate == key_event.key_down: enabled = not enabled state = 'ВКЛ' if enabled else 'ВЫКЛ' log(f"Транслитерация {state}.", debug) notify(f"Транслитерация {state}", debug) continue # Изменение регистра: верхний (Ctrl+U) if ctrl_pressed and keycode == UPPER_KEY and keystate == key_event.key_down: process_selection(str.upper, debug, "ВЕРХНИЙ РЕГИСТР") continue # Изменение регистра: нижний (Ctrl+L) if ctrl_pressed and keycode == LOWER_KEY and keystate == key_event.key_down: process_selection(str.lower, debug, "НИЖНИЙ РЕГИСТР") continue # Основная горячая клавиша: транслитерация if keycode == TRIGGER_KEY and keystate == key_event.key_down: if not enabled: continue def translit_func(text: str) -> str: """ Определяет направление трансформации по преобладающему алфавиту: - если больше кириллических символов - считаем, что текст "по-русски" и транслитерируем в латиницу; - если больше латинских - наоборот, в кириллицу; - при равенстве или отсутствии букв - текст не меняем. """ rus_count = sum(1 for c in text if c in rus) lat_count = sum(1 for c in text if c in lat) if rus_count > lat_count: return ''.join(reverse_translit_dict.get(c, c) for c in text) elif lat_count > rus_count: return ''.join(translit_dict.get(c, c) for c in text) else: return text process_selection(translit_func, debug, "Транслитерация") except KeyboardInterrupt: log("Завершение работы по Ctrl+C...", debug) except Exception as e: log(f"Критическая ошибка в основном цикле: {e}", debug) finally: try: keyboard.close() except Exception: pass if __name__ == "__main__": parser = argparse.ArgumentParser( description=( "PySwitcher - автоматический транслитератор выделенного текста " "по горячей клавише." ) ) parser.add_argument( '-d', '--debug', action='store_true', help='Включить подробный лог.', ) args = parser.parse_args() # Проверка наличия xclip try: subprocess.run( ['xclip', '-version'], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=True, ) except FileNotFoundError: print("Установите xclip: sudo apt install xclip") sys.exit(1) except subprocess.CalledProcessError: print("Проблема с xclip. Проверьте установку: sudo apt install --reinstall xclip") sys.exit(1) # Проверка наличия xdotool try: subprocess.run( ['xdotool', '--version'], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=True, ) except FileNotFoundError: print("Установите xdotool: sudo apt install xdotool") sys.exit(1) except subprocess.CalledProcessError: print("Проблема c xdotool. Проверьте установку: sudo apt install --reinstall xdotool") sys.exit(1) # Проверка наличия python3-evdev try: import evdev # noqa: F401 except ImportError: print("Установите python3-evdev: sudo apt install python3-evdev") sys.exit(1) main(args.debug)
pyswitcher.pyСохраните код в файл, например, pyswitcher.py, и запустите:
python3 pyswitcher.py
Для режима отладки:
python3 pyswitcher.py -d
Как использовать?
привет, как дела? => на самом деле вы ввели ghbdtn, hfccz rfr?привет, как дела?.Если вы вдруг передумали - просто нажмите Ctrl+Z для отмены.
PySwitcher в фоне при старте ОСПосле проверки, что все работает, скопируйте скрипт pyswitcher.py в папку /usr/local/bin/pyswitcher.py и сделайте исполняемым:
# копируем cp pyswitcher.py /usr/local/bin/pyswitcher.py # делаем скрипт исполняемым chmod +x /usr/local/bin/pyswitcher.py
Для решения проблемы автозапуска и работы в фоновом режиме есть два способа:
systemdsystemd для PySwitcherСоздаем юнит-файл в домашней директории для конкретного пользователя, например командой:
# создаем пустой unit-файл touch ~/.config/systemd/user/pyswitcher.service
Открываем pyswitcher.service и вставляем следующие настройки:
[Unit] Description=PySwitcher - автоматический транслитератор выделенного текста # Важно для доступа к GUI After=graphical-session.target PartOf=graphical-session.target [Service] Type=simple Environment="DISPLAY=:0" # %h разворачивается в /home/ваш_пользователь Environment="XAUTHORITY=%h/.Xauthority" # Запуск скрипта pyswitcher.py ExecStart=/usr/bin/python3 /usr/local/bin/pyswitcher.py Restart=on-failure RestartSec=5s # Для работы с буфером обмена и GUI: WorkingDirectory=%h StandardOutput=journal StandardError=journal [Install] # Запускать после входа в графическую сессию WantedBy=graphical-session.target
Активируем службу PySwitcher:
# применяем изменения systemctl --user daemon-reload # включаем автозагрузку для текущего пользователя systemctl --user enable pyswitcher.service # запускаем в фоне systemctl --user start pyswitcher.service
Готово. После перезапуска системы PySwitcher будет готов к работе.
Остановка и удаление службы PySwitcher:
# останавливаем systemctl --user stop pyswitcher.service # отключаем автозагрузку systemctl --user disable pyswitcher.service # удаляем скрипт rm ~/.config/systemd/user/pyswitcher.service # применяем изменения systemctl --user daemon-reload
Разберём ключевые части кода:
lat = "`qwertyuiop[]asdfghjkl;'zxcvbnm,./~QWERTYUIOP{}ASDFGHJKL:\"ZXCVBNM<>?#" rus = "ёйцукенгшщзхъфывапролджэячсмитьбю.ЁЙЦУКЕНГШЩЗХЪФЫВАПРОЛДЖЭЯЧСМИТЬБЮ,№" translit_dict = dict(zip(lat, rus)) reverse_translit_dict = {v: k for k, v in translit_dict.items()}
Создаются два словаря: прямой и обратный. Это позволяет мгновенно заменять символы по соответствию.
rus_count = sum(1 for c in text if c in rus) lat_count = sum(1 for c in text if c in lat)
Скрипт подсчитывает, сколько символов принадлежит кириллице и сколько - латинице. Если кириллицы больше - значит, текст нужно перевести в латиницу, и наоборот.
get_clipboard_text() - вызывает xclip -o -selection clipboard для чтения;set_clipboard_text() - отправляет текст обратно в буфер.subprocess.run(['xdotool', 'key', '--clearmodifiers', 'ctrl+c'])
Используется xdotool для имитации нажатия Ctrl+C и Ctrl+V. Это стандартный способ "принудительного" копирования выделенного текста в X11.
keyboard = evdev.InputDevice(path) for event in keyboard.read_loop():
Через evdev скрипт читает "сырые" события с клавиатуры, минуя DE. Это позволяет реагировать на клавиши, даже если фокус не на терминале.
После вставки транслитерированного текста оригинальное содержимое буфера восстанавливается. Это важно, чтобы не потерять то, что вы копировали до этого.
Скрипт можно расширить:
wtype вместо xdotool);PySwitcher - это пример того, как простой Python-скрипт может решить реальную проблему, с которой сталкиваются тысячи пользователей Linux. Он не требует сложной установки, легко настраивается и работает стабильно на современных системах.
Хотя он не такой "умный", как Punto Switcher (например, не анализирует контекст или грамматику), его функционала достаточно для повседневного использования. А главное - он открытый, понятный и под вашим контролем.
Вы можете взять этот скрипт за основу и адаптировать под свои нужды: изменить горячие клавиши, добавить новые функции или даже сделать полноценное приложение.