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:
yusufipk
2026-08-01 20:32:07 +07:00
parent 3d4c29c853
commit 0e6ec3aea9
4 changed files with 256 additions and 187 deletions
+6 -14
View File
@@ -101,30 +101,22 @@ TR = {
"Unexpected error: {error}": "Beklenmeyen hata: {error}", "Unexpected error: {error}": "Beklenmeyen hata: {error}",
# --- audio / paste errors ----------------------------------------- # --- audio / paste errors -----------------------------------------
"pw-record not found. Is pipewire-audio installed?":
"pw-record bulunamadı. pipewire-audio kurulu mu?",
"Could not start recording: {error}": "Kayıt başlatılamadı: {error}", "Could not start recording: {error}": "Kayıt başlatılamadı: {error}",
"No audio recorder found. Install pulseaudio-utils or pipewire-audio.": "No audio recorder found. Install pulseaudio-utils or pipewire-audio.":
"Ses kayıt aracı bulunamadı. pulseaudio-utils ya da pipewire-audio kur.", "Ses kayıt aracı bulunamadı. pulseaudio-utils ya da pipewire-audio kur.",
"Audio recorder stopped before receiving sound: {error}": "Audio recorder stopped before receiving sound: {error}":
"Ses kayıt aracı veri alamadan kapandı: {error}", "Ses kayıt aracı veri alamadan kapandı: {error}",
"wl-copy not found. Install wl-clipboard.":
"wl-copy bulunamadı. wl-clipboard paketini kur.",
"Could not copy to clipboard: {error}": "Panoya kopyalanamadı: {error}", "Could not copy to clipboard: {error}": "Panoya kopyalanamadı: {error}",
"{tool} not found; clipboard copy is unavailable.": "{tool} not found. Install {packages}.":
"{tool} bulunamadı; panoya kopyalama kullanılamıyor.", "{tool} bulunamadı. {packages} paketlerini kur.",
"{tool} exited with code {code}.": "{tool} {code} koduyla çıktı.", "{tool} exited with code {code}.": "{tool} {code} koduyla çıktı.",
"{tool} not found, cannot paste automatically.": "{tool} not found, cannot paste automatically.":
"{tool} bulunamadı, otomatik yapıştırma yapılamıyor.", "{tool} bulunamadı, otomatik yapıştırma yapılamıyor.",
"Could not run xdotool: {error}": "xdotool çalıştırılamadı: {error}",
"xdotool failed: {error}": "xdotool hatası: {error}",
"wl-copy exited with code {code}.": "wl-copy {code} koduyla çıktı.",
"ydotool not found, cannot paste automatically.":
"ydotool bulunamadı, otomatik yapıştırma yapılamıyor.",
"Unknown key: {key}": "Bilinmeyen tuş: {key}", "Unknown key: {key}": "Bilinmeyen tuş: {key}",
"Could not run ydotool: {error}": "ydotool çalıştırılamadı: {error}", "Could not run {tool}: {error}": "{tool} çalıştırılamadı: {error}",
"ydotool failed: {error}\nIs ydotoold running? (systemctl --user status ydotool)": "{tool} failed: {error}": "{tool} hatası: {error}",
"ydotool hatası: {error}\nydotoold çalışıyor mu? (systemctl --user status ydotool)", "Is ydotoold running? (systemctl --user status ydotool)":
"ydotoold çalışıyor mu? (systemctl --user status ydotool)",
# --- api errors ---------------------------------------------------- # --- api errors ----------------------------------------------------
"{service} API key is empty. Add it in Settings.": "{service} API key is empty. Add it in Settings.":
+110 -58
View File
@@ -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 os
import shutil import shutil
import subprocess import subprocess
@@ -7,35 +15,105 @@ import time
from i18n import t 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 = { KEYCODES = {
"ctrl": 29, "control": 29, "shift": 42, "alt": 56, "super": 125, "meta": 125, "ctrl": 29, "control": 29, "shift": 42, "alt": 56, "super": 125, "meta": 125,
"v": 47, "insert": 110, "enter": 28, "return": 28, "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): class PasteError(Exception):
pass 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(): def read_clipboard():
command = (["xclip", "-selection", "clipboard", "-out"] if _x11() here = desktop()
else ["wl-paste", "--no-newline"]) if not shutil.which(here.read_command[0]):
if not shutil.which(command[0]):
return None return None
try: 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): except (subprocess.SubprocessError, OSError):
return None return None
return res.stdout if res.returncode == 0 else None return res.stdout if res.returncode == 0 else None
def _run_copy(payload): def _run_copy(payload):
"""The clipboard owner may fork; do not leave inherited pipes open.""" """The clipboard owner forks to keep holding the selection; leaving its
command = (["xclip", "-selection", "clipboard", "-in"] if _x11() pipes open makes subprocess.run wait for EOF forever, hence DEVNULL."""
else ["wl-copy"])
return subprocess.run( return subprocess.run(
command, desktop().copy_command,
input=payload, input=payload,
stdout=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
@@ -44,21 +122,21 @@ def _run_copy(payload):
def copy(text): def copy(text):
tool = "xclip" if _x11() else "wl-copy" here = desktop()
if not shutil.which(tool): if not shutil.which(here.clipboard):
raise PasteError(t("{tool} not found; clipboard copy is unavailable.", tool=tool)) raise PasteError(t("{tool} not found. Install {packages}.",
tool=here.clipboard, packages=here.packages))
try: try:
res = _run_copy(text.encode("utf-8")) res = _run_copy(text.encode("utf-8"))
except (subprocess.SubprocessError, OSError) as exc: except (subprocess.SubprocessError, OSError) as exc:
raise PasteError(t("Could not copy to clipboard: {error}", error=exc)) from exc raise PasteError(t("Could not copy to clipboard: {error}", error=exc)) from exc
if res.returncode != 0: if res.returncode != 0:
raise PasteError(t("{tool} exited with code {code}.", raise PasteError(t("{tool} exited with code {code}.",
tool=tool, code=res.returncode)) tool=here.clipboard, code=res.returncode))
def copy_bytes(data): def copy_bytes(data):
tool = "xclip" if _x11() else "wl-copy" if data is None or not shutil.which(desktop().clipboard):
if data is None or not shutil.which(tool):
return return
try: try:
_run_copy(data) _run_copy(data)
@@ -66,54 +144,28 @@ def copy_bytes(data):
pass pass
def ydotool_ready(): # --- the key press ---------------------------------------------------------
tool = "xdotool" if _x11() else "ydotool"
return shutil.which(tool) is not None
def paste_ready():
def _x11(): return shutil.which(desktop().keyboard) is not None
return (os.environ.get("XDG_SESSION_TYPE") == "x11"
or bool(os.environ.get("DISPLAY") and not os.environ.get("WAYLAND_DISPLAY")))
def press(shortcut="ctrl+v", delay=0.12): def press(shortcut="ctrl+v", delay=0.12):
"""Press a key combination through xdotool or ydotool.""" """Press a key combination, e.g. 'ctrl+v'."""
if not ydotool_ready(): here = desktop()
tool = "xdotool" if _x11() else "ydotool" if not paste_ready():
raise PasteError(t("{tool} not found, cannot paste automatically.", tool=tool)) raise PasteError(t("{tool} not found, cannot paste automatically.",
tool=here.keyboard))
if _x11(): command = here.key_command(shortcut)
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)]
time.sleep(delay) # let the selection settle and focus come back time.sleep(delay) # let the selection settle and focus come back
try: try:
res = subprocess.run(["ydotool", "key", *seq], capture_output=True, res = subprocess.run(command, capture_output=True, text=True, timeout=10)
text=True, timeout=10)
except (subprocess.SubprocessError, OSError) as exc: 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: if res.returncode != 0:
raise PasteError(t( message = t("{tool} failed: {error}", tool=here.keyboard,
"ydotool failed: {error}\nIs ydotoold running? " error=res.stderr.strip() or "unknown error")
"(systemctl --user status ydotool)", raise PasteError(f"{message}\n{t(here.key_hint)}" if here.key_hint
error=res.stderr.strip() or "unknown error", else message)
))
-55
View File
@@ -1,55 +0,0 @@
import os
import unittest
from unittest import mock
import audio
import hotkey
import paste
class AudioBackendTests(unittest.TestCase):
def test_parec_is_preferred(self):
with mock.patch.object(audio.shutil, "which", side_effect=lambda cmd: f"/usr/bin/{cmd}"):
self.assertEqual(audio.recording_command()[0], "parec")
def test_pw_record_remains_the_fallback(self):
with mock.patch.object(
audio.shutil, "which", side_effect=lambda cmd: "/usr/bin/pw-record"
if cmd == "pw-record" else None,
):
self.assertEqual(audio.recording_command()[0], "pw-record")
class DesktopBackendTests(unittest.TestCase):
def test_x11_uses_xclip(self):
result = mock.Mock(returncode=0)
with mock.patch.dict(os.environ, {"XDG_SESSION_TYPE": "x11"}), \
mock.patch.object(paste.shutil, "which", return_value="/usr/bin/xclip"), \
mock.patch.object(paste.subprocess, "run", return_value=result) as run:
paste.copy("hello")
self.assertEqual(run.call_args.args[0][:3],
["xclip", "-selection", "clipboard"])
def test_x11_uses_xdotool(self):
result = mock.Mock(returncode=0, stderr="")
with mock.patch.dict(os.environ, {"XDG_SESSION_TYPE": "x11"}), \
mock.patch.object(paste.shutil, "which", return_value="/usr/bin/xdotool"), \
mock.patch.object(paste.time, "sleep"), \
mock.patch.object(paste.subprocess, "run", return_value=result) as run:
paste.press("ctrl+shift+v")
self.assertEqual(run.call_args.args[0],
["xdotool", "key", "--clearmodifiers", "ctrl+shift+v"])
class GnomeShortcutTests(unittest.TestCase):
def test_accelerator_round_trip(self):
accelerator = hotkey.gnome_accelerator("Ctrl+Alt+A")
self.assertEqual(accelerator, "<Primary><Alt>a")
self.assertEqual(hotkey.display_accelerator(accelerator), "Ctrl+Alt+A")
def test_empty_gsettings_array(self):
self.assertEqual(hotkey._gsettings_array("@as []"), [])
if __name__ == "__main__":
unittest.main()
+140 -60
View File
@@ -1,12 +1,18 @@
"""The clipboard and the key press, which is where a dictation actually lands. """The clipboard and the key press, which is where a dictation actually lands.
Everything here shells out, so the tools are faked. What the tests hold onto is Everything here shells out, so the tools are faked. What the tests hold onto is
the command line: a paste that presses the wrong codes, or in the wrong order, the command line: a paste that presses the wrong keys, or in the wrong order,
types nothing and looks like a hang. types nothing and looks like a hang.
Both desktops owe the same promises, so those are written once and run against
each of them. A third one added to paste.py inherits the same list rather than
needing its own copy of it.
""" """
import os
import subprocess import subprocess
import unittest import unittest
from typing import ClassVar
from unittest import mock from unittest import mock
import paste import paste
@@ -14,47 +20,86 @@ from tests.support import DikteTest, FakeCompleted, linux_only, only_these_tools
@linux_only @linux_only
class ReadClipboard(DikteTest): class Chooser(DikteTest):
def test_no_wl_paste_installed(self): """Which pair of programs this session's clipboard goes through."""
def under(self, **env):
with mock.patch.dict(os.environ, env, clear=True):
return paste.desktop()
def test_a_wayland_session(self):
self.assertIs(self.under(XDG_SESSION_TYPE="wayland",
WAYLAND_DISPLAY="wayland-0"), paste.WAYLAND)
def test_an_x11_session(self):
self.assertIs(self.under(XDG_SESSION_TYPE="x11", DISPLAY=":0"), paste.X11)
def test_a_display_with_no_wayland_beside_it(self):
self.assertIs(self.under(DISPLAY=":0"), paste.X11)
def test_an_x11_display_under_wayland_is_still_wayland(self):
"""XWayland sets DISPLAY too; the session type is the one to believe."""
self.assertIs(self.under(XDG_SESSION_TYPE="wayland",
DISPLAY=":0", WAYLAND_DISPLAY="wayland-0"),
paste.WAYLAND)
def test_nothing_set_at_all(self):
self.assertIs(self.under(), paste.WAYLAND)
class DesktopContract:
"""What both desktops owe. Each of them subclasses this once, below."""
env: ClassVar[dict] = {}
here = None
def setUp(self):
super().setUp()
self.enterContext(mock.patch.dict(os.environ, self.env, clear=True))
# ---- reading the clipboard -------------------------------------------
def test_no_reader_installed(self):
with only_these_tools(): with only_these_tools():
self.assertIsNone(paste.read_clipboard()) self.assertIsNone(paste.read_clipboard())
def test_what_is_on_the_clipboard_comes_back_as_bytes(self): def test_what_is_on_the_clipboard_comes_back_as_bytes(self):
with only_these_tools("wl-paste"), \ with only_these_tools(self.here.read_command[0]), \
mock.patch.object(subprocess, "run", mock.patch.object(subprocess, "run",
return_value=FakeCompleted(stdout=b"hello")) as run: return_value=FakeCompleted(stdout=b"hello")) as run:
self.assertEqual(paste.read_clipboard(), b"hello") self.assertEqual(paste.read_clipboard(), b"hello")
self.assertEqual(run.call_args.args[0], ["wl-paste", "--no-newline"]) self.assertEqual(run.call_args.args[0], self.here.read_command)
def test_an_empty_clipboard_is_not_an_error(self): def test_an_empty_clipboard_is_not_an_error(self):
with only_these_tools("wl-paste"), \ with only_these_tools(self.here.read_command[0]), \
mock.patch.object(subprocess, "run", mock.patch.object(subprocess, "run",
return_value=FakeCompleted(returncode=1)): return_value=FakeCompleted(returncode=1)):
self.assertIsNone(paste.read_clipboard()) self.assertIsNone(paste.read_clipboard())
def test_a_tool_that_will_not_run(self): def test_a_reader_that_will_not_run(self):
with only_these_tools("wl-paste"), \ with only_these_tools(self.here.read_command[0]), \
mock.patch.object(subprocess, "run", side_effect=OSError("nope")): mock.patch.object(subprocess, "run", side_effect=OSError("nope")):
self.assertIsNone(paste.read_clipboard()) self.assertIsNone(paste.read_clipboard())
# ---- copying ----------------------------------------------------------
@linux_only def test_no_clipboard_tool_installed_says_what_to_install(self):
class Copy(DikteTest):
def test_no_wl_copy_installed(self):
with only_these_tools(), self.assertRaises(paste.PasteError) as caught: with only_these_tools(), self.assertRaises(paste.PasteError) as caught:
paste.copy("hello") paste.copy("hello")
self.assertIn("wl-clipboard", str(caught.exception)) self.assertIn(self.here.clipboard, str(caught.exception))
self.assertIn(self.here.packages.split(" and ")[0], str(caught.exception))
def test_the_text_goes_in_as_utf8(self): def test_the_text_goes_in_as_utf8(self):
with only_these_tools("wl-copy"), \ with only_these_tools(self.here.clipboard), \
mock.patch.object(subprocess, "run", mock.patch.object(subprocess, "run",
return_value=FakeCompleted()) as run: return_value=FakeCompleted()) as run:
paste.copy("günaydın") paste.copy("günaydın")
self.assertEqual(run.call_args.args[0], self.here.copy_command)
self.assertEqual(run.call_args.kwargs["input"], "günaydın".encode()) self.assertEqual(run.call_args.kwargs["input"], "günaydın".encode())
def test_the_pipes_are_closed_so_the_call_can_return(self): def test_the_pipes_are_closed_so_the_call_can_return(self):
"""wl-copy forks and holds the selection; a pipe nobody drains hangs.""" """The clipboard owner forks; a pipe nobody drains hangs the caller."""
with only_these_tools("wl-copy"), \ with only_these_tools(self.here.clipboard), \
mock.patch.object(subprocess, "run", mock.patch.object(subprocess, "run",
return_value=FakeCompleted()) as run: return_value=FakeCompleted()) as run:
paste.copy("hello") paste.copy("hello")
@@ -62,95 +107,130 @@ class Copy(DikteTest):
self.assertEqual(run.call_args.kwargs["stderr"], subprocess.DEVNULL) self.assertEqual(run.call_args.kwargs["stderr"], subprocess.DEVNULL)
def test_a_non_zero_exit_is_reported(self): def test_a_non_zero_exit_is_reported(self):
with only_these_tools("wl-copy"), \ with only_these_tools(self.here.clipboard), \
mock.patch.object(subprocess, "run", mock.patch.object(subprocess, "run",
return_value=FakeCompleted(returncode=1)), \ return_value=FakeCompleted(returncode=1)), \
self.assertRaises(paste.PasteError): self.assertRaises(paste.PasteError):
paste.copy("hello") paste.copy("hello")
def test_a_tool_that_will_not_run(self): def test_a_clipboard_tool_that_will_not_run(self):
with only_these_tools("wl-copy"), \ with only_these_tools(self.here.clipboard), \
mock.patch.object(subprocess, "run", side_effect=OSError("nope")), \ mock.patch.object(subprocess, "run", side_effect=OSError("nope")), \
self.assertRaises(paste.PasteError): self.assertRaises(paste.PasteError):
paste.copy("hello") paste.copy("hello")
def test_there_is_nothing_to_restore(self):
@linux_only with only_these_tools(self.here.clipboard), \
class CopyBytes(DikteTest):
def test_nothing_to_restore(self):
with only_these_tools("wl-copy"), \
mock.patch.object(subprocess, "run") as run: mock.patch.object(subprocess, "run") as run:
paste.copy_bytes(None) paste.copy_bytes(None)
run.assert_not_called() run.assert_not_called()
def test_restoring_never_raises(self): def test_restoring_never_raises(self):
"""It runs after the paste went in; failing here must not undo that.""" """It runs after the paste went in; failing here must not undo that."""
with only_these_tools("wl-copy"), \ with only_these_tools(self.here.clipboard), \
mock.patch.object(subprocess, "run", side_effect=OSError("nope")): mock.patch.object(subprocess, "run", side_effect=OSError("nope")):
paste.copy_bytes(b"whatever was there before") paste.copy_bytes(b"whatever was there before")
def test_the_bytes_go_back_untouched(self): def test_the_bytes_go_back_untouched(self):
with only_these_tools("wl-copy"), \ with only_these_tools(self.here.clipboard), \
mock.patch.object(subprocess, "run", mock.patch.object(subprocess, "run",
return_value=FakeCompleted()) as run: return_value=FakeCompleted()) as run:
paste.copy_bytes(b"\x89PNG\r\n") paste.copy_bytes(b"\x89PNG\r\n")
self.assertEqual(run.call_args.kwargs["input"], b"\x89PNG\r\n") self.assertEqual(run.call_args.kwargs["input"], b"\x89PNG\r\n")
# ---- pressing the key -------------------------------------------------
@linux_only def press(self, shortcut, result=None):
class Press(DikteTest): with only_these_tools(self.here.keyboard), \
def setUp(self): mock.patch.object(paste.time, "sleep", lambda seconds: None), \
super().setUp()
# The settle delay is real time nobody needs to spend in a test.
self.patch_attr(paste.time, "sleep", lambda seconds: None)
def run_press(self, shortcut, result=None):
with only_these_tools("ydotool"), \
mock.patch.object(subprocess, "run", mock.patch.object(subprocess, "run",
return_value=result or FakeCompleted()) as run: return_value=result or FakeCompleted()) as run:
paste.press(shortcut) paste.press(shortcut)
return run.call_args.args[0] return run.call_args.args[0]
def test_no_ydotool_installed(self): def test_no_keyboard_tool_installed(self):
with only_these_tools(): with only_these_tools():
self.assertFalse(paste.ydotool_ready()) self.assertFalse(paste.paste_ready())
with self.assertRaises(paste.PasteError): with self.assertRaises(paste.PasteError) as caught:
paste.press() paste.press()
self.assertIn(self.here.keyboard, str(caught.exception))
def test_ctrl_v_presses_down_then_lets_go_in_reverse(self):
self.assertEqual(self.run_press("ctrl+v"),
["ydotool", "key", "29:1", "47:1", "47:0", "29:0"])
def test_three_keys(self):
self.assertEqual(self.run_press("ctrl+shift+v"),
["ydotool", "key", "29:1", "42:1", "47:1",
"47:0", "42:0", "29:0"])
def test_case_and_spacing_do_not_matter(self): def test_case_and_spacing_do_not_matter(self):
self.assertEqual(self.run_press(" Ctrl + V "), self.run_press("ctrl+v")) self.assertEqual(self.press(" Ctrl + V "), self.press("ctrl+v"))
def test_the_synonyms_land_on_the_same_codes(self): def test_a_key_nobody_mapped_is_refused_before_the_tool_runs(self):
self.assertEqual(self.run_press("control+insert"), """Whichever desktop it is, the shortcut is held to one table."""
["ydotool", "key", "29:1", "110:1", "110:0", "29:0"]) with only_these_tools(self.here.keyboard), \
self.assertEqual(self.run_press("super+enter"), self.run_press("meta+return")) mock.patch.object(subprocess, "run") as run, \
def test_a_key_nobody_mapped(self):
with only_these_tools("ydotool"), mock.patch.object(subprocess, "run"), \
self.assertRaises(paste.PasteError) as caught: self.assertRaises(paste.PasteError) as caught:
paste.press("ctrl+f13") paste.press("ctrl+f13")
self.assertIn("f13", str(caught.exception)) self.assertIn("f13", str(caught.exception))
run.assert_not_called()
def test_ydotoold_not_running_says_so(self):
with self.assertRaises(paste.PasteError) as caught:
self.run_press("ctrl+v", FakeCompleted(returncode=1, stderr="no socket"))
self.assertIn("ydotoold", str(caught.exception))
def test_a_tool_that_will_not_run(self): def test_a_tool_that_will_not_run(self):
with only_these_tools("ydotool"), \ with only_these_tools(self.here.keyboard), \
mock.patch.object(paste.time, "sleep", lambda seconds: None), \
mock.patch.object(subprocess, "run", side_effect=OSError("nope")), \ mock.patch.object(subprocess, "run", side_effect=OSError("nope")), \
self.assertRaises(paste.PasteError): self.assertRaises(paste.PasteError):
paste.press("ctrl+v") paste.press("ctrl+v")
def test_a_failed_key_press_names_the_tool_and_what_it_said(self):
with self.assertRaises(paste.PasteError) as caught:
self.press("ctrl+v", FakeCompleted(returncode=1, stderr="no socket"))
self.assertIn(self.here.keyboard, str(caught.exception))
self.assertIn("no socket", str(caught.exception))
@linux_only
class Wayland(DesktopContract, DikteTest):
env: ClassVar[dict] = {"XDG_SESSION_TYPE": "wayland",
"WAYLAND_DISPLAY": "wayland-0"}
here = paste.WAYLAND
def test_ydotool_presses_down_then_lets_go_in_reverse(self):
self.assertEqual(self.press("ctrl+v"),
["ydotool", "key", "29:1", "47:1", "47:0", "29:0"])
def test_three_keys(self):
self.assertEqual(self.press("ctrl+shift+v"),
["ydotool", "key", "29:1", "42:1", "47:1",
"47:0", "42:0", "29:0"])
def test_the_synonyms_land_on_the_same_codes(self):
self.assertEqual(self.press("control+insert"),
["ydotool", "key", "29:1", "110:1", "110:0", "29:0"])
self.assertEqual(self.press("super+enter"), self.press("meta+return"))
def test_a_failure_asks_after_the_daemon(self):
"""ydotool needs ydotoold, and says nothing useful when it is not up."""
with self.assertRaises(paste.PasteError) as caught:
self.press("ctrl+v", FakeCompleted(returncode=1, stderr="no socket"))
self.assertIn("ydotoold", str(caught.exception))
@linux_only
class X11(DesktopContract, DikteTest):
env: ClassVar[dict] = {"XDG_SESSION_TYPE": "x11", "DISPLAY": ":0"}
here = paste.X11
def test_xdotool_takes_the_combination_as_one_argument(self):
self.assertEqual(self.press("ctrl+v"),
["xdotool", "key", "--clearmodifiers", "ctrl+v"])
def test_three_keys(self):
self.assertEqual(self.press("ctrl+shift+v"),
["xdotool", "key", "--clearmodifiers", "ctrl+shift+v"])
def test_the_keys_x_spells_differently(self):
"""xdotool wants keysyms, not the names the code table is keyed by."""
self.assertEqual(self.press("control+insert")[-1], "ctrl+Insert")
self.assertEqual(self.press("meta+enter")[-1], "super+Return")
def test_no_daemon_to_ask_after(self):
with self.assertRaises(paste.PasteError) as caught:
self.press("ctrl+v", FakeCompleted(returncode=1, stderr="bad keysym"))
self.assertNotIn("ydotoold", str(caught.exception))
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()