Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions docs/CONFIGURATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -893,6 +893,8 @@ Quick way to fix it on Wayland (no arithmetic):

Advanced (if you already know the Linux evdev keycode): set `paste_keycode` directly.

On GNOME/Mutter Wayland, hyprwhspr may use `ydotool type` because Mutter can drop synthetic paste chords. If you set `paste_keycode_wev` or a non-default `paste_keycode`, this direct typing fallback is disabled because it cannot honor that layout workaround; the dictated text remains on the clipboard if no paste backend succeeds.

### Auto-submit

Automatically press Enter after pasting.
Expand All @@ -911,9 +913,9 @@ Useful for chat applications, search boxes, or any input where you want to submi

### Clipboard behavior

hyprwhspr saves your clipboard before injection and restores it automatically afterward your clipboard contents are never permanently overwritten by dictated text.
For clipboard-based paste injection, hyprwhspr saves your clipboard before injection and restores it automatically afterward -- your clipboard contents are never permanently overwritten by dictated text.

The paste hotkey is sent via `wtype` (Wayland virtual-keyboard protocol), which works correctly in all applications including Kitty-protocol terminals (Ghostty, Kitty, WezTerm). `ydotool` is used as a fallback when `wtype` is not installed.
The paste hotkey is sent via `wtype` (Wayland virtual-keyboard protocol), which works correctly in all applications including Kitty-protocol terminals (Ghostty, Kitty, WezTerm). `ydotool key` is used as a fallback when `wtype` is not installed or cannot paste. On GNOME/Mutter Wayland, where synthetic modifier chords may be dropped, hyprwhspr can use `ydotool type` and inject the text directly without touching the clipboard.

### Post-transcription hook

Expand Down
5 changes: 4 additions & 1 deletion lib/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -1571,7 +1571,10 @@ def _inject_text(self, text):
return

try:
self.text_injector.inject_text(text)
if not self.text_injector.inject_text(text):
print(f"[ERROR] Text injection failed ({len(text)} chars)", flush=True)
return

print(f"[INJECT] Text injected ({len(text)} chars)", flush=True)

# Text injection succeeded - system is fully healthy
Expand Down
87 changes: 84 additions & 3 deletions lib/src/text_injector.py
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,24 @@ def _get_paste_keycode(self) -> int:
except Exception:
return DEFAULT_PASTE_KEYCODE

def _has_custom_paste_keycode(self) -> bool:
"""Return True when config indicates a non-QWERTY paste key workaround."""
if not self.config_manager:
return False

wev_keycode = self.config_manager.get_setting('paste_keycode_wev', None)
if wev_keycode is not None:
try:
return int(wev_keycode) - 8 != DEFAULT_PASTE_KEYCODE
except Exception:
return True

paste_keycode = self.config_manager.get_setting('paste_keycode', DEFAULT_PASTE_KEYCODE)
try:
return int(paste_keycode) != DEFAULT_PASTE_KEYCODE
except Exception:
return True

def _get_active_window_info(self) -> Optional[Dict[str, Any]]:
"""Get active window info, trying multiple compositor APIs."""
# Niri
Expand Down Expand Up @@ -350,6 +368,49 @@ def _key(*args):
print(f"Slow paste key injection failed: {e}")
return False

def _is_gnome_wayland_session(self) -> bool:
"""Return True for Mutter/GNOME Wayland sessions where uinput chords are unreliable."""
session_type = os.environ.get('XDG_SESSION_TYPE', '').lower()
if session_type and session_type != 'wayland':
return False
if not session_type and not os.environ.get('WAYLAND_DISPLAY'):
return False

desktop_values = [
os.environ.get('XDG_CURRENT_DESKTOP', ''),
os.environ.get('XDG_SESSION_DESKTOP', ''),
os.environ.get('DESKTOP_SESSION', ''),
]
desktop = ':'.join(desktop_values).lower()
desktop_tokens = set(filter(None, re.split(r'[^a-z0-9]+', desktop)))
return bool(desktop_tokens & {'gnome', 'pop'})

def _type_text_ydotool(self, text: str) -> bool:
"""
Type text directly with ydotool.

This is slower and less layout-aware than clipboard paste on compositors
where paste chords work, but it avoids Mutter dropping synthetic modifier
combos from uinput devices.
"""
if not self.ydotool_available:
return False

try:
result = subprocess.run(
['ydotool', 'type', '--key-delay', '5', '--key-hold', '5', '--', text],
capture_output=True,
timeout=10,
)
if result.returncode != 0:
stderr = (result.stderr or b'').decode('utf-8', 'ignore')
print(f" ydotool type failed: {stderr}")
return False
return True
except Exception as e:
print(f"ydotool type injection failed: {e}")
return False

def _save_clipboard(self) -> Optional[bytes]:
"""Save current clipboard contents. Returns raw bytes or None."""
if shutil.which("wl-paste"):
Expand Down Expand Up @@ -624,6 +685,24 @@ def _inject_via_clipboard_and_hotkey(self, text: str) -> bool:
"""Copy text to clipboard, then trigger paste via wtype (or ydotool fallback)."""
try:
window_info = self._get_active_window_info()
gnome_wayland_session = self._is_gnome_wayland_session()

# On GNOME/Mutter, both wtype and ydotool paste chords are unreliable.
# Type directly before touching the clipboard, unless the user has
# configured a non-QWERTY paste key workaround that ydotool type
# cannot honor.
if (
gnome_wayland_session
and self.ydotool_available
and not self._has_custom_paste_keycode()
):
self._clear_stuck_modifiers()
time.sleep(0.05)
typed = self._type_text_ydotool(text)
if typed:
self._send_enter_if_auto_submit()
return True

saved_clipboard = self._save_clipboard()

# Copy text to clipboard
Expand All @@ -645,7 +724,9 @@ def _inject_via_clipboard_and_hotkey(self, text: str) -> bool:
else:
paste_mode = self._detect_paste_mode(window_info)

# Send paste hotkey: prefer wtype (Wayland virtual-keyboard), fall back to ydotool
# Send paste hotkey: prefer wtype (Wayland virtual-keyboard), fall back to ydotool.
# Mutter/GNOME accepts individual uinput key events but drops modifier
# chords, so skip ydotool's paste chord there and use direct typing.
pasted = False
if self.wtype_available:
pasted = self._send_paste_keys_wtype(paste_mode)
Expand All @@ -654,7 +735,7 @@ def _inject_via_clipboard_and_hotkey(self, text: str) -> bool:
# state so subsequent physical keypresses are not affected.
self._clear_stuck_modifiers()

if not pasted and self.ydotool_available:
if not pasted and self.ydotool_available and not gnome_wayland_session:
self._clear_stuck_modifiers()
time.sleep(0.02)
pasted = self._send_paste_keys_slow(paste_mode)
Expand All @@ -665,7 +746,7 @@ def _inject_via_clipboard_and_hotkey(self, text: str) -> bool:
# and don't auto-submit (nothing was pasted into the field).
return True

# Only restore clipboard after a successful hotkey paste — if paste failed,
# Only restore clipboard after successful injection — if injection failed,
# leave dictated text on clipboard so the user can paste manually.
if pasted:
restore_delay = 0.5
Expand Down
34 changes: 34 additions & 0 deletions tests/test_main_startup_safety.py
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,40 @@ def test_process_audio_finally_clears_transcript_preview(self):

self.assertTrue(clears_in_finally)

def test_inject_text_checks_injector_result_before_success_log(self):
tree = ast.parse((ROOT / "lib" / "main.py").read_text(encoding="utf-8"))

inject_func = self._find_function(tree, "_inject_text")
self.assertIsNotNone(inject_func)

result_checked_line = None
success_log_line = None
for node in ast.walk(inject_func):
if (
isinstance(node, ast.UnaryOp)
and isinstance(node.op, ast.Not)
and isinstance(node.operand, ast.Call)
and isinstance(node.operand.func, ast.Attribute)
and node.operand.func.attr == "inject_text"
):
result_checked_line = node.lineno
elif (
isinstance(node, ast.Call)
and isinstance(node.func, ast.Name)
and node.func.id == "print"
and node.args
and isinstance(node.args[0], ast.JoinedStr)
and any(
isinstance(part, ast.Constant) and "[INJECT] Text injected" in str(part.value)
for part in node.args[0].values
)
):
success_log_line = node.lineno

self.assertIsNotNone(result_checked_line)
self.assertIsNotNone(success_log_line)
self.assertLess(result_checked_line, success_log_line)


if __name__ == "__main__":
unittest.main()
157 changes: 157 additions & 0 deletions tests/test_text_injector_injection.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
import sys
import types
import unittest
from pathlib import Path
from unittest import mock


ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / "lib" / "src"))
sys.modules.setdefault("pyperclip", types.SimpleNamespace(copy=lambda text: None, paste=lambda: ""))

from text_injector import TextInjector


class ConfigStub:
def __init__(self, settings=None):
self.settings = settings or {}

def get_setting(self, name, default=None):
return self.settings.get(name, default)


class TextInjectorInjectionTests(unittest.TestCase):
def _injector(self):
injector = TextInjector.__new__(TextInjector)
injector.config_manager = ConfigStub()
injector.ydotool_available = True
injector.wtype_available = False
return injector

def test_gnome_wayland_uses_ydotool_type_instead_of_paste_chord(self):
injector = self._injector()

with (
mock.patch("text_injector.shutil.which", return_value=None),
mock.patch("text_injector.pyperclip.copy") as copy,
mock.patch.object(injector, "_get_active_window_info", return_value=None),
mock.patch.object(injector, "_save_clipboard", return_value=b"old clipboard") as save_clipboard,
mock.patch.object(injector, "_send_paste_keys_slow", return_value=True) as paste_chord,
mock.patch.object(injector, "_type_text_ydotool", return_value=True) as direct_type,
mock.patch.object(injector, "_restore_clipboard") as restore_clipboard,
mock.patch.object(injector, "_send_enter_if_auto_submit") as auto_submit,
mock.patch.dict(
"text_injector.os.environ",
{
"XDG_SESSION_TYPE": "wayland",
"XDG_CURRENT_DESKTOP": "pop:GNOME",
"XDG_SESSION_DESKTOP": "pop",
"DESKTOP_SESSION": "pop",
"WAYLAND_DISPLAY": "wayland-0",
},
clear=True,
),
):
self.assertTrue(injector._inject_via_clipboard_and_hotkey("hello"))

paste_chord.assert_not_called()
direct_type.assert_called_once_with("hello")
auto_submit.assert_called_once_with()
save_clipboard.assert_not_called()
copy.assert_not_called()
restore_clipboard.assert_not_called()

def test_gnome_wayland_with_custom_paste_keycode_keeps_clipboard_fallback(self):
injector = self._injector()
injector.config_manager = ConfigStub({"paste_keycode": 54})

with (
mock.patch("text_injector.shutil.which", return_value=None),
mock.patch("text_injector.pyperclip.copy") as copy,
mock.patch.object(injector, "_get_active_window_info", return_value=None),
mock.patch.object(injector, "_save_clipboard", return_value=b"old clipboard"),
mock.patch.object(injector, "_send_paste_keys_slow", return_value=True) as paste_chord,
mock.patch.object(injector, "_type_text_ydotool", return_value=True) as direct_type,
mock.patch.object(injector, "_restore_clipboard") as restore_clipboard,
mock.patch.object(injector, "_send_enter_if_auto_submit") as auto_submit,
mock.patch.dict(
"text_injector.os.environ",
{
"XDG_SESSION_TYPE": "wayland",
"XDG_CURRENT_DESKTOP": "GNOME",
"XDG_SESSION_DESKTOP": "gnome",
"DESKTOP_SESSION": "gnome",
"WAYLAND_DISPLAY": "wayland-0",
},
clear=True,
),
):
self.assertFalse(injector._inject_via_clipboard_and_hotkey("hello"))

copy.assert_called_once_with("hello")
paste_chord.assert_not_called()
direct_type.assert_not_called()
restore_clipboard.assert_not_called()
auto_submit.assert_not_called()

def test_non_gnome_leaves_clipboard_on_failed_paste_chord(self):
injector = self._injector()

with (
mock.patch("text_injector.shutil.which", return_value=None),
mock.patch("text_injector.pyperclip.copy"),
mock.patch.object(injector, "_get_active_window_info", return_value=None),
mock.patch.object(injector, "_save_clipboard", return_value=None),
mock.patch.object(injector, "_send_paste_keys_slow", return_value=False) as paste_chord,
mock.patch.object(injector, "_type_text_ydotool", return_value=True) as direct_type,
mock.patch.object(injector, "_restore_clipboard"),
mock.patch.object(injector, "_send_enter_if_auto_submit"),
mock.patch.dict(
"text_injector.os.environ",
{
"XDG_SESSION_TYPE": "wayland",
"XDG_CURRENT_DESKTOP": "sway",
"XDG_SESSION_DESKTOP": "sway",
"DESKTOP_SESSION": "sway",
"WAYLAND_DISPLAY": "wayland-1",
},
clear=True,
),
):
self.assertFalse(injector._inject_via_clipboard_and_hotkey("hello"))

paste_chord.assert_called_once_with("ctrl")
direct_type.assert_not_called()

def test_ydotool_type_command_uses_small_delay_and_argument_separator(self):
injector = self._injector()
completed = types.SimpleNamespace(returncode=0, stderr=b"")

with mock.patch("text_injector.subprocess.run", return_value=completed) as run:
self.assertTrue(injector._type_text_ydotool("hello -- world"))

run.assert_called_once_with(
['ydotool', 'type', '--key-delay', '5', '--key-hold', '5', '--', 'hello -- world'],
capture_output=True,
timeout=10,
)

def test_gnome_detection_does_not_match_pop_substrings(self):
injector = self._injector()

with mock.patch.dict(
"text_injector.os.environ",
{
"XDG_SESSION_TYPE": "wayland",
"XDG_CURRENT_DESKTOP": "popup",
"XDG_SESSION_DESKTOP": "popterm",
"DESKTOP_SESSION": "custom",
"WAYLAND_DISPLAY": "wayland-1",
},
clear=True,
):
self.assertFalse(injector._is_gnome_wayland_session())


if __name__ == "__main__":
unittest.main()
Loading