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:
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user