mirror of
https://github.com/yusufipk/dikte.git
synced 2026-09-11 19:06:11 +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:
@@ -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.":
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user