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}",
# --- 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}",
"No audio recorder found. Install pulseaudio-utils or pipewire-audio.":
"Ses kayıt aracı bulunamadı. pulseaudio-utils ya da pipewire-audio kur.",
"Audio recorder stopped before receiving sound: {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}",
"{tool} not found; clipboard copy is unavailable.":
"{tool} bulunamadı; panoya kopyalama kullanılamıyor.",
"{tool} not found. Install {packages}.":
"{tool} bulunamadı. {packages} paketlerini kur.",
"{tool} exited with code {code}.": "{tool} {code} koduyla çıktı.",
"{tool} not found, cannot paste automatically.":
"{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}",
"Could not run ydotool: {error}": "ydotool çalıştırılamadı: {error}",
"ydotool failed: {error}\nIs ydotoold running? (systemctl --user status ydotool)":
"ydotool hatası: {error}\nydotoold çalışıyor mu? (systemctl --user status ydotool)",
"Could not run {tool}: {error}": "{tool} çalıştırılamadı: {error}",
"{tool} failed: {error}": "{tool} hatası: {error}",
"Is ydotoold running? (systemctl --user status ydotool)":
"ydotoold çalışıyor mu? (systemctl --user status ydotool)",
# --- api errors ----------------------------------------------------
"{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 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)
-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.
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.
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 unittest
from typing import ClassVar
from unittest import mock
import paste
@@ -14,47 +20,86 @@ from tests.support import DikteTest, FakeCompleted, linux_only, only_these_tools
@linux_only
class ReadClipboard(DikteTest):
def test_no_wl_paste_installed(self):
class Chooser(DikteTest):
"""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():
self.assertIsNone(paste.read_clipboard())
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",
return_value=FakeCompleted(stdout=b"hello")) as run:
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):
with only_these_tools("wl-paste"), \
with only_these_tools(self.here.read_command[0]), \
mock.patch.object(subprocess, "run",
return_value=FakeCompleted(returncode=1)):
self.assertIsNone(paste.read_clipboard())
def test_a_tool_that_will_not_run(self):
with only_these_tools("wl-paste"), \
def test_a_reader_that_will_not_run(self):
with only_these_tools(self.here.read_command[0]), \
mock.patch.object(subprocess, "run", side_effect=OSError("nope")):
self.assertIsNone(paste.read_clipboard())
# ---- copying ----------------------------------------------------------
@linux_only
class Copy(DikteTest):
def test_no_wl_copy_installed(self):
def test_no_clipboard_tool_installed_says_what_to_install(self):
with only_these_tools(), self.assertRaises(paste.PasteError) as caught:
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):
with only_these_tools("wl-copy"), \
with only_these_tools(self.here.clipboard), \
mock.patch.object(subprocess, "run",
return_value=FakeCompleted()) as run:
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())
def test_the_pipes_are_closed_so_the_call_can_return(self):
"""wl-copy forks and holds the selection; a pipe nobody drains hangs."""
with only_these_tools("wl-copy"), \
"""The clipboard owner forks; a pipe nobody drains hangs the caller."""
with only_these_tools(self.here.clipboard), \
mock.patch.object(subprocess, "run",
return_value=FakeCompleted()) as run:
paste.copy("hello")
@@ -62,95 +107,130 @@ class Copy(DikteTest):
self.assertEqual(run.call_args.kwargs["stderr"], subprocess.DEVNULL)
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",
return_value=FakeCompleted(returncode=1)), \
self.assertRaises(paste.PasteError):
paste.copy("hello")
def test_a_tool_that_will_not_run(self):
with only_these_tools("wl-copy"), \
def test_a_clipboard_tool_that_will_not_run(self):
with only_these_tools(self.here.clipboard), \
mock.patch.object(subprocess, "run", side_effect=OSError("nope")), \
self.assertRaises(paste.PasteError):
paste.copy("hello")
@linux_only
class CopyBytes(DikteTest):
def test_nothing_to_restore(self):
with only_these_tools("wl-copy"), \
def test_there_is_nothing_to_restore(self):
with only_these_tools(self.here.clipboard), \
mock.patch.object(subprocess, "run") as run:
paste.copy_bytes(None)
run.assert_not_called()
def test_restoring_never_raises(self):
"""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")):
paste.copy_bytes(b"whatever was there before")
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",
return_value=FakeCompleted()) as run:
paste.copy_bytes(b"\x89PNG\r\n")
self.assertEqual(run.call_args.kwargs["input"], b"\x89PNG\r\n")
# ---- pressing the key -------------------------------------------------
@linux_only
class Press(DikteTest):
def setUp(self):
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"), \
def press(self, shortcut, result=None):
with only_these_tools(self.here.keyboard), \
mock.patch.object(paste.time, "sleep", lambda seconds: None), \
mock.patch.object(subprocess, "run",
return_value=result or FakeCompleted()) as run:
paste.press(shortcut)
return run.call_args.args[0]
def test_no_ydotool_installed(self):
def test_no_keyboard_tool_installed(self):
with only_these_tools():
self.assertFalse(paste.ydotool_ready())
with self.assertRaises(paste.PasteError):
self.assertFalse(paste.paste_ready())
with self.assertRaises(paste.PasteError) as caught:
paste.press()
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"])
self.assertIn(self.here.keyboard, str(caught.exception))
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):
self.assertEqual(self.run_press("control+insert"),
["ydotool", "key", "29:1", "110:1", "110:0", "29:0"])
self.assertEqual(self.run_press("super+enter"), self.run_press("meta+return"))
def test_a_key_nobody_mapped(self):
with only_these_tools("ydotool"), mock.patch.object(subprocess, "run"), \
def test_a_key_nobody_mapped_is_refused_before_the_tool_runs(self):
"""Whichever desktop it is, the shortcut is held to one table."""
with only_these_tools(self.here.keyboard), \
mock.patch.object(subprocess, "run") as run, \
self.assertRaises(paste.PasteError) as caught:
paste.press("ctrl+f13")
self.assertIn("f13", str(caught.exception))
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))
run.assert_not_called()
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")), \
self.assertRaises(paste.PasteError):
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__":
unittest.main()