166 lines
5.6 KiB
Python
166 lines
5.6 KiB
Python
"""Text injection (xdotool/ydotool/clipboard) for whisper.linux."""
|
|
|
|
import os
|
|
import subprocess
|
|
import time
|
|
|
|
from .config import Config, log
|
|
|
|
|
|
class TextInjector:
|
|
"""Injects text at cursor position using xdotool, ydotool, or clipboard."""
|
|
|
|
# X11 keysym → evdev name (for ydotool which uses evdev names)
|
|
_YDOTOOL_KEY_MAP = {
|
|
"Return": "Enter",
|
|
"Escape": "Esc",
|
|
}
|
|
|
|
def __init__(self, config: Config):
|
|
self.config = config
|
|
|
|
def inject(self, text: str):
|
|
if not text:
|
|
return
|
|
ds = self.config.display_server
|
|
if ds == "wayland":
|
|
self._inject_wayland(text)
|
|
else:
|
|
self._inject_x11(text)
|
|
|
|
def _inject_x11(self, text: str):
|
|
if self.config.use_clipboard_fallback:
|
|
self._inject_clipboard_x11(text)
|
|
return
|
|
try:
|
|
subprocess.run(
|
|
["xdotool", "type", "--delay", "12", "--clearmodifiers", "--", text],
|
|
check=True, timeout=10,
|
|
)
|
|
except (subprocess.CalledProcessError, FileNotFoundError):
|
|
log.warning("xdotool type failed, falling back to clipboard")
|
|
self._inject_clipboard_x11(text)
|
|
|
|
def _inject_clipboard_x11(self, text: str):
|
|
encoded = text.encode("utf-8")
|
|
# Set both CLIPBOARD and PRIMARY so shift+Insert works everywhere
|
|
subprocess.run(
|
|
["xclip", "-selection", "clipboard"],
|
|
input=encoded, check=True, timeout=5,
|
|
)
|
|
subprocess.run(
|
|
["xclip", "-selection", "primary"],
|
|
input=encoded, check=True, timeout=5,
|
|
)
|
|
time.sleep(0.05)
|
|
paste_keys = self.config.paste_keys
|
|
subprocess.run(
|
|
["xdotool", "key", "--clearmodifiers", paste_keys],
|
|
check=True, timeout=5,
|
|
)
|
|
|
|
def _inject_wayland(self, text: str):
|
|
try:
|
|
subprocess.run(["wtype", "--", text], check=True, timeout=10)
|
|
return
|
|
except (subprocess.CalledProcessError, FileNotFoundError):
|
|
pass
|
|
|
|
try:
|
|
subprocess.run(
|
|
["wl-copy", "--", text], check=True, timeout=5,
|
|
)
|
|
subprocess.run(
|
|
["wl-copy", "--primary", "--", text], check=True, timeout=5,
|
|
)
|
|
except (subprocess.CalledProcessError, FileNotFoundError):
|
|
log.warning("wl-copy failed, falling back to xclip (XWayland only)")
|
|
self._inject_x11(text)
|
|
return
|
|
|
|
paste_keys = self.config.paste_keys
|
|
log.info("Clipboard set via wl-copy, sending %s", paste_keys)
|
|
time.sleep(0.3)
|
|
|
|
try:
|
|
subprocess.run(
|
|
["ydotool", "key", "--delay", "100", paste_keys],
|
|
check=True, timeout=5, capture_output=True,
|
|
)
|
|
log.info("Paste sent via ydotool (%s)", paste_keys)
|
|
return
|
|
except (subprocess.CalledProcessError, FileNotFoundError, RuntimeError) as e:
|
|
log.debug("ydotool failed: %s", e)
|
|
|
|
try:
|
|
subprocess.run(
|
|
["xdotool", "key", "--clearmodifiers", paste_keys],
|
|
check=True, timeout=5,
|
|
)
|
|
log.info("Paste sent via xdotool (%s)", paste_keys)
|
|
return
|
|
except (subprocess.CalledProcessError, FileNotFoundError):
|
|
pass
|
|
|
|
log.warning("Text copied to clipboard (Ctrl+V to paste). "
|
|
"For auto-paste: sudo chmod 0660 /dev/uinput && "
|
|
"sudo chown root:$USER /dev/uinput")
|
|
|
|
def send_key(self, key: str):
|
|
"""Send a key press (e.g. 'Return', 'Tab', 'ctrl+BackSpace')."""
|
|
ds = self.config.display_server
|
|
if ds == "wayland":
|
|
self._send_key_wayland(key)
|
|
else:
|
|
self._send_key_x11(key)
|
|
|
|
def _send_key_x11(self, key: str):
|
|
try:
|
|
subprocess.run(
|
|
["xdotool", "key", "--clearmodifiers", key],
|
|
check=True, timeout=5,
|
|
)
|
|
except (subprocess.CalledProcessError, FileNotFoundError) as e:
|
|
log.warning("xdotool key '%s' failed: %s", key, e)
|
|
|
|
def _send_key_wayland(self, key: str):
|
|
# wtype supports XKB key names natively on Wayland
|
|
try:
|
|
if "+" in key:
|
|
parts = key.split("+")
|
|
cmd = ["wtype"]
|
|
for mod in parts[:-1]:
|
|
cmd.extend(["-M", mod.lower()])
|
|
cmd.extend(["-k", parts[-1]])
|
|
else:
|
|
cmd = ["wtype", "-k", key]
|
|
subprocess.run(cmd, check=True, timeout=5)
|
|
return
|
|
except (subprocess.CalledProcessError, FileNotFoundError):
|
|
pass
|
|
# ydotool uses evdev key names (Enter, not Return; Esc, not Escape)
|
|
try:
|
|
if "+" in key:
|
|
parts = key.split("+")
|
|
evdev_parts = [self._YDOTOOL_KEY_MAP.get(p, p) for p in parts]
|
|
evdev_key = "+".join(evdev_parts)
|
|
else:
|
|
evdev_key = self._YDOTOOL_KEY_MAP.get(key, key)
|
|
subprocess.run(
|
|
["ydotool", "key", "--delay", "50", evdev_key],
|
|
check=True, timeout=5, capture_output=True,
|
|
)
|
|
return
|
|
except (subprocess.CalledProcessError, FileNotFoundError):
|
|
pass
|
|
# xdotool via XWayland
|
|
try:
|
|
subprocess.run(
|
|
["xdotool", "key", "--clearmodifiers", key],
|
|
check=True, timeout=5,
|
|
)
|
|
return
|
|
except (subprocess.CalledProcessError, FileNotFoundError):
|
|
pass
|
|
log.warning("Failed to send key '%s' — no working key sender", key)
|