fix: add clipboard fallback for Wayland compositors without virtual keyboard support (#299)

* fix: add clipboard fallback for Wayland compositors without virtual keyboard support

Fixes #297

On KDE Plasma Wayland, the compositor doesn't support the virtual keyboard
protocol that wtype requires. When both wtype and xdotool fallback fail,
text injection was completely broken.

Changes:
- Add _fallback_to_clipboard() method that tries wl-copy, xclip, and xsel
- When all injection methods fail, copy text to clipboard so user can paste
- Play success sound when clipboard fallback succeeds (text is recoverable)
- Improve error messages with KDE-specific guidance and ydotool instructions

For KDE Plasma Wayland users, the recommended solution is:
1. Install ydotool: sudo apt install ydotool
2. Enable daemon: sudo systemctl enable --now ydotoold

Alternatively, clipboard fallback will copy text for manual pasting.

* style: fix black formatting

* fix: wrap clipboard fallback in try/except to handle test mocks

* feat(clipboard): add configurable always-copy-to-clipboard feature

Enhanced the clipboard functionality beyond just a fallback mechanism:

- Renamed _fallback_to_clipboard to _copy_to_clipboard (more general)
- Added _should_copy_to_clipboard() to read user preference from config
- After successful text injection, copies to clipboard if setting enabled
- Still falls back to clipboard on injection failure regardless of setting
- Added 'copy_to_clipboard' setting to config (default: true)
- Added toggle switch in Settings > General for user control

This provides better UX by:
1. Allowing users to reference pasted text later via clipboard history
2. Providing a backup when injection partially works
3. Enabling easy pasting into apps where injection might not work

Fixes #297

* feat(clipboard): show notification when text copied as fallback

When text injection fails but clipboard succeeds, show a desktop
notification telling the user to paste with Ctrl+V. This ensures
users know their text was saved and can be recovered.
This commit is contained in:
Jatin K Malik
2026-03-14 12:53:16 -07:00
committed by GitHub
parent e35bdb2f62
commit 73082fa393
3 changed files with 157 additions and 4 deletions
+126 -4
View File
@@ -211,9 +211,15 @@ class TextInjector:
logger.error(
"No text injection tools found. Please install one of:\n"
"- IBus (recommended, usually pre-installed)\n"
"- wtype: sudo apt install wtype\n"
"- ydotool: sudo apt install ydotool\n"
"- xdotool: sudo apt install xdotool"
"- wtype: sudo apt install wtype (GNOME/Sway)\n"
"- ydotool: sudo apt install ydotool (works on all Wayland compositors)\n"
"- xdotool: sudo apt install xdotool (X11/XWayland only)\n"
"\n"
"For KDE Plasma Wayland users: wtype is not supported. "
"Install ydotool or wl-copy for clipboard fallback:\n"
" sudo apt install ydotool\n"
" sudo systemctl enable --now ydotoold\n"
"Or for clipboard fallback: sudo apt install wl-copy"
)
raise RuntimeError("Missing required dependencies for text injection")
@@ -299,6 +305,108 @@ class TextInjector:
logger.debug("No better tools available, continuing with xdotool fallback")
return False
def _copy_to_clipboard(self, text: str) -> bool:
"""
Copy text to clipboard.
This is useful for:
- Fallback when injection fails on unsupported compositors (like KDE Plasma)
- Always-on clipboard copy so users can paste recognized text elsewhere
Args:
text: The text to copy to clipboard
Returns:
True if clipboard copy was successful, False otherwise
"""
logger.info("Copying text to clipboard")
# Try wl-copy first (Wayland native)
if shutil.which("wl-copy"):
try:
subprocess.run(
["wl-copy", text],
check=True,
stderr=subprocess.PIPE,
text=True,
timeout=5,
)
logger.info("Text copied to Wayland clipboard using wl-copy")
return True
except (subprocess.CalledProcessError, subprocess.TimeoutExpired) as e:
logger.warning(f"wl-copy failed: {e}")
# Try xclip (X11 / XWayland)
if shutil.which("xclip"):
try:
subprocess.run(
["xclip", "-selection", "clipboard"],
input=text,
check=True,
stderr=subprocess.PIPE,
text=True,
timeout=5,
)
logger.info("Text copied to clipboard using xclip")
return True
except (subprocess.CalledProcessError, subprocess.TimeoutExpired) as e:
logger.warning(f"xclip failed: {e}")
# Try xsel as last resort
if shutil.which("xsel"):
try:
subprocess.run(
["xsel", "--clipboard", "--input"],
input=text,
check=True,
stderr=subprocess.PIPE,
text=True,
timeout=5,
)
logger.info("Text copied to clipboard using xsel")
return True
except (subprocess.CalledProcessError, subprocess.TimeoutExpired) as e:
logger.warning(f"xsel failed: {e}")
logger.warning(
"Clipboard copy failed. Install wl-copy (Wayland) or xclip/xsel "
"to enable clipboard functionality."
)
return False
def _should_copy_to_clipboard(self) -> bool:
"""Check if copy-to-clipboard setting is enabled."""
try:
import json
config_path = os.path.expanduser("~/.config/vocalinux/config.json")
if os.path.exists(config_path):
with open(config_path, "r") as f:
config = json.load(f)
return config.get("text_injection", {}).get("copy_to_clipboard", True)
except Exception as e:
logger.debug(f"Could not read copy_to_clipboard setting: {e}")
return True # Default to enabled
def _show_clipboard_fallback_notification(self):
"""Show a desktop notification when text is copied to clipboard as fallback."""
try:
subprocess.Popen(
[
"notify-send",
"-i",
"edit-paste",
"-a",
"Vocalinux",
"Text copied to clipboard",
"Text injection failed - paste with Ctrl+V",
],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
except Exception as e:
logger.debug(f"Could not show clipboard notification: {e}")
def inject_text(self, text: str) -> bool:
"""
Inject text into the currently focused application.
@@ -362,13 +470,27 @@ class TextInjector:
else:
raise
logger.info("Text injection completed successfully")
if self._should_copy_to_clipboard():
self._copy_to_clipboard(text)
logger.debug("Text also copied to clipboard (setting enabled)")
return True
except Exception as e:
logger.error(f"Failed to inject text: {e}", exc_info=True)
try:
if self._copy_to_clipboard(text):
logger.info("Text copied to clipboard as fallback - user can paste manually")
self._show_clipboard_fallback_notification()
return True
except Exception as clipboard_error:
logger.debug(f"Clipboard fallback also failed: {clipboard_error}")
try:
from ..ui.audio_feedback import play_error_sound
play_error_sound() # Play error sound when text injection fails
play_error_sound()
except ImportError:
logger.warning("Could not import audio feedback module")
return False
+3
View File
@@ -46,6 +46,9 @@ DEFAULT_CONFIG = {
"autostart": False,
"first_run": True,
},
"text_injection": {
"copy_to_clipboard": True, # Always copy recognized text to clipboard
},
"advanced": {
"debug_logging": False,
"wayland_mode": False,
+28
View File
@@ -943,10 +943,23 @@ class SettingsDialog(Gtk.Dialog):
)
group.add_row(start_minimized_row)
self.copy_to_clipboard_switch = Gtk.Switch()
self.copy_to_clipboard_switch.set_tooltip_text(
"Copy recognized text to clipboard after each transcription. "
"Useful if injection fails or you want to paste elsewhere."
)
copy_to_clipboard_row = PreferenceRow(
title="Copy to Clipboard",
subtitle="Always copy recognized text to clipboard for easy pasting",
widget=self.copy_to_clipboard_switch,
)
group.add_row(copy_to_clipboard_row)
self.general_tab.pack_start(group, False, False, 0)
self.autostart_switch.connect("state-set", self._on_autostart_toggled)
self.start_minimized_switch.connect("state-set", self._on_start_minimized_toggled)
self.copy_to_clipboard_switch.connect("state-set", self._on_copy_to_clipboard_toggled)
def _on_autostart_toggled(self, widget, state):
"""Handle toggle of the autostart switch."""
@@ -978,6 +991,18 @@ class SettingsDialog(Gtk.Dialog):
logger.info(f"Start minimized {'enabled' if enabled else 'disabled'}")
return False
def _on_copy_to_clipboard_toggled(self, widget, state):
"""Handle toggle of the copy to clipboard switch."""
if self._initializing or self._applying_settings:
return False
enabled = bool(state)
logger.info(f"Copy to clipboard toggled: {enabled}")
self.config_manager.set("text_injection", "copy_to_clipboard", enabled)
self.config_manager.save_settings()
logger.info(f"Copy to clipboard {'enabled' if enabled else 'disabled'}")
return False
def _build_engine_section(self):
"""Build the Speech Engine section."""
group = PreferencesGroup(title="Speech Engine")
@@ -1392,12 +1417,15 @@ class SettingsDialog(Gtk.Dialog):
general_settings = self.config_manager.get_settings().get("general", {})
ui_settings = self.config_manager.get_settings().get("ui", {})
text_injection_settings = self.config_manager.get_settings().get("text_injection", {})
autostart_enabled = general_settings.get("autostart", False)
start_minimized = ui_settings.get("start_minimized", False)
copy_to_clipboard = text_injection_settings.get("copy_to_clipboard", True)
self.autostart_switch.set_active(autostart_enabled)
self.start_minimized_switch.set_active(start_minimized)
self.copy_to_clipboard_switch.set_active(copy_to_clipboard)
# Populate engine combo with only available engines
available_engines = get_available_engines()