Каждый, кто работает с двумя раскладками клавиатуры, знаком с этой проблемой: набрал текст в неправильной раскладке. Вместо `привет` получилось `ghbdtn`, и приходится удалять и набирать заново. В Windows есть Punto Switcher, но в Linux хороших решений мало.
Я решил написать своё — простое, быстрое и надёжное.
Первая попытка: GNOME Shell расширение
Изначально я пытался создать расширение для GNOME Shell на TypeScript. Казалось логичным — нативная интеграция, доступ к системным API, горячие клавиши из коробки.
Но реальность оказалась суровой:
- Проблемы с компиляцией: TypeScript генерировал CommonJS вместо ES modules
- Конфликты импортов: `St` (Shell Toolkit) не загружался до инициализации
- GSettings схемы: нужна компиляция XML в бинарный формат
- Отладка: логи не попадали в `journalctl`, расширение молча падало в ERROR
После нескольких часов борьбы с `metadata.json`, `gschemas.compiled` и загадочными ошибками загрузки модулей, я решил: это слишком сложно для такой простой задачи.
Второй подход: bash-скрипт с xdotool
Решение стало очевидным — использовать стандартные утилиты Linux:
- xdotool — симуляция нажатий клавиш
- xclip — работа с буфером обмена
- Bash для склейки всего вместе
#!/bin/bash
# Копируем выделенный текст
xdotool key ctrl+c
sleep 0.3
# Получаем из буфера
text=$(xclip -o -selection clipboard)
# Конвертируем через case
result=""
while IFS= read -r -n1 char; do
case "$char" in
q) result+="й";;
w) result+="ц";;
# ... 100+ строк
esac
done <<< "$text"
# Вставляем обратно
echo -n "$result" | xclip -selection clipboard
xdotool key ctrl+v
Проблемы:
- Медленно: цикл `while read -n1` обрабатывает символы по одному
- Сложный маппинг: 100+ строк `case` для всех символов
- Задержки: `sleep 0.3` — костыль, чтобы текст успел попасть в буфер
Но главное — оно работало! Теперь оставалось только оптимизировать.
Третья попытка: Python
Python быстрее bash для обработки строк:
#!/usr/bin/env python3
import subprocess
import time
en_ru = {
'q':'й','w':'ц','e':'у', # ...
}
ru_en = {v:k for k,v in en_ru.items()}
time.sleep(0.1)
subprocess.run(['xdotool', 'key', 'ctrl+c'])
time.sleep(0.4)
text = subprocess.run(['xclip', '-o', '-selection', 'clipboard'],
capture_output=True, text=True).stdout
result = ''.join(en_ru.get(c, ru_en.get(c, c)) for c in text)
subprocess.run(['xclip', '-selection', 'clipboard'], input=result, text=True)
subprocess.run(['xdotool', 'key', 'ctrl+v'])
Плюсы:
- Быстрее bash
- Читаемый код
- Простой маппинг через словарь
Минусы:
- Всё ещё ~300-400ms задержка
- Зависимость от Python runtime
- Холодный старт интерпретатора
Финальное решение: Go
Go даёт нам скорость нативного кода и простоту высокоуровневого языка:
package main
import (
"os/exec"
"strings"
"time"
)
var enRu = map[rune]rune{
'q': 'й', 'w': 'ц', 'e': 'у', 'r': 'к', 't': 'е',
'y': 'н', 'u': 'г', 'i': 'ш', 'o': 'щ', 'p': 'з',
'[': 'х', ']': 'ъ', 'a': 'ф', 's': 'ы', 'd': 'в',
'f': 'а', 'g': 'п', 'h': 'р', 'j': 'о', 'k': 'л',
'l': 'д', ';': 'ж', '\'': 'э', 'z': 'я', 'x': 'ч',
'c': 'с', 'v': 'м', 'b': 'и', 'n': 'т', 'm': 'ь',
',': 'б', '.': 'ю', '/': '.', '`': 'ё',
// Заглавные буквы
'Q': 'Й', 'W': 'Ц', 'E': 'У', // ...
}
var ruEn map[rune]rune
func init() {
ruEn = make(map[rune]rune, len(enRu))
for k, v := range enRu {
ruEn[v] = k
}
}
func main() {
time.Sleep(80 * time.Millisecond)
// Сохраняем старый буфер
oldOut, _ := exec.Command("xclip", "-o", "-selection", "clipboard").Output()
// Очищаем буфер
clearCmd := exec.Command("xclip", "-selection", "clipboard")
clearCmd.Stdin = strings.NewReader("")
clearCmd.Run()
// Копируем выделенное
exec.Command("xdotool", "key", "ctrl+c").Run()
time.Sleep(250 * time.Millisecond)
// Получаем текст
out, err := exec.Command("xclip", "-o", "-selection", "clipboard").Output()
if err != nil || len(out) == 0 {
// Восстанавливаем старый буфер
restoreCmd := exec.Command("xclip", "-selection", "clipboard")
restoreCmd.Stdin = strings.NewReader(string(oldOut))
restoreCmd.Run()
return
}
// Конвертируем (in-place для скорости)
runes := []rune(string(out))
for i, char := range runes {
if mapped, ok := enRu[char]; ok {
runes[i] = mapped
} else if mapped, ok := ruEn[char]; ok {
runes[i] = mapped
}
}
// Вставляем
setCmd := exec.Command("xclip", "-selection", "clipboard")
setCmd.Stdin = strings.NewReader(string(runes))
setCmd.Run()
exec.Command("xdotool", "key", "ctrl+v").Run()
}
Оптимизации
1. In-place конвертация: изменяем массив рун напрямую, без создания промежуточных строк
2. Предварительная инициализация: обратный маппинг `ruEn` создаётся один раз в `init()`
3. Компиляция с оптимизацией: флаги `-ldflags="-s -w"` убирают отладочную информацию
4. Минимальные задержки: 80ms + 250ms = 330ms (оптимальный баланс скорости и надёжности)
Установка
# Установка зависимостей
sudo apt install xdotool xclip golang-go
# Сборка
mkdir -p ~/bin
cd ~/bin
# Скачайте layout-convert.go
go build -ldflags="-s -w" -o layout-convert layout-convert.go
# Настройка горячей клавиши в GNOME
gnome-control-center keyboard
# Добавьте Custom Shortcut:
# Command: /home/YOUR_USERNAME/bin/layout-convert
# Shortcut: Super+Space
Использование
- Выделите текст: `ghbdtn`
- Нажмите `Super+Space`
- Текст заменится: `привет`
Работает в обе стороны автоматически!
Подводные камни
Проблема 1: xdotool не копирует текст
Симптом: скрипт конвертирует старый буфер обмена вместо выделенного текста.
Решение: добавить задержку перед `ctrl+c` и очистку буфера:
time.Sleep(80 * time.Millisecond) // даём фокусу вернуться к окну
// Очищаем буфер
clearCmd := exec.Command("xclip", "-selection", "clipboard")
clearCmd.Stdin = strings.NewReader("")
clearCmd.Run()
Проблема 3: Менеджеры буфера обмена
Diodon и подобные могут конфликтовать. Убедитесь, что скрипт правильно очищает и восстанавливает буфер.
Выводы
Иногда простое решение — лучшее решение. Вместо борьбы с GNOME Shell API я получил:
- ✅ Кросс-платформенность (работает на любом DE с X11)
- ✅ Простоту отладки (один бинарник, никаких зависимостей)
- ✅ Высокую производительность (~330ms)
- ✅ Надёжность (не ломается при обновлении GNOME Shell)
Исходный код: github.com/semelyanov86/layout-converter