mirror of
https://github.com/yusufipk/dikte.git
synced 2026-09-12 03:16:19 +00:00
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.
This commit is contained in:
@@ -1,5 +1,13 @@
|
||||
"""Clipboard and key injection for Wayland and X11."""
|
||||
"""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
|
||||
@@ -7,35 +15,105 @@ import time
|
||||
|
||||
from i18n import t
|
||||
|
||||
# Linux input event codes (linux/input-event-codes.h)
|
||||
# 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():
|
||||
command = (["xclip", "-selection", "clipboard", "-out"] if _x11()
|
||||
else ["wl-paste", "--no-newline"])
|
||||
if not shutil.which(command[0]):
|
||||
here = desktop()
|
||||
if not shutil.which(here.read_command[0]):
|
||||
return None
|
||||
try:
|
||||
res = subprocess.run(command, capture_output=True, timeout=5)
|
||||
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 may fork; do not leave inherited pipes open."""
|
||||
command = (["xclip", "-selection", "clipboard", "-in"] if _x11()
|
||||
else ["wl-copy"])
|
||||
"""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(
|
||||
command,
|
||||
desktop().copy_command,
|
||||
input=payload,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
@@ -44,21 +122,21 @@ def _run_copy(payload):
|
||||
|
||||
|
||||
def copy(text):
|
||||
tool = "xclip" if _x11() else "wl-copy"
|
||||
if not shutil.which(tool):
|
||||
raise PasteError(t("{tool} not found; clipboard copy is unavailable.", tool=tool))
|
||||
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=tool, code=res.returncode))
|
||||
tool=here.clipboard, code=res.returncode))
|
||||
|
||||
|
||||
def copy_bytes(data):
|
||||
tool = "xclip" if _x11() else "wl-copy"
|
||||
if data is None or not shutil.which(tool):
|
||||
if data is None or not shutil.which(desktop().clipboard):
|
||||
return
|
||||
try:
|
||||
_run_copy(data)
|
||||
@@ -66,54 +144,28 @@ def copy_bytes(data):
|
||||
pass
|
||||
|
||||
|
||||
def ydotool_ready():
|
||||
tool = "xdotool" if _x11() else "ydotool"
|
||||
return shutil.which(tool) is not None
|
||||
# --- the key press ---------------------------------------------------------
|
||||
|
||||
|
||||
def _x11():
|
||||
return (os.environ.get("XDG_SESSION_TYPE") == "x11"
|
||||
or bool(os.environ.get("DISPLAY") and not os.environ.get("WAYLAND_DISPLAY")))
|
||||
def paste_ready():
|
||||
return shutil.which(desktop().keyboard) is not None
|
||||
|
||||
|
||||
def press(shortcut="ctrl+v", delay=0.12):
|
||||
"""Press a key combination through xdotool or ydotool."""
|
||||
if not ydotool_ready():
|
||||
tool = "xdotool" if _x11() else "ydotool"
|
||||
raise PasteError(t("{tool} not found, cannot paste automatically.", tool=tool))
|
||||
"""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))
|
||||
|
||||
if _x11():
|
||||
key = shortcut.lower().replace("control", "ctrl")
|
||||
time.sleep(delay)
|
||||
try:
|
||||
res = subprocess.run(
|
||||
["xdotool", "key", "--clearmodifiers", key],
|
||||
capture_output=True, text=True, timeout=10,
|
||||
)
|
||||
except (subprocess.SubprocessError, OSError) as exc:
|
||||
raise PasteError(t("Could not run xdotool: {error}", error=exc)) from exc
|
||||
if res.returncode != 0:
|
||||
raise PasteError(t("xdotool failed: {error}",
|
||||
error=res.stderr.strip() or "unknown error"))
|
||||
return
|
||||
|
||||
codes = []
|
||||
for key in (k.strip().lower() for k in shortcut.split("+") if k.strip()):
|
||||
code = KEYCODES.get(key)
|
||||
if code is None:
|
||||
raise PasteError(t("Unknown key: {key}", key=key))
|
||||
codes.append(code)
|
||||
|
||||
seq = [f"{c}:1" for c in codes] + [f"{c}:0" for c in reversed(codes)]
|
||||
command = here.key_command(shortcut)
|
||||
time.sleep(delay) # let the selection settle and focus come back
|
||||
try:
|
||||
res = subprocess.run(["ydotool", "key", *seq], capture_output=True,
|
||||
text=True, timeout=10)
|
||||
res = subprocess.run(command, capture_output=True, text=True, timeout=10)
|
||||
except (subprocess.SubprocessError, OSError) as exc:
|
||||
raise PasteError(t("Could not run ydotool: {error}", error=exc)) from exc
|
||||
raise PasteError(t("Could not run {tool}: {error}",
|
||||
tool=here.keyboard, error=exc)) from exc
|
||||
if res.returncode != 0:
|
||||
raise PasteError(t(
|
||||
"ydotool failed: {error}\nIs ydotoold running? "
|
||||
"(systemctl --user status ydotool)",
|
||||
error=res.stderr.strip() or "unknown error",
|
||||
))
|
||||
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)
|
||||
|
||||
Reference in New Issue
Block a user