diff --git a/src/vocalinux/text_injection/text_injector.py b/src/vocalinux/text_injection/text_injector.py index ff1c2dd..5d64c17 100644 --- a/src/vocalinux/text_injection/text_injector.py +++ b/src/vocalinux/text_injection/text_injector.py @@ -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 diff --git a/src/vocalinux/ui/config_manager.py b/src/vocalinux/ui/config_manager.py index a6b809d..5a15594 100644 --- a/src/vocalinux/ui/config_manager.py +++ b/src/vocalinux/ui/config_manager.py @@ -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, diff --git a/src/vocalinux/ui/settings_dialog.py b/src/vocalinux/ui/settings_dialog.py index 2f0e1cc..e942684 100644 --- a/src/vocalinux/ui/settings_dialog.py +++ b/src/vocalinux/ui/settings_dialog.py @@ -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()