Сообщить об ошибке.

PySwitcher - транслятор раскладки выделенного текста

Содержание:

В Windows пользователи давно оценили удобство инструментов вроде Punto Switcher - утилиты, которая исправляет текст, случайно набранный не в той раскладке. Достаточно выделить фразу и нажать горячую клавишу и текст транслитерируется из кириллицы в латиницу или наоборот. Это особенно полезно при написании кода, когда переключаешься между языками, но забываешь переключить саму раскладку клавиатуры.

В Linux аналогичных решений есть несколько, например, xneur (написан на C++, не обновлялся с 2016 года), loloswitcher (написан на C++, не обновлялся с 2018 года), xswitcher (написан на golang, не обновлялся с 2021 года). Большинство из них давно не обновляются и работают нестабильно на современных дистрибутивах, при этом требуют для запуска root права, что не безопасно и требует досконального изучения кода перед использованием. Обратите внимание, что все языки компилируемые. Нет простого решения - поправил что надо и запустил.

Так как Python славится своей универсальностью, была предпринята попытка написать свое легковесное, гибкое и полностью контролируемое решение переключения раскладки выделенного текста PySwitcher. Скрипт реализует простую идею - выделил текст не в той раскладке, нажал горячую клавишу - получил текст в нужной раскладке. Его можно легко настроить под себя, расширить функционал и использовать в повседневной работе.

Почему не готовые аналоги?

Punto Switcher - отличный инструмент, но он существует только для Windows. На Linux его официальной версии нет, а сторонние попытки воссоздать его функционал зачастую не дотягивают до стабильности и гибкости. Кроме того, многие из них:

  • Требуют установки проприетарных компонентов;
  • Не поддерживаются разработчиками;
  • Конфликтуют с DE (рабочими окружениями).
  • Требуют права суперпользователя root для запуска.

PySwitcher работает на X11 (и может быть адаптирован под Wayland с использованием wtype вместо xdotool), не требует прав суперпользователя во время работы и легко модифицируется.

Почему не использовался модуль pyperclip

Можно было бы использовать готовые библиотеки, например, pyperclip - популярную обёртку для работы с буфером обмена. Однако в PySwitcher принято решение работать с утилитами напрямую: xclip и xdotool. Почему?

  1. Меньше зависимостей. Модуль pyperclip - это обёртка над xclip, xsel и pbcopy. Зачем тащить целую библиотеку, если можно вызывать xclip напрямую?
  2. Полный контроль - через subprocess можно точно настроить поведение: выбор буфера (clipboard или primary), обработку ошибок, задержки.
  3. Гибкость и отладка - при использовании низкоуровневых вызовов проще логировать, тестировать и адаптировать под разные окружения.
  4. Нет привязки к Python-библиотекам - даже если pyperclip перестанет поддерживаться, xclip и xdotool останутся стандартными утилитами в большинстве дистрибутивов.

Как это работает?

PySwitcher - это фоновый скрипт, который:

  1. Перехватывает события с физической клавиатуры (через evdev);
  2. При нажатии заданной горячей клавиши (по умолчанию - Scroll Lock) захватывает выделенный текст;
  3. Определяет, на каком языке он набран (латиница или кириллица);
  4. Автоматически транслитерирует его в противоположную раскладку;
  5. Вставляет результат обратно.

Также скрипт поддерживает:

  • Включение/выключение функции транслитерации (по умолчанию - клавиша Pause);
  • Преобразование регистра: Ctrl+U - в верхний, Ctrl+L - в нижний;
  • Уведомления через notify-send;
  • Работу только с выделенным текстом, без вмешательства в общее поведение системы.

Установка и настройка (Linux, X11)

Установка зависимостей

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

Как использовать?

  1. Наберите текст, забыв переключить раскладку. Например:привет, как дела? => на самом деле вы ввели ghbdtn, hfccz rfr?
  2. Выделите этот текст с помощью клавиатуры или мышкой.
  3. Нажмите Scroll Lock. У меня висит на клавише переключения раскладки. Одним нажатием получаю трансляцию текста + переключение раскладки.
  4. Скрипт автоматически заменит выделение на правильный текст: привет, как дела?.

Если вы вдруг передумали - просто нажмите 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

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

  1. Добавить скрипт в автозагрузку штатными средствами операционной системы. Например в Kubuntu => "Параметры системы" => "Запуск и завершение" => "Автозапуск" => "+ Добавить" => выбираем скрипт. Готово.
  2. Создать unit-файл для systemd

Unit-файл systemd для 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

Как работает скрипт под капотом?

Разберём ключевые части кода:

1. Транслитерация

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()}

Создаются два словаря: прямой и обратный. Это позволяет мгновенно заменять символы по соответствию.

2. Определение языка текста

rus_count = sum(1 for c in text if c in rus)
lat_count = sum(1 for c in text if c in lat)

Скрипт подсчитывает, сколько символов принадлежит кириллице и сколько - латинице. Если кириллицы больше - значит, текст нужно перевести в латиницу, и наоборот.

3. Работа с буфером обмена

  • get_clipboard_text() - вызывает xclip -o -selection clipboard для чтения;
  • set_clipboard_text() - отправляет текст обратно в буфер.

4. Копирование и вставка выделенного текста

subprocess.run(['xdotool', 'key', '--clearmodifiers', 'ctrl+c'])

Используется xdotool для имитации нажатия Ctrl+C и Ctrl+V. Это стандартный способ "принудительного" копирования выделенного текста в X11.

5. Перехват событий клавиатуры

keyboard = evdev.InputDevice(path)
for event in keyboard.read_loop():

Через evdev скрипт читает "сырые" события с клавиатуры, минуя DE. Это позволяет реагировать на клавиши, даже если фокус не на терминале.

6. Восстановление буфера

После вставки транслитерированного текста оригинальное содержимое буфера восстанавливается. Это важно, чтобы не потерять то, что вы копировали до этого.

Возможные улучшения

Скрипт можно расширить:

  • Поддержка Waylandwtype вместо xdotool);
  • Автоматическая транслитерация без выделения, при наборе (требует более сложной логики);
  • GUI-настройки;

Заключение

PySwitcher - это пример того, как простой Python-скрипт может решить реальную проблему, с которой сталкиваются тысячи пользователей Linux. Он не требует сложной установки, легко настраивается и работает стабильно на современных системах.

Хотя он не такой "умный", как Punto Switcher (например, не анализирует контекст или грамматику), его функционала достаточно для повседневного использования. А главное - он открытый, понятный и под вашим контролем.

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