From 040520098033909f339e001343ef9dd784f81bc2 Mon Sep 17 00:00:00 2001 From: Storm Dragon Date: Tue, 1 Jul 2025 13:56:18 -0400 Subject: [PATCH] New plugin, updates to a few other things. Yes, I know that's not descriptive, but apparently I forgot to add a few things last time. lol --- src/cthulhu/cthulhuVersion.py | 4 +- .../plugins/IndentationAudio/Makefile.am | 10 + .../plugins/IndentationAudio/__init__.py | 14 + .../plugins/IndentationAudio/plugin.info | 9 + .../plugins/IndentationAudio/plugin.py | 334 ++++++++++++++++++ src/cthulhu/plugins/Makefile.am | 2 +- 6 files changed, 370 insertions(+), 3 deletions(-) create mode 100644 src/cthulhu/plugins/IndentationAudio/Makefile.am create mode 100644 src/cthulhu/plugins/IndentationAudio/__init__.py create mode 100644 src/cthulhu/plugins/IndentationAudio/plugin.info create mode 100644 src/cthulhu/plugins/IndentationAudio/plugin.py diff --git a/src/cthulhu/cthulhuVersion.py b/src/cthulhu/cthulhuVersion.py index 5b7ac94..b8e759a 100644 --- a/src/cthulhu/cthulhuVersion.py +++ b/src/cthulhu/cthulhuVersion.py @@ -23,5 +23,5 @@ # Fork of Orca Screen Reader (GNOME) # Original source: https://gitlab.gnome.org/GNOME/orca -version = "2025.06.05" -codeName = "plugins" +version = "2025.07.01" +codeName = "testing" diff --git a/src/cthulhu/plugins/IndentationAudio/Makefile.am b/src/cthulhu/plugins/IndentationAudio/Makefile.am new file mode 100644 index 0000000..ebd1225 --- /dev/null +++ b/src/cthulhu/plugins/IndentationAudio/Makefile.am @@ -0,0 +1,10 @@ +indentationaudio_PYTHON = \ + __init__.py \ + plugin.py + +indentationaudiodir = $(pkgdatadir)/cthulhu/plugins/IndentationAudio + +indentationaudio_DATA = \ + plugin.info + +EXTRA_DIST = $(indentationaudio_DATA) \ No newline at end of file diff --git a/src/cthulhu/plugins/IndentationAudio/__init__.py b/src/cthulhu/plugins/IndentationAudio/__init__.py new file mode 100644 index 0000000..7e5fc5a --- /dev/null +++ b/src/cthulhu/plugins/IndentationAudio/__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. + +"""IndentationAudio plugin package.""" + +from .plugin import IndentationAudio + +__all__ = ['IndentationAudio'] \ No newline at end of file diff --git a/src/cthulhu/plugins/IndentationAudio/plugin.info b/src/cthulhu/plugins/IndentationAudio/plugin.info new file mode 100644 index 0000000..5ab2067 --- /dev/null +++ b/src/cthulhu/plugins/IndentationAudio/plugin.info @@ -0,0 +1,9 @@ +[Core] +Name = IndentationAudio +Module = IndentationAudio + +[Documentation] +Description = Provides audio feedback for indentation level changes when navigating code or text +Author = Stormux +Version = 1.0.0 +Website = https://git.stormux.org/storm/cthulhu \ No newline at end of file diff --git a/src/cthulhu/plugins/IndentationAudio/plugin.py b/src/cthulhu/plugins/IndentationAudio/plugin.py new file mode 100644 index 0000000..7b65002 --- /dev/null +++ b/src/cthulhu/plugins/IndentationAudio/plugin.py @@ -0,0 +1,334 @@ +#!/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. + +"""IndentationAudio plugin for Cthulhu - Provides audio feedback for indentation level changes.""" + +import logging +import re +from gi.repository import GLib + +from cthulhu.plugin import Plugin, cthulhu_hookimpl +from cthulhu import cmdnames +from cthulhu import debug +from cthulhu import input_event +from cthulhu import keybindings +from cthulhu import messages +from cthulhu import script_utilities +from cthulhu import settings_manager +from cthulhu import sound +from cthulhu.sound_generator import Tone + +logger = logging.getLogger(__name__) +_settingsManager = settings_manager.getManager() + + +class IndentationAudio(Plugin): + """Plugin that provides audio cues for indentation level changes.""" + + def __init__(self, *args, **kwargs): + """Initialize the IndentationAudio plugin.""" + super().__init__(*args, **kwargs) + self._enabled = True # Start enabled by default + self._last_indentation_level = {} # Track per-object indentation + self._event_listener_id = None + self._kb_binding = None + self._player = 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 + + logger.info("IndentationAudio plugin initialized") + + @cthulhu_hookimpl + def activate(self, plugin=None): + """Activate the IndentationAudio plugin.""" + if plugin is not None and plugin is not self: + return + + try: + logger.info("=== IndentationAudio plugin activation starting ===") + + # Initialize sound player + self._player = sound.getPlayer() + + # Register keybinding for toggle (Cthulhu+I) + self._register_keybinding() + + # Connect to text caret movement events + self._connect_to_events() + + logger.info("IndentationAudio plugin activated successfully") + return True + + except Exception as e: + logger.error(f"Failed to activate IndentationAudio plugin: {e}") + return False + + @cthulhu_hookimpl + def deactivate(self, plugin=None): + """Deactivate the IndentationAudio plugin.""" + if plugin is not None and plugin is not self: + return + + try: + logger.info("=== IndentationAudio plugin deactivation starting ===") + + # Disconnect from events + self._disconnect_from_events() + + # Clear tracking data + self._last_indentation_level.clear() + + logger.info("IndentationAudio plugin deactivated successfully") + return True + + except Exception as e: + logger.error(f"Failed to deactivate IndentationAudio plugin: {e}") + return False + + def _register_keybinding(self): + """Register the Cthulhu+I keybinding for toggling the plugin.""" + try: + if not self.app: + logger.error("IndentationAudio: No app reference available for keybinding") + return + + api_helper = self.app.getAPIHelper() + if not api_helper: + logger.error("IndentationAudio: No API helper available") + return + + # Register Cthulhu+I keybinding + gesture_string = "Cthulhu+i" + description = "Toggle indentation audio feedback" + handler = self._toggle_indentation_audio + + self._kb_binding = api_helper.registerGestureByString( + gesture_string, handler, description + ) + + if self._kb_binding: + logger.info(f"IndentationAudio: Registered keybinding {gesture_string}") + else: + logger.error(f"IndentationAudio: Failed to register keybinding {gesture_string}") + + except Exception as e: + logger.error(f"IndentationAudio: Error registering keybinding: {e}") + + def _connect_to_events(self): + """Connect to text navigation events.""" + try: + # Hook into the dynamic API to make ourselves available to scripts + if self.app: + api_manager = self.app.getDynamicApiManager() + api_manager.registerAPI('IndentationAudioPlugin', self) + logger.info("IndentationAudio: Registered with dynamic API manager") + + # We'll also monkey-patch the default script's sayLine method + self._monkey_patch_script_methods() + + except Exception as e: + logger.error(f"IndentationAudio: Error connecting to events: {e}") + + def _disconnect_from_events(self): + """Disconnect from text navigation events.""" + try: + # Unregister from dynamic API + if self.app: + api_manager = self.app.getDynamicApiManager() + api_manager.unregisterAPI('IndentationAudioPlugin') + + # Restore original script methods + self._restore_script_methods() + + except Exception as e: + logger.error(f"IndentationAudio: Error disconnecting from events: {e}") + + def _monkey_patch_script_methods(self): + """Monkey-patch the default script's line navigation methods.""" + try: + # Get the current active script + if self.app: + state = self.app.getDynamicApiManager().getAPI('CthulhuState') + if state and hasattr(state, 'activeScript') and state.activeScript: + script = state.activeScript + + # Store original method + if hasattr(script, 'sayLine'): + self._original_sayLine = script.sayLine + + # Create wrapped version + def wrapped_sayLine(obj): + # Call original method first + result = self._original_sayLine(obj) + + # Add our indentation audio check + try: + line, caretOffset, startOffset = script.getTextLineAtCaret(obj) + self.check_indentation_change(obj, line) + except Exception as e: + logger.error(f"IndentationAudio: Error in wrapped_sayLine: {e}") + + return result + + # Replace the method + script.sayLine = wrapped_sayLine + logger.info("IndentationAudio: Successfully monkey-patched sayLine method") + + except Exception as e: + logger.error(f"IndentationAudio: Error monkey-patching script methods: {e}") + + def _restore_script_methods(self): + """Restore original script methods.""" + try: + if self.app and hasattr(self, '_original_sayLine'): + state = self.app.getDynamicApiManager().getAPI('CthulhuState') + if state and hasattr(state, 'activeScript') and state.activeScript: + script = state.activeScript + if hasattr(script, 'sayLine'): + script.sayLine = self._original_sayLine + logger.info("IndentationAudio: Restored original sayLine method") + + except Exception as e: + logger.error(f"IndentationAudio: Error restoring script methods: {e}") + + 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" + + # Announce the state change + message = f"Indentation audio {state}" + if hasattr(script, 'speakMessage'): + script.speakMessage(message) + + logger.info(f"IndentationAudio: Toggled to {state}") + return True + + except Exception as e: + logger.error(f"IndentationAudio: Error toggling state: {e}") + return False + + def _calculate_indentation_level(self, line_text): + """Calculate the indentation level of a line.""" + if not line_text: + return 0 + + # Remove non-breaking spaces and convert to regular spaces + 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) + + def _generate_indentation_tone(self, new_level, old_level): + """Generate an audio tone for indentation level change.""" + if not self._enabled or not self._player: + return + + # Calculate frequency based on new indentation level + frequency = min( + self._base_frequency + (new_level * self._frequency_step), + self._max_frequency + ) + + # Determine stereo panning based on change direction + # Left channel for indent increase, right for decrease + volume_multiplier = 0.7 + + try: + # Create tone + tone = Tone( + duration=self._tone_duration, + frequency=frequency, + volumeMultiplier=volume_multiplier, + wave=Tone.SINE_WAVE + ) + + # Play the tone + self._player.play(tone, interrupt=False) + + debug_msg = f"IndentationAudio: Played tone - Level: {new_level}, Freq: {frequency}Hz" + logger.debug(debug_msg) + + except Exception as e: + logger.error(f"IndentationAudio: Error generating tone: {e}") + + def check_indentation_change(self, obj, line_text): + """Check if indentation has changed and play audio cue if needed. + + This method is intended to be called by scripts during line navigation. + """ + if not self._enabled or not line_text: + 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 + + # Play audio cue if indentation changed + if current_level != previous_level: + self._generate_indentation_tone(current_level, previous_level) + + debug_msg = f"IndentationAudio: Indentation changed from {previous_level} to {current_level}" + logger.debug(debug_msg) + + except Exception as e: + logger.error(f"IndentationAudio: Error checking indentation change: {e}") + + def is_enabled(self): + """Return whether the plugin is currently enabled.""" + return self._enabled + + def set_enabled(self, enabled): + """Set the enabled state of the plugin.""" + self._enabled = enabled + + def on_script_change(self, new_script): + """Handle when the active script changes.""" + try: + # Restore previous script if it was patched + self._restore_script_methods() + + # Re-apply patches to new script + self._monkey_patch_script_methods() + + # Clear tracking data for new context + self._last_indentation_level.clear() + + logger.info("IndentationAudio: Handled script change") + + except Exception as e: + logger.error(f"IndentationAudio: Error handling script change: {e}") \ No newline at end of file diff --git a/src/cthulhu/plugins/Makefile.am b/src/cthulhu/plugins/Makefile.am index a79ed41..cbbb653 100644 --- a/src/cthulhu/plugins/Makefile.am +++ b/src/cthulhu/plugins/Makefile.am @@ -1,4 +1,4 @@ -SUBDIRS = Clipboard DisplayVersion hello_world self_voice ByeCthulhu HelloCthulhu SimplePluginSystem +SUBDIRS = Clipboard DisplayVersion IndentationAudio hello_world self_voice ByeCthulhu HelloCthulhu SimplePluginSystem cthulhu_pythondir=$(pkgpythondir)/plugins