Files
dikte/paste.py
T
yusufipk 0e6ec3aea9 Choose the clipboard's programs once, instead of in every function
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.
2026-08-01 20:32:07 +07:00

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)