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 []