diff --git a/src/cthulhu/plugins/nvda2cthulhu/README.md b/src/cthulhu/plugins/nvda2cthulhu/README.md index 27f0e4f..79654da 100644 --- a/src/cthulhu/plugins/nvda2cthulhu/README.md +++ b/src/cthulhu/plugins/nvda2cthulhu/README.md @@ -19,3 +19,4 @@ will not start the server. - The server listens on 127.0.0.1 and uses the port from NVDA2SPEECHD_HOST if set; otherwise it defaults to 3457. - Toggle interrupt/no-interrupt mode with cthulhu+shift+n. +- Toggle translation with cthulhu+control+shift+t (requires translate-shell). diff --git a/src/cthulhu/plugins/nvda2cthulhu/plugin.py b/src/cthulhu/plugins/nvda2cthulhu/plugin.py index 3bb74c5..8e13dad 100644 --- a/src/cthulhu/plugins/nvda2cthulhu/plugin.py +++ b/src/cthulhu/plugins/nvda2cthulhu/plugin.py @@ -23,6 +23,9 @@ import asyncio import logging import os +import shutil +from collections import OrderedDict +import subprocess import threading import urllib.parse @@ -49,6 +52,9 @@ from cthulhu import settings_manager logger = logging.getLogger(__name__) DEFAULT_PORT = 3457 +TRANSLATE_COMMAND = ("trans", "-no-autocorrect", "-no-warn", "-brief") +TRANSLATE_TIMEOUT = 5.0 +TRANSLATE_CACHE_MAX = 512 def _coerce_text(value): @@ -92,6 +98,8 @@ class Nvda2Cthulhu(Plugin): self.httpServer = None self.ioLoop = None self.asyncioLoop = None + self.translationCache = OrderedDict() + self.translationCacheLock = threading.Lock() @cthulhu_hookimpl def activate(self, plugin=None): @@ -105,6 +113,12 @@ class Nvda2Cthulhu(Plugin): "kb:cthulhu+shift+n", learnModeEnabled=True ) + self.registerGestureByString( + self.toggle_translation, + "NVDA to Cthulhu translation", + "kb:cthulhu+control+shift+t", + learnModeEnabled=True + ) if not self._dependencies_available(): self._present_message("NVDA to Cthulhu missing dependencies: python-msgpack and python-tornado") logger.warning("NVDA to Cthulhu dependencies missing: msgpack or tornado") @@ -160,6 +174,19 @@ class Nvda2Cthulhu(Plugin): self._present_message(f"NVDA to Cthulhu {mode}") return True + def toggle_translation(self, script=None, inputEvent=None): + if not self.settingsManager: + return False + if not self._translation_command_available(): + self._present_message("NVDA to Cthulhu translation unavailable (missing translate-shell)") + return True + currentValue = bool(self.settingsManager.getSetting('nvda2cthulhuTranslateEnabled')) + newValue = not currentValue + self.settingsManager.setSetting('nvda2cthulhuTranslateEnabled', newValue) + mode = "translation enabled" if newValue else "translation disabled" + self._present_message(f"NVDA to Cthulhu {mode}") + return True + def handle_message(self, message): request = self._parse_request(message) if not request: @@ -272,6 +299,8 @@ class Nvda2Cthulhu(Plugin): def _handle_speak(self, text): if not text or not text.strip(): return + if self._translation_enabled(): + text = self._translate_text(text) speech.speak(text, interrupt=self.interruptEnabled) def _handle_braille(self, text): @@ -307,3 +336,62 @@ class Nvda2Cthulhu(Plugin): def _dependencies_available(self): return msgpack is not None and tornado is not None + + def _translation_enabled(self): + if not self.settingsManager: + return False + return bool(self.settingsManager.getSetting('nvda2cthulhuTranslateEnabled')) + + def _translation_command_available(self): + return shutil.which(TRANSLATE_COMMAND[0]) is not None + + def _translate_text(self, text): + cached = self._get_cached_translation(text) + if cached is not None: + return cached + if not self._translation_command_available(): + logger.warning("NVDA to Cthulhu translation failed: translate-shell not available") + return text + try: + result = subprocess.run( + TRANSLATE_COMMAND, + input=text, + text=True, + capture_output=True, + check=False, + timeout=TRANSLATE_TIMEOUT + ) + except subprocess.TimeoutExpired: + logger.warning("NVDA to Cthulhu translation failed: timed out") + return text + except Exception as exc: + logger.warning(f"NVDA to Cthulhu translation failed: {exc}") + return text + + if result.returncode != 0: + stderr = result.stderr.strip() + if stderr: + logger.warning(f"NVDA to Cthulhu translation failed: {stderr}") + return text + + output = result.stdout.strip() + if not output: + return text + self._set_cached_translation(text, output) + return output + + def _get_cached_translation(self, text): + with self.translationCacheLock: + cached = self.translationCache.get(text) + if cached is None: + return None + self.translationCache.move_to_end(text) + return cached + + def _set_cached_translation(self, text, translated): + with self.translationCacheLock: + if text in self.translationCache: + self.translationCache.move_to_end(text) + self.translationCache[text] = translated + while len(self.translationCache) > TRANSLATE_CACHE_MAX: + self.translationCache.popitem(last=False) diff --git a/src/cthulhu/settings.py b/src/cthulhu/settings.py index 082d8ba..3140d87 100644 --- a/src/cthulhu/settings.py +++ b/src/cthulhu/settings.py @@ -65,6 +65,7 @@ userCustomizableSettings = [ "enableEchoBySentence", "enableKeyEcho", "gameMode", + "nvda2cthulhuTranslateEnabled", "enableAlphabeticKeys", "enableNumericKeys", "enablePunctuationKeys", @@ -319,6 +320,7 @@ speechVerbosityLevel = VERBOSITY_LEVEL_VERBOSE messagesAreDetailed = True enablePauseBreaks = True gameMode = False +nvda2cthulhuTranslateEnabled = False speakDescription = True speakContextBlockquote = True speakContextPanel = True