From c1ede7728e27cbea39407b9579fde3972777923c Mon Sep 17 00:00:00 2001 From: Storm Dragon Date: Sat, 3 Jan 2026 15:13:48 -0500 Subject: [PATCH 01/14] Completely redesigned the indentation plugin. Now adds a tab to preferences with complete customization for how indentation is presented. --- src/cthulhu/cthulhu-setup.ui | 691 +++++++++++++++++- src/cthulhu/cthulhu_gui_prefs.py | 205 +++++- src/cthulhu/input_event_manager.py | 42 ++ src/cthulhu/messages.py | 10 + .../plugins/IndentationAudio/plugin.py | 245 +++++-- src/cthulhu/script_utilities.py | 187 ++++- src/cthulhu/scripts/default.py | 15 +- src/cthulhu/settings.py | 44 +- src/cthulhu/speech_and_verbosity_manager.py | 19 + src/cthulhu/speech_dbus_manager.py | 19 +- src/cthulhu/speech_generator.py | 3 +- 11 files changed, 1366 insertions(+), 114 deletions(-) diff --git a/src/cthulhu/cthulhu-setup.ui b/src/cthulhu/cthulhu-setup.ui index f69e8c3..5585122 100644 --- a/src/cthulhu/cthulhu-setup.ui +++ b/src/cthulhu/cthulhu-setup.ui @@ -21,6 +21,55 @@ 10 50 + + 1 + 16 + 4 + 1 + 1 + + + 1 + 16 + 4 + 1 + 1 + + + 50 + 2000 + 200 + 10 + 50 + + + 1 + 500 + 80 + 5 + 20 + + + 200 + 5000 + 1200 + 50 + 100 + + + 0.01 + 2 + 0.15 + 0.01 + 0.1 + + + 0 + 1 + 0.7 + 0.05 + 0.1 + @@ -1830,21 +1879,6 @@ 0 - - - Speak _indentation and justification - True - True - False - True - True - - - - 0 - 1 - - Spea_k object mnemonics @@ -3331,8 +3365,8 @@ 0 - - + + True False @@ -3553,6 +3587,621 @@ False + + + True + False + 12 + 10 + 10 + + + True + False + 0 + none + + + True + False + 12 + + + True + False + 6 + + + _Off + True + True + False + True + True + + + + 0 + 0 + + + + + _Speech + True + True + False + True + True + indentationPresentationOffButton + + + + 0 + 1 + + + + + _Beeps + True + True + False + True + True + indentationPresentationOffButton + + + + 0 + 2 + + + + + Speech _and beeps + True + True + False + True + True + indentationPresentationOffButton + + + + 0 + 3 + + + + + + + + + True + False + Presentation Mode + + + + + + + + 0 + 0 + + + + + True + False + 0 + none + + + True + False + 12 + + + True + False + 6 + + + Always present indentation + True + True + False + True + True + + + + 0 + 0 + + + + + Present any indentation changes + True + True + False + True + True + indentationChangeAlwaysButton + + + + 0 + 1 + + + + + Present only when indentation level changes + True + True + False + True + True + indentationChangeAlwaysButton + + + + 0 + 2 + + + + + + + + + True + False + When to Present + + + + + + + + 1 + 0 + + + + + True + False + 0 + none + + + True + False + 12 + + + True + False + 6 + + + Spaces and tabs + True + True + False + True + True + + + + 0 + 0 + + + + + Indentation level + True + True + False + True + True + indentationSpeechSpacesTabsButton + + + + 0 + 1 + + + + + Indentation columns + True + True + False + True + True + indentationSpeechSpacesTabsButton + + + + 0 + 2 + + + + + + + + + True + False + Speech Format + + + + + + + + 0 + 1 + + + + + True + False + 0 + none + + + True + False + 12 + + + True + False + 6 + 10 + + + True + False + Spaces per level: + True + indentationSpacesPerLevelSpinButton + 0 + + + 0 + 0 + + + + + True + True + indentationSpacesPerLevelAdjustment + 1 + True + + + + 1 + 0 + + + + + True + False + Tab width: + True + indentationTabWidthSpinButton + 0 + + + 0 + 1 + + + + + True + True + indentationTabWidthAdjustment + 1 + True + + + + 1 + 1 + + + + + True + False + Audio unit: + 0 + + + 0 + 2 + + + + + True + False + 4 + + + Levels + True + True + False + True + True + + + + 0 + 0 + + + + + Columns + True + True + False + True + True + indentationAudioUnitLevelsButton + + + + 0 + 1 + + + + + 1 + 2 + + + + + + + + + True + False + Units + + + + + + + + 1 + 1 + + + + + True + False + 0 + none + + + True + False + 12 + + + True + False + 6 + 10 + + + True + False + Base frequency (Hz): + True + indentationAudioBaseFrequencySpinButton + 0 + + + 0 + 0 + + + + + True + True + indentationAudioBaseFrequencyAdjustment + 1 + True + + + + 1 + 0 + + + + + True + False + Step per unit (Hz): + True + indentationAudioStepFrequencySpinButton + 0 + + + 0 + 1 + + + + + True + True + indentationAudioStepFrequencyAdjustment + 1 + True + + + + 1 + 1 + + + + + True + False + Maximum frequency (Hz): + True + indentationAudioMaxFrequencySpinButton + 0 + + + 0 + 2 + + + + + True + True + indentationAudioMaxFrequencyAdjustment + 1 + True + + + + 1 + 2 + + + + + True + False + Tone duration (sec): + True + indentationAudioDurationSpinButton + 0 + + + 0 + 3 + + + + + True + True + indentationAudioDurationAdjustment + 0.01 + 2 + True + + + + 1 + 3 + + + + + True + False + Volume multiplier: + True + indentationAudioVolumeSpinButton + 0 + + + 0 + 4 + + + + + True + True + indentationAudioVolumeAdjustment + 0.05 + 2 + True + + + + 1 + 4 + + + + + + + + + True + False + Audio + + + + + + + + 0 + 2 + 2 + + + + + 8 + + + + + True + False + Indentation + + + 8 + False + + True @@ -3773,7 +4422,7 @@ - 8 + 9 @@ -3783,7 +4432,7 @@ AI Assistant - 8 + 9 False @@ -3959,7 +4608,7 @@ - 9 + 10 @@ -3969,7 +4618,7 @@ OCR - 9 + 10 False diff --git a/src/cthulhu/cthulhu_gui_prefs.py b/src/cthulhu/cthulhu_gui_prefs.py index 09814ef..7195c9c 100644 --- a/src/cthulhu/cthulhu_gui_prefs.py +++ b/src/cthulhu/cthulhu_gui_prefs.py @@ -1482,8 +1482,6 @@ class CthulhuSetupGUI(cthulhu_gtkbuilder.GtkBuilderWrapper): self.get_widget("onlySpeakDisplayedTextCheckButton").set_active( prefs["onlySpeakDisplayedText"]) - self.get_widget("enableSpeechIndentationCheckButton").set_active(\ - prefs["enableSpeechIndentation"]) self.get_widget("speakBlankLinesCheckButton").set_active(\ prefs["speakBlankLines"]) @@ -1825,6 +1823,10 @@ class CthulhuSetupGUI(cthulhu_gtkbuilder.GtkBuilderWrapper): # AI Assistant settings # self._initAIState() + + # Indentation settings + # + self._initIndentationState() # OCR Plugin settings # @@ -1929,6 +1931,119 @@ class CthulhuSetupGUI(cthulhu_gtkbuilder.GtkBuilderWrapper): if enabled: current_provider = self.prefsDict.get("aiProvider", settings.aiProvider) self._updateProviderControls(current_provider) + + def _initIndentationState(self): + """Initialize Indentation tab widgets with current settings.""" + prefs = self.prefsDict + + self.indentationPresentationOffButton = self.get_widget("indentationPresentationOffButton") + self.indentationPresentationSpeechButton = self.get_widget("indentationPresentationSpeechButton") + self.indentationPresentationBeepsButton = self.get_widget("indentationPresentationBeepsButton") + self.indentationPresentationSpeechAndBeepsButton = self.get_widget("indentationPresentationSpeechAndBeepsButton") + + self.indentationChangeAlwaysButton = self.get_widget("indentationChangeAlwaysButton") + self.indentationChangeAnyButton = self.get_widget("indentationChangeAnyButton") + self.indentationChangeLevelButton = self.get_widget("indentationChangeLevelButton") + + self.indentationSpeechSpacesTabsButton = self.get_widget("indentationSpeechSpacesTabsButton") + self.indentationSpeechLevelsButton = self.get_widget("indentationSpeechLevelsButton") + self.indentationSpeechColumnsButton = self.get_widget("indentationSpeechColumnsButton") + + self.indentationAudioUnitLevelsButton = self.get_widget("indentationAudioUnitLevelsButton") + self.indentationAudioUnitColumnsButton = self.get_widget("indentationAudioUnitColumnsButton") + + self.indentationSpacesPerLevelSpinButton = self.get_widget("indentationSpacesPerLevelSpinButton") + self.indentationTabWidthSpinButton = self.get_widget("indentationTabWidthSpinButton") + self.indentationAudioBaseFrequencySpinButton = self.get_widget("indentationAudioBaseFrequencySpinButton") + self.indentationAudioStepFrequencySpinButton = self.get_widget("indentationAudioStepFrequencySpinButton") + self.indentationAudioMaxFrequencySpinButton = self.get_widget("indentationAudioMaxFrequencySpinButton") + self.indentationAudioDurationSpinButton = self.get_widget("indentationAudioDurationSpinButton") + self.indentationAudioVolumeSpinButton = self.get_widget("indentationAudioVolumeSpinButton") + + mode = prefs.get("indentationPresentationMode", settings.indentationPresentationMode) + if mode == settings.INDENTATION_PRESENTATION_SPEECH: + self.indentationPresentationSpeechButton.set_active(True) + elif mode == settings.INDENTATION_PRESENTATION_BEEPS: + self.indentationPresentationBeepsButton.set_active(True) + elif mode == settings.INDENTATION_PRESENTATION_SPEECH_AND_BEEPS: + self.indentationPresentationSpeechAndBeepsButton.set_active(True) + else: + self.indentationPresentationOffButton.set_active(True) + + enableSpeechIndentation = mode in ( + settings.INDENTATION_PRESENTATION_SPEECH, + settings.INDENTATION_PRESENTATION_SPEECH_AND_BEEPS, + ) + self.prefsDict["enableSpeechIndentation"] = enableSpeechIndentation + + only_if_changed = prefs.get("speakIndentationOnlyIfChanged", settings.speakIndentationOnlyIfChanged) + change_mode = prefs.get("indentationChangeMode", settings.indentationChangeMode) + if not only_if_changed or change_mode == settings.INDENTATION_CHANGE_ALWAYS: + self.indentationChangeAlwaysButton.set_active(True) + elif change_mode == settings.INDENTATION_CHANGE_ANY: + self.indentationChangeAnyButton.set_active(True) + else: + self.indentationChangeLevelButton.set_active(True) + + speech_style = prefs.get("indentationSpeechStyle", settings.indentationSpeechStyle) + if speech_style == settings.INDENTATION_SPEECH_STYLE_LEVELS: + self.indentationSpeechLevelsButton.set_active(True) + elif speech_style == settings.INDENTATION_SPEECH_STYLE_COLUMNS: + self.indentationSpeechColumnsButton.set_active(True) + else: + self.indentationSpeechSpacesTabsButton.set_active(True) + + audio_unit = prefs.get("indentationAudioUnit", settings.indentationAudioUnit) + if audio_unit == settings.INDENTATION_UNIT_COLUMNS: + self.indentationAudioUnitColumnsButton.set_active(True) + else: + self.indentationAudioUnitLevelsButton.set_active(True) + + self.indentationSpacesPerLevelSpinButton.set_value( + prefs.get("indentationSpacesPerLevel", settings.indentationSpacesPerLevel)) + self.indentationTabWidthSpinButton.set_value( + prefs.get("indentationTabWidth", settings.indentationTabWidth)) + self.indentationAudioBaseFrequencySpinButton.set_value( + prefs.get("indentationAudioBaseFrequency", settings.indentationAudioBaseFrequency)) + self.indentationAudioStepFrequencySpinButton.set_value( + prefs.get("indentationAudioStepFrequency", settings.indentationAudioStepFrequency)) + self.indentationAudioMaxFrequencySpinButton.set_value( + prefs.get("indentationAudioMaxFrequency", settings.indentationAudioMaxFrequency)) + self.indentationAudioDurationSpinButton.set_value( + prefs.get("indentationAudioDuration", settings.indentationAudioDuration)) + self.indentationAudioVolumeSpinButton.set_value( + prefs.get("indentationAudioVolume", settings.indentationAudioVolume)) + + self._updateIndentationControlsState(mode) + + def _updateIndentationControlsState(self, mode): + """Enable or disable indentation controls based on presentation mode.""" + speech_enabled = mode in ( + settings.INDENTATION_PRESENTATION_SPEECH, + settings.INDENTATION_PRESENTATION_SPEECH_AND_BEEPS, + ) + beeps_enabled = mode in ( + settings.INDENTATION_PRESENTATION_BEEPS, + settings.INDENTATION_PRESENTATION_SPEECH_AND_BEEPS, + ) + + for widget in ( + self.indentationSpeechSpacesTabsButton, + self.indentationSpeechLevelsButton, + self.indentationSpeechColumnsButton, + ): + widget.set_sensitive(speech_enabled) + + for widget in ( + self.indentationAudioUnitLevelsButton, + self.indentationAudioUnitColumnsButton, + self.indentationAudioBaseFrequencySpinButton, + self.indentationAudioStepFrequencySpinButton, + self.indentationAudioMaxFrequencySpinButton, + self.indentationAudioDurationSpinButton, + self.indentationAudioVolumeSpinButton, + ): + widget.set_sensitive(beeps_enabled) def _updateProviderControls(self, provider): """Update visibility/sensitivity of provider-specific controls.""" @@ -3839,6 +3954,92 @@ class CthulhuSetupGUI(cthulhu_gtkbuilder.GtkBuilderWrapper): self.__initProfileCombo() + # Indentation signal handlers + + def indentationPresentationModeToggled(self, widget): + """Indentation presentation mode radio toggled handler.""" + if not widget.get_active(): + return + + mapping = { + "indentationPresentationOffButton": settings.INDENTATION_PRESENTATION_OFF, + "indentationPresentationSpeechButton": settings.INDENTATION_PRESENTATION_SPEECH, + "indentationPresentationBeepsButton": settings.INDENTATION_PRESENTATION_BEEPS, + "indentationPresentationSpeechAndBeepsButton": settings.INDENTATION_PRESENTATION_SPEECH_AND_BEEPS, + } + widget_name = Gtk.Buildable.get_name(widget) + mode = mapping.get(widget_name) + if mode is None: + return + + self.prefsDict["indentationPresentationMode"] = mode + self.prefsDict["enableSpeechIndentation"] = mode in ( + settings.INDENTATION_PRESENTATION_SPEECH, + settings.INDENTATION_PRESENTATION_SPEECH_AND_BEEPS, + ) + self._updateIndentationControlsState(mode) + + def indentationChangeModeToggled(self, widget): + """Indentation change mode radio toggled handler.""" + if not widget.get_active(): + return + + widget_name = Gtk.Buildable.get_name(widget) + if widget_name == "indentationChangeAlwaysButton": + self.prefsDict["speakIndentationOnlyIfChanged"] = False + self.prefsDict["indentationChangeMode"] = settings.INDENTATION_CHANGE_ALWAYS + elif widget_name == "indentationChangeAnyButton": + self.prefsDict["speakIndentationOnlyIfChanged"] = True + self.prefsDict["indentationChangeMode"] = settings.INDENTATION_CHANGE_ANY + else: + self.prefsDict["speakIndentationOnlyIfChanged"] = True + self.prefsDict["indentationChangeMode"] = settings.INDENTATION_CHANGE_LEVEL + + def indentationSpeechStyleToggled(self, widget): + """Indentation speech style radio toggled handler.""" + if not widget.get_active(): + return + + mapping = { + "indentationSpeechSpacesTabsButton": settings.INDENTATION_SPEECH_STYLE_SPACES_TABS, + "indentationSpeechLevelsButton": settings.INDENTATION_SPEECH_STYLE_LEVELS, + "indentationSpeechColumnsButton": settings.INDENTATION_SPEECH_STYLE_COLUMNS, + } + widget_name = Gtk.Buildable.get_name(widget) + style = mapping.get(widget_name) + if style is None: + return + + self.prefsDict["indentationSpeechStyle"] = style + + def indentationAudioUnitToggled(self, widget): + """Indentation audio unit radio toggled handler.""" + if not widget.get_active(): + return + + if Gtk.Buildable.get_name(widget) == "indentationAudioUnitColumnsButton": + self.prefsDict["indentationAudioUnit"] = settings.INDENTATION_UNIT_COLUMNS + else: + self.prefsDict["indentationAudioUnit"] = settings.INDENTATION_UNIT_LEVELS + + def indentationSpinValueChanged(self, widget): + """Indentation spin button value changed handler.""" + settingName = Gtk.Buildable.get_name(widget) + if settingName.endswith("SpinButton"): + settingName = settingName[:-10] + + int_settings = { + "indentationSpacesPerLevel", + "indentationTabWidth", + "indentationAudioBaseFrequency", + "indentationAudioStepFrequency", + "indentationAudioMaxFrequency", + } + if settingName in int_settings: + self.prefsDict[settingName] = int(widget.get_value()) + else: + self.prefsDict[settingName] = float(widget.get_value()) + # AI Assistant signal handlers def enableAIToggled(self, widget): diff --git a/src/cthulhu/input_event_manager.py b/src/cthulhu/input_event_manager.py index 0b21d2c..5d4d9fe 100644 --- a/src/cthulhu/input_event_manager.py +++ b/src/cthulhu/input_event_manager.py @@ -44,6 +44,12 @@ from typing import TYPE_CHECKING, Optional import gi gi.require_version("Atspi", "2.0") from gi.repository import Atspi +try: + gi.require_version("Wnck", "3.0") + from gi.repository import Wnck + _wnck_available = True +except Exception: + _wnck_available = False from . import debug from . import focus_manager @@ -53,6 +59,7 @@ from . import settings from . import cthulhu_state from .ax_object import AXObject from .ax_utilities import AXUtilities +from .ax_utilities_application import AXUtilitiesApplication if TYPE_CHECKING: from . import keybindings @@ -69,6 +76,35 @@ class InputEventManager: self._grabbed_bindings: dict[int, keybindings.KeyBinding] = {} self._paused: bool = False + def _active_window_has_accessible_app(self) -> Optional[bool]: + """Returns True if the WM active window maps to an AT-SPI app.""" + + if not _wnck_available: + return None + + screen = Wnck.Screen.get_default() + if not screen: + return None + + try: + screen.force_update() + active_window = screen.get_active_window() + except Exception: + return None + + if not active_window: + return None + + try: + pid = active_window.get_pid() + except Exception: + return None + + if pid <= 0: + return None + + return AXUtilitiesApplication.get_application_with_pid(pid) is not None + def start_key_watcher(self) -> None: """Starts the watcher for keyboard input events.""" @@ -281,6 +317,12 @@ class InputEventManager: manager = focus_manager.get_manager() if pressed: + has_accessible_app = self._active_window_has_accessible_app() + if has_accessible_app is False and manager.get_active_window() is not None: + msg = "INPUT EVENT MANAGER: Active window has no AT-SPI app. Clearing active window." + debug.print_message(debug.LEVEL_INFO, msg, True) + manager.set_active_window(None, notify_script=True) + window = manager.get_active_window() if not AXUtilities.can_be_active_window(window): new_window = AXUtilities.find_active_window() diff --git a/src/cthulhu/messages.py b/src/cthulhu/messages.py index b7632ee..7294133 100644 --- a/src/cthulhu/messages.py +++ b/src/cthulhu/messages.py @@ -2812,6 +2812,16 @@ def tabsCount(count): # tab characters in a string. return ngettext("%d tab", "%d tabs", count) % count +def indentationLevelCount(count): + # Translators: This message is presented to inform the user of the + # indentation level for a line of text. + return ngettext("indentation level %d", "indentation level %d", count) % count + +def indentationColumnCount(count): + # Translators: This message is presented to inform the user of the number + # of indentation columns for a line of text. + return ngettext("indentation %d column", "indentation %d columns", count) % count + def tableCount(count, onlyIfFound=True): if not count and onlyIfFound: return "" diff --git a/src/cthulhu/plugins/IndentationAudio/plugin.py b/src/cthulhu/plugins/IndentationAudio/plugin.py index 04252ba..12ca4d8 100644 --- a/src/cthulhu/plugins/IndentationAudio/plugin.py +++ b/src/cthulhu/plugins/IndentationAudio/plugin.py @@ -10,11 +10,14 @@ """IndentationAudio plugin for Cthulhu - Provides audio feedback for indentation level changes.""" import logging +import math import re from gi.repository import GLib from cthulhu.plugin import Plugin, cthulhu_hookimpl from cthulhu import debug +from cthulhu import settings +from cthulhu import settings_manager from cthulhu.ax_object import AXObject from cthulhu.ax_text import AXText @@ -28,6 +31,7 @@ except ImportError as e: debug.printMessage(debug.LEVEL_INFO, f"IndentationAudio: Sound import failed: {e}", True) logger = logging.getLogger(__name__) +_settingsManager = settings_manager.getManager() class IndentationAudio(Plugin): @@ -37,14 +41,18 @@ class IndentationAudio(Plugin): """Initialize the IndentationAudio plugin.""" super().__init__(*args, **kwargs) self._enabled = True # Start enabled by default - self._last_indentation_level = {} # Track per-object indentation + self._last_indentation_data = {} # Track per-object indentation self._event_listener_id = None self._kb_binding = None # Audio settings - self._base_frequency = 200 # Base frequency in Hz - self._frequency_step = 80 # Hz per indentation level - self._tone_duration = 0.15 # Seconds - self._max_frequency = 1200 # Cap frequency to avoid harsh sounds + self._base_frequency = settings.indentationAudioBaseFrequency + self._frequency_step = settings.indentationAudioStepFrequency + self._tone_duration = settings.indentationAudioDuration + self._max_frequency = settings.indentationAudioMaxFrequency + self._volume_multiplier = settings.indentationAudioVolume + self._alerts_suspended = False + self._saved_presentation_mode = None + self._saved_speech_indentation = None self._activated = False debug.printMessage(debug.LEVEL_INFO, "IndentationAudio: Plugin initialized", True) @@ -98,7 +106,7 @@ class IndentationAudio(Plugin): self._disconnect_from_events() # Clear tracking data - self._last_indentation_level.clear() + self._last_indentation_data.clear() self._activated = False debug.printMessage(debug.LEVEL_INFO, "IndentationAudio: Plugin deactivated successfully", True) @@ -117,7 +125,7 @@ class IndentationAudio(Plugin): # Register Cthulhu+I keybinding using the plugin's registerGestureByString method gesture_string = "kb:cthulhu+i" - description = "Toggle indentation audio feedback" + description = "Toggle indentation alerts" self._kb_binding = self.registerGestureByString( self._toggle_indentation_audio, @@ -169,7 +177,7 @@ class IndentationAudio(Plugin): def _on_caret_moved(self, event): """Handle caret movement events.""" try: - if not self._enabled: + if not self._beeps_enabled(): return debug.printMessage(debug.LEVEL_INFO, f"IndentationAudio: Caret moved in {event.source}", True) @@ -292,18 +300,41 @@ class IndentationAudio(Plugin): def _toggle_indentation_audio(self, script, inputEvent=None): """Toggle the indentation audio feedback on/off.""" try: - self._enabled = not self._enabled - state = "enabled" if self._enabled else "disabled" - + current_mode = _settingsManager.getSetting('indentationPresentationMode') \ + or settings.indentationPresentationMode + current_speech = _settingsManager.getSetting('enableSpeechIndentation') + + if self._alerts_suspended: + new_mode = self._saved_presentation_mode + if new_mode is None: + new_mode = current_mode + _settingsManager.setSetting('indentationPresentationMode', new_mode) + if self._saved_speech_indentation is None: + self._sync_speech_setting(new_mode) + else: + _settingsManager.setSetting('enableSpeechIndentation', self._saved_speech_indentation) + self._alerts_suspended = False + state = "enabled" + else: + self._saved_presentation_mode = current_mode + self._saved_speech_indentation = current_speech + _settingsManager.setSetting( + 'indentationPresentationMode', + settings.INDENTATION_PRESENTATION_OFF, + ) + _settingsManager.setSetting('enableSpeechIndentation', False) + self._alerts_suspended = True + state = "disabled" + # Announce the state change - message = f"Indentation audio {state}" + message = f"Indentation alerts {state}" if hasattr(script, 'speakMessage'): script.speakMessage(message) - - debug.printMessage(debug.LEVEL_INFO, f"IndentationAudio: Toggled to {state}", True) - + + debug.printMessage(debug.LEVEL_INFO, f"IndentationAudio: Alerts toggled to {state}", True) + # Test the indentation detection on current line when enabled - if self._enabled and script: + if state == "enabled" and script: try: # Try to get current focus object and line text from cthulhu import cthulhu_state @@ -324,62 +355,107 @@ class IndentationAudio(Plugin): logger.error(f"IndentationAudio: Error toggling state: {e}") debug.printMessage(debug.LEVEL_INFO, f"IndentationAudio: Error toggling: {e}", True) return False - - def _calculate_indentation_level(self, line_text): - """Calculate the indentation level of a line.""" + + def _sync_speech_setting(self, presentation_mode): + enable_speech = presentation_mode in ( + settings.INDENTATION_PRESENTATION_SPEECH, + settings.INDENTATION_PRESENTATION_SPEECH_AND_BEEPS, + ) + _settingsManager.setSetting('enableSpeechIndentation', enable_speech) + + def _beeps_enabled(self): + if not self._enabled: + return False + presentation_mode = _settingsManager.getSetting('indentationPresentationMode') \ + or settings.indentationPresentationMode + return presentation_mode in ( + settings.INDENTATION_PRESENTATION_BEEPS, + settings.INDENTATION_PRESENTATION_SPEECH_AND_BEEPS, + ) + + @staticmethod + def _extract_indentation(line_text): if not line_text: - return 0 - - # Remove non-breaking spaces and convert to regular spaces + return "" + line = line_text.replace("\u00a0", " ") - - # Find the first non-whitespace character match = re.search(r"[^ \t]", line) if not match: - return 0 # Empty or whitespace-only line - - indent_text = line[:match.start()] - - # Calculate indentation level (4 spaces = 1 level, 1 tab = 1 level) - level = 0 - for char in indent_text: - if char == '\t': - level += 1 - elif char == ' ': - level += 0.25 # 4 spaces = 1 level - - return int(level) + return line + + return line[:match.start()] + + @staticmethod + def _count_columns(indentation, tab_width): + columns = 0 + tab_width = max(1, tab_width) + for char in indentation: + if char == "\t": + columns += tab_width - (columns % tab_width) + else: + columns += 1 + + return columns + + @staticmethod + def _count_levels(columns, spaces_per_level): + spaces_per_level = max(1, spaces_per_level) + if columns <= 0: + return 0 + + return int(math.ceil(columns / spaces_per_level)) + + def _get_indentation_data(self, line_text): + indentation = self._extract_indentation(line_text) + tab_width = _settingsManager.getSetting('indentationTabWidth') \ + or settings.indentationTabWidth + spaces_per_level = _settingsManager.getSetting('indentationSpacesPerLevel') \ + or settings.indentationSpacesPerLevel + columns = self._count_columns(indentation, tab_width) + levels = self._count_levels(columns, spaces_per_level) + return indentation, columns, levels - def _generate_indentation_tone(self, new_level, old_level): - """Generate an audio tone for indentation level change.""" - if not self._enabled: + def _generate_indentation_tone(self, new_units, old_units): + """Generate an audio tone for indentation change.""" + if not self._beeps_enabled(): return + + base_frequency = _settingsManager.getSetting('indentationAudioBaseFrequency') \ + or self._base_frequency + frequency_step = _settingsManager.getSetting('indentationAudioStepFrequency') \ + or self._frequency_step + max_frequency = _settingsManager.getSetting('indentationAudioMaxFrequency') \ + or self._max_frequency + tone_duration = _settingsManager.getSetting('indentationAudioDuration') \ + or self._tone_duration + volume_multiplier = _settingsManager.getSetting('indentationAudioVolume') \ + or self._volume_multiplier - # Calculate frequency based on new indentation level + # Calculate frequency based on new indentation units base_frequency = min( - self._base_frequency + (new_level * self._frequency_step), - self._max_frequency + base_frequency + (new_units * frequency_step), + max_frequency ) # Add directional audio cues - if new_level > old_level: + if new_units > old_units: # Indentation increased - higher pitch frequency = base_frequency + 50 - elif new_level < old_level: - # Indentation decreased - lower pitch + elif new_units < old_units: + # Indentation decreased - lower pitch frequency = max(base_frequency - 50, 100) else: # Same level (shouldn't happen but just in case) frequency = base_frequency try: - debug.printMessage(debug.LEVEL_INFO, f"IndentationAudio: About to generate tone for level {new_level} (freq: {frequency}Hz)", True) + debug.printMessage(debug.LEVEL_INFO, f"IndentationAudio: About to generate tone for units {new_units} (freq: {frequency}Hz)", True) if not SOUND_AVAILABLE or not self._player: debug.printMessage(debug.LEVEL_INFO, "IndentationAudio: Sound player not available, using fallback", True) # Fallback to ASCII bell - if new_level > 0: - beeps = min(new_level, 5) + if new_units > 0: + beeps = min(new_units, 5) for i in range(beeps): print("\a", end="", flush=True) debug.printMessage(debug.LEVEL_INFO, f"IndentationAudio: Sent {beeps} ASCII bell beeps", True) @@ -388,8 +464,7 @@ class IndentationAudio(Plugin): # Use Cthulhu's proper sound system try: # Create a tone based on indentation level - duration = self._tone_duration - volume_multiplier = 0.7 + duration = tone_duration tone = Tone( duration=duration, @@ -400,13 +475,13 @@ class IndentationAudio(Plugin): # Play the tone self._player.play(tone, interrupt=False) - debug.printMessage(debug.LEVEL_INFO, f"IndentationAudio: Played Cthulhu tone - Level: {new_level}, Freq: {frequency}Hz", True) + debug.printMessage(debug.LEVEL_INFO, f"IndentationAudio: Played Cthulhu tone - Units: {new_units}, Freq: {frequency}Hz", True) except Exception as sound_e: debug.printMessage(debug.LEVEL_INFO, f"IndentationAudio: Cthulhu sound failed: {sound_e}", True) # Fallback to ASCII bell - if new_level > 0: - beeps = min(new_level, 5) + if new_units > 0: + beeps = min(new_units, 5) for i in range(beeps): print("\a", end="", flush=True) debug.printMessage(debug.LEVEL_INFO, f"IndentationAudio: Used fallback ASCII bell ({beeps} beeps)", True) @@ -420,31 +495,57 @@ class IndentationAudio(Plugin): This method is intended to be called by scripts during line navigation. """ - debug.printMessage(debug.LEVEL_INFO, f"IndentationAudio: check_indentation_change called: enabled={self._enabled}, line='{line_text}'", True) - - if not self._enabled or not line_text: + debug.printMessage(debug.LEVEL_INFO, f"IndentationAudio: check_indentation_change called: line='{line_text}'", True) + + if not line_text or not self._beeps_enabled(): return try: # Get object identifier for tracking obj_id = str(obj) if obj else "unknown" - # Calculate current indentation level - current_level = self._calculate_indentation_level(line_text) - - # Get previous level for this object - previous_level = self._last_indentation_level.get(obj_id, current_level) - - # Update tracking - self._last_indentation_level[obj_id] = current_level - - debug.printMessage(debug.LEVEL_INFO, f"IndentationAudio: Levels - previous: {previous_level}, current: {current_level}", True) - + # Calculate current indentation data + indentation, columns, levels = self._get_indentation_data(line_text) + audio_unit = _settingsManager.getSetting('indentationAudioUnit') \ + or settings.indentationAudioUnit + current_units = columns if audio_unit == settings.INDENTATION_UNIT_COLUMNS else levels + + previous_data = self._last_indentation_data.get(obj_id) + previous_units = previous_data.get("units") if previous_data else current_units + + self._last_indentation_data[obj_id] = { + "signature": indentation, + "levels": levels, + "columns": columns, + "units": current_units, + } + + change_mode = _settingsManager.getSetting('indentationChangeMode') \ + or settings.indentationChangeMode + only_if_changed = _settingsManager.getSetting('speakIndentationOnlyIfChanged') + + if not only_if_changed: + changed = True + elif previous_data is None: + changed = True + elif change_mode == settings.INDENTATION_CHANGE_ANY: + changed = previous_data.get("signature") != indentation + elif change_mode == settings.INDENTATION_CHANGE_LEVEL: + changed = previous_data.get("levels") != levels + else: + changed = True + + debug.printMessage( + debug.LEVEL_INFO, + f"IndentationAudio: Units - previous: {previous_units}, current: {current_units}, changed={changed}", + True, + ) + # Play audio cue if indentation changed - if current_level != previous_level: - self._generate_indentation_tone(current_level, previous_level) + if changed: + self._generate_indentation_tone(current_units, previous_units) - debug_msg = f"IndentationAudio: Indentation changed from {previous_level} to {current_level}" + debug_msg = f"IndentationAudio: Indentation units changed from {previous_units} to {current_units}" debug.printMessage(debug.LEVEL_INFO, debug_msg, True) except Exception as e: @@ -469,7 +570,7 @@ class IndentationAudio(Plugin): self._monkey_patch_script_methods() # Clear tracking data for new context - self._last_indentation_level.clear() + self._last_indentation_data.clear() logger.info("IndentationAudio: Handled script change") diff --git a/src/cthulhu/script_utilities.py b/src/cthulhu/script_utilities.py index f8b1c03..8c9233e 100644 --- a/src/cthulhu/script_utilities.py +++ b/src/cthulhu/script_utilities.py @@ -118,6 +118,7 @@ class Utilities: self._script = script self._clipboardHandlerId = None + self._lastIndentationData = {} self._selectedMenuBarMenu = {} ######################################################################### @@ -3126,27 +3127,193 @@ class Utilities: return string - def indentationDescription(self, line): - if _settingsManager.getSetting('onlySpeakDisplayedText') \ - or not _settingsManager.getSetting('enableSpeechIndentation'): + def _get_indentation_key(self, obj): + if obj is None: + return "global" + + try: + return id(obj) + except Exception: + return str(obj) + + @staticmethod + def _extract_indentation(line): + if not line: return "" line = line.replace("\u00a0", " ") end = re.search("[^ \t]", line) if end: - line = line[:end.start()] + return line[:end.start()] - result = "" - spaces = [m.span() for m in re.finditer(" +", line)] - tabs = [m.span() for m in re.finditer("\t+", line)] + return line + + @staticmethod + def _get_indentation_segments(indentation): + if not indentation: + return [] + + spaces = [m.span() for m in re.finditer(" +", indentation)] + tabs = [m.span() for m in re.finditer("\t+", indentation)] spans = sorted(spaces + tabs) + segments = [] for (start, end) in spans: if (start, end) in spaces: - result += f"{messages.spacesCount(end - start)} " + segments.append(("spaces", end - start)) else: - result += f"{messages.tabsCount(end - start)} " + segments.append(("tabs", end - start)) - return result + return segments + + @staticmethod + def _get_indentation_columns(indentation, tab_width): + columns = 0 + tab_width = max(1, tab_width) + for char in indentation: + if char == "\t": + columns += tab_width - (columns % tab_width) + else: + columns += 1 + + return columns + + @staticmethod + def _get_indentation_levels(columns, spaces_per_level): + spaces_per_level = max(1, spaces_per_level) + if columns <= 0: + return 0 + + return int(math.ceil(columns / spaces_per_level)) + + def _get_indentation_data(self, line): + indentation = self._extract_indentation(line) + tab_width = _settingsManager.getSetting('indentationTabWidth') \ + or settings.indentationTabWidth + spaces_per_level = _settingsManager.getSetting('indentationSpacesPerLevel') \ + or settings.indentationSpacesPerLevel + columns = self._get_indentation_columns(indentation, tab_width) + levels = self._get_indentation_levels(columns, spaces_per_level) + segments = self._get_indentation_segments(indentation) + return { + "indentation": indentation, + "segments": segments, + "columns": columns, + "levels": levels, + } + + def _remember_indentation(self, obj, data): + key = self._get_indentation_key(obj) + self._lastIndentationData[key] = { + "signature": data["indentation"], + "levels": data["levels"], + "columns": data["columns"], + } + + def _indentation_has_changed(self, obj, data): + key = self._get_indentation_key(obj) + previous = self._lastIndentationData.get(key) + self._remember_indentation(obj, data) + if previous is None: + return True + + change_mode = _settingsManager.getSetting('indentationChangeMode') \ + or settings.indentationChangeMode + if change_mode == settings.INDENTATION_CHANGE_ANY: + return previous.get("signature") != data["indentation"] + if change_mode == settings.INDENTATION_CHANGE_LEVEL: + return previous.get("levels") != data["levels"] + + return True + + def _indentation_speech_enabled(self): + if _settingsManager.getSetting('onlySpeakDisplayedText'): + return False + if not _settingsManager.getSetting('enableSpeechIndentation'): + return False + + presentation_mode = _settingsManager.getSetting('indentationPresentationMode') \ + or settings.indentationPresentationMode + return presentation_mode in ( + settings.INDENTATION_PRESENTATION_SPEECH, + settings.INDENTATION_PRESENTATION_SPEECH_AND_BEEPS, + ) + + def _format_indentation_description(self, data): + style = _settingsManager.getSetting('indentationSpeechStyle') \ + or settings.indentationSpeechStyle + if style == settings.INDENTATION_SPEECH_STYLE_LEVELS: + return messages.indentationLevelCount(data["levels"]) + if style == settings.INDENTATION_SPEECH_STYLE_COLUMNS: + return messages.indentationColumnCount(data["columns"]) + + result = "" + for kind, count in data["segments"]: + if kind == "spaces": + result += f"{messages.spacesCount(count)} " + else: + result += f"{messages.tabsCount(count)} " + + if not result: + return messages.spacesCount(0) + + return result.strip() + + def get_indentation_presentation(self, line, obj=None): + data = self._get_indentation_data(line) + has_indentation = bool(data["indentation"]) + presentation_mode = _settingsManager.getSetting('indentationPresentationMode') \ + or settings.indentationPresentationMode + only_if_changed = _settingsManager.getSetting('speakIndentationOnlyIfChanged') + change_mode = _settingsManager.getSetting('indentationChangeMode') \ + or settings.indentationChangeMode + indent_debug = data["indentation"].replace("\t", "\\t").replace(" ", ".") + + if not self._indentation_speech_enabled(): + self._remember_indentation(obj, data) + msg = ( + f"INDENTATION: speech disabled mode={presentation_mode} " + f"onlyIfChanged={only_if_changed} changeMode={change_mode} " + f"levels={data['levels']} columns={data['columns']} indent='{indent_debug}'" + ) + debug.printMessage(debug.LEVEL_INFO, msg, True) + return "", has_indentation + + if only_if_changed: + changed = self._indentation_has_changed(obj, data) + else: + changed = True + self._remember_indentation(obj, data) + + if not changed: + msg = ( + f"INDENTATION: unchanged mode={presentation_mode} " + f"onlyIfChanged={only_if_changed} changeMode={change_mode} " + f"levels={data['levels']} columns={data['columns']} indent='{indent_debug}'" + ) + debug.printMessage(debug.LEVEL_INFO, msg, True) + return "", has_indentation + + description = self._format_indentation_description(data) + msg = ( + f"INDENTATION: speaking '{description}' mode={presentation_mode} " + f"onlyIfChanged={only_if_changed} changeMode={change_mode} " + f"levels={data['levels']} columns={data['columns']} indent='{indent_debug}'" + ) + debug.printMessage(debug.LEVEL_INFO, msg, True) + return description, has_indentation + + def should_strip_indentation(self, line): + presentation_mode = _settingsManager.getSetting('indentationPresentationMode') \ + or settings.indentationPresentationMode + if presentation_mode == settings.INDENTATION_PRESENTATION_OFF: + return False + + data = self._get_indentation_data(line) + return bool(data["indentation"]) + + def indentationDescription(self, line, obj=None): + description, _has_indentation = self.get_indentation_presentation(line, obj=obj) + return description @staticmethod def absoluteMouseCoordinates(): diff --git a/src/cthulhu/scripts/default.py b/src/cthulhu/scripts/default.py index 5199f32..5f66569 100644 --- a/src/cthulhu/scripts/default.py +++ b/src/cthulhu/scripts/default.py @@ -2354,9 +2354,11 @@ class Script(script.Script): [line, caretOffset, startOffset] = self.getTextLineAtCaret(obj) if len(line) and line != "\n": - indentationDescription = self.utilities.indentationDescription(line) + indentationDescription, hasIndentation = \ + self.utilities.get_indentation_presentation(line, obj=obj) if indentationDescription: self.speakMessage(indentationDescription) + stripIndentation = hasIndentation and self.utilities.should_strip_indentation(line) endOffset = startOffset + len(line) cthulhu.emitRegionChanged(obj, startOffset, endOffset, cthulhu.CARET_TRACKING) @@ -2376,7 +2378,7 @@ class Script(script.Script): # Some synthesizers will verbalize the whitespace, so if we've already # described it, prevent double-presentation by stripping it off. - if not utterance and indentationDescription: + if not utterance and stripIndentation: string = string.lstrip() result = [string] @@ -2406,13 +2408,16 @@ class Script(script.Script): return if len(phrase) > 1 or phrase.isalnum(): - result = self.utilities.indentationDescription(phrase) - if result: - self.speakMessage(result) + indentationDescription, hasIndentation = \ + self.utilities.get_indentation_presentation(phrase, obj=obj) + if indentationDescription: + self.speakMessage(indentationDescription) cthulhu.emitRegionChanged(obj, startOffset, endOffset, cthulhu.CARET_TRACKING) voice = self.speechGenerator.voice(obj=obj, string=phrase) + if hasIndentation and self.utilities.should_strip_indentation(phrase): + phrase = phrase.lstrip() phrase = self.utilities.adjustForRepeats(phrase) if self.utilities.shouldVerbalizeAllPunctuation(obj): phrase = self.utilities.verbalizeAllPunctuation(phrase) diff --git a/src/cthulhu/settings.py b/src/cthulhu/settings.py index d4ea97d..7dbb6ef 100644 --- a/src/cthulhu/settings.py +++ b/src/cthulhu/settings.py @@ -48,6 +48,18 @@ userCustomizableSettings = [ "readFullRowInDocumentTable", "readFullRowInSpreadSheet", "enableSpeechIndentation", + "indentationPresentationMode", + "indentationChangeMode", + "speakIndentationOnlyIfChanged", + "indentationSpeechStyle", + "indentationAudioUnit", + "indentationSpacesPerLevel", + "indentationTabWidth", + "indentationAudioBaseFrequency", + "indentationAudioStepFrequency", + "indentationAudioMaxFrequency", + "indentationAudioDuration", + "indentationAudioVolume", "enableEchoByCharacter", "enableEchoByWord", "enableEchoBySentence", @@ -218,6 +230,22 @@ AI_SCREENSHOT_QUALITY_LOW = "low" AI_SCREENSHOT_QUALITY_MEDIUM = "medium" AI_SCREENSHOT_QUALITY_HIGH = "high" +INDENTATION_PRESENTATION_OFF = "off" +INDENTATION_PRESENTATION_SPEECH = "speech" +INDENTATION_PRESENTATION_BEEPS = "beeps" +INDENTATION_PRESENTATION_SPEECH_AND_BEEPS = "speech_and_beeps" + +INDENTATION_CHANGE_ALWAYS = "always" +INDENTATION_CHANGE_ANY = "any" +INDENTATION_CHANGE_LEVEL = "level" + +INDENTATION_SPEECH_STYLE_SPACES_TABS = "spaces_tabs" +INDENTATION_SPEECH_STYLE_LEVELS = "levels" +INDENTATION_SPEECH_STYLE_COLUMNS = "columns" + +INDENTATION_UNIT_LEVELS = "levels" +INDENTATION_UNIT_COLUMNS = "columns" + DEFAULT_VOICE = "default" UPPERCASE_VOICE = "uppercase" HYPERLINK_VOICE = "hyperlink" @@ -252,7 +280,19 @@ silenceSpeech = False enableTutorialMessages = False enableMnemonicSpeaking = False enablePositionSpeaking = False -enableSpeechIndentation = False +enableSpeechIndentation = True +indentationPresentationMode = INDENTATION_PRESENTATION_SPEECH_AND_BEEPS +indentationChangeMode = INDENTATION_CHANGE_ANY +speakIndentationOnlyIfChanged = True +indentationSpeechStyle = INDENTATION_SPEECH_STYLE_COLUMNS +indentationAudioUnit = INDENTATION_UNIT_COLUMNS +indentationSpacesPerLevel = 4 +indentationTabWidth = 4 +indentationAudioBaseFrequency = 200 +indentationAudioStepFrequency = 30 +indentationAudioMaxFrequency = 2700 +indentationAudioDuration = 0.15 +indentationAudioVolume = 0.7 onlySpeakDisplayedText = False presentToolTips = False speakBlankLines = True @@ -449,7 +489,7 @@ presentChatRoomLast = False presentLiveRegionFromInactiveTab = False # Plugins -activePlugins = ['AIAssistant', 'DisplayVersion', 'OCR', 'PluginManager', 'HelloCthulhu', 'ByeCthulhu'] +activePlugins = ['AIAssistant', 'DisplayVersion', 'OCR', 'PluginManager', 'HelloCthulhu', 'ByeCthulhu', 'IndentationAudio'] # AI Assistant settings (disabled by default for opt-in behavior) aiAssistantEnabled = True diff --git a/src/cthulhu/speech_and_verbosity_manager.py b/src/cthulhu/speech_and_verbosity_manager.py index ef9e602..0b28914 100644 --- a/src/cthulhu/speech_and_verbosity_manager.py +++ b/src/cthulhu/speech_and_verbosity_manager.py @@ -967,11 +967,29 @@ class SpeechAndVerbosityManager: """Returns whether speaking of indentation and justification is enabled.""" return _settings_manager.getSetting('enableSpeechIndentation') + @staticmethod + def _sync_indentation_presentation_mode(enable_speech): + mode = _settings_manager.getSetting('indentationPresentationMode') \ + or settings.indentationPresentationMode + if enable_speech: + if mode == settings.INDENTATION_PRESENTATION_OFF: + mode = settings.INDENTATION_PRESENTATION_SPEECH + elif mode == settings.INDENTATION_PRESENTATION_BEEPS: + mode = settings.INDENTATION_PRESENTATION_SPEECH_AND_BEEPS + else: + if mode == settings.INDENTATION_PRESENTATION_SPEECH: + mode = settings.INDENTATION_PRESENTATION_OFF + elif mode == settings.INDENTATION_PRESENTATION_SPEECH_AND_BEEPS: + mode = settings.INDENTATION_PRESENTATION_BEEPS + + _settings_manager.setSetting('indentationPresentationMode', mode) + @dbus_service.setter def set_speak_indentation_and_justification(self, value: bool) -> bool: """Sets whether speaking of indentation and justification is enabled.""" try: _settings_manager.setSetting('enableSpeechIndentation', value) + self._sync_indentation_presentation_mode(value) return True except Exception as e: debug.printMessage(debug.LEVEL_WARNING, f"Error setting speak indentation: {e}", True) @@ -1804,6 +1822,7 @@ class SpeechAndVerbosityManager: value = _settings_manager.getSetting('enableSpeechIndentation') _settings_manager.setSetting('enableSpeechIndentation', not value) + self._sync_indentation_presentation_mode(not value) if _settings_manager.getSetting('enableSpeechIndentation'): full = messages.INDENTATION_JUSTIFICATION_ON_FULL brief = messages.INDENTATION_JUSTIFICATION_ON_BRIEF diff --git a/src/cthulhu/speech_dbus_manager.py b/src/cthulhu/speech_dbus_manager.py index 03a774c..974b043 100644 --- a/src/cthulhu/speech_dbus_manager.py +++ b/src/cthulhu/speech_dbus_manager.py @@ -193,6 +193,22 @@ class SpeechDBusManager: return self._settings_manager.getSetting("enableSpeechIndentation") + def _sync_indentation_presentation_mode(self, enable_speech): + mode = self._settings_manager.getSetting("indentationPresentationMode") \ + or settings.indentationPresentationMode + if enable_speech: + if mode == settings.INDENTATION_PRESENTATION_OFF: + mode = settings.INDENTATION_PRESENTATION_SPEECH + elif mode == settings.INDENTATION_PRESENTATION_BEEPS: + mode = settings.INDENTATION_PRESENTATION_SPEECH_AND_BEEPS + else: + if mode == settings.INDENTATION_PRESENTATION_SPEECH: + mode = settings.INDENTATION_PRESENTATION_OFF + elif mode == settings.INDENTATION_PRESENTATION_SPEECH_AND_BEEPS: + mode = settings.INDENTATION_PRESENTATION_BEEPS + + self._settings_manager.setSetting("indentationPresentationMode", mode) + @dbus_service.setter def set_speak_indentation_and_justification(self, value: bool) -> bool: """Sets whether speaking of indentation and justification is enabled.""" @@ -200,6 +216,7 @@ class SpeechDBusManager: msg = f"SPEECH DBUS MANAGER: Setting speak indentation and justification to {value}." debug.printMessage(debug.LEVEL_INFO, msg, True) self._settings_manager.setSetting("enableSpeechIndentation", value) + self._sync_indentation_presentation_mode(value) return True @dbus_service.command @@ -512,4 +529,4 @@ class SpeechDBusManager: self._settings_manager.setSetting("enableEchoBySentence", new_sentence) if script is not None: - script.presentMessage(full, brief) \ No newline at end of file + script.presentMessage(full, brief) diff --git a/src/cthulhu/speech_generator.py b/src/cthulhu/speech_generator.py index 4c6b6f3..a364ae4 100644 --- a/src/cthulhu/speech_generator.py +++ b/src/cthulhu/speech_generator.py @@ -1685,7 +1685,8 @@ class SpeechGenerator(generator.Generator): return [] line, caretOffset, startOffset = self._script.getTextLineAtCaret(obj) - description = self._script.utilities.indentationDescription(line) + description, _hasIndentation = \ + self._script.utilities.get_indentation_presentation(line, obj=obj) if not description: return [] From 28652e24f4e7dd35b18cc0a9438c5ffea41cfe68 Mon Sep 17 00:00:00 2001 From: Storm Dragon Date: Sat, 3 Jan 2026 16:22:33 -0500 Subject: [PATCH 02/14] Intial redesign of plugins manager. This is likely to be buggy. --- src/cthulhu/cthulhu_gui_prefs.py | 361 ++++++++++++++++++++ src/cthulhu/plugin_system_manager.py | 344 ++++++++++++++++--- src/cthulhu/plugins/PluginManager/plugin.py | 3 - src/cthulhu/settings.py | 2 + 4 files changed, 663 insertions(+), 47 deletions(-) diff --git a/src/cthulhu/cthulhu_gui_prefs.py b/src/cthulhu/cthulhu_gui_prefs.py index 7195c9c..27c0987 100644 --- a/src/cthulhu/cthulhu_gui_prefs.py +++ b/src/cthulhu/cthulhu_gui_prefs.py @@ -38,6 +38,7 @@ gi.require_version("Gtk", "3.0") from gi.repository import Atspi import os +import threading from gi.repository import Gdk from gi.repository import GLib from gi.repository import Gtk @@ -134,6 +135,19 @@ class CthulhuSetupGUI(cthulhu_gtkbuilder.GtkBuilderWrapper): self.planeCellRendererText = None self.pronunciationModel = None self.pronunciationView = None + self._plugin_checkboxes = {} + self._plugin_listbox = None + self._plugin_sources = [] + self._plugin_sources_entry = None + self._plugin_sources_listbox = None + self._plugin_sources_original = [] + self._available_plugins = set() + self._plugin_canonical_map = {} + self._plugin_group_map = {} + self._plugin_update_button = None + self._plugin_update_progress = None + self._plugin_update_status = None + self._plugin_update_in_progress = False self.screenHeight = None self.screenWidth = None self.speechFamiliesChoice = None @@ -365,6 +379,11 @@ class CthulhuSetupGUI(cthulhu_gtkbuilder.GtkBuilderWrapper): self._isInitialSetup = \ not os.path.exists(_settingsManager.getPrefsDir()) + try: + self._initPluginsPage() + except Exception as e: + debug.printMessage(debug.LEVEL_WARNING, f"PREFERENCES DIALOG: Plugin page init failed: {e}", True) + appPage = self.script.getAppPreferencesGUI() if appPage: label = Gtk.Label(label=AXObject.get_name(self.script.app)) @@ -373,6 +392,347 @@ class CthulhuSetupGUI(cthulhu_gtkbuilder.GtkBuilderWrapper): self._initGUIState() self._initSoundThemeState() + def _initPluginsPage(self): + self._plugin_sources = list(self.prefsDict.get("pluginSources", settings.pluginSources) or []) + self._plugin_sources_original = list(self._plugin_sources) + + pluginsPage = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=10) + pluginsPage.set_border_width(12) + + infoLabel = Gtk.Label(label="Enable or disable plugins and manage plugin sources.") + infoLabel.set_line_wrap(True) + infoLabel.set_halign(Gtk.Align.START) + pluginsPage.pack_start(infoLabel, False, False, 0) + + pluginsFrame = Gtk.Frame(label="Plugins") + try: + pluginsFrame.set_label_align(0.0, 0.5) + except Exception: + try: + pluginsFrame.set_label_xalign(0.0) + except Exception: + pass + pluginsFrame.set_shadow_type(Gtk.ShadowType.NONE) + pluginsFrameBox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=6) + pluginsFrameBox.set_border_width(6) + pluginsFrame.add(pluginsFrameBox) + + pluginsScrolled = Gtk.ScrolledWindow() + pluginsScrolled.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC) + pluginsScrolled.set_size_request(-1, 200) + self._plugin_listbox = Gtk.ListBox() + self._plugin_listbox.set_selection_mode(Gtk.SelectionMode.NONE) + pluginsScrolled.add(self._plugin_listbox) + pluginsFrameBox.pack_start(pluginsScrolled, True, True, 0) + + pluginsPage.pack_start(pluginsFrame, True, True, 0) + + sourcesFrame = Gtk.Frame(label="Plugin Sources") + try: + sourcesFrame.set_label_align(0.0, 0.5) + except Exception: + try: + sourcesFrame.set_label_xalign(0.0) + except Exception: + pass + sourcesFrame.set_shadow_type(Gtk.ShadowType.NONE) + sourcesFrameBox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=6) + sourcesFrameBox.set_border_width(6) + sourcesFrame.add(sourcesFrameBox) + + sourcesGrid = Gtk.Grid(row_spacing=6, column_spacing=6) + sourcesLabel = Gtk.Label(label="_Source URL:") + sourcesLabel.set_use_underline(True) + sourcesLabel.set_halign(Gtk.Align.START) + self._plugin_sources_entry = Gtk.Entry() + self._plugin_sources_entry.set_placeholder_text("https://example.com/repo.git") + sourcesLabel.set_mnemonic_widget(self._plugin_sources_entry) + addButton = Gtk.Button(label="Add Source") + addButton.connect("clicked", self._on_add_plugin_source) + sourcesGrid.attach(sourcesLabel, 0, 0, 1, 1) + sourcesGrid.attach(self._plugin_sources_entry, 1, 0, 1, 1) + sourcesGrid.attach(addButton, 2, 0, 1, 1) + sourcesFrameBox.pack_start(sourcesGrid, False, False, 0) + + updateRow = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6) + self._plugin_update_button = Gtk.Button(label="Update Plugins") + self._plugin_update_button.connect("clicked", self._on_update_plugins_clicked) + self._plugin_update_progress = Gtk.ProgressBar() + self._plugin_update_progress.set_show_text(True) + self._plugin_update_progress.set_text("Idle") + updateRow.pack_start(self._plugin_update_button, False, False, 0) + updateRow.pack_start(self._plugin_update_progress, True, True, 0) + sourcesFrameBox.pack_start(updateRow, False, False, 0) + + sourcesScrolled = Gtk.ScrolledWindow() + sourcesScrolled.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC) + sourcesScrolled.set_size_request(-1, 120) + self._plugin_sources_listbox = Gtk.ListBox() + self._plugin_sources_listbox.set_selection_mode(Gtk.SelectionMode.NONE) + sourcesScrolled.add(self._plugin_sources_listbox) + sourcesFrameBox.pack_start(sourcesScrolled, True, True, 0) + + sourcesNote = Gtk.Label(label="Use Update Plugins to install or update sources, then Apply or OK to save settings.") + sourcesNote.set_line_wrap(True) + sourcesNote.set_halign(Gtk.Align.START) + sourcesFrameBox.pack_start(sourcesNote, False, False, 0) + + self._plugin_update_status = Gtk.Label(label="") + self._plugin_update_status.set_line_wrap(True) + self._plugin_update_status.set_halign(Gtk.Align.START) + sourcesFrameBox.pack_start(self._plugin_update_status, False, False, 0) + + pluginsPage.pack_start(sourcesFrame, True, True, 0) + + notebook = self.get_widget("notebook") + notebook.append_page(pluginsPage, Gtk.Label(label="Plugins")) + + self._populate_plugin_list() + self._populate_plugin_sources_list() + pluginsPage.show_all() + + def _clear_listbox(self, listbox): + for child in listbox.get_children(): + listbox.remove(child) + + def _populate_plugin_list(self): + if not self._plugin_listbox: + return + + try: + self._clear_listbox(self._plugin_listbox) + self._plugin_checkboxes.clear() + self._available_plugins = set() + self._plugin_canonical_map = {} + self._plugin_group_map = {} + + manager = cthulhu.cthulhuApp.getPluginSystemManager() + if manager: + try: + manager.rescanPlugins() + except Exception as e: + debug.printMessage(debug.LEVEL_WARNING, f"PREFERENCES DIALOG: Plugin rescan failed: {e}", True) + manager = None + + active_plugins = list(self.prefsDict.get("activePlugins", settings.activePlugins) or []) + active_plugins_lower = {name.lower() for name in active_plugins} + + plugin_infos = manager.plugins if manager else [] + self._available_plugins = {info.get_module_name() for info in plugin_infos} + canonical_counts = {} + canonical_builtins = {} + for info in plugin_infos: + canonical = info.get_canonical_name() + canonical_counts[canonical] = canonical_counts.get(canonical, 0) + 1 + if info.builtin: + canonical_builtins[canonical] = True + for plugin_info in sorted(plugin_infos, key=lambda item: (item.get_name() or item.get_module_name()).lower()): + plugin_name = plugin_info.get_module_name() + canonical_name = plugin_info.get_canonical_name() + if plugin_info.hidden or canonical_name == "PluginManager": + continue + + row = Gtk.ListBoxRow() + row.set_activatable(False) + hbox = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=10) + hbox.set_border_width(5) + + checkbox = Gtk.CheckButton() + is_active = ( + plugin_name in active_plugins + or plugin_name.lower() in active_plugins_lower + or (plugin_info.preferred_alias and (canonical_name in active_plugins or canonical_name.lower() in active_plugins_lower)) + or plugin_info.builtin + ) + if canonical_builtins.get(canonical_name) and not plugin_info.builtin: + is_active = False + checkbox.set_active(is_active) + if plugin_info.builtin: + checkbox.set_sensitive(False) + elif canonical_builtins.get(canonical_name): + checkbox.set_sensitive(False) + checkbox.connect("toggled", self._on_plugin_checkbox_toggled, plugin_name) + + display_name = GLib.markup_escape_text(plugin_info.get_name() or plugin_name) + info_text = f"{display_name}" + description = plugin_info.get_description() + if description: + info_text += f"\n{GLib.markup_escape_text(description)}" + version = plugin_info.get_version() + if version: + info_text += f" (v{GLib.markup_escape_text(version)})" + if canonical_counts.get(canonical_name, 0) > 1: + info_text += f"\nSource: {GLib.markup_escape_text(plugin_info.get_source_label())}" + if canonical_builtins.get(canonical_name) and not plugin_info.builtin: + info_text += "\nDisabled because a builtin plugin uses this name." + + label = Gtk.Label() + label.set_markup(info_text) + label.set_halign(Gtk.Align.START) + label.set_line_wrap(True) + + hbox.pack_start(checkbox, False, False, 0) + hbox.pack_start(label, True, True, 0) + row.add(hbox) + self._plugin_listbox.add(row) + self._plugin_checkboxes[plugin_name] = checkbox + self._plugin_canonical_map[plugin_name] = canonical_name + self._plugin_group_map.setdefault(canonical_name, []).append(plugin_name) + + self._plugin_listbox.show_all() + except Exception as e: + debug.printMessage(debug.LEVEL_WARNING, f"PREFERENCES DIALOG: Plugin list build failed: {e}", True) + + def _populate_plugin_sources_list(self): + if not self._plugin_sources_listbox: + return + + self._clear_listbox(self._plugin_sources_listbox) + + for source in self._plugin_sources: + row = Gtk.ListBoxRow() + row.set_activatable(False) + hbox = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=10) + hbox.set_border_width(5) + + label = Gtk.Label(label=source) + label.set_halign(Gtk.Align.START) + label.set_line_wrap(True) + + remove_button = Gtk.Button(label="Remove") + remove_button.connect("clicked", self._on_remove_plugin_source, source, row) + + hbox.pack_start(label, True, True, 0) + hbox.pack_start(remove_button, False, False, 0) + row.add(hbox) + self._plugin_sources_listbox.add(row) + + self._plugin_sources_listbox.show_all() + + def _on_add_plugin_source(self, widget): + if not self._plugin_sources_entry: + return + source = self._plugin_sources_entry.get_text().strip() + if not source: + return + if source in self._plugin_sources: + return + self._plugin_sources.append(source) + self._plugin_sources_entry.set_text("") + self._populate_plugin_sources_list() + + def _on_remove_plugin_source(self, widget, source, row): + if source in self._plugin_sources: + self._plugin_sources.remove(source) + if self._plugin_sources_listbox and row: + self._plugin_sources_listbox.remove(row) + + def _on_update_plugins_clicked(self, widget): + if self._plugin_update_in_progress: + return + + sources = list(self._plugin_sources) + if not sources: + self._set_plugin_update_status("No sources to update.", done=True) + return + + self._plugin_update_in_progress = True + if self._plugin_update_button: + self._plugin_update_button.set_sensitive(False) + self._set_plugin_update_progress(0, len(sources), "Starting updates...") + + def _run_updates(): + manager = cthulhu.cthulhuApp.getPluginSystemManager() + if not manager: + GLib.idle_add(self._finish_plugin_updates, "Plugin manager unavailable.") + return + + def _progress_callback(index, total, message): + GLib.idle_add(self._set_plugin_update_progress, index, total, message) + + manager.syncPluginSources(sources, progress_callback=_progress_callback) + try: + manager.rescanPlugins() + except Exception as e: + GLib.idle_add(self._finish_plugin_updates, f"Update finished with errors: {e}") + return + + GLib.idle_add(self._finish_plugin_updates, "Update complete.") + + thread = threading.Thread(target=_run_updates, daemon=True) + thread.start() + + def _set_plugin_update_progress(self, index, total, message): + if not self._plugin_update_progress: + return + fraction = 0.0 if total <= 0 else min(1.0, float(index) / float(total)) + self._plugin_update_progress.set_fraction(fraction) + self._plugin_update_progress.set_text(message) + self._set_plugin_update_status(message) + + def _set_plugin_update_status(self, message, done=False): + if self._plugin_update_status: + self._plugin_update_status.set_text(message) + if done and self._plugin_update_progress: + self._plugin_update_progress.set_fraction(0.0) + self._plugin_update_progress.set_text("Idle") + + def _finish_plugin_updates(self, message): + self._plugin_update_in_progress = False + if self._plugin_update_button: + self._plugin_update_button.set_sensitive(True) + if message: + self._set_plugin_update_status(message) + self._populate_plugin_list() + + def _on_plugin_checkbox_toggled(self, checkbox, plugin_name): + if not checkbox.get_active(): + return + canonical_name = self._plugin_canonical_map.get(plugin_name) + if not canonical_name: + return + for other_name in self._plugin_group_map.get(canonical_name, []): + if other_name == plugin_name: + continue + other_checkbox = self._plugin_checkboxes.get(other_name) + if other_checkbox and other_checkbox.get_active(): + other_checkbox.set_active(False) + + def _get_active_plugins_from_ui(self): + existing_plugins = list(self.prefsDict.get("activePlugins", settings.activePlugins) or []) + preserved_plugins = [ + name for name in existing_plugins + if name not in self._plugin_checkboxes and name in self._available_plugins + ] + selected_plugins = [] + for canonical_name, plugin_names in self._plugin_group_map.items(): + active_in_group = [ + name for name in plugin_names + if self._plugin_checkboxes.get(name) and self._plugin_checkboxes[name].get_active() + ] + if active_in_group: + selected_plugins.append(active_in_group[-1]) + return preserved_plugins + selected_plugins + + def _apply_plugin_changes(self): + active_plugins = self._get_active_plugins_from_ui() + plugin_sources = list(self._plugin_sources) + + self.prefsDict["activePlugins"] = active_plugins + self.prefsDict["pluginSources"] = plugin_sources + + removed_sources = [source for source in self._plugin_sources_original if source not in plugin_sources] + manager = cthulhu.cthulhuApp.getPluginSystemManager() + if manager: + try: + manager.removePluginSources(removed_sources) + manager.rescanPlugins() + except Exception as e: + debug.printMessage(debug.LEVEL_WARNING, f"PREFERENCES DIALOG: Plugin sync failed: {e}", True) + + self._plugin_sources_original = list(plugin_sources) + self._populate_plugin_list() + def _getACSSForVoiceType(self, voiceType): """Return the ACSS value for the given voice type. @@ -3683,6 +4043,7 @@ class CthulhuSetupGUI(cthulhu_gtkbuilder.GtkBuilderWrapper): self.prefsDict['startingProfile'] = startingProfile _settingsManager.setStartingProfile(startingProfile) + self._apply_plugin_changes() self.writeUserPreferences() cthulhu.loadUserSettings(self.script) braille.checkBrailleSetting() diff --git a/src/cthulhu/plugin_system_manager.py b/src/cthulhu/plugin_system_manager.py index c749054..1f751c5 100644 --- a/src/cthulhu/plugin_system_manager.py +++ b/src/cthulhu/plugin_system_manager.py @@ -8,11 +8,15 @@ """Plugin System Manager for Cthulhu using pluggy.""" -import os -import inspect -import importlib.util -import logging import configparser +import hashlib +import importlib.util +import inspect +import logging +import os +import re +import shutil +import subprocess from enum import IntEnum # Import pluggy if available @@ -54,11 +58,15 @@ class PluginType(IntEnum): class PluginInfo: """Information about a plugin.""" - def __init__(self, name, module_name, module_dir, metadata=None): + def __init__(self, name, module_name, module_dir, metadata=None, canonical_name=None, source_id=None, origin=None): self.name = name self.module_name = module_name self.module_dir = module_dir self.metadata = metadata or {} + self.canonical_name = canonical_name or module_name + self.source_id = source_id or "unknown" + self.origin = origin or "unknown" + self.preferred_alias = False self.builtin = False self.hidden = False self.module = None @@ -68,6 +76,9 @@ class PluginInfo: def get_module_name(self): return self.module_name + def get_canonical_name(self): + return self.canonical_name + def get_name(self): return self.metadata.get('name', self.name) @@ -77,6 +88,17 @@ class PluginInfo: def get_description(self): return self.metadata.get('description', '') + def get_source_id(self): + return self.source_id + + def get_origin(self): + return self.origin + + def get_source_label(self): + if self.origin == "sources": + return self.source_id + return self.origin + def get_module_dir(self): return self.module_dir @@ -117,6 +139,7 @@ class PluginSystemManager: # Plugin storage self._plugins = {} # module_name -> PluginInfo + self._plugin_name_index = {} # canonical_name -> [module_name] self._active_plugins = [] # Create plugin directories @@ -310,6 +333,53 @@ class PluginSystemManager: """Ensure plugin directories exist.""" os.makedirs(PluginType.SYSTEM.get_root_dir(), exist_ok=True) os.makedirs(PluginType.USER.get_root_dir(), exist_ok=True) + os.makedirs(self._get_plugin_sources_root(), exist_ok=True) + + def _get_plugin_sources_root(self): + return os.path.expanduser('~/.local/share/cthulhu/plugin-sources') + + def _get_additional_plugin_dirs(self): + return [os.path.expanduser('~/.local/share/plugins')] + + def _path_under_root(self, path, root): + try: + return os.path.commonpath([os.path.abspath(path), os.path.abspath(root)]) == os.path.abspath(root) + except ValueError: + return False + + def _sanitize_source_id(self, source_id): + return re.sub(r'[^a-zA-Z0-9._-]+', '-', source_id).strip('-') or "source" + + def _get_origin_info(self, plugin_dir): + system_root = PluginType.SYSTEM.get_root_dir() + user_root = PluginType.USER.get_root_dir() + local_root = os.path.expanduser('~/.local/share/plugins') + sources_root = self._get_plugin_sources_root() + + if self._path_under_root(plugin_dir, system_root): + return ("system", "system") + if self._path_under_root(plugin_dir, user_root): + return ("user", "user") + if self._path_under_root(plugin_dir, local_root): + return ("local", "local") + if self._path_under_root(plugin_dir, sources_root): + rel_path = os.path.relpath(plugin_dir, sources_root) + source_id = rel_path.split(os.sep, 1)[0] + return ("sources", self._sanitize_source_id(source_id)) + + return ("unknown", "unknown") + + def _make_unique_plugin_id(self, canonical_name, source_id): + base_id = canonical_name + if canonical_name in self._plugin_name_index: + base_id = f"{canonical_name}@{self._sanitize_source_id(source_id)}" + + unique_id = base_id + suffix = 2 + while unique_id in self._plugins: + unique_id = f"{base_id}-{suffix}" + suffix += 1 + return unique_id @property def plugins(self): @@ -323,10 +393,15 @@ class PluginSystemManager: """Scan for plugins in the plugin directories.""" old_plugins = self._plugins.copy() self._plugins = {} + self._plugin_name_index = {} # Scan system and user plugins self._scan_plugins_in_directory(PluginType.SYSTEM.get_root_dir()) - self._scan_plugins_in_directory(PluginType.USER.get_root_dir()) + self._scan_plugins_in_directory(PluginType.USER.get_root_dir(), max_depth=1) + for plugin_dir in self._get_additional_plugin_dirs(): + if os.path.isdir(plugin_dir): + self._scan_plugins_in_directory(plugin_dir, max_depth=1) + self._scan_plugins_in_directory(self._get_plugin_sources_root(), max_depth=1) # Preserve state for already loaded plugins for name, old_info in old_plugins.items(): @@ -335,52 +410,157 @@ class PluginSystemManager: self._plugins[name].instance = old_info.instance self._plugins[name].module = old_info.module - def _scan_plugins_in_directory(self, directory): + def _scan_plugins_in_directory(self, directory, max_depth=0, _depth=0): """Scan for plugins in a directory.""" if not os.path.exists(directory) or not os.path.isdir(directory): - logger.warning(f"Plugin directory not found or not a directory: {directory}") + if _depth == 0: + logger.warning(f"Plugin directory not found or not a directory: {directory}") return - logger.info(f"Scanning for plugins in directory: {directory}") - for item in os.listdir(directory): + if _depth == 0: + logger.info(f"Scanning for plugins in directory: {directory}") + + for item in sorted(os.listdir(directory)): + if item.startswith('.'): + continue plugin_dir = os.path.join(directory, item) if not os.path.isdir(plugin_dir): continue - # Check for the traditional structure first (plugin.py & plugin.info) - plugin_file = os.path.join(plugin_dir, "plugin.py") - metadata_file = os.path.join(plugin_dir, "plugin.info") + if self._register_plugin_from_directory(plugin_dir): + continue - # Fall back to [PluginName].py if plugin.py doesn't exist - if not os.path.isfile(plugin_file): - alternative_plugin_file = os.path.join(plugin_dir, f"{item}.py") - if os.path.isfile(alternative_plugin_file): - plugin_file = alternative_plugin_file - logger.info(f"Using alternative plugin file: {alternative_plugin_file}") - - # Check if we have any valid plugin file - if os.path.isfile(plugin_file): - # Extract plugin info - module_name = os.path.basename(plugin_dir) - logger.info(f"Found plugin: {module_name} in {plugin_dir}") - metadata = self._load_plugin_metadata(metadata_file) - - plugin_info = PluginInfo( - metadata.get('name', module_name), - module_name, - plugin_dir, - metadata - ) - - # Check if it's a built-in or hidden plugin - plugin_info.builtin = metadata.get('builtin', 'false').lower() == 'true' - plugin_info.hidden = metadata.get('hidden', 'false').lower() == 'true' - - logger.info(f"Adding plugin to registry: {module_name}") - self._plugins[module_name] = plugin_info - else: + if _depth < max_depth: + self._scan_plugins_in_directory(plugin_dir, max_depth=max_depth, _depth=_depth + 1) + elif max_depth == 0: logger.warning(f"No plugin file found in directory: {plugin_dir}") + def _register_plugin_from_directory(self, plugin_dir): + item = os.path.basename(plugin_dir) + plugin_file = os.path.join(plugin_dir, "plugin.py") + metadata_file = os.path.join(plugin_dir, "plugin.info") + + if not os.path.isfile(plugin_file): + alternative_plugin_file = os.path.join(plugin_dir, f"{item}.py") + if os.path.isfile(alternative_plugin_file): + plugin_file = alternative_plugin_file + logger.info(f"Using alternative plugin file: {alternative_plugin_file}") + + if not os.path.isfile(plugin_file): + return False + + canonical_name = os.path.basename(plugin_dir) + origin, source_id = self._get_origin_info(plugin_dir) + module_name = self._make_unique_plugin_id(canonical_name, source_id) + if canonical_name != module_name: + logger.warning( + "Duplicate plugin name detected: " + f"{canonical_name} (origin={origin}, source={source_id}). " + f"Registering as {module_name}." + ) + logger.info(f"Found plugin: {canonical_name} in {plugin_dir}") + metadata = self._load_plugin_metadata(metadata_file) + + plugin_info = PluginInfo( + metadata.get('name', canonical_name), + module_name, + plugin_dir, + metadata, + canonical_name=canonical_name, + source_id=source_id, + origin=origin, + ) + + plugin_info.builtin = metadata.get('builtin', 'false').lower() == 'true' + plugin_info.hidden = metadata.get('hidden', 'false').lower() == 'true' + + if canonical_name not in self._plugin_name_index: + plugin_info.preferred_alias = True + self._plugin_name_index[canonical_name] = [] + self._plugin_name_index[canonical_name].append(module_name) + + logger.info(f"Adding plugin to registry: {module_name}") + self._plugins[module_name] = plugin_info + return True + + def _plugin_source_dir(self, source_url): + base_name = source_url.rstrip('/').split('/')[-1] or 'plugin-source' + if base_name.endswith('.git'): + base_name = base_name[:-4] + base_name = re.sub(r'[^a-zA-Z0-9._-]+', '-', base_name).strip('-') or 'plugin-source' + digest = hashlib.sha1(source_url.encode('utf-8')).hexdigest()[:8] + return os.path.join(self._get_plugin_sources_root(), f"{base_name}-{digest}") + + def syncPluginSources(self, sources, progress_callback=None): + """Clone or update plugin source repositories.""" + if not sources: + return + + git_path = shutil.which("git") + if not git_path: + logger.error("Git not available; cannot sync plugin sources.") + return + + os.makedirs(self._get_plugin_sources_root(), exist_ok=True) + total = len(sources) + for index, source in enumerate(sources, start=1): + source = source.strip() + if not source: + continue + dest_dir = self._plugin_source_dir(source) + if os.path.isdir(dest_dir): + try: + subprocess.run( + [git_path, "-C", dest_dir, "pull", "--ff-only"], + check=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + logger.info(f"Updated plugin source: {source}") + if progress_callback: + progress_callback(index, total, f"Updated {source}") + except subprocess.CalledProcessError as exc: + logger.error(f"Failed to update plugin source {source}: {exc.stderr.decode('utf-8', 'ignore')}") + if progress_callback: + progress_callback(index, total, f"Failed to update {source}") + else: + try: + subprocess.run( + [git_path, "clone", source, dest_dir], + check=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + logger.info(f"Installed plugin source: {source}") + if progress_callback: + progress_callback(index, total, f"Installed {source}") + except subprocess.CalledProcessError as exc: + logger.error(f"Failed to clone plugin source {source}: {exc.stderr.decode('utf-8', 'ignore')}") + if progress_callback: + progress_callback(index, total, f"Failed to clone {source}") + + def removePluginSources(self, sources): + """Remove plugin source repositories.""" + if not sources: + return + + sources_root = self._get_plugin_sources_root() + for source in sources: + source = source.strip() + if not source: + continue + dest_dir = self._plugin_source_dir(source) + if not os.path.isdir(dest_dir): + continue + if os.path.commonpath([dest_dir, sources_root]) != sources_root: + logger.warning(f"Skipping removal outside plugin sources root: {dest_dir}") + continue + try: + shutil.rmtree(dest_dir) + logger.info(f"Removed plugin source: {source}") + except Exception as exc: + logger.error(f"Failed to remove plugin source {source}: {exc}") + def _load_plugin_metadata(self, metadata_file): """Load plugin metadata from a file.""" metadata = {} @@ -448,10 +628,71 @@ class PluginSystemManager: logger.info("No plugins found, rescanning...") self.rescanPlugins() - self._active_plugins = activePlugins + plugin_by_id = {info.get_module_name(): info for info in self.plugins} + plugin_by_id_lower = {name.lower(): info for name, info in plugin_by_id.items()} + preferred_by_canonical = { + info.get_canonical_name().lower(): info.get_module_name() + for info in self.plugins if info.preferred_alias + } + canonical_with_builtin = { + info.get_canonical_name().lower() + for info in self.plugins if info.builtin + } + + normalized_active = [] + seen_canonicals = set() + unknown_active = [] + + for name in activePlugins: + if not name: + continue + name_lower = name.lower() + plugin_info = plugin_by_id.get(name) or plugin_by_id_lower.get(name_lower) + if plugin_info: + canonical_lower = plugin_info.get_canonical_name().lower() + if canonical_lower in canonical_with_builtin and not plugin_info.builtin: + logger.warning( + f"Skipping plugin {plugin_info.get_module_name()} because builtin plugin " + f"uses name {plugin_info.get_canonical_name()}" + ) + continue + if canonical_lower in seen_canonicals: + logger.warning( + f"Skipping plugin {plugin_info.get_module_name()} because another plugin " + f"with name {plugin_info.get_canonical_name()} is already active" + ) + continue + normalized_active.append(plugin_info.get_module_name()) + seen_canonicals.add(canonical_lower) + continue + + preferred_id = preferred_by_canonical.get(name_lower) + if preferred_id: + preferred_info = plugin_by_id.get(preferred_id) + canonical_lower = name_lower + if preferred_info and canonical_lower in canonical_with_builtin and not preferred_info.builtin: + logger.warning( + f"Skipping plugin {preferred_id} because builtin plugin uses name {preferred_info.get_canonical_name()}" + ) + continue + if canonical_lower in seen_canonicals: + logger.warning( + f"Skipping plugin {preferred_id} because another plugin with name {name} is already active" + ) + continue + normalized_active.append(preferred_id) + seen_canonicals.add(canonical_lower) + continue + + unknown_active.append(name) + + self._active_plugins = normalized_active + unknown_active # Log active vs available plugins available_plugins = [p.get_module_name() for p in self.plugins] + available_aliases = [p.get_canonical_name() for p in self.plugins if p.preferred_alias] + available_plugins_lower = {p.lower() for p in available_plugins} + available_aliases_lower = {p.lower() for p in available_aliases} logger.info(f"Available plugins: {available_plugins}") logger.info(f"Active plugins: {self._active_plugins}") @@ -462,7 +703,13 @@ class PluginSystemManager: logger.warning("DisplayVersion is NOT in active plugins list!") # Find missing plugins - missing_plugins = [p for p in self._active_plugins if p not in available_plugins] + missing_plugins = [ + p for p in self._active_plugins + if p not in available_plugins + and p not in available_aliases + and p.lower() not in available_plugins_lower + and p.lower() not in available_aliases_lower + ] if missing_plugins: logger.warning(f"Active plugins not found: {missing_plugins}") @@ -488,6 +735,7 @@ class PluginSystemManager: def isPluginActive(self, pluginInfo): """Check if a plugin is active.""" module_name = pluginInfo.get_module_name() + canonical_name = pluginInfo.get_canonical_name() # Builtin plugins are always active if pluginInfo.builtin: @@ -507,9 +755,16 @@ class PluginSystemManager: logger.debug(f"Plugin {module_name} found in active plugins list") return True + if pluginInfo.preferred_alias and canonical_name in active_plugins: + logger.debug(f"Plugin {module_name} matched canonical name {canonical_name}") + return True + # Try case-insensitive match module_name_lower = module_name.lower() + canonical_lower = canonical_name.lower() is_active = any(plugin.lower() == module_name_lower for plugin in active_plugins) + if not is_active and pluginInfo.preferred_alias: + is_active = any(plugin.lower() == canonical_lower for plugin in active_plugins) if is_active: logger.debug(f"Plugin {module_name} found in active plugins list (case-insensitive match)") @@ -573,7 +828,8 @@ class PluginSystemManager: # Fall back to [PluginName].py if plugin.py doesn't exist if not os.path.exists(plugin_file): - alternative_plugin_file = os.path.join(plugin_dir, f"{module_name}.py") + canonical_name = pluginInfo.get_canonical_name() + alternative_plugin_file = os.path.join(plugin_dir, f"{canonical_name}.py") if os.path.exists(alternative_plugin_file): plugin_file = alternative_plugin_file logger.info(f"Using alternative plugin file: {alternative_plugin_file}") diff --git a/src/cthulhu/plugins/PluginManager/plugin.py b/src/cthulhu/plugins/PluginManager/plugin.py index 493e172..f10ce4a 100644 --- a/src/cthulhu/plugins/PluginManager/plugin.py +++ b/src/cthulhu/plugins/PluginManager/plugin.py @@ -53,9 +53,6 @@ class PluginManager(Plugin): try: debug.printMessage(debug.LEVEL_INFO, "PluginManager: Plugin activation starting", True) - # Register keybinding for opening plugin manager (Cthulhu+Shift+P) - self._register_keybinding() - self._activated = True debug.printMessage(debug.LEVEL_INFO, "PluginManager: Plugin activated successfully", True) return True diff --git a/src/cthulhu/settings.py b/src/cthulhu/settings.py index 7dbb6ef..65916e6 100644 --- a/src/cthulhu/settings.py +++ b/src/cthulhu/settings.py @@ -136,6 +136,7 @@ userCustomizableSettings = [ "presentTimeFormat", "activeProfile", "activePlugins", + "pluginSources", "startingProfile", "spellcheckSpellError", "spellcheckSpellSuggestion", @@ -490,6 +491,7 @@ presentLiveRegionFromInactiveTab = False # Plugins activePlugins = ['AIAssistant', 'DisplayVersion', 'OCR', 'PluginManager', 'HelloCthulhu', 'ByeCthulhu', 'IndentationAudio'] +pluginSources = [] # AI Assistant settings (disabled by default for opt-in behavior) aiAssistantEnabled = True From 5c9ceb42d5473d35eccc1d56c471fe78a1a8c7d0 Mon Sep 17 00:00:00 2001 From: Storm Dragon Date: Sat, 3 Jan 2026 19:36:30 -0500 Subject: [PATCH 03/14] Improved support for mumble. Mumble chat messages are now in the message history list. Also, for message review, add shift to copy the message to the clipboard. --- src/cthulhu/chat.py | 40 ++++- src/cthulhu/cmdnames.py | 5 + .../plugins/IndentationAudio/plugin.py | 44 ++++- src/cthulhu/scripts/apps/Mumble/__init__.py | 26 +++ src/cthulhu/scripts/apps/Mumble/chat.py | 150 ++++++++++++++++++ src/cthulhu/scripts/apps/Mumble/meson.build | 11 ++ src/cthulhu/scripts/apps/Mumble/script.py | 146 +++++++++++++++++ .../scripts/apps/Mumble/script_utilities.py | 60 +++++++ src/cthulhu/scripts/apps/__init__.py | 1 + src/cthulhu/scripts/apps/meson.build | 3 +- 10 files changed, 481 insertions(+), 5 deletions(-) create mode 100644 src/cthulhu/scripts/apps/Mumble/__init__.py create mode 100644 src/cthulhu/scripts/apps/Mumble/chat.py create mode 100644 src/cthulhu/scripts/apps/Mumble/meson.build create mode 100644 src/cthulhu/scripts/apps/Mumble/script.py create mode 100644 src/cthulhu/scripts/apps/Mumble/script_utilities.py diff --git a/src/cthulhu/chat.py b/src/cthulhu/chat.py index 1f595a0..1e4baad 100644 --- a/src/cthulhu/chat.py +++ b/src/cthulhu/chat.py @@ -315,6 +315,7 @@ class Chat: self.messageKeys = \ ["F1", "F2", "F3", "F4", "F5", "F6", "F7", "F8", "F9"] self.messageKeyModifier = keybindings.CTHULHU_MODIFIER_MASK + self.messageCopyKeyModifier = keybindings.CTHULHU_SHIFT_MODIFIER_MASK self.inputEventHandlers = {} self.setupInputEventHandlers() self.keyBindings = self.getKeyBindings() @@ -356,6 +357,11 @@ class Chat: self.readPreviousMessage, cmdnames.CHAT_PREVIOUS_MESSAGE) + self.inputEventHandlers["copyMessage"] = \ + input_event.InputEventHandler( + self.copyPreviousMessage, + cmdnames.CHAT_COPY_PREVIOUS_MESSAGE) + return def getKeyBindings(self): @@ -396,6 +402,14 @@ class Chat: keybindings.CTHULHU_MODIFIER_MASK, self.inputEventHandlers["reviewMessage"])) + for messageKey in self.messageKeys: + keyBindings.add( + keybindings.KeyBinding( + messageKey, + self.messageCopyKeyModifier, + self.messageCopyKeyModifier, + self.inputEventHandlers["copyMessage"])) + return keyBindings def getAppPreferencesGUI(self): @@ -558,6 +572,27 @@ class Chat: except Exception: pass + message, chatRoomName = self._get_message_for_index(index) + if message and chatRoomName: + self.utterMessage(chatRoomName, message, True) + + def copyPreviousMessage(self, script, inputEvent=None, index=0): + """Copy a previous chat room message to the clipboard.""" + + try: + index = self.messageKeys.index(inputEvent.event_string) + except Exception: + pass + + message, chatRoomName = self._get_message_for_index(index) + if not message: + return + + self._script.utilities.setClipboardText(message) + line = f"Copied {message} to clipboard." + self._script.presentMessage(line) + + def _get_message_for_index(self, index): messageNumber = self.messageListLength - (index + 1) message, chatRoomName = None, None @@ -570,8 +605,7 @@ class Chat: message, chatRoomName = \ self._conversationList.getNthMessageAndName(messageNumber) - if message and chatRoomName: - self.utterMessage(chatRoomName, message, True) + return message, chatRoomName def utterMessage(self, chatRoomName, message, focused=True): """ Speak/braille a chat room message. @@ -816,7 +850,7 @@ class Chat: # things working. And people should not be in multiple chat # rooms with identical names anyway. :-) # - if (AXUtilities.is_text(obj) or AXObject.is_entry(obj)) \ + if (AXUtilities.is_text(obj) or AXUtilities.is_entry(obj)) \ and AXUtilities.is_editable(obj): name = self.getChatRoomName(obj) diff --git a/src/cthulhu/cmdnames.py b/src/cthulhu/cmdnames.py index c6ef3a0..7b3ab06 100644 --- a/src/cthulhu/cmdnames.py +++ b/src/cthulhu/cmdnames.py @@ -606,6 +606,11 @@ BYPASS_NEXT_COMMAND = \ # keyboard commands used to review those previous messages. CHAT_PREVIOUS_MESSAGE = _("Speak and braille a previous chat room message") +# Translators: Cthulhu has a command to copy a previous chat room message to +# the clipboard. This string is associated with the keyboard commands used to +# copy those messages. +CHAT_COPY_PREVIOUS_MESSAGE = _("Copy a previous chat room message to clipboard") + # Translators: In chat applications, it is often possible to see that a "buddy" # is typing currently (e.g. via a keyboard icon or status text). Some users like # to have this typing status announced by Cthulhu; others find that announcement diff --git a/src/cthulhu/plugins/IndentationAudio/plugin.py b/src/cthulhu/plugins/IndentationAudio/plugin.py index 12ca4d8..afb2c3a 100644 --- a/src/cthulhu/plugins/IndentationAudio/plugin.py +++ b/src/cthulhu/plugins/IndentationAudio/plugin.py @@ -19,6 +19,7 @@ from cthulhu import debug from cthulhu import settings from cthulhu import settings_manager from cthulhu.ax_object import AXObject +from cthulhu.ax_utilities import AXUtilities from cthulhu.ax_text import AXText # Import Cthulhu's sound system @@ -489,6 +490,25 @@ class IndentationAudio(Plugin): except Exception as e: logger.error(f"IndentationAudio: Error generating tone: {e}") debug.printMessage(debug.LEVEL_INFO, f"IndentationAudio: Exception in _generate_indentation_tone: {e}", True) + + @staticmethod + def _get_indentation_key(obj): + if obj is None: + return "global" + + document = None + try: + document = AXObject.find_ancestor(obj, AXUtilities.is_document) + except Exception: + document = None + + if document is None: + document = obj + + try: + return f"{id(document)}" + except Exception: + return str(document) def check_indentation_change(self, obj, line_text): """Check if indentation has changed and play audio cue if needed. @@ -502,7 +522,7 @@ class IndentationAudio(Plugin): try: # Get object identifier for tracking - obj_id = str(obj) if obj else "unknown" + obj_id = self._get_indentation_key(obj) # Calculate current indentation data indentation, columns, levels = self._get_indentation_data(line_text) @@ -523,6 +543,8 @@ class IndentationAudio(Plugin): change_mode = _settingsManager.getSetting('indentationChangeMode') \ or settings.indentationChangeMode only_if_changed = _settingsManager.getSetting('speakIndentationOnlyIfChanged') + if only_if_changed is None: + only_if_changed = settings.speakIndentationOnlyIfChanged if not only_if_changed: changed = True @@ -543,10 +565,30 @@ class IndentationAudio(Plugin): # Play audio cue if indentation changed if changed: + indent_debug = indentation.replace("\t", "\\t").replace(" ", ".") + debug.printMessage( + debug.LEVEL_INFO, + ( + f"IndentationAudio: Reporting indentation key={obj_id} " + f"units={current_units} levels={levels} columns={columns} " + f"mode={change_mode} onlyIfChanged={only_if_changed} " + f"indent='{indent_debug}'" + ), + True, + ) self._generate_indentation_tone(current_units, previous_units) debug_msg = f"IndentationAudio: Indentation units changed from {previous_units} to {current_units}" debug.printMessage(debug.LEVEL_INFO, debug_msg, True) + else: + debug.printMessage( + debug.LEVEL_INFO, + ( + "IndentationAudio: No indentation change; skipping tone " + f"key={obj_id} units={current_units} levels={levels} columns={columns}" + ), + True, + ) except Exception as e: logger.error(f"IndentationAudio: Error checking indentation change: {e}") diff --git a/src/cthulhu/scripts/apps/Mumble/__init__.py b/src/cthulhu/scripts/apps/Mumble/__init__.py new file mode 100644 index 0000000..b006f6c --- /dev/null +++ b/src/cthulhu/scripts/apps/Mumble/__init__.py @@ -0,0 +1,26 @@ +#!/usr/bin/env python3 +# +# Copyright (c) 2024 Stormux +# Copyright (c) 2010-2012 The Orca Team +# Copyright (c) 2012 Igalia, S.L. +# Copyright (c) 2005-2010 Sun Microsystems Inc. +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 2.1 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library; if not, write to the +# Free Software Foundation, Inc., Franklin Street, Fifth Floor, +# Boston MA 02110-1301 USA. +# +# Forked from Orca screen reader. +# Cthulhu project: https://git.stormux.org/storm/cthulhu + +from .script import Script diff --git a/src/cthulhu/scripts/apps/Mumble/chat.py b/src/cthulhu/scripts/apps/Mumble/chat.py new file mode 100644 index 0000000..eccd2a8 --- /dev/null +++ b/src/cthulhu/scripts/apps/Mumble/chat.py @@ -0,0 +1,150 @@ +#!/usr/bin/env python3 +# +# Copyright (c) 2024 Stormux +# Copyright (c) 2010-2012 The Orca Team +# Copyright (c) 2012 Igalia, S.L. +# Copyright (c) 2005-2010 Sun Microsystems Inc. +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 2.1 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library; if not, write to the +# Free Software Foundation, Inc., Franklin Street, Fifth Floor, +# Boston MA 02110-1301 USA. +# +# Forked from Orca screen reader. +# Cthulhu project: https://git.stormux.org/storm/cthulhu + +"""Custom chat module for Mumble.""" + +__id__ = "$Id$" +__version__ = "$Revision$" +__date__ = "$Date$" +__copyright__ = "Copyright (c) 2024 Stormux." +__license__ = "LGPL" + +import re + +import cthulhu.chat as chat +import cthulhu.keybindings as keybindings +import cthulhu.cthulhu_state as cthulhu_state +import cthulhu.settings_manager as settings_manager +from cthulhu.ax_object import AXObject +from cthulhu.ax_selection import AXSelection +from cthulhu.ax_utilities import AXUtilities + +_settingsManager = settings_manager.getManager() + + +class Chat(chat.Chat): + """Mumble-specific chat helpers.""" + + def __init__(self, script): + super().__init__(script, []) + + # Mumble can have more than nine recent messages that are useful to review. + self.messageKeys = [ + "F1", "F2", "F3", "F4", "F5", "F6", + "F7", "F8", "F9", "F10", "F11", "F12", + ] + self.messageKeyModifier = keybindings.CTHULHU_MODIFIER_MASK + self.inputEventHandlers = {} + self.setupInputEventHandlers() + self.keyBindings = self.getKeyBindings() + self.messageListLength = len(self.messageKeys) + self._conversationList = chat.ConversationList(self.messageListLength) + + self._channelTree = None + + def isChatRoomMsg(self, obj): + if not obj: + return False + + name = AXObject.get_name(obj) + if name != "Activity log": + return False + + if AXUtilities.is_label(obj) or AXUtilities.is_text(obj): + return True + + return False + + def getMessageFromEvent(self, event): + message = event.any_data or "" + message = message.replace("\u2028", " ") + message = re.sub(r"[\ufdd0-\ufdef]", "", message) + message = " ".join(message.split()) + if not message: + return "" + + if re.match(r"^\[\d{2}:\d{2}:\d{2}\]$", message): + return "" + + message = re.sub(r"^\[\d{2}:\d{2}:\d{2}\]\s*", "", message) + return message.strip() + + def _get_message_for_index(self, index): + messageNumber = self.messageListLength - (index + 1) + message, chatRoomName = None, None + + if _settingsManager.getSetting('chatRoomHistories'): + conversation = self.getConversation(cthulhu_state.locusOfFocus) + if conversation: + message = conversation.getNthMessage(messageNumber) + chatRoomName = conversation.name + + if not message: + message, chatRoomName = \ + self._conversationList.getNthMessageAndName(messageNumber) + + return message, chatRoomName + + def getChatRoomName(self, obj): + channelName = self._get_selected_channel_name() + return channelName or "Mumble" + + def _get_selected_channel_name(self): + channelTree = self._get_channel_tree() + if not channelTree: + return "" + + selectedChildren = AXSelection.get_selected_children(channelTree) + for child in selectedChildren: + childName = AXObject.get_name(child) + if not childName: + continue + if childName.lower().startswith("channel "): + return childName.replace("channel ", "", 1).strip() + + for child in selectedChildren: + channelAncestor = AXObject.find_ancestor( + child, + lambda x: (AXObject.get_name(x) or "").lower().startswith("channel ") + ) + if channelAncestor: + ancestorName = AXObject.get_name(channelAncestor) + return ancestorName.replace("channel ", "", 1).strip() + + return "" + + def _get_channel_tree(self): + if self._channelTree and not AXUtilities.is_defunct(self._channelTree): + return self._channelTree + + activeWindow = cthulhu_state.activeWindow + if not activeWindow: + return None + + def is_channels_tree(obj): + return AXUtilities.is_tree(obj) and AXObject.get_name(obj) == "Channels and users" + + self._channelTree = AXObject.find_descendant(activeWindow, is_channels_tree) + return self._channelTree diff --git a/src/cthulhu/scripts/apps/Mumble/meson.build b/src/cthulhu/scripts/apps/Mumble/meson.build new file mode 100644 index 0000000..9e83272 --- /dev/null +++ b/src/cthulhu/scripts/apps/Mumble/meson.build @@ -0,0 +1,11 @@ +mumble_python_sources = files([ + '__init__.py', + 'chat.py', + 'script.py', + 'script_utilities.py', +]) + +python3.install_sources( + mumble_python_sources, + subdir: 'cthulhu/scripts/apps/Mumble' +) diff --git a/src/cthulhu/scripts/apps/Mumble/script.py b/src/cthulhu/scripts/apps/Mumble/script.py new file mode 100644 index 0000000..7452184 --- /dev/null +++ b/src/cthulhu/scripts/apps/Mumble/script.py @@ -0,0 +1,146 @@ +#!/usr/bin/env python3 +# +# Copyright (c) 2024 Stormux +# Copyright (c) 2010-2012 The Orca Team +# Copyright (c) 2012 Igalia, S.L. +# Copyright (c) 2005-2010 Sun Microsystems Inc. +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 2.1 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library; if not, write to the +# Free Software Foundation, Inc., Franklin Street, Fifth Floor, +# Boston MA 02110-1301 USA. +# +# Forked from Orca screen reader. +# Cthulhu project: https://git.stormux.org/storm/cthulhu + +"""Custom script for Mumble.""" + +__id__ = "$Id$" +__version__ = "$Revision$" +__date__ = "$Date$" +__copyright__ = "Copyright (c) 2024 Stormux." +__license__ = "LGPL" + +import time + +import cthulhu.debug as debug +import cthulhu.scripts.toolkits.Qt.script as Qt +from cthulhu.ax_object import AXObject +from cthulhu.ax_utilities import AXUtilities + +from .chat import Chat +from .script_utilities import Utilities + + +class Script(Qt.Script): + """Mumble-specific script tweaks.""" + + def __init__(self, app): + self._lastConnectFocusName = "" + self._lastConnectFocusRole = None + self._lastConnectFocusTime = 0.0 + self._lastMessageDialogId = None + + super().__init__(app) + + def getChat(self): + return Chat(self) + + def getUtilities(self): + return Utilities(self) + + def setupInputEventHandlers(self): + super().setupInputEventHandlers() + self.inputEventHandlers.update(self.chat.inputEventHandlers) + + def getAppKeyBindings(self): + return self.chat.keyBindings + + def getAppPreferencesGUI(self): + return self.chat.getAppPreferencesGUI() + + def getPreferencesFromGUI(self): + return self.chat.getPreferencesFromGUI() + + def onTextInserted(self, event): + if self.chat.presentInsertedText(event): + return + + super().onTextInserted(event) + + def onFocusedChanged(self, event): + if self._should_ignore_connect_dialog_focus(event): + return + + super().onFocusedChanged(event) + + def onCaretMoved(self, event): + super().onCaretMoved(event) + self._maybe_announce_message_dialog_input(event.source) + + def _should_ignore_connect_dialog_focus(self, event): + if not event.detail1: + return False + + obj = event.source + if not self._is_in_connect_dialog(obj): + return False + + objName = AXObject.get_name(obj) or "" + if not objName: + return False + + role = AXObject.get_role(obj) + now = time.time() + if self._lastConnectFocusName == objName \ + and self._lastConnectFocusRole == role \ + and (now - self._lastConnectFocusTime) < 0.35: + msg = "MUMBLE: Ignoring duplicate focus event in connect dialog" + debug.printMessage(debug.LEVEL_INFO, msg, True) + return True + + self._lastConnectFocusName = objName + self._lastConnectFocusRole = role + self._lastConnectFocusTime = now + return False + + def _is_in_connect_dialog(self, obj): + dialog = AXObject.find_ancestor(obj, AXUtilities.is_dialog) + return bool(dialog) and AXObject.get_name(dialog) == "Mumble Server Connect" + + def _maybe_announce_message_dialog_input(self, obj): + if not obj or not AXUtilities.is_text(obj): + return + + if not AXUtilities.is_editable(obj): + return + + if AXObject.get_name(obj): + return + + dialog = AXObject.find_ancestor(obj, AXUtilities.is_dialog) + if not dialog: + return + + dialogName = AXObject.get_name(dialog) or "" + if not dialogName.startswith("Sending message to "): + return + + dialogHash = hash(dialog) + if dialogHash == self._lastMessageDialogId: + return + + self._lastMessageDialogId = dialogHash + label = "Message" + voice = self.speechGenerator.voice(string=label) + self.speakMessage(label, voice=voice) diff --git a/src/cthulhu/scripts/apps/Mumble/script_utilities.py b/src/cthulhu/scripts/apps/Mumble/script_utilities.py new file mode 100644 index 0000000..9405c72 --- /dev/null +++ b/src/cthulhu/scripts/apps/Mumble/script_utilities.py @@ -0,0 +1,60 @@ +#!/usr/bin/env python3 +# +# Copyright (c) 2024 Stormux +# Copyright (c) 2010-2012 The Orca Team +# Copyright (c) 2012 Igalia, S.L. +# Copyright (c) 2005-2010 Sun Microsystems Inc. +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 2.1 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library; if not, write to the +# Free Software Foundation, Inc., Franklin Street, Fifth Floor, +# Boston MA 02110-1301 USA. +# +# Forked from Orca screen reader. +# Cthulhu project: https://git.stormux.org/storm/cthulhu + +"""Custom script utilities for Mumble.""" + +__id__ = "$Id$" +__version__ = "$Revision$" +__date__ = "$Date$" +__copyright__ = "Copyright (c) 2024 Stormux." +__license__ = "LGPL" + +import cthulhu.scripts.toolkits.Qt.script_utilities as Qt +from cthulhu.ax_object import AXObject +from cthulhu.ax_utilities import AXUtilities + + +class Utilities(Qt.Utilities): + """Mumble-specific script utilities.""" + + def shouldReadFullRow(self, obj): + if self._is_connect_server_list_cell(obj): + return False + + return super().shouldReadFullRow(obj) + + def _is_connect_server_list_cell(self, obj): + if not AXUtilities.is_table_cell_or_header(obj): + return False + + dialog = AXObject.find_ancestor(obj, AXUtilities.is_dialog) + if not dialog or AXObject.get_name(dialog) != "Mumble Server Connect": + return False + + tree = AXObject.find_ancestor( + obj, + lambda x: AXUtilities.is_tree(x) and AXObject.get_name(x) == "Server list" + ) + return tree is not None diff --git a/src/cthulhu/scripts/apps/__init__.py b/src/cthulhu/scripts/apps/__init__.py index 8333088..c28d24d 100644 --- a/src/cthulhu/scripts/apps/__init__.py +++ b/src/cthulhu/scripts/apps/__init__.py @@ -25,6 +25,7 @@ __all__ = ['Banshee', 'Eclipse', + 'Mumble', 'epiphany', 'evince', 'evolution', diff --git a/src/cthulhu/scripts/apps/meson.build b/src/cthulhu/scripts/apps/meson.build index 91991cd..39b1438 100644 --- a/src/cthulhu/scripts/apps/meson.build +++ b/src/cthulhu/scripts/apps/meson.build @@ -9,6 +9,7 @@ python3.install_sources( subdir('Banshee') subdir('Eclipse') +subdir('Mumble') subdir('SeaMonkey') subdir('Thunderbird') subdir('epiphany') @@ -26,4 +27,4 @@ subdir('pidgin') subdir('soffice') subdir('smuxi-frontend-gnome') subdir('steamwebhelper') -subdir('xfwm4') \ No newline at end of file +subdir('xfwm4') From 31cf0dbf5a2a86e68d609dde4c28a1fa0ebcc46d Mon Sep 17 00:00:00 2001 From: Storm Dragon Date: Sat, 3 Jan 2026 22:07:03 -0500 Subject: [PATCH 04/14] Fixed the copy key for messages. --- src/cthulhu/chat.py | 4 ++-- src/cthulhu/settings_manager.py | 14 ++++++++++++++ 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/src/cthulhu/chat.py b/src/cthulhu/chat.py index 1e4baad..ae10ed6 100644 --- a/src/cthulhu/chat.py +++ b/src/cthulhu/chat.py @@ -398,15 +398,15 @@ class Chat: keyBindings.add( keybindings.KeyBinding( messageKey, + keybindings.defaultModifierMask, self.messageKeyModifier, - keybindings.CTHULHU_MODIFIER_MASK, self.inputEventHandlers["reviewMessage"])) for messageKey in self.messageKeys: keyBindings.add( keybindings.KeyBinding( messageKey, - self.messageCopyKeyModifier, + keybindings.defaultModifierMask, self.messageCopyKeyModifier, self.inputEventHandlers["copyMessage"])) diff --git a/src/cthulhu/settings_manager.py b/src/cthulhu/settings_manager.py index b2e3eb9..eefb2d5 100644 --- a/src/cthulhu/settings_manager.py +++ b/src/cthulhu/settings_manager.py @@ -110,6 +110,7 @@ class SettingsManager(object): self._appGeneral = {} self._appPronunciations = {} self._appKeybindings = {} + self._lastRoleSoundPresentation = None if not self._loadBackend(): raise Exception('SettingsManager._loadBackend failed.') @@ -457,6 +458,19 @@ class SettingsManager(object): msg = 'SETTINGS MANAGER: Runtime settings set.' debug.printMessage(debug.LEVEL_INFO, msg, True) + self._logRoleSoundPresentationChange() + + def _logRoleSoundPresentationChange(self): + current = getattr(settings, "roleSoundPresentation", None) + if current == self._lastRoleSoundPresentation: + return + + msg = ( + "SETTINGS MANAGER: roleSoundPresentation changed to " + f"{current} profile={self.profile} app={self._activeApp}" + ) + debug.printMessage(debug.LEVEL_INFO, msg, True) + self._lastRoleSoundPresentation = current def _setPronunciationsRuntime(self, pronunciationsDict): pronunciation_dict.pronunciation_dict = {} From 1f6a2a06e5f59b959f73b8060a7f58b1a8222ad7 Mon Sep 17 00:00:00 2001 From: Storm Dragon Date: Sun, 4 Jan 2026 15:09:28 -0500 Subject: [PATCH 05/14] Improvements to the plugins tab. --- src/cthulhu/cthulhu_gui_prefs.py | 201 ++++++++++++----- src/cthulhu/input_event.py | 18 +- src/cthulhu/messages.py | 6 + src/cthulhu/plugin_system_manager.py | 39 ++++ src/cthulhu/plugins/GameMode/__init__.py | 14 ++ src/cthulhu/plugins/GameMode/meson.build | 14 ++ src/cthulhu/plugins/GameMode/plugin.info | 8 + src/cthulhu/plugins/GameMode/plugin.py | 97 +++++++++ src/cthulhu/plugins/PluginManager/plugin.py | 227 ++++++++++++++------ src/cthulhu/plugins/meson.build | 1 + src/cthulhu/scripts/default.py | 1 + src/cthulhu/settings.py | 2 + 12 files changed, 511 insertions(+), 117 deletions(-) create mode 100644 src/cthulhu/plugins/GameMode/__init__.py create mode 100644 src/cthulhu/plugins/GameMode/meson.build create mode 100644 src/cthulhu/plugins/GameMode/plugin.info create mode 100644 src/cthulhu/plugins/GameMode/plugin.py diff --git a/src/cthulhu/cthulhu_gui_prefs.py b/src/cthulhu/cthulhu_gui_prefs.py index 27c0987..6feb971 100644 --- a/src/cthulhu/cthulhu_gui_prefs.py +++ b/src/cthulhu/cthulhu_gui_prefs.py @@ -106,6 +106,10 @@ if louis and not tablesdir: DATE_FORMAT_ABBREVIATED_MDY, DATE_FORMAT_ABBREVIATED_YMD) = list(range(16)) class CthulhuSetupGUI(cthulhu_gtkbuilder.GtkBuilderWrapper): + PLUGIN_COL_ENABLED = 0 + PLUGIN_COL_DISPLAY = 1 + PLUGIN_COL_CAN_TOGGLE = 2 + PLUGIN_COL_NAME = 3 def __init__(self, fileName, windowName, prefsDict): """Initialize the Cthulhu configuration GUI. @@ -135,8 +139,11 @@ class CthulhuSetupGUI(cthulhu_gtkbuilder.GtkBuilderWrapper): self.planeCellRendererText = None self.pronunciationModel = None self.pronunciationView = None - self._plugin_checkboxes = {} - self._plugin_listbox = None + self._plugin_treeview = None + self._plugin_model = None + self._plugin_iters = {} + self._plugin_enabled_iter = None + self._plugin_disabled_iter = None self._plugin_sources = [] self._plugin_sources_entry = None self._plugin_sources_listbox = None @@ -420,9 +427,41 @@ class CthulhuSetupGUI(cthulhu_gtkbuilder.GtkBuilderWrapper): pluginsScrolled = Gtk.ScrolledWindow() pluginsScrolled.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC) pluginsScrolled.set_size_request(-1, 200) - self._plugin_listbox = Gtk.ListBox() - self._plugin_listbox.set_selection_mode(Gtk.SelectionMode.NONE) - pluginsScrolled.add(self._plugin_listbox) + self._plugin_model = Gtk.TreeStore( + GObject.TYPE_BOOLEAN, # enabled + GObject.TYPE_STRING, # display text + GObject.TYPE_BOOLEAN, # can toggle + GObject.TYPE_STRING, # plugin name + ) + self._plugin_treeview = Gtk.TreeView(model=self._plugin_model) + self._plugin_treeview.set_headers_visible(True) + self._plugin_treeview.set_enable_search(False) + self._plugin_treeview.connect("key-press-event", self._on_plugin_tree_key_press) + self._plugin_treeview.connect("row-activated", self._on_plugin_tree_row_activated) + + toggle_renderer = Gtk.CellRendererToggle() + toggle_renderer.set_activatable(True) + toggle_renderer.connect("toggled", self._on_plugin_tree_toggled) + toggle_column = Gtk.TreeViewColumn( + "Enabled", + toggle_renderer, + active=self.PLUGIN_COL_ENABLED, + activatable=self.PLUGIN_COL_CAN_TOGGLE + ) + toggle_column.add_attribute(toggle_renderer, "visible", self.PLUGIN_COL_CAN_TOGGLE) + self._plugin_treeview.append_column(toggle_column) + + text_renderer = Gtk.CellRendererText() + text_renderer.set_property("ellipsize", Pango.EllipsizeMode.END) + text_column = Gtk.TreeViewColumn( + "Plugin", + text_renderer, + text=self.PLUGIN_COL_DISPLAY + ) + text_column.set_expand(True) + self._plugin_treeview.append_column(text_column) + + pluginsScrolled.add(self._plugin_treeview) pluginsFrameBox.pack_start(pluginsScrolled, True, True, 0) pluginsPage.pack_start(pluginsFrame, True, True, 0) @@ -496,12 +535,12 @@ class CthulhuSetupGUI(cthulhu_gtkbuilder.GtkBuilderWrapper): listbox.remove(child) def _populate_plugin_list(self): - if not self._plugin_listbox: + if not self._plugin_treeview: return try: - self._clear_listbox(self._plugin_listbox) - self._plugin_checkboxes.clear() + self._plugin_model.clear() + self._plugin_iters = {} self._available_plugins = set() self._plugin_canonical_map = {} self._plugin_group_map = {} @@ -526,60 +565,62 @@ class CthulhuSetupGUI(cthulhu_gtkbuilder.GtkBuilderWrapper): canonical_counts[canonical] = canonical_counts.get(canonical, 0) + 1 if info.builtin: canonical_builtins[canonical] = True + + self._plugin_enabled_iter = self._plugin_model.append( + None, + [False, "Enabled plugins", False, ""] + ) + self._plugin_disabled_iter = self._plugin_model.append( + None, + [False, "Disabled plugins", False, ""] + ) + for plugin_info in sorted(plugin_infos, key=lambda item: (item.get_name() or item.get_module_name()).lower()): plugin_name = plugin_info.get_module_name() canonical_name = plugin_info.get_canonical_name() if plugin_info.hidden or canonical_name == "PluginManager": continue - row = Gtk.ListBoxRow() - row.set_activatable(False) - hbox = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=10) - hbox.set_border_width(5) + self._plugin_canonical_map[plugin_name] = canonical_name - checkbox = Gtk.CheckButton() is_active = ( plugin_name in active_plugins or plugin_name.lower() in active_plugins_lower or (plugin_info.preferred_alias and (canonical_name in active_plugins or canonical_name.lower() in active_plugins_lower)) or plugin_info.builtin ) + + can_toggle = True if canonical_builtins.get(canonical_name) and not plugin_info.builtin: is_active = False - checkbox.set_active(is_active) - if plugin_info.builtin: - checkbox.set_sensitive(False) - elif canonical_builtins.get(canonical_name): - checkbox.set_sensitive(False) - checkbox.connect("toggled", self._on_plugin_checkbox_toggled, plugin_name) + can_toggle = False - display_name = GLib.markup_escape_text(plugin_info.get_name() or plugin_name) - info_text = f"{display_name}" + if plugin_info.builtin: + can_toggle = False + + display_name = plugin_info.get_name() or plugin_name + info_text = display_name description = plugin_info.get_description() if description: - info_text += f"\n{GLib.markup_escape_text(description)}" + info_text += f" - {description}" version = plugin_info.get_version() if version: - info_text += f" (v{GLib.markup_escape_text(version)})" + info_text += f" (v{version})" if canonical_counts.get(canonical_name, 0) > 1: - info_text += f"\nSource: {GLib.markup_escape_text(plugin_info.get_source_label())}" + info_text += f" - Source: {plugin_info.get_source_label()}" if canonical_builtins.get(canonical_name) and not plugin_info.builtin: - info_text += "\nDisabled because a builtin plugin uses this name." + info_text += " - Disabled because a builtin plugin uses this name." - label = Gtk.Label() - label.set_markup(info_text) - label.set_halign(Gtk.Align.START) - label.set_line_wrap(True) - - hbox.pack_start(checkbox, False, False, 0) - hbox.pack_start(label, True, True, 0) - row.add(hbox) - self._plugin_listbox.add(row) - self._plugin_checkboxes[plugin_name] = checkbox - self._plugin_canonical_map[plugin_name] = canonical_name + parent_iter = self._plugin_enabled_iter if is_active else self._plugin_disabled_iter + tree_iter = self._plugin_model.append( + parent_iter, + [is_active, info_text, can_toggle, plugin_name] + ) + self._plugin_iters[plugin_name] = tree_iter self._plugin_group_map.setdefault(canonical_name, []).append(plugin_name) - self._plugin_listbox.show_all() + self._plugin_treeview.collapse_all() + self._plugin_treeview.show_all() except Exception as e: debug.printMessage(debug.LEVEL_WARNING, f"PREFERENCES DIALOG: Plugin list build failed: {e}", True) @@ -685,30 +726,92 @@ class CthulhuSetupGUI(cthulhu_gtkbuilder.GtkBuilderWrapper): self._set_plugin_update_status(message) self._populate_plugin_list() - def _on_plugin_checkbox_toggled(self, checkbox, plugin_name): - if not checkbox.get_active(): + def _on_plugin_tree_toggled(self, renderer, path): + if not self._plugin_model: return - canonical_name = self._plugin_canonical_map.get(plugin_name) - if not canonical_name: + + tree_iter = self._plugin_model.get_iter(path) + if not tree_iter: return - for other_name in self._plugin_group_map.get(canonical_name, []): - if other_name == plugin_name: - continue - other_checkbox = self._plugin_checkboxes.get(other_name) - if other_checkbox and other_checkbox.get_active(): - other_checkbox.set_active(False) + + can_toggle = self._plugin_model.get_value(tree_iter, self.PLUGIN_COL_CAN_TOGGLE) + if not can_toggle: + return + + plugin_name = self._plugin_model.get_value(tree_iter, self.PLUGIN_COL_NAME) + if not plugin_name: + return + + current_active = self._plugin_model.get_value(tree_iter, self.PLUGIN_COL_ENABLED) + new_active = not current_active + + if new_active: + canonical_name = self._plugin_canonical_map.get(plugin_name) + for other_name in self._plugin_group_map.get(canonical_name, []): + if other_name == plugin_name: + continue + self._set_plugin_row_active(other_name, False) + + self._set_plugin_row_active(plugin_name, new_active) + + def _on_plugin_tree_key_press(self, widget, event): + if event.keyval != Gdk.KEY_space: + return False + + selection = self._plugin_treeview.get_selection() + model, tree_iter = selection.get_selected() + if not tree_iter: + return False + + path = model.get_path(tree_iter) + self._on_plugin_tree_toggled(None, path) + return True + + def _on_plugin_tree_row_activated(self, treeview, path, column): + self._on_plugin_tree_toggled(None, path) + + def _set_plugin_row_active(self, plugin_name, is_active): + tree_iter = self._plugin_iters.get(plugin_name) + if not tree_iter: + return + + current_active = self._plugin_model.get_value(tree_iter, self.PLUGIN_COL_ENABLED) + if current_active == is_active: + return + + can_toggle = self._plugin_model.get_value(tree_iter, self.PLUGIN_COL_CAN_TOGGLE) + display_text = self._plugin_model.get_value(tree_iter, self.PLUGIN_COL_DISPLAY) + + selection = self._plugin_treeview.get_selection() + model, selected_iter = selection.get_selected() + was_selected = selected_iter == tree_iter + + self._plugin_model.remove(tree_iter) + + parent_iter = self._plugin_enabled_iter if is_active else self._plugin_disabled_iter + new_iter = self._plugin_model.append( + parent_iter, + [is_active, display_text, can_toggle, plugin_name] + ) + self._plugin_iters[plugin_name] = new_iter + + if was_selected: + path = self._plugin_model.get_path(new_iter) + self._plugin_treeview.expand_to_path(path) + selection.select_path(path) def _get_active_plugins_from_ui(self): existing_plugins = list(self.prefsDict.get("activePlugins", settings.activePlugins) or []) preserved_plugins = [ name for name in existing_plugins - if name not in self._plugin_checkboxes and name in self._available_plugins + if name not in self._plugin_iters and name in self._available_plugins ] selected_plugins = [] for canonical_name, plugin_names in self._plugin_group_map.items(): active_in_group = [ name for name in plugin_names - if self._plugin_checkboxes.get(name) and self._plugin_checkboxes[name].get_active() + if self._plugin_iters.get(name) + and self._plugin_model.get_value(self._plugin_iters[name], self.PLUGIN_COL_ENABLED) ] if active_in_group: selected_plugins.append(active_in_group[-1]) diff --git a/src/cthulhu/input_event.py b/src/cthulhu/input_event.py index 276a429..4da6fb8 100644 --- a/src/cthulhu/input_event.py +++ b/src/cthulhu/input_event.py @@ -876,9 +876,25 @@ class KeyboardEvent(InputEvent): return method.__func__ == self._handler.function + def _should_interrupt_presentation_on_press(self): + if not settings.gameMode: + return True + + if cthulhu_state.bypassNextCommand and not self.is_modifier_key(): + return False + + if self._handler or self._consumer: + return True + + if self.isCthulhuModifier(): + return True + + return False + def _present(self, inputEvent=None): if self.is_pressed_key(): - self._script.presentationInterrupt() + if self._should_interrupt_presentation_on_press(): + self._script.presentationInterrupt() if self._script.learnModePresenter.is_active(): return False diff --git a/src/cthulhu/messages.py b/src/cthulhu/messages.py index 7294133..26d8466 100644 --- a/src/cthulhu/messages.py +++ b/src/cthulhu/messages.py @@ -2333,6 +2333,12 @@ STOP_CTHULHU = _("Cthulhu lurks beneath the waves.") SLEEP_MODE_ENABLED_FOR = _("Sleep mode enabled for %s") SLEEP_MODE_DISABLED_FOR = _("Sleep mode disabled for %s") +# Translators: This message is presented to the user when game mode is enabled. +GAME_MODE_ENABLED = _("Game mode enabled") + +# Translators: This message is presented to the user when game mode is disabled. +GAME_MODE_DISABLED = _("Game mode disabled") + # Translators: This message means speech synthesis is not installed or working. SPEECH_UNAVAILABLE = _("Speech is unavailable.") diff --git a/src/cthulhu/plugin_system_manager.py b/src/cthulhu/plugin_system_manager.py index 1f751c5..a6a27ed 100644 --- a/src/cthulhu/plugin_system_manager.py +++ b/src/cthulhu/plugin_system_manager.py @@ -34,6 +34,14 @@ logger = logging.getLogger(__name__) if PLUGIN_DEBUG: logger.setLevel(logging.DEBUG) +LEGACY_PLUGIN_NAME_ALIASES = { + "ocrdesktop": "OCR", +} + +LEGACY_PLUGIN_DIR_ALIASES = { + "OCRDesktop": "OCR", +} + _manager = None def getManager(): @@ -450,6 +458,18 @@ class PluginSystemManager: return False canonical_name = os.path.basename(plugin_dir) + canonical_override = LEGACY_PLUGIN_DIR_ALIASES.get(canonical_name) + if canonical_override: + if canonical_override in self._plugin_name_index: + logger.info( + f"Skipping deprecated plugin directory {canonical_name} because " + f"{canonical_override} is already registered." + ) + return True + logger.info( + f"Registering deprecated plugin directory {canonical_name} as {canonical_override}." + ) + canonical_name = canonical_override origin, source_id = self._get_origin_info(plugin_dir) module_name = self._make_unique_plugin_id(canonical_name, source_id) if canonical_name != module_name: @@ -623,6 +643,25 @@ class PluginSystemManager: logger.info(f"PLUGIN SYSTEM: setActivePlugins called with: {activePlugins}") logger.info(f"Setting active plugins: {activePlugins}") + original_active = list(activePlugins or []) + normalized_requested = [] + for name in original_active: + if not name: + continue + name_lower = name.lower() + alias = LEGACY_PLUGIN_NAME_ALIASES.get(name_lower) + if alias: + logger.info(f"Mapping legacy plugin name {name} to {alias}") + name = alias + normalized_requested.append(name) + activePlugins = normalized_requested + if activePlugins != original_active: + try: + from . import settings as settings_module + settings_module.activePlugins = list(activePlugins) + except Exception: + logger.debug("Unable to normalize settings.activePlugins for legacy plugin aliases.") + # Make sure we have scanned for plugins first if not self._plugins: logger.info("No plugins found, rescanning...") diff --git a/src/cthulhu/plugins/GameMode/__init__.py b/src/cthulhu/plugins/GameMode/__init__.py new file mode 100644 index 0000000..cb0c944 --- /dev/null +++ b/src/cthulhu/plugins/GameMode/__init__.py @@ -0,0 +1,14 @@ +#!/usr/bin/env python3 +# +# Copyright (c) 2025 Stormux +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 2.1 of the License, or (at your option) any later version. + +"""GameMode plugin package.""" + +from .plugin import GameMode + +__all__ = ['GameMode'] diff --git a/src/cthulhu/plugins/GameMode/meson.build b/src/cthulhu/plugins/GameMode/meson.build new file mode 100644 index 0000000..4b80c49 --- /dev/null +++ b/src/cthulhu/plugins/GameMode/meson.build @@ -0,0 +1,14 @@ +gamemode_python_sources = files([ + '__init__.py', + 'plugin.py' +]) + +python3.install_sources( + gamemode_python_sources, + subdir: 'cthulhu/plugins/GameMode' +) + +install_data( + 'plugin.info', + install_dir: python3.get_install_dir() / 'cthulhu' / 'plugins' / 'GameMode' +) diff --git a/src/cthulhu/plugins/GameMode/plugin.info b/src/cthulhu/plugins/GameMode/plugin.info new file mode 100644 index 0000000..753c0d4 --- /dev/null +++ b/src/cthulhu/plugins/GameMode/plugin.info @@ -0,0 +1,8 @@ +name = GameMode +version = 1.0.0 +description = Reduces speech interruptions while holding keys +authors = Stormux +website = https://git.stormux.org/storm/cthulhu +copyright = Copyright 2025 +builtin = false +hidden = false diff --git a/src/cthulhu/plugins/GameMode/plugin.py b/src/cthulhu/plugins/GameMode/plugin.py new file mode 100644 index 0000000..d89a43e --- /dev/null +++ b/src/cthulhu/plugins/GameMode/plugin.py @@ -0,0 +1,97 @@ +#!/usr/bin/env python3 +# +# Copyright (c) 2025 Stormux +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 2.1 of the License, or (at your option) any later version. + +"""GameMode plugin for Cthulhu - reduce speech interruption while gaming.""" + +from cthulhu.plugin import Plugin, cthulhu_hookimpl +from cthulhu import debug +from cthulhu import messages +from cthulhu import settings_manager + +settingsManager = settings_manager.getManager() + + +class GameMode(Plugin): + """Plugin that reduces speech interruption while holding keys.""" + + def __init__(self, *args, **kwargs): + """Initialize the GameMode plugin.""" + super().__init__(*args, **kwargs) + self._activated = False + self._kbBinding = None + + debug.printMessage(debug.LEVEL_INFO, "GameMode: Plugin initialized", True) + + @cthulhu_hookimpl + def activate(self, plugin=None): + """Activate the GameMode plugin.""" + if plugin is not None and plugin is not self: + return + + if self._activated: + debug.printMessage(debug.LEVEL_INFO, "GameMode: Already activated, skipping", True) + return + + try: + debug.printMessage(debug.LEVEL_INFO, "GameMode: Plugin activation starting", True) + self._register_keybinding() + self._activated = True + debug.printMessage(debug.LEVEL_INFO, "GameMode: Plugin activated successfully", True) + return True + except Exception as error: + debug.printMessage(debug.LEVEL_INFO, f"GameMode: ERROR activating plugin: {error}", True) + return False + + @cthulhu_hookimpl + def deactivate(self, plugin=None): + """Deactivate the GameMode plugin.""" + if plugin is not None and plugin is not self: + return + + try: + debug.printMessage(debug.LEVEL_INFO, "GameMode: Plugin deactivation starting", True) + settingsManager.setSetting('gameMode', False) + self._activated = False + debug.printMessage(debug.LEVEL_INFO, "GameMode: Plugin deactivated successfully", True) + return True + except Exception as error: + debug.printMessage(debug.LEVEL_INFO, f"GameMode: ERROR deactivating plugin: {error}", True) + return False + + def _register_keybinding(self): + """Register the Cthulhu+Control+G keybinding for toggling game mode.""" + try: + if not self.app: + debug.printMessage(debug.LEVEL_INFO, "GameMode: No app reference for keybinding", True) + return + + gestureString = "kb:cthulhu+control+g" + description = "Toggle game mode" + self._kbBinding = self.registerGestureByString( + self._toggle_game_mode, + description, + gestureString + ) + if self._kbBinding: + debug.printMessage(debug.LEVEL_INFO, f"GameMode: Registered keybinding {gestureString}", True) + else: + debug.printMessage(debug.LEVEL_INFO, f"GameMode: Failed to register keybinding {gestureString}", True) + except Exception as error: + debug.printMessage(debug.LEVEL_INFO, f"GameMode: Error registering keybinding: {error}", True) + + def _toggle_game_mode(self, script, inputEvent=None): + """Toggle game mode on or off.""" + currentValue = bool(settingsManager.getSetting('gameMode')) + newValue = not currentValue + settingsManager.setSetting('gameMode', newValue) + + message = messages.GAME_MODE_ENABLED if newValue else messages.GAME_MODE_DISABLED + if script: + script.presentMessage(message) + return True diff --git a/src/cthulhu/plugins/PluginManager/plugin.py b/src/cthulhu/plugins/PluginManager/plugin.py index f10ce4a..f336471 100644 --- a/src/cthulhu/plugins/PluginManager/plugin.py +++ b/src/cthulhu/plugins/PluginManager/plugin.py @@ -16,7 +16,7 @@ from pathlib import Path import gi gi.require_version('Gtk', '3.0') -from gi.repository import Gtk, GLib +from gi.repository import Gtk, Gdk, Pango from cthulhu.plugin import Plugin, cthulhu_hookimpl from cthulhu import debug @@ -28,13 +28,19 @@ _settingsManager = settings_manager.getManager() class PluginManager(Plugin): """Plugin that provides a GUI interface for managing other plugins.""" + + PLUGIN_COL_ENABLED = 0 + PLUGIN_COL_DISPLAY = 1 + PLUGIN_COL_CAN_TOGGLE = 2 + PLUGIN_COL_NAME = 3 def __init__(self, *args, **kwargs): """Initialize the PluginManager plugin.""" super().__init__(*args, **kwargs) self._kb_binding = None self._dialog = None - self._plugin_checkboxes = {} + self._plugin_treeview = None + self._plugin_model = None self._activated = False debug.printMessage(debug.LEVEL_INFO, "PluginManager: Plugin initialized", True) @@ -163,11 +169,37 @@ class PluginManager(Plugin): scrolled = Gtk.ScrolledWindow() scrolled.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC) scrolled.set_size_request(-1, 200) - - # Create list box for plugins - self._plugin_listbox = Gtk.ListBox() - self._plugin_listbox.set_selection_mode(Gtk.SelectionMode.NONE) - scrolled.add(self._plugin_listbox) + + self._plugin_model = Gtk.TreeStore(bool, str, bool, str) + self._plugin_treeview = Gtk.TreeView(model=self._plugin_model) + self._plugin_treeview.set_headers_visible(True) + self._plugin_treeview.set_enable_search(False) + self._plugin_treeview.connect("key-press-event", self._on_plugin_list_key_press) + self._plugin_treeview.connect("row-activated", self._on_plugin_row_activated) + + toggle_renderer = Gtk.CellRendererToggle() + toggle_renderer.set_activatable(True) + toggle_renderer.connect("toggled", self._on_plugin_toggled) + toggle_column = Gtk.TreeViewColumn( + "Enabled", + toggle_renderer, + active=self.PLUGIN_COL_ENABLED, + activatable=self.PLUGIN_COL_CAN_TOGGLE + ) + toggle_column.add_attribute(toggle_renderer, "visible", self.PLUGIN_COL_CAN_TOGGLE) + self._plugin_treeview.append_column(toggle_column) + + text_renderer = Gtk.CellRendererText() + text_renderer.set_property("ellipsize", Pango.EllipsizeMode.END) + text_column = Gtk.TreeViewColumn( + "Plugin", + text_renderer, + text=self.PLUGIN_COL_DISPLAY + ) + text_column.set_expand(True) + self._plugin_treeview.append_column(text_column) + + scrolled.add(self._plugin_treeview) content_area.pack_start(scrolled, True, True, 0) @@ -197,49 +229,41 @@ class PluginManager(Plugin): debug.printMessage(debug.LEVEL_INFO, f"PluginManager: Found {len(available_plugins)} plugins", True) - # Clear existing checkboxes - self._plugin_checkboxes.clear() - + # Clear existing model rows + self._plugin_model.clear() + # Add each plugin as a checkbox (except PluginManager itself) + enabled_iter = self._plugin_model.append( + None, + [False, "Enabled plugins", False, ""] + ) + disabled_iter = self._plugin_model.append( + None, + [False, "Disabled plugins", False, ""] + ) + for plugin_name, plugin_info in sorted(available_plugins.items()): # Skip PluginManager to prevent users from disabling plugin management if plugin_name == "PluginManager": continue - # Create row container - row = Gtk.ListBoxRow() - row.set_activatable(False) - - # Create horizontal box for checkbox and info - hbox = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=10) - hbox.set_border_width(5) - - # Create checkbox - checkbox = Gtk.CheckButton() - checkbox.set_active(plugin_name in active_plugins) - checkbox.connect("toggled", self._on_plugin_toggled, plugin_name) - - # Create plugin info label - info_text = f"{plugin_info.get('name', plugin_name)}" - if plugin_info.get('description'): - info_text += f"\n{plugin_info['description']}" - if plugin_info.get('version'): - info_text += f" (v{plugin_info['version']})" - - label = Gtk.Label() - label.set_markup(info_text) - label.set_halign(Gtk.Align.START) - label.set_line_wrap(True) - - # Pack widgets - hbox.pack_start(checkbox, False, False, 0) - hbox.pack_start(label, True, True, 0) - - row.add(hbox) - self._plugin_listbox.add(row) - - # Store checkbox reference - self._plugin_checkboxes[plugin_name] = checkbox + display_name = plugin_info.get('name', plugin_name) + display_text = display_name + description = plugin_info.get('description') + if description: + display_text += f" - {description}" + version = plugin_info.get('version') + if version: + display_text += f" (v{version})" + + is_active = plugin_name in active_plugins + parent_iter = enabled_iter if is_active else disabled_iter + self._plugin_model.append( + parent_iter, + [is_active, display_text, True, plugin_name] + ) + + self._plugin_treeview.collapse_all() except Exception as e: logger.error(f"PluginManager: Error populating plugin list: {e}") @@ -344,51 +368,120 @@ class PluginManager(Plugin): except Exception as e: logger.error(f"PluginManager: Error scanning directory {directory}: {e}") - def _on_plugin_toggled(self, checkbox, plugin_name): - """Handle plugin checkbox toggle.""" + def _on_plugin_toggled(self, renderer, path): + """Handle plugin toggle via TreeView.""" + try: + tree_iter = self._plugin_model.get_iter(path) + if not tree_iter: + return + + can_toggle = self._plugin_model.get_value(tree_iter, self.PLUGIN_COL_CAN_TOGGLE) + if not can_toggle: + return + + is_active = self._plugin_model.get_value(tree_iter, self.PLUGIN_COL_ENABLED) + plugin_name = self._plugin_model.get_value(tree_iter, self.PLUGIN_COL_NAME) + new_active = not is_active + self._plugin_model.set_value(tree_iter, self.PLUGIN_COL_ENABLED, new_active) + self._set_plugin_active(plugin_name, new_active) + self._rebuild_plugin_groups() + except Exception as error: + logger.error(f"PluginManager: Error toggling plugin at path {path}: {error}") + debug.printMessage(debug.LEVEL_INFO, f"PluginManager: Error toggling plugin at path {path}: {error}", True) + + def _on_plugin_list_key_press(self, widget, event): + """Toggle plugin on Space while keeping Tab navigation outside the list.""" + if event.keyval != Gdk.KEY_space: + return False + + selection = self._plugin_treeview.get_selection() + model, tree_iter = selection.get_selected() + if not tree_iter: + return False + + path = model.get_path(tree_iter) + self._on_plugin_toggled(None, path) + return True + + def _on_plugin_row_activated(self, treeview, path, column): + """Toggle plugin when activating a row.""" + self._on_plugin_toggled(None, path) + + def _rebuild_plugin_groups(self): + """Rebuild the tree so enabled/disabled groups stay accurate.""" + if not self._plugin_model: + return + + selection = self._plugin_treeview.get_selection() + model, tree_iter = selection.get_selected() + selected_name = None + if tree_iter: + selected_name = model.get_value(tree_iter, self.PLUGIN_COL_NAME) + + self._populate_plugin_list() + + if selected_name: + self._select_plugin_row(selected_name) + + def _select_plugin_row(self, plugin_name): + """Select the row for the given plugin name.""" + def _walk(model, tree_iter): + while tree_iter: + name = model.get_value(tree_iter, self.PLUGIN_COL_NAME) + if name == plugin_name: + path = model.get_path(tree_iter) + self._plugin_treeview.expand_to_path(path) + self._plugin_treeview.get_selection().select_path(path) + return True + if model.iter_has_child(tree_iter): + child = model.iter_children(tree_iter) + if _walk(model, child): + return True + tree_iter = model.iter_next(tree_iter) + return False + + root = self._plugin_model.get_iter_first() + _walk(self._plugin_model, root) + + def _set_plugin_active(self, plugin_name, is_active): + """Update settings to enable or disable a plugin.""" try: - is_active = checkbox.get_active() debug.printMessage(debug.LEVEL_INFO, f"PluginManager: Plugin {plugin_name} toggled to {'active' if is_active else 'inactive'}", True) - + # Get current active plugins active_plugins = _settingsManager.getSetting('activePlugins') or [] - active_plugins = list(active_plugins) # Make a copy - - # Update the list + active_plugins = list(active_plugins) + if is_active and plugin_name not in active_plugins: active_plugins.append(plugin_name) elif not is_active and plugin_name in active_plugins: active_plugins.remove(plugin_name) - - # Save updated settings + _settingsManager.setSetting('activePlugins', active_plugins) - - # Save to disk using the backend directly + try: - # Get current general settings current_general = _settingsManager.getGeneralSettings() current_general['activePlugins'] = active_plugins - - # Save using the backend + backend = _settingsManager._backend if backend: backend.saveDefaultSettings( current_general, - _settingsManager.getPronunciations(), + _settingsManager.getPronunciations(), _settingsManager.getKeybindings() ) debug.printMessage(debug.LEVEL_INFO, "PluginManager: Settings saved to backend", True) else: debug.printMessage(debug.LEVEL_INFO, "PluginManager: No backend available for saving", True) - - except Exception as save_e: - debug.printMessage(debug.LEVEL_INFO, f"PluginManager: Error saving via backend: {save_e}", True) - + + except Exception as save_error: + debug.printMessage(debug.LEVEL_INFO, f"PluginManager: Error saving via backend: {save_error}", True) + debug.printMessage(debug.LEVEL_INFO, f"PluginManager: Updated active plugins: {active_plugins}", True) - - except Exception as e: - logger.error(f"PluginManager: Error toggling plugin {plugin_name}: {e}") - debug.printMessage(debug.LEVEL_INFO, f"PluginManager: Error toggling {plugin_name}: {e}", True) + + except Exception as error: + logger.error(f"PluginManager: Error toggling plugin {plugin_name}: {error}") + debug.printMessage(debug.LEVEL_INFO, f"PluginManager: Error toggling {plugin_name}: {error}", True) def _on_dialog_response(self, dialog, response_id): diff --git a/src/cthulhu/plugins/meson.build b/src/cthulhu/plugins/meson.build index 73c5d97..707506c 100644 --- a/src/cthulhu/plugins/meson.build +++ b/src/cthulhu/plugins/meson.build @@ -4,6 +4,7 @@ subdir('ByeCthulhu') subdir('Clipboard') subdir('DisplayVersion') subdir('HelloCthulhu') +subdir('GameMode') subdir('IndentationAudio') subdir('OCR') subdir('PluginManager') diff --git a/src/cthulhu/scripts/default.py b/src/cthulhu/scripts/default.py index 5f66569..264e751 100644 --- a/src/cthulhu/scripts/default.py +++ b/src/cthulhu/scripts/default.py @@ -824,6 +824,7 @@ class Script(script.Script): sleepModeManager.toggleSleepMode(self) return True + def bypassNextCommand(self, inputEvent=None): """Causes the next keyboard command to be ignored by Cthulhu and passed along to the current application. diff --git a/src/cthulhu/settings.py b/src/cthulhu/settings.py index 65916e6..f0ae224 100644 --- a/src/cthulhu/settings.py +++ b/src/cthulhu/settings.py @@ -64,6 +64,7 @@ userCustomizableSettings = [ "enableEchoByWord", "enableEchoBySentence", "enableKeyEcho", + "gameMode", "enableAlphabeticKeys", "enableNumericKeys", "enablePunctuationKeys", @@ -317,6 +318,7 @@ verbalizePunctuationStyle = PUNCTUATION_STYLE_MOST speechVerbosityLevel = VERBOSITY_LEVEL_VERBOSE messagesAreDetailed = True enablePauseBreaks = True +gameMode = False speakDescription = True speakContextBlockquote = True speakContextPanel = True From 89573b754418563f34b12b9e6fa72da07bee09b9 Mon Sep 17 00:00:00 2001 From: Storm Dragon Date: Sun, 4 Jan 2026 18:46:56 -0500 Subject: [PATCH 06/14] nvda2cthulhu server plugin added. This can replace the nvda2speechd server if desired. --- distro-packages/Arch-Linux/PKGBUILD | 4 + src/cthulhu/plugins/meson.build | 1 + src/cthulhu/plugins/nvda2cthulhu/README.md | 21 ++ src/cthulhu/plugins/nvda2cthulhu/__init__.py | 1 + src/cthulhu/plugins/nvda2cthulhu/meson.build | 14 + src/cthulhu/plugins/nvda2cthulhu/plugin.info | 8 + src/cthulhu/plugins/nvda2cthulhu/plugin.py | 309 +++++++++++++++++++ 7 files changed, 358 insertions(+) create mode 100644 src/cthulhu/plugins/nvda2cthulhu/README.md create mode 100644 src/cthulhu/plugins/nvda2cthulhu/__init__.py create mode 100644 src/cthulhu/plugins/nvda2cthulhu/meson.build create mode 100644 src/cthulhu/plugins/nvda2cthulhu/plugin.info create mode 100644 src/cthulhu/plugins/nvda2cthulhu/plugin.py diff --git a/distro-packages/Arch-Linux/PKGBUILD b/distro-packages/Arch-Linux/PKGBUILD index 74dd5e7..94e25b7 100644 --- a/distro-packages/Arch-Linux/PKGBUILD +++ b/distro-packages/Arch-Linux/PKGBUILD @@ -65,6 +65,10 @@ optdepends=( 'python-webcolors: Color name lookup for OCR text decoration' 'tesseract: OCR engine for text recognition' 'tesseract-data-eng: English language data for Tesseract' + + # nvda2cthulhu plugin (optional) + 'python-msgpack: Msgpack decoding for nvda2cthulhu' + 'python-tornado: WebSocket server for nvda2cthulhu' ) makedepends=( git diff --git a/src/cthulhu/plugins/meson.build b/src/cthulhu/plugins/meson.build index 707506c..0ce7488 100644 --- a/src/cthulhu/plugins/meson.build +++ b/src/cthulhu/plugins/meson.build @@ -6,6 +6,7 @@ subdir('DisplayVersion') subdir('HelloCthulhu') subdir('GameMode') subdir('IndentationAudio') +subdir('nvda2cthulhu') subdir('OCR') subdir('PluginManager') subdir('SpeechHistory') diff --git a/src/cthulhu/plugins/nvda2cthulhu/README.md b/src/cthulhu/plugins/nvda2cthulhu/README.md new file mode 100644 index 0000000..27f0e4f --- /dev/null +++ b/src/cthulhu/plugins/nvda2cthulhu/README.md @@ -0,0 +1,21 @@ +# NVDA to Cthulhu plugin + +This plugin starts a local WebSocket server that accepts nvda2cthulhu messages +and speaks/brailles them through Cthulhu. + +## Dependencies + +These are optional and only needed when the plugin is enabled: + +- python-msgpack +- python-tornado + +If they are missing, the plugin will announce that dependencies are missing and +will not start the server. + +## Usage + +- Enable the plugin in Cthulhu's Plugin Manager. +- 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. diff --git a/src/cthulhu/plugins/nvda2cthulhu/__init__.py b/src/cthulhu/plugins/nvda2cthulhu/__init__.py new file mode 100644 index 0000000..778b2a3 --- /dev/null +++ b/src/cthulhu/plugins/nvda2cthulhu/__init__.py @@ -0,0 +1 @@ +"""NVDA to Cthulhu bridge plugin.""" diff --git a/src/cthulhu/plugins/nvda2cthulhu/meson.build b/src/cthulhu/plugins/nvda2cthulhu/meson.build new file mode 100644 index 0000000..56a553c --- /dev/null +++ b/src/cthulhu/plugins/nvda2cthulhu/meson.build @@ -0,0 +1,14 @@ +nvda2cthulhu_python_sources = files([ + '__init__.py', + 'plugin.py' +]) + +python3.install_sources( + nvda2cthulhu_python_sources, + subdir: 'cthulhu/plugins/nvda2cthulhu' +) + +install_data( + 'plugin.info', + install_dir: python3.get_install_dir() / 'cthulhu' / 'plugins' / 'nvda2cthulhu' +) diff --git a/src/cthulhu/plugins/nvda2cthulhu/plugin.info b/src/cthulhu/plugins/nvda2cthulhu/plugin.info new file mode 100644 index 0000000..e00d8a4 --- /dev/null +++ b/src/cthulhu/plugins/nvda2cthulhu/plugin.info @@ -0,0 +1,8 @@ +name = NVDA to Cthulhu +version = 1.0.0 +description = WebSocket listener that accepts nvda2cthulhu messages and speaks them through Cthulhu +authors = Storm Dragon storm_dragon@stormux.org +website = https://stormux.org +copyright = Copyright 2026 +builtin = false +hidden = false diff --git a/src/cthulhu/plugins/nvda2cthulhu/plugin.py b/src/cthulhu/plugins/nvda2cthulhu/plugin.py new file mode 100644 index 0000000..3bb74c5 --- /dev/null +++ b/src/cthulhu/plugins/nvda2cthulhu/plugin.py @@ -0,0 +1,309 @@ +#!/usr/bin/env python3 +# +# Copyright (c) 2026 Stormux +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 2.1 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library; if not, write to the +# Free Software Foundation, Inc., Franklin Street, Fifth Floor, +# Boston MA 02110-1301 USA. +# + +"""NVDA to Cthulhu bridge plugin.""" + +import asyncio +import logging +import os +import threading +import urllib.parse + +try: + import msgpack +except Exception: # pragma: no cover - optional dependency + msgpack = None + +try: + import tornado + import tornado.httpserver + import tornado.ioloop + import tornado.web + import tornado.websocket + from tornado.platform.asyncio import AsyncIOMainLoop +except Exception: # pragma: no cover - optional dependency + tornado = None + +from cthulhu.plugin import Plugin, cthulhu_hookimpl +from cthulhu import braille +from cthulhu import speech +from cthulhu import settings_manager + +logger = logging.getLogger(__name__) + +DEFAULT_PORT = 3457 + + +def _coerce_text(value): + if value is None: + return '' + if isinstance(value, bytes): + try: + return value.decode('utf-8') + except Exception: + return value.decode('utf-8', errors='replace') + return str(value) + + +if tornado is None: + class Nvda2CthulhuSocket: # pragma: no cover - optional dependency + """Placeholder when tornado is not available.""" + pass +else: + class Nvda2CthulhuSocket(tornado.websocket.WebSocketHandler): + """WebSocket handler for NVDA to Cthulhu messages.""" + + def initialize(self, plugin): + self.plugin = plugin + + def check_origin(self, origin): + return True + + def on_message(self, message): + self.plugin.handle_message(message) + + +class Nvda2Cthulhu(Plugin): + """Bridge server that accepts NVDA to Cthulhu messages.""" + + def __init__(self): + super().__init__() + self.settingsManager = settings_manager.getManager() + self.interruptEnabled = True + self.serverThread = None + self.serverLock = threading.Lock() + self.httpServer = None + self.ioLoop = None + self.asyncioLoop = None + + @cthulhu_hookimpl + def activate(self, plugin=None): + if plugin is not None and plugin is not self: + return + + logger.info("Activating NVDA to Cthulhu plugin") + self.registerGestureByString( + self.toggle_interrupt, + "NVDA to Cthulhu interrupt mode", + "kb:cthulhu+shift+n", + 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") + return + self.start_server() + + @cthulhu_hookimpl + def deactivate(self, plugin=None): + if plugin is not None and plugin is not self: + return + + logger.info("Deactivating NVDA to Cthulhu plugin") + self.stop_server() + + def start_server(self): + with self.serverLock: + if self.serverThread and self.serverThread.is_alive(): + return + self.serverThread = threading.Thread( + target=self._server_main, + name="Nvda2CthulhuServer", + daemon=True + ) + self.serverThread.start() + + def stop_server(self): + with self.serverLock: + ioLoop = self.ioLoop + httpServer = self.httpServer + + if ioLoop is None: + return + + def _stop(): + if httpServer: + httpServer.stop() + ioLoop.stop() + + try: + ioLoop.add_callback(_stop) + except Exception as exc: + logger.warning(f"NVDA to Cthulhu: failed to stop server cleanly: {exc}") + + if self.serverThread and self.serverThread.is_alive(): + try: + self.serverThread.join(timeout=2.0) + except Exception as exc: + logger.warning(f"NVDA to Cthulhu: failed to join server thread: {exc}") + + def toggle_interrupt(self, script=None, inputEvent=None): + self.interruptEnabled = not self.interruptEnabled + mode = "interrupt" if self.interruptEnabled else "no interrupt" + self._present_message(f"NVDA to Cthulhu {mode}") + return True + + def handle_message(self, message): + request = self._parse_request(message) + if not request: + return + + requestType, payload = request + if requestType == "SpeakText": + self._handle_speak(payload) + elif requestType == "BrailleText": + self._handle_braille(payload) + elif requestType == "CancelSpeech": + speech.stop() + + def _server_main(self): + if not self._dependencies_available(): + return + address, port = self._get_bind_address() + try: + self.asyncioLoop = asyncio.new_event_loop() + asyncio.set_event_loop(self.asyncioLoop) + AsyncIOMainLoop().install() + + app = tornado.web.Application([ + (r"/", Nvda2CthulhuSocket, {"plugin": self}), + ]) + + self.httpServer = tornado.httpserver.HTTPServer(app) + self.httpServer.listen(port, address=address) + self.ioLoop = tornado.ioloop.IOLoop.current() + logger.info(f"NVDA to Cthulhu listening on {address}:{port}") + self.ioLoop.start() + except Exception as exc: + logger.error(f"NVDA to Cthulhu failed to start server: {exc}") + self._present_message("NVDA to Cthulhu server failed to start") + finally: + self.httpServer = None + if self.ioLoop: + try: + self.ioLoop.stop() + except Exception: + pass + self.ioLoop = None + self.asyncioLoop = None + + def _get_bind_address(self): + port = DEFAULT_PORT + host = os.environ.get("NVDA2SPEECHD_HOST") + if host: + try: + parsed = urllib.parse.urlparse(host) + if parsed.port: + port = parsed.port + except Exception: + logger.warning(f"NVDA to Cthulhu: invalid NVDA2SPEECHD_HOST: {host}") + return "127.0.0.1", port + + def _parse_request(self, message): + if not self._dependencies_available(): + return None + if isinstance(message, str): + return "SpeakText", message + + if not isinstance(message, (bytes, bytearray)): + return None + + try: + payload = msgpack.unpackb(message, raw=False) + except Exception as exc: + logger.warning(f"NVDA to Cthulhu: failed to decode msgpack: {exc}") + return None + + return self._parse_payload(payload) + + def _parse_payload(self, payload): + if isinstance(payload, str): + if payload == "CancelSpeech": + return "CancelSpeech", None + return "SpeakText", payload + + if isinstance(payload, dict): + if len(payload) != 1: + return None + key, value = next(iter(payload.items())) + key = _coerce_text(key) + if key == "SpeakText": + return "SpeakText", _coerce_text(value) + if key == "BrailleText": + return "BrailleText", _coerce_text(value) + if key == "CancelSpeech": + return "CancelSpeech", None + return None + + if isinstance(payload, (list, tuple)): + if not payload: + return None + key = _coerce_text(payload[0]) + if key == "CancelSpeech": + return "CancelSpeech", None + if len(payload) < 2: + return None + value = _coerce_text(payload[1]) + if key == "SpeakText": + return "SpeakText", value + if key == "BrailleText": + return "BrailleText", value + return None + + return None + + def _handle_speak(self, text): + if not text or not text.strip(): + return + speech.speak(text, interrupt=self.interruptEnabled) + + def _handle_braille(self, text): + if not text or not text.strip(): + return + if not self._braille_enabled(): + return + duration = self._get_braille_flash_time() + braille.displayMessage(text, flashTime=duration) + + def _braille_enabled(self): + if not self.settingsManager: + return False + enableBraille = self.settingsManager.getSetting('enableBraille') + enableMonitor = self.settingsManager.getSetting('enableBrailleMonitor') + enableFlash = self.settingsManager.getSetting('enableFlashMessages') + return bool((enableBraille or enableMonitor) and enableFlash) + + def _get_braille_flash_time(self): + if not self.settingsManager: + return 0 + if self.settingsManager.getSetting('flashIsPersistent'): + return -1 + return self.settingsManager.getSetting('brailleFlashTime') + + def _present_message(self, message): + try: + scriptManagerApi = self.app.getDynamicApiManager().getAPI('ScriptManager') + scriptManager = scriptManagerApi.get_manager() + scriptManager.get_default_script().presentMessage(message, resetStyles=False) + except Exception: + logger.info(message) + + def _dependencies_available(self): + return msgpack is not None and tornado is not None From 2bd2dffccce1c9482aa4a6606a54532414ef8f12 Mon Sep 17 00:00:00 2001 From: Storm Dragon Date: Mon, 5 Jan 2026 14:00:12 -0500 Subject: [PATCH 07/14] Speech-dispatcher SSIPProxy protocol plugin added. This allows for thing like playing Slay the Spire without running a separate instance of speech-dispatcher. --- src/cthulhu/plugins/SSIPProxy/__init__.py | 2 + src/cthulhu/plugins/SSIPProxy/meson.build | 14 + src/cthulhu/plugins/SSIPProxy/plugin.info | 11 + src/cthulhu/plugins/SSIPProxy/plugin.py | 587 ++++++++++++++++++++++ src/cthulhu/plugins/meson.build | 1 + 5 files changed, 615 insertions(+) create mode 100644 src/cthulhu/plugins/SSIPProxy/__init__.py create mode 100644 src/cthulhu/plugins/SSIPProxy/meson.build create mode 100644 src/cthulhu/plugins/SSIPProxy/plugin.info create mode 100644 src/cthulhu/plugins/SSIPProxy/plugin.py diff --git a/src/cthulhu/plugins/SSIPProxy/__init__.py b/src/cthulhu/plugins/SSIPProxy/__init__.py new file mode 100644 index 0000000..f099bac --- /dev/null +++ b/src/cthulhu/plugins/SSIPProxy/__init__.py @@ -0,0 +1,2 @@ +# SSIPProxy plugin - SSIP protocol proxy for Cthulhu +from .plugin import * diff --git a/src/cthulhu/plugins/SSIPProxy/meson.build b/src/cthulhu/plugins/SSIPProxy/meson.build new file mode 100644 index 0000000..0c5f8b7 --- /dev/null +++ b/src/cthulhu/plugins/SSIPProxy/meson.build @@ -0,0 +1,14 @@ +ssipproxy_python_sources = files([ + '__init__.py', + 'plugin.py' +]) + +python3.install_sources( + ssipproxy_python_sources, + subdir: 'cthulhu/plugins/SSIPProxy' +) + +install_data( + 'plugin.info', + install_dir: python3.get_install_dir() / 'cthulhu' / 'plugins' / 'SSIPProxy' +) diff --git a/src/cthulhu/plugins/SSIPProxy/plugin.info b/src/cthulhu/plugins/SSIPProxy/plugin.info new file mode 100644 index 0000000..9c2d297 --- /dev/null +++ b/src/cthulhu/plugins/SSIPProxy/plugin.info @@ -0,0 +1,11 @@ +[Plugin] +name=SSIP Proxy +module_name=SSIPProxy +version=1.0.0 +description=SSIP protocol proxy for speech-dispatcher TCP clients. Allows applications like Say the Spire (Slay the Spire accessibility mod) to have their speech output go through Cthulhu for both speech and braille support. +authors=Stormux +copyright=Copyright (c) 2024-2025 Stormux +website=https://stormux.org +icon_name=audio-speakers +builtin=false +hidden=false diff --git a/src/cthulhu/plugins/SSIPProxy/plugin.py b/src/cthulhu/plugins/SSIPProxy/plugin.py new file mode 100644 index 0000000..ea79179 --- /dev/null +++ b/src/cthulhu/plugins/SSIPProxy/plugin.py @@ -0,0 +1,587 @@ +#!/usr/bin/env python3 +# +# Copyright (c) 2024-2025 Stormux +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 2.1 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library; if not, write to the +# Free Software Foundation, Inc., Franklin Street, Fifth Floor, +# Boston MA 02110-1301 USA. +# + +"""SSIP Proxy plugin for Cthulhu screen reader. + +This plugin acts as a speech-dispatcher SSIP server, allowing applications +that use speech-dispatcher's TCP interface (like Say the Spire for Slay the Spire) +to have their speech output go through Cthulhu for both speech and braille support. +""" + +import logging +import select +import socket +import threading +from threading import Thread, Lock +from cthulhu.plugin import Plugin, cthulhu_hookimpl +from cthulhu import debug + +logger = logging.getLogger(__name__) + +# Default speech-dispatcher TCP port +SSIP_PORT = 6560 + +# SSIP Response codes +RESP_OK = "200 OK" +RESP_CLIENT_NAME_SET = "208 OK CLIENT NAME SET" +RESP_RECEIVING_DATA = "230 OK RECEIVING DATA" +RESP_MSG_QUEUED = "225 OK MESSAGE QUEUED" +RESP_BYE = "231 HAPPY HACKING" +RESP_ERR_INVALID = "300 ERR INVALID COMMAND" + + +class SSIPConnection: + """Handles a single SSIP client connection.""" + + def __init__(self, clientSocket, plugin): + """Initialize the connection handler. + + Args: + clientSocket: The client socket connection + plugin: Reference to the parent plugin + """ + self.socket = clientSocket + self.plugin = plugin + self.clientName = None + self.receivingData = False + self.dataBuffer = [] + self.msgId = 0 + + def send(self, message): + """Send a response to the client. + + Args: + message: The response message to send + """ + try: + response = message + "\r\n" + self.socket.sendall(response.encode("utf-8")) + except Exception as e: + debug.printMessage(debug.LEVEL_WARNING, f"SSIP send error: {e}", True) + + def sendMessageQueued(self, msgId): + """Send a message queued response with message ID. + + Sends both lines in a single write to avoid race conditions. + + Args: + msgId: The message ID to include + """ + self.sendMultiLine(f"225-{msgId}", "225 OK MESSAGE QUEUED") + + def sendMultiLine(self, line1, line2): + """Send a two-line response in a single write. + + This avoids race conditions where the client reads between lines. + + Args: + line1: First line of response + line2: Second line of response + """ + try: + response = f"{line1}\r\n{line2}\r\n" + self.socket.sendall(response.encode("utf-8")) + except Exception as e: + debug.printMessage(debug.LEVEL_WARNING, f"SSIP send error: {e}", True) + + def handleLine(self, line): + """Process a single line from the client. + + Args: + line: The line to process + + Returns: + True to continue, False to close connection + """ + line = line.strip() + if not line: + return True + + logger.debug(f"SSIP <<< {line}") + + # If we're receiving SPEAK data + if self.receivingData: + return self.handleDataLine(line) + + # Parse command + parts = line.split(None, 1) + if not parts: + return True + + cmd = parts[0].upper() + args = parts[1] if len(parts) > 1 else "" + + if cmd == "SET": + return self.handleSet(args) + elif cmd == "SPEAK": + return self.handleSpeak() + elif cmd == "STOP": + return self.handleStop(args) + elif cmd == "CANCEL": + return self.handleCancel(args) + elif cmd == "PAUSE": + self.send(RESP_OK) + return True + elif cmd == "RESUME": + self.send(RESP_OK) + return True + elif cmd == "QUIT": + self.send(RESP_BYE) + return False + elif cmd == "HELP": + self.send("299-SSIP Proxy for Cthulhu") + self.send("299 OK HELP SENT") + return True + elif cmd == "GET": + return self.handleGet(args) + elif cmd == "LIST": + return self.handleList(args) + elif cmd == "HISTORY": + return self.handleHistory(args) + elif cmd == "CHAR": + # Character speaking - extract and speak the character + if args: + char = args.strip() + if char.lower() == "space": + char = " " + self.plugin.outputMessage(char) + self.msgId += 1 + self.sendMessageQueued(self.msgId) + return True + elif cmd == "KEY": + # Key speaking - speak the key name + if args: + self.plugin.outputMessage(args.strip()) + self.msgId += 1 + self.sendMessageQueued(self.msgId) + return True + elif cmd == "SOUND_ICON": + # Sound icon - acknowledge but ignore + self.msgId += 1 + self.sendMessageQueued(self.msgId) + return True + else: + logger.debug(f"Unknown SSIP command: {cmd}") + self.send(RESP_OK) + return True + + def handleGet(self, args): + """Handle GET command. + + Args: + args: Command arguments + + Returns: + True to continue + """ + param = args.strip().upper() if args else "" + + if param == "VERSION": + self.send("240-speechd-ssip-proxy 0.1") + self.send("240 OK VERSION SENT") + elif param == "OUTPUT_MODULES": + self.send("250-cthulhu") + self.send("250 OK MODULES LIST SENT") + elif param == "VOICES": + self.send("251-cthulhu\tdefault\tnone") + self.send("251 OK VOICES LIST SENT") + elif param == "RATE" or param == "PITCH" or param == "VOLUME": + self.send("251-0") + self.send("251 OK GET RETURNED") + else: + logger.debug(f"Unknown GET parameter: {param}") + self.send("260-unknown") + self.send("260 OK GET RETURNED") + + return True + + def handleList(self, args): + """Handle LIST command. + + Args: + args: Command arguments + + Returns: + True to continue + """ + param = args.strip().upper() if args else "" + + if param == "OUTPUT_MODULES": + self.send("250-cthulhu") + self.send("250 OK MODULES LIST SENT") + elif param == "VOICES": + self.send("251-cthulhu\tdefault\tnone") + self.send("251 OK VOICE LIST SENT") + else: + self.send("250 OK LIST SENT") + + return True + + def handleHistory(self, args): + """Handle HISTORY command. + + Args: + args: Command arguments + + Returns: + True to continue + """ + parts = args.split() if args else [] + + if len(parts) >= 2 and parts[0].upper() == "GET": + subCmd = parts[1].upper() + if subCmd == "CLIENT_ID": + # Return a client ID - send both lines in single write + self.sendMultiLine("240-1", "240 OK CLIENT ID SENT") + elif subCmd == "MESSAGE_ID": + self.sendMultiLine(f"240-{self.msgId}", "240 OK MESSAGE ID SENT") + else: + self.sendMultiLine("240-0", "240 OK HISTORY GET") + elif len(parts) >= 1 and parts[0].upper() == "CURSOR": + self.send("200 OK CURSOR SET") + elif len(parts) >= 1 and parts[0].upper() == "SAY": + # History say - acknowledge + self.msgId += 1 + self.sendMessageQueued(self.msgId) + else: + self.send("200 OK HISTORY") + + return True + + def handleSet(self, args): + """Handle SET command. + + Args: + args: Command arguments + + Returns: + True to continue + """ + parts = args.split(None, 2) + if len(parts) < 2: + self.send(RESP_OK) + return True + + scope = parts[0].lower() + param = parts[1].upper() + value = parts[2] if len(parts) > 2 else "" + + if param == "CLIENT_NAME": + self.clientName = value + logger.info(f"SSIP client connected: {value}") + self.send(RESP_CLIENT_NAME_SET) + elif param == "PRIORITY": + self.send(f"200 OK PRIORITY SET") + elif param == "RATE": + self.send(f"200 OK RATE SET") + elif param == "PITCH": + self.send(f"200 OK PITCH SET") + elif param == "VOLUME": + self.send(f"200 OK VOLUME SET") + elif param == "VOICE_TYPE": + self.send(f"200 OK VOICE SET") + elif param == "LANGUAGE": + self.send(f"200 OK LANGUAGE SET") + elif param == "SSML_MODE": + self.send(f"200 OK SSML MODE SET") + elif param == "PUNCTUATION": + self.send(f"200 OK PUNCTUATION SET") + elif param == "SPELLING": + self.send(f"200 OK SPELLING SET") + elif param == "CAP_LET_RECOGN": + self.send(f"200 OK CAP LET RECOGNITION SET") + elif param == "OUTPUT_MODULE": + self.send(f"200 OK OUTPUT MODULE SET") + else: + logger.debug(f"Unknown SET parameter: {param}") + self.send(RESP_OK) + + return True + + def handleSpeak(self): + """Handle SPEAK command. + + Returns: + True to continue + """ + self.receivingData = True + self.dataBuffer = [] + self.send(RESP_RECEIVING_DATA) + return True + + def handleDataLine(self, line): + """Handle a line of SPEAK data. + + Args: + line: The data line + + Returns: + True to continue + """ + # Check for end-of-data marker (single dot) + if line == ".": + # End of data - process the message + self.receivingData = False + text = "\n".join(self.dataBuffer) + self.dataBuffer = [] + + # Send message ID and queued response together + self.msgId += 1 + self.sendMessageQueued(self.msgId) + + # Forward to Cthulhu + if text.strip(): + self.plugin.outputMessage(text.strip()) + + return True + + # Handle dot-escaping (lines starting with . have extra dot) + if line.startswith(".."): + line = line[1:] + + self.dataBuffer.append(line) + return True + + def handleStop(self, args): + """Handle STOP command. + + Args: + args: Command arguments + + Returns: + True to continue + """ + # Don't explicitly stop - let speech.speak(interrupt=True) handle it + # Calling stop() followed by speak() can cause race conditions + # where speech-dispatcher drops the new message + self.send(RESP_OK) + return True + + def handleCancel(self, args): + """Handle CANCEL command. + + Args: + args: Command arguments + + Returns: + True to continue + """ + self.send(RESP_OK) + return True + + +class SSIPProxy(Plugin): + """Plugin that provides an SSIP server interface for external applications.""" + + def __init__(self): + """Initialize the plugin.""" + super().__init__() + self.lock = Lock() + self.active = False + self.serverThread = None + self.serverSocket = None + self.clientSockets = [] # Track active client connections + self.clientLock = Lock() # Lock for client list + + @cthulhu_hookimpl + def activate(self): + """Activate the SSIP proxy plugin.""" + super().activate() + logger.info("Activating SSIP Proxy Plugin") + self.activateServer() + + @cthulhu_hookimpl + def deactivate(self): + """Deactivate the SSIP proxy plugin.""" + logger.info("Deactivating SSIP Proxy Plugin") + self.deactivateServer() + super().deactivate() + + def activateServer(self): + """Start the SSIP server thread.""" + with self.lock: + self.active = True + + # Only start if not already running + if self.serverThread is None or not self.serverThread.is_alive(): + self.serverThread = Thread(target=self.serverWorker) + self.serverThread.daemon = True + self.serverThread.start() + + def deactivateServer(self): + """Stop the SSIP server thread.""" + with self.lock: + self.active = False + + # Close all client connections first + with self.clientLock: + for clientSocket in self.clientSockets: + try: + clientSocket.close() + except Exception: + pass + self.clientSockets.clear() + + # Close the server socket to interrupt accept() + if self.serverSocket: + try: + self.serverSocket.close() + except Exception: + pass + + # Try to join the thread if it's alive, with a timeout + if self.serverThread and self.serverThread.is_alive(): + try: + self.serverThread.join(timeout=2.0) + except Exception as e: + logger.warning(f"Error stopping server thread: {e}") + + def isActive(self): + """Check if the server is active.""" + with self.lock: + return self.active + + def outputMessage(self, message): + """Output a message through Cthulhu's speech and braille systems. + + Args: + message: The message to output + """ + try: + # Use speech.speak directly for more reliable rapid-fire messages + from cthulhu import speech + speech.speak(message, interrupt=True) + + # Also update braille if possible + try: + scriptManager = self.app.getDynamicApiManager().getAPI('ScriptManager') + manager = scriptManager.get_manager() + script = manager.get_default_script() + if script: + # Flash message to braille + from cthulhu import braille + braille.displayMessage(message) + except Exception: + pass # Braille is optional + except Exception as e: + debug.printMessage(debug.LEVEL_WARNING, + f"SSIP output error: {e} for: {message}", True) + + def serverWorker(self): + """Worker thread that runs the SSIP server.""" + try: + # Create TCP socket + self.serverSocket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + self.serverSocket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + + try: + self.serverSocket.bind(("127.0.0.1", SSIP_PORT)) + except OSError as e: + logger.error(f"Cannot bind to port {SSIP_PORT}: {e}") + logger.error("Another process may be using this port.") + return + + self.serverSocket.listen(5) + debug.printMessage(debug.LEVEL_INFO, + f"SSIP Proxy listening on 127.0.0.1:{SSIP_PORT}", True) + + while self.isActive(): + # Check for new connections with timeout + try: + ready, _, _ = select.select([self.serverSocket], [], [], 0.8) + except (select.error, ValueError): + break + + if not ready: + continue + + try: + clientSocket, addr = self.serverSocket.accept() + debug.printMessage(debug.LEVEL_INFO, + f"SSIP client connected from {addr}", True) + + # Handle this client in a separate thread + clientThread = Thread( + target=self.handleClient, + args=(clientSocket,) + ) + clientThread.daemon = True + clientThread.start() + except Exception as e: + if self.isActive(): + logger.error(f"Error accepting connection: {e}") + + except Exception as e: + logger.error(f"SSIP server error: {e}") + finally: + if self.serverSocket: + try: + self.serverSocket.close() + except Exception: + pass + logger.info("SSIP Proxy server stopped") + + def handleClient(self, clientSocket): + """Handle a client connection. + + Args: + clientSocket: The client socket + """ + # Track this client socket + with self.clientLock: + self.clientSockets.append(clientSocket) + + # Disable Nagle's algorithm for immediate sending + clientSocket.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1) + clientSocket.settimeout(30.0) + connection = SSIPConnection(clientSocket, self) + buffer = "" + + try: + while self.isActive(): + try: + data = clientSocket.recv(4096) + if not data: + break + + buffer += data.decode("utf-8") + + # Process complete lines + while "\n" in buffer: + line, buffer = buffer.split("\n", 1) + line = line.rstrip("\r") + if not connection.handleLine(line): + return + except socket.timeout: + continue + except Exception as e: + logger.debug(f"Client read error: {e}") + break + finally: + # Remove from tracking list + with self.clientLock: + if clientSocket in self.clientSockets: + self.clientSockets.remove(clientSocket) + + try: + clientSocket.close() + except Exception: + pass + debug.printMessage(debug.LEVEL_INFO, "SSIP client disconnected", True) diff --git a/src/cthulhu/plugins/meson.build b/src/cthulhu/plugins/meson.build index 0ce7488..31a0684 100644 --- a/src/cthulhu/plugins/meson.build +++ b/src/cthulhu/plugins/meson.build @@ -13,3 +13,4 @@ subdir('SpeechHistory') subdir('SimplePluginSystem') subdir('hello_world') subdir('self_voice') +subdir('SSIPProxy') From 21cfec55ee7ae1442000a08daa43cd890614338b Mon Sep 17 00:00:00 2001 From: Storm Dragon Date: Mon, 5 Jan 2026 19:28:09 -0500 Subject: [PATCH 08/14] Hopefully fixed Thunderbird address completion and a few other areas. --- .../scripts/apps/Thunderbird/spellcheck.py | 6 +++++- src/cthulhu/scripts/toolkits/Gecko/script.py | 16 ++++++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/src/cthulhu/scripts/apps/Thunderbird/spellcheck.py b/src/cthulhu/scripts/apps/Thunderbird/spellcheck.py index 5e1f615..1752d2f 100644 --- a/src/cthulhu/scripts/apps/Thunderbird/spellcheck.py +++ b/src/cthulhu/scripts/apps/Thunderbird/spellcheck.py @@ -31,6 +31,7 @@ __date__ = "$Date$" __copyright__ = "Copyright (c) 2014 Igalia, S.L." __license__ = "LGPL" +import cthulhu.debug as debug import cthulhu.cthulhu_state as cthulhu_state import cthulhu.spellcheck as spellcheck from cthulhu.ax_object import AXObject @@ -59,7 +60,10 @@ class SpellCheck(spellcheck.SpellCheck): return False def _isCandidateWindow(self, window): - if not AXUtilities.is_dialog(window): + # Check for dialog or modal window (Thunderbird may use either) + if not (AXUtilities.is_dialog(window) or AXUtilities.is_modal(window)): + tokens = ["THUNDERBIRD SPELL CHECK:", window, "is not a dialog or modal window"] + debug.printTokens(debug.LEVEL_INFO, tokens, True) return False def isNonSpellCheckChild(x): diff --git a/src/cthulhu/scripts/toolkits/Gecko/script.py b/src/cthulhu/scripts/toolkits/Gecko/script.py index c840234..f856d8f 100644 --- a/src/cthulhu/scripts/toolkits/Gecko/script.py +++ b/src/cthulhu/scripts/toolkits/Gecko/script.py @@ -39,6 +39,7 @@ from cthulhu import debug from cthulhu import cthulhu from cthulhu import cthulhu_state from cthulhu.ax_object import AXObject +from cthulhu.ax_utilities import AXUtilities from cthulhu.scripts import default from cthulhu.scripts import web from .script_utilities import Utilities @@ -241,6 +242,13 @@ class Script(web.Script): debug.printMessage(debug.LEVEL_INFO, msg, True) return + # We're sometimes getting a spurious focus claim from the Firefox/Thunderbird + # window after opening a file from file manager. + if AXObject.get_role(event.source) == Atspi.Role.FRAME: + msg = "GECKO: Ignoring event believed to be noise." + debug.printMessage(debug.LEVEL_INFO, msg, True) + return + msg = "GECKO: Passing along event to default script" debug.printMessage(debug.LEVEL_INFO, msg, True) default.Script.onFocusedChanged(self, event) @@ -301,6 +309,14 @@ class Script(web.Script): if super().onShowingChanged(event): return + # Set focus to newly shown menus outside of document content + if event.detail1 and AXUtilities.is_menu(event.source) \ + and not self.utilities.inDocumentContent(event.source): + msg = "GECKO: Setting locus of focus to newly shown menu." + debug.printMessage(debug.LEVEL_INFO, msg, True) + cthulhu.setLocusOfFocus(event, event.source) + return + msg = "GECKO: Passing along event to default script" debug.printMessage(debug.LEVEL_INFO, msg, True) default.Script.onShowingChanged(self, event) From 3c4cbe3c3f67f890c2db7c612d8edb0fb5bd82e6 Mon Sep 17 00:00:00 2001 From: Storm Dragon Date: Tue, 6 Jan 2026 12:09:40 -0500 Subject: [PATCH 09/14] Started porting over some bug fixes and modernizations from Orca. --- src/cthulhu/braille.py | 2 +- src/cthulhu/script_utilities.py | 3 +- src/cthulhu/scripts/web/speech_generator.py | 3 -- src/cthulhu/speech_generator.py | 4 ++ src/cthulhu/structural_navigation.py | 45 ++++++--------------- 5 files changed, 18 insertions(+), 39 deletions(-) diff --git a/src/cthulhu/braille.py b/src/cthulhu/braille.py index cbaf5d3..139d0e6 100644 --- a/src/cthulhu/braille.py +++ b/src/cthulhu/braille.py @@ -540,7 +540,7 @@ class Component(Region): been scrolled off the display.""" if cthulhu_state.activeScript and cthulhu_state.activeScript.utilities.\ - grabFocusBeforeRouting(self.accessible, offset): + grabFocusBeforeRouting(self.accessible): if AXObject.supports_component(self.accessible): try: Atspi.Component.grab_focus(self.accessible) diff --git a/src/cthulhu/script_utilities.py b/src/cthulhu/script_utilities.py index 8c9233e..9df07ff 100644 --- a/src/cthulhu/script_utilities.py +++ b/src/cthulhu/script_utilities.py @@ -560,13 +560,12 @@ class Utilities: return False - def grabFocusBeforeRouting(self, obj, offset): + def grabFocusBeforeRouting(self, obj): """Whether or not we should perform a grabFocus before routing the cursor via the braille cursor routing keys. Arguments: - obj: the accessible object where the cursor should be routed - - offset: the offset to which it should be routed Returns True if we should do an explicit grabFocus on obj prior to routing the cursor. diff --git a/src/cthulhu/scripts/web/speech_generator.py b/src/cthulhu/scripts/web/speech_generator.py index 824d0c5..3f0c39f 100644 --- a/src/cthulhu/scripts/web/speech_generator.py +++ b/src/cthulhu/scripts/web/speech_generator.py @@ -81,9 +81,6 @@ class SpeechGenerator(speech_generator.SpeechGenerator): if not self._script.utilities.inDocumentContent(obj): return super()._generateAncestors(obj, **args) - if self._script.inSayAll() and obj == cthulhu_state.locusOfFocus: - return [] - result = [] priorObj = args.get('priorObj') if priorObj and self._script.utilities.inDocumentContent(priorObj): diff --git a/src/cthulhu/speech_generator.py b/src/cthulhu/speech_generator.py index a364ae4..f277ea7 100644 --- a/src/cthulhu/speech_generator.py +++ b/src/cthulhu/speech_generator.py @@ -651,6 +651,10 @@ class SpeechGenerator(generator.Generator): Note that a 'role' attribute in args will override the accessible role of the obj. """ + # Prevent role repetition when object is same as prior object + if obj == args.get("priorObj"): + return [] + if _settingsManager.getSetting('onlySpeakDisplayedText'): return [] diff --git a/src/cthulhu/structural_navigation.py b/src/cthulhu/structural_navigation.py index e48a812..55e414d 100644 --- a/src/cthulhu/structural_navigation.py +++ b/src/cthulhu/structural_navigation.py @@ -596,15 +596,11 @@ class StructuralNavigation: # self.lastTableCell = [-1, -1] - self._objectCache = {} - self._inModalDialog = False def clearCache(self, document=None): - if document: - self._objectCache[hash(document)] = {} - else: - self._objectCache = {} + """No-op for backwards compatibility. Caching has been removed.""" + pass def structuralNavigationObjectCreator(self, name): """This convenience method creates a StructuralNavigationObject @@ -794,7 +790,11 @@ class StructuralNavigation: structuralNavigationObject.present(cell, arg) def _getAll(self, structuralNavigationObject, arg=None): - """Returns all the instances of structuralNavigationObject.""" + """Returns all the instances of structuralNavigationObject. + + Queries fresh each time without caching to ensure accurate results + when returning to a page after switching applications. + """ modalDialog = self._script.utilities.getModalDialog(cthulhu_state.locusOfFocus) inModalDialog = bool(modalDialog) @@ -804,29 +804,11 @@ class StructuralNavigation: f"{self._inModalDialog} to {inModalDialog}" ) debug.printMessage(debug.LEVEL_INFO, msg, True) - self.clearCache() self._inModalDialog = inModalDialog - def filterZombies(matchList): - if not matchList: - return [] - return [match for match in matchList if not self._script.utilities.isZombie(match)] - document = self._script.utilities.documentFrame() - cache = self._objectCache.get(hash(document), {}) - key = f"{structuralNavigationObject.objType}:{arg}" - matches = cache.get(key, []) - if matches: - matches = filterZombies(matches) - if matches: - tokens = ["STRUCTURAL NAVIGATION: Returning", len(matches), "matches from cache"] - debug.printTokens(debug.LEVEL_INFO, tokens, True) - return matches.copy() - - tokens = ["STRUCTURAL NAVIGATION: Cached matches are zombies; refreshing"] - debug.printTokens(debug.LEVEL_INFO, tokens, True) - cache.pop(key, None) - self._objectCache[hash(document)] = cache + if not document: + return [] if structuralNavigationObject.getter: matches = structuralNavigationObject.getter(document, arg) @@ -843,15 +825,12 @@ class StructuralNavigation: if inModalDialog: originalSize = len(matches) matches = [m for m in matches if AXObject.find_ancestor(m, lambda x: x == modalDialog)] - tokens = ["STRUCTURAL NAVIGATION: Removed", {originalSize - len(matches)}, + tokens = ["STRUCTURAL NAVIGATION: Removed", originalSize - len(matches), "objects outside of modal dialog", modalDialog] debug.printTokens(debug.LEVEL_INFO, tokens, True) - matches = filterZombies(matches) - rv = matches.copy() - cache[key] = matches - self._objectCache[hash(document)] = cache - return rv + # Filter out zombie objects and return + return [m for m in matches if not self._script.utilities.isZombie(m)] def goEdge(self, structuralNavigationObject, isStart, container=None, arg=None): if container is None: From 1d230603532c43646b4f9497a487486abda30bcf Mon Sep 17 00:00:00 2001 From: Storm Dragon Date: Tue, 6 Jan 2026 16:40:26 -0500 Subject: [PATCH 10/14] More ports from orca. --- src/cthulhu/ax_text.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/cthulhu/ax_text.py b/src/cthulhu/ax_text.py index 9cafdbf..11f429c 100644 --- a/src/cthulhu/ax_text.py +++ b/src/cthulhu/ax_text.py @@ -585,8 +585,9 @@ class AXText: pattern = r"[.!?]+(?=\s|\ufffc|$)" for match in re.finditer(pattern, text): end_pos = match.end() - # Skip whitespace and embedded objects to find start of next sentence. - while end_pos < len(text) and (text[end_pos].isspace() or text[end_pos] == "\ufffc"): + # Skip whitespace to find start of next sentence. Do not skip embedded object + # characters since they represent child objects that must be traversed. + while end_pos < len(text) and text[end_pos].isspace(): end_pos += 1 # Only add boundary if we haven't reached the end and it's not a duplicate. if end_pos < len(text) and end_pos not in boundaries: From 17c9eee4a18253249ddf12d28b1f5b9155d608ba Mon Sep 17 00:00:00 2001 From: Storm Dragon Date: Tue, 6 Jan 2026 16:59:30 -0500 Subject: [PATCH 11/14] Orca ports 3rd commit. --- src/cthulhu/scripts/web/script.py | 4 ++++ src/cthulhu/scripts/web/script_utilities.py | 13 ++++++++++++- 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/src/cthulhu/scripts/web/script.py b/src/cthulhu/scripts/web/script.py index a9f4341..e482f2b 100644 --- a/src/cthulhu/scripts/web/script.py +++ b/src/cthulhu/scripts/web/script.py @@ -2350,6 +2350,10 @@ class Script(default.Script): if prevDocument != document: tokens = ["WEB: document changed from", prevDocument, "to", document] debug.printTokens(debug.LEVEL_INFO, tokens, True) + elif document == event.source: + msg = "WEB: Ignoring focus change to document ancestor of focus" + debug.printMessage(debug.LEVEL_INFO, msg, True) + return True if self.utilities.isWebAppDescendant(event.source): if self._browseModeIsSticky: diff --git a/src/cthulhu/scripts/web/script_utilities.py b/src/cthulhu/scripts/web/script_utilities.py index 8826ed4..9ebd461 100644 --- a/src/cthulhu/scripts/web/script_utilities.py +++ b/src/cthulhu/scripts/web/script_utilities.py @@ -991,7 +991,7 @@ class Utilities(script_utilities.Utilities): rv = False elif rv and not self.isLiveRegion(obj): - doNotQuery = [Atspi.Role.LIST_BOX] + doNotQuery = [Atspi.Role.LIST_BOX, Atspi.Role.TABLE, Atspi.Role.TABLE_ROW] role = AXObject.get_role(obj) if rv and role in doNotQuery: tokens = ["WEB: Treating", obj, "as non-text due to role."] @@ -1773,6 +1773,7 @@ class Utilities(script_utilities.Utilities): extents = self.getExtents(container, 0, 1) objBanner = AXObject.find_ancestor(obj, self.isLandmarkBanner) + objRow = AXObject.find_ancestor(obj, AXUtilities.is_table_row) def _include(x): if x in objects: @@ -1844,6 +1845,9 @@ class Utilities(script_utilities.Utilities): and firstObj == prevObj: break + if objRow != AXObject.find_ancestor(prevObj, AXUtilities.is_table_row): + break + onLeft = self._getContentsForObj(prevObj, pOffset, boundary) onLeft = list(filter(_include, onLeft)) if not onLeft: @@ -1871,6 +1875,9 @@ class Utilities(script_utilities.Utilities): and lastObj == nextObj: break + if objRow != AXObject.find_ancestor(nextObj, AXUtilities.is_table_row): + break + onRight = self._getContentsForObj(nextObj, nOffset, boundary) if onRight and self._contentIsSubsetOf(objects[0], onRight[-1]): onRight = onRight[0:-1] @@ -4758,6 +4765,10 @@ class Utilities(script_utilities.Utilities): tokens = ["WEB: Landmark can have caret context", obj] debug.printTokens(debug.LEVEL_INFO, tokens, True) rv = True + elif AXUtilities.is_table_related(obj, True): + tokens = ["WEB: Table-related object can have caret context", obj] + debug.printTokens(debug.LEVEL_INFO, tokens, True) + rv = True elif self.isStaticTextLeaf(obj): tokens = ["WEB: Static text leaf cannot have caret context", obj] debug.printTokens(debug.LEVEL_INFO, tokens, True) From 7aaa94fd4f9e96d6fb9920d7c23f672e073fe30f Mon Sep 17 00:00:00 2001 From: Storm Dragon Date: Tue, 6 Jan 2026 18:15:29 -0500 Subject: [PATCH 12/14] Bug fixes from ports lol. --- distro-packages/Arch-Linux/PKGBUILD | 2 +- meson.build | 2 +- src/cthulhu/cthulhuVersion.py | 2 +- src/cthulhu/generator.py | 2 -- src/cthulhu/speech_generator.py | 2 +- 5 files changed, 4 insertions(+), 6 deletions(-) diff --git a/distro-packages/Arch-Linux/PKGBUILD b/distro-packages/Arch-Linux/PKGBUILD index 94e25b7..bb21b1b 100644 --- a/distro-packages/Arch-Linux/PKGBUILD +++ b/distro-packages/Arch-Linux/PKGBUILD @@ -1,7 +1,7 @@ # Maintainer: Storm Dragon pkgname=cthulhu -pkgver=2025.12.31 +pkgver=2026.01.06 pkgrel=1 pkgdesc="Desktop-agnostic screen reader with plugin system, forked from Orca" url="https://git.stormux.org/storm/cthulhu" diff --git a/meson.build b/meson.build index 6f09605..5c6b411 100644 --- a/meson.build +++ b/meson.build @@ -1,5 +1,5 @@ project('cthulhu', - version: '2025.12.31-testing', + version: '2026.01.06-testing', meson_version: '>= 1.0.0', ) diff --git a/src/cthulhu/cthulhuVersion.py b/src/cthulhu/cthulhuVersion.py index 59a019b..6ddc298 100644 --- a/src/cthulhu/cthulhuVersion.py +++ b/src/cthulhu/cthulhuVersion.py @@ -23,5 +23,5 @@ # Forked from Orca screen reader. # Cthulhu project: https://git.stormux.org/storm/cthulhu -version = "2025.12.31" +version = "2026.01.06" codeName = "testing" diff --git a/src/cthulhu/generator.py b/src/cthulhu/generator.py index f774c1b..9c2a2f5 100644 --- a/src/cthulhu/generator.py +++ b/src/cthulhu/generator.py @@ -1342,8 +1342,6 @@ class Generator: if self._script.utilities.isLandmarkRegion(obj): return 'ROLE_REGION' return Atspi.Role.LANDMARK - if self._script.utilities.isFocusableLabel(obj): - return Atspi.Role.LIST_ITEM if self._script.utilities.isDocument(obj) and AXObject.supports_image(obj): return Atspi.Role.IMAGE diff --git a/src/cthulhu/speech_generator.py b/src/cthulhu/speech_generator.py index f277ea7..a651600 100644 --- a/src/cthulhu/speech_generator.py +++ b/src/cthulhu/speech_generator.py @@ -1457,7 +1457,7 @@ class SpeechGenerator(generator.Generator): string = result[0].strip() if len(string) == 1 and self._script.utilities.isMath(obj): charname = chnames.getCharacterName(string, preferMath=True) - if charname != string: + if charname and charname != string: result[0] = charname result.extend(self.voice(DEFAULT, obj=obj, **args)) From 8932dacc33b03bb9fbd251dac14e2d779e3d9df5 Mon Sep 17 00:00:00 2001 From: Storm Dragon Date: Tue, 6 Jan 2026 22:53:29 -0500 Subject: [PATCH 13/14] Small update to the AI Assistant plugin. --- src/cthulhu/plugins/AIAssistant/ai_providers.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/cthulhu/plugins/AIAssistant/ai_providers.py b/src/cthulhu/plugins/AIAssistant/ai_providers.py index 9f0d67a..a668466 100644 --- a/src/cthulhu/plugins/AIAssistant/ai_providers.py +++ b/src/cthulhu/plugins/AIAssistant/ai_providers.py @@ -60,6 +60,7 @@ The user is using the Cthulhu screen reader, so they cannot see images visually. 3. Information about the currently focused element The user is using the Cthulhu screen reader, so they cannot see the screen visually. Your responses should be clear, concise, and focused on accessibility. +**Critical**: If there are any codes, or information like an ID, be sure to include it in your output. """ From 42bfacdd2c42ce0b9ae924dd36eeaf5cefcb917d Mon Sep 17 00:00:00 2001 From: Storm Dragon Date: Wed, 7 Jan 2026 11:52:46 -0500 Subject: [PATCH 14/14] Updated version presentation to be more similar to orca. --- src/cthulhu.py | 24 +++++++++++-- src/cthulhu/cthulhu_bin.py.in | 24 +++++++++++-- src/cthulhu/dbus_service.py | 16 ++++++++- src/cthulhu/plugins/DisplayVersion/plugin.py | 36 +++++++++++++++++--- 4 files changed, 90 insertions(+), 10 deletions(-) diff --git a/src/cthulhu.py b/src/cthulhu.py index e416abd..2843c66 100644 --- a/src/cthulhu.py +++ b/src/cthulhu.py @@ -91,7 +91,7 @@ from cthulhu import messages from cthulhu import settings from cthulhu.ax_object import AXObject from cthulhu.ax_utilities import AXUtilities -from cthulhu.cthulhu_platform import version +from cthulhu.cthulhu_platform import version, revision class ListApps(argparse.Action): def __call__(self, parser, namespace, values, option_string=None): @@ -115,6 +115,26 @@ class ListApps(argparse.Action): parser.exit() +class PrintVersion(argparse.Action): + """Action to print the version of Cthulhu.""" + + def __call__(self, parser, namespace, values, option_string=None): + msg = version + if revision: + msg += f" (rev {revision})" + + atspi_version = Atspi.get_version() + msg += f", AT-SPI2 version: {atspi_version[0]}.{atspi_version[1]}.{atspi_version[2]}" + + session_type = os.environ.get("XDG_SESSION_TYPE") or "" + session_desktop = os.environ.get("XDG_SESSION_DESKTOP") or "" + session = f"{session_type} {session_desktop}".strip() + if session: + msg += f", Session: {session}" + + print(msg) + parser.exit() + class Settings(argparse.Action): def __call__(self, parser, namespace, values, option_string=None): settingsDict = getattr(namespace, 'settings', {}) @@ -151,7 +171,7 @@ class Parser(argparse.ArgumentParser): self.add_argument( "-h", "--help", action="help", help=messages.CLI_HELP) self.add_argument( - "-v", "--version", action="version", version=version, help=messages.CLI_VERSION) + "-v", "--version", action=PrintVersion, nargs=0, help=messages.CLI_VERSION) self.add_argument( "-r", "--replace", action="store_true", help=messages.CLI_REPLACE) self.add_argument( diff --git a/src/cthulhu/cthulhu_bin.py.in b/src/cthulhu/cthulhu_bin.py.in index f2b1ca5..98e01ad 100644 --- a/src/cthulhu/cthulhu_bin.py.in +++ b/src/cthulhu/cthulhu_bin.py.in @@ -49,7 +49,7 @@ from cthulhu import messages from cthulhu import settings from cthulhu.ax_object import AXObject from cthulhu.ax_utilities import AXUtilities -from cthulhu.cthulhu_platform import version +from cthulhu.cthulhu_platform import version, revision class ListApps(argparse.Action): def __call__(self, parser, namespace, values, option_string=None): @@ -73,6 +73,26 @@ class ListApps(argparse.Action): parser.exit() +class PrintVersion(argparse.Action): + """Action to print the version of Cthulhu.""" + + def __call__(self, parser, namespace, values, option_string=None): + msg = version + if revision: + msg += f" (rev {revision})" + + atspi_version = Atspi.get_version() + msg += f", AT-SPI2 version: {atspi_version[0]}.{atspi_version[1]}.{atspi_version[2]}" + + session_type = os.environ.get("XDG_SESSION_TYPE") or "" + session_desktop = os.environ.get("XDG_SESSION_DESKTOP") or "" + session = f"{session_type} {session_desktop}".strip() + if session: + msg += f", Session: {session}" + + print(msg) + parser.exit() + class Settings(argparse.Action): def __call__(self, parser, namespace, values, option_string=None): settingsDict = getattr(namespace, 'settings', {}) @@ -109,7 +129,7 @@ class Parser(argparse.ArgumentParser): self.add_argument( "-h", "--help", action="help", help=messages.CLI_HELP) self.add_argument( - "-v", "--version", action="version", version=version, help=messages.CLI_VERSION) + "-v", "--version", action=PrintVersion, nargs=0, help=messages.CLI_VERSION) self.add_argument( "-r", "--replace", action="store_true", help=messages.CLI_REPLACE) self.add_argument( diff --git a/src/cthulhu/dbus_service.py b/src/cthulhu/dbus_service.py index f77aad5..c2f5ebe 100644 --- a/src/cthulhu/dbus_service.py +++ b/src/cthulhu/dbus_service.py @@ -29,6 +29,7 @@ __license__ = "LGPL" import enum import inspect +import os from typing import Callable, Optional try: @@ -43,6 +44,10 @@ except ImportError: from gi.repository import GLib +import gi +gi.require_version("Atspi", "2.0") +from gi.repository import Atspi + from . import debug from . import cthulhu_platform # pylint: disable=no-name-in-module from . import script_manager @@ -482,12 +487,21 @@ if _dasbus_available: return True def GetVersion(self) -> str: # pylint: disable=invalid-name - """Returns Cthulhu's version and revision if available.""" + """Returns Cthulhu's version, AT-SPI version, and session information.""" result = cthulhu_platform.version if cthulhu_platform.revision: result += f" (rev {cthulhu_platform.revision})" + atspi_version = Atspi.get_version() + result += f", AT-SPI2 version: {atspi_version[0]}.{atspi_version[1]}.{atspi_version[2]}" + + session_type = os.environ.get("XDG_SESSION_TYPE") or "" + session_desktop = os.environ.get("XDG_SESSION_DESKTOP") or "" + session = f"{session_type} {session_desktop}".strip() + if session: + result += f", Session: {session}" + msg = f"DBUS SERVICE: GetVersion called, returning: {result}" debug.printMessage(debug.LEVEL_INFO, msg, True) return result diff --git a/src/cthulhu/plugins/DisplayVersion/plugin.py b/src/cthulhu/plugins/DisplayVersion/plugin.py index b3a80e5..bcd279a 100644 --- a/src/cthulhu/plugins/DisplayVersion/plugin.py +++ b/src/cthulhu/plugins/DisplayVersion/plugin.py @@ -10,21 +10,45 @@ """Display Version plugin for Cthulhu.""" import logging +import os + +import gi +gi.require_version("Atspi", "2.0") +from gi.repository import Atspi + from cthulhu.plugin import Plugin, cthulhu_hookimpl from cthulhu import cthulhuVersion +from cthulhu import cthulhu_platform from cthulhu import debug logger = logging.getLogger(__name__) class DisplayVersion(Plugin): """Plugin that announces the current Cthulhu version.""" - + def __init__(self, *args, **kwargs): """Initialize the plugin.""" super().__init__(*args, **kwargs) debug.printMessage(debug.LEVEL_INFO, "DisplayVersion: Plugin initialized", True) self._kb_binding = None self._activated = False + + def _get_version_string(self): + """Generate the full version string with AT-SPI and session information.""" + msg = f'Cthulhu screen reader version {cthulhuVersion.version}-{cthulhuVersion.codeName}' + if cthulhu_platform.revision: + msg += f' revision {cthulhu_platform.revision}' + + atspi_version = Atspi.get_version() + msg += f', AT-SPI2 version {atspi_version[0]}.{atspi_version[1]}.{atspi_version[2]}' + + session_type = os.environ.get('XDG_SESSION_TYPE') or '' + session_desktop = os.environ.get('XDG_SESSION_DESKTOP') or '' + session = f'{session_type} {session_desktop}'.strip() + if session: + msg += f', Session {session}' + + return msg @cthulhu_hookimpl def activate(self, plugin=None): @@ -51,10 +75,11 @@ class DisplayVersion(Plugin): # Register keyboard shortcut gesture_string = 'kb:cthulhu+shift+v' debug.printMessage(debug.LEVEL_INFO, f"DisplayVersion: Registering gesture: {gesture_string}", True) - + + version_message = self._get_version_string() self._kb_binding = self.registerGestureByString( - self.speakText, - f'Cthulhu screen reader version {cthulhuVersion.version}-{cthulhuVersion.codeName}', + self.speakText, + version_message, gesture_string ) @@ -87,8 +112,9 @@ class DisplayVersion(Plugin): if self.app: state = self.app.getDynamicApiManager().getAPI('CthulhuState') if state.activeScript: + version_message = self._get_version_string() state.activeScript.presentMessage( - f'Cthulhu screen reader version {cthulhuVersion.version}-{cthulhuVersion.codeName}', + version_message, resetStyles=False ) return True