mirror of
https://github.com/yusufipk/dikte.git
synced 2026-09-11 19:06:11 +00:00
Which pair of tools a session uses was being asked inside copy(), read(), copy_bytes() and press(), four times, in a conditional each. A third desktop means a third branch in each of them, and macOS is already open as a pull request doing exactly that in a different style. Wayland and X11 are two entries at the top of the file now, each naming its programs and how to build a key press from them. The functions below take whichever entry the session gets. Adding a desktop is an entry and a line in desktop(), and it touches nothing else. Two things fall out of it. The key press is checked against the same table on both: xdotool was being handed the shortcut as typed, so ctrl+f13 was refused on Wayland and passed through on X11. And a missing tool names the package to install again, which the shared message had dropped. The tests say the promises once and run them against both desktops, so a third one inherits the list rather than needing a copy of it.
172 lines
5.8 KiB
Python
172 lines
5.8 KiB
Python
"""Clipboard and key injection, through whichever pair of programs is here.
|
|
|
|
A Wayland session has wl-clipboard and ydotool, an X11 one has xclip and
|
|
xdotool, and a session is one or the other. Which it is gets decided in one
|
|
place, and each desktop is a small group of functions below it: another desktop,
|
|
or another operating system, adds a group and a line to the chooser rather than
|
|
a branch inside every function here.
|
|
"""
|
|
|
|
import collections
|
|
import os
|
|
import shutil
|
|
import subprocess
|
|
import time
|
|
|
|
from i18n import t
|
|
|
|
# Linux input event codes (linux/input-event-codes.h), which is what ydotool
|
|
# takes. They are also the list of keys a paste shortcut may be built from, so
|
|
# xdotool is held to the same table rather than being handed the text as typed.
|
|
KEYCODES = {
|
|
"ctrl": 29, "control": 29, "shift": 42, "alt": 56, "super": 125, "meta": 125,
|
|
"v": 47, "insert": 110, "enter": 28, "return": 28,
|
|
}
|
|
|
|
# xdotool speaks X keysyms, which spell some of those differently.
|
|
KEYSYMS = {"control": "ctrl", "meta": "super", "insert": "Insert",
|
|
"enter": "Return", "return": "Return"}
|
|
|
|
|
|
class PasteError(Exception):
|
|
pass
|
|
|
|
|
|
def _keys(shortcut):
|
|
"""'Ctrl+V' -> ['ctrl', 'v'], every one of them a key we know."""
|
|
parts = [key.strip().lower() for key in str(shortcut).split("+") if key.strip()]
|
|
for key in parts:
|
|
if key not in KEYCODES:
|
|
raise PasteError(t("Unknown key: {key}", key=key))
|
|
return parts
|
|
|
|
|
|
def _ydotool_command(shortcut):
|
|
"""ydotool wants a press event per key, then a release in reverse."""
|
|
codes = [KEYCODES[key] for key in _keys(shortcut)]
|
|
return ["ydotool", "key", *[f"{code}:1" for code in codes],
|
|
*[f"{code}:0" for code in reversed(codes)]]
|
|
|
|
|
|
def _xdotool_command(shortcut):
|
|
"""xdotool takes the whole combination as one argument."""
|
|
keys = [KEYSYMS.get(key, key) for key in _keys(shortcut)]
|
|
return ["xdotool", "key", "--clearmodifiers", "+".join(keys)]
|
|
|
|
|
|
Desktop = collections.namedtuple(
|
|
"Desktop",
|
|
# The two programs, the packages to install them from, how to build the key
|
|
# press, and what else to say when the key press fails.
|
|
"clipboard keyboard packages read_command copy_command key_command key_hint",
|
|
)
|
|
|
|
WAYLAND = Desktop(
|
|
clipboard="wl-copy",
|
|
keyboard="ydotool",
|
|
packages="wl-clipboard and ydotool",
|
|
read_command=["wl-paste", "--no-newline"],
|
|
copy_command=["wl-copy"],
|
|
key_command=_ydotool_command,
|
|
key_hint="Is ydotoold running? (systemctl --user status ydotool)",
|
|
)
|
|
|
|
X11 = Desktop(
|
|
clipboard="xclip",
|
|
keyboard="xdotool",
|
|
packages="xclip and xdotool",
|
|
read_command=["xclip", "-selection", "clipboard", "-out"],
|
|
copy_command=["xclip", "-selection", "clipboard", "-in"],
|
|
key_command=_xdotool_command,
|
|
key_hint="",
|
|
)
|
|
|
|
|
|
def desktop():
|
|
"""The pair of programs this session's clipboard and keyboard go through.
|
|
|
|
Read every time rather than settled at import: a session started before the
|
|
display server was up would otherwise be stuck with the wrong answer, and a
|
|
test would have nowhere to say which one it means.
|
|
"""
|
|
if os.environ.get("XDG_SESSION_TYPE") == "x11":
|
|
return X11
|
|
if os.environ.get("DISPLAY") and not os.environ.get("WAYLAND_DISPLAY"):
|
|
return X11
|
|
return WAYLAND
|
|
|
|
|
|
# --- the clipboard ---------------------------------------------------------
|
|
|
|
def read_clipboard():
|
|
here = desktop()
|
|
if not shutil.which(here.read_command[0]):
|
|
return None
|
|
try:
|
|
res = subprocess.run(here.read_command, capture_output=True, timeout=5)
|
|
except (subprocess.SubprocessError, OSError):
|
|
return None
|
|
return res.stdout if res.returncode == 0 else None
|
|
|
|
|
|
def _run_copy(payload):
|
|
"""The clipboard owner forks to keep holding the selection; leaving its
|
|
pipes open makes subprocess.run wait for EOF forever, hence DEVNULL."""
|
|
return subprocess.run(
|
|
desktop().copy_command,
|
|
input=payload,
|
|
stdout=subprocess.DEVNULL,
|
|
stderr=subprocess.DEVNULL,
|
|
timeout=10,
|
|
)
|
|
|
|
|
|
def copy(text):
|
|
here = desktop()
|
|
if not shutil.which(here.clipboard):
|
|
raise PasteError(t("{tool} not found. Install {packages}.",
|
|
tool=here.clipboard, packages=here.packages))
|
|
try:
|
|
res = _run_copy(text.encode("utf-8"))
|
|
except (subprocess.SubprocessError, OSError) as exc:
|
|
raise PasteError(t("Could not copy to clipboard: {error}", error=exc)) from exc
|
|
if res.returncode != 0:
|
|
raise PasteError(t("{tool} exited with code {code}.",
|
|
tool=here.clipboard, code=res.returncode))
|
|
|
|
|
|
def copy_bytes(data):
|
|
if data is None or not shutil.which(desktop().clipboard):
|
|
return
|
|
try:
|
|
_run_copy(data)
|
|
except (subprocess.SubprocessError, OSError):
|
|
pass
|
|
|
|
|
|
# --- the key press ---------------------------------------------------------
|
|
|
|
def paste_ready():
|
|
return shutil.which(desktop().keyboard) is not None
|
|
|
|
|
|
def press(shortcut="ctrl+v", delay=0.12):
|
|
"""Press a key combination, e.g. 'ctrl+v'."""
|
|
here = desktop()
|
|
if not paste_ready():
|
|
raise PasteError(t("{tool} not found, cannot paste automatically.",
|
|
tool=here.keyboard))
|
|
|
|
command = here.key_command(shortcut)
|
|
time.sleep(delay) # let the selection settle and focus come back
|
|
try:
|
|
res = subprocess.run(command, capture_output=True, text=True, timeout=10)
|
|
except (subprocess.SubprocessError, OSError) as exc:
|
|
raise PasteError(t("Could not run {tool}: {error}",
|
|
tool=here.keyboard, error=exc)) from exc
|
|
if res.returncode != 0:
|
|
message = t("{tool} failed: {error}", tool=here.keyboard,
|
|
error=res.stderr.strip() or "unknown error")
|
|
raise PasteError(f"{message}\n{t(here.key_hint)}" if here.key_hint
|
|
else message)
|