|
|
|
@ -14,18 +14,18 @@ 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
|
|
|
|
|
|
|
|
|
|
# Import Cthulhu's sound system
|
|
|
|
|
try:
|
|
|
|
|
from cthulhu import sound
|
|
|
|
|
from cthulhu.sound_generator import Tone
|
|
|
|
|
SOUND_AVAILABLE = True
|
|
|
|
|
except ImportError as e:
|
|
|
|
|
SOUND_AVAILABLE = False
|
|
|
|
|
debug.printMessage(debug.LEVEL_INFO, f"IndentationAudio: Sound import failed: {e}", True)
|
|
|
|
|
|
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
_settingsManager = settings_manager.getManager()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class IndentationAudio(Plugin):
|
|
|
|
@ -38,8 +38,6 @@ class IndentationAudio(Plugin):
|
|
|
|
|
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
|
|
|
|
@ -58,7 +56,12 @@ class IndentationAudio(Plugin):
|
|
|
|
|
logger.info("=== IndentationAudio plugin activation starting ===")
|
|
|
|
|
|
|
|
|
|
# Initialize sound player
|
|
|
|
|
if SOUND_AVAILABLE:
|
|
|
|
|
self._player = sound.getPlayer()
|
|
|
|
|
debug.printMessage(debug.LEVEL_INFO, "IndentationAudio: Sound player initialized", True)
|
|
|
|
|
else:
|
|
|
|
|
self._player = None
|
|
|
|
|
debug.printMessage(debug.LEVEL_INFO, "IndentationAudio: Sound not available", True)
|
|
|
|
|
|
|
|
|
|
# Register keybinding for toggle (Cthulhu+I)
|
|
|
|
|
self._register_keybinding()
|
|
|
|
@ -102,22 +105,20 @@ class IndentationAudio(Plugin):
|
|
|
|
|
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"
|
|
|
|
|
# Register Cthulhu+I keybinding using the plugin's registerGestureByString method
|
|
|
|
|
gesture_string = "kb:cthulhu+i"
|
|
|
|
|
description = "Toggle indentation audio feedback"
|
|
|
|
|
handler = self._toggle_indentation_audio
|
|
|
|
|
|
|
|
|
|
self._kb_binding = api_helper.registerGestureByString(
|
|
|
|
|
gesture_string, handler, description
|
|
|
|
|
self._kb_binding = self.registerGestureByString(
|
|
|
|
|
self._toggle_indentation_audio,
|
|
|
|
|
description,
|
|
|
|
|
gesture_string
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
if self._kb_binding:
|
|
|
|
|
logger.info(f"IndentationAudio: Registered keybinding {gesture_string}")
|
|
|
|
|
logger.info(f"Binding keysymstring: {self._kb_binding.keysymstring}")
|
|
|
|
|
logger.info(f"Binding modifiers: {self._kb_binding.modifiers}")
|
|
|
|
|
else:
|
|
|
|
|
logger.error(f"IndentationAudio: Failed to register keybinding {gesture_string}")
|
|
|
|
|
|
|
|
|
@ -131,27 +132,107 @@ class IndentationAudio(Plugin):
|
|
|
|
|
if self.app:
|
|
|
|
|
api_manager = self.app.getDynamicApiManager()
|
|
|
|
|
api_manager.registerAPI('IndentationAudioPlugin', self)
|
|
|
|
|
logger.info("IndentationAudio: Registered with dynamic API manager")
|
|
|
|
|
debug.printMessage(debug.LEVEL_INFO, "IndentationAudio: Registered with dynamic API manager", True)
|
|
|
|
|
|
|
|
|
|
# We'll also monkey-patch the default script's sayLine method
|
|
|
|
|
self._monkey_patch_script_methods()
|
|
|
|
|
# Try to connect to ATSPI events directly
|
|
|
|
|
self._connect_to_atspi_events()
|
|
|
|
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
logger.error(f"IndentationAudio: Error connecting to events: {e}")
|
|
|
|
|
debug.printMessage(debug.LEVEL_INFO, f"IndentationAudio: Error connecting to events: {e}", True)
|
|
|
|
|
|
|
|
|
|
def _connect_to_atspi_events(self):
|
|
|
|
|
"""Connect to ATSPI caret movement events."""
|
|
|
|
|
try:
|
|
|
|
|
# Import ATSPI event system
|
|
|
|
|
from gi.repository import Atspi
|
|
|
|
|
|
|
|
|
|
# Register for text caret movement events
|
|
|
|
|
self._event_listener = Atspi.EventListener.new(self._on_caret_moved)
|
|
|
|
|
Atspi.EventListener.register(self._event_listener, "object:text-caret-moved")
|
|
|
|
|
|
|
|
|
|
debug.printMessage(debug.LEVEL_INFO, "IndentationAudio: Registered for text-caret-moved events", True)
|
|
|
|
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
debug.printMessage(debug.LEVEL_INFO, f"IndentationAudio: ATSPI connection error: {e}", True)
|
|
|
|
|
|
|
|
|
|
def _on_caret_moved(self, event):
|
|
|
|
|
"""Handle caret movement events."""
|
|
|
|
|
try:
|
|
|
|
|
if not self._enabled:
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
debug.printMessage(debug.LEVEL_INFO, f"IndentationAudio: Caret moved in {event.source}", True)
|
|
|
|
|
|
|
|
|
|
# Use Cthulhu's script system to get line text
|
|
|
|
|
try:
|
|
|
|
|
from cthulhu import cthulhu_state
|
|
|
|
|
|
|
|
|
|
# Get current active script
|
|
|
|
|
if cthulhu_state.activeScript:
|
|
|
|
|
script = cthulhu_state.activeScript
|
|
|
|
|
obj = event.source
|
|
|
|
|
|
|
|
|
|
# Use script's getTextLineAtCaret method
|
|
|
|
|
if hasattr(script, 'getTextLineAtCaret'):
|
|
|
|
|
line, caret_offset, start_offset = script.getTextLineAtCaret(obj)
|
|
|
|
|
if line is not None:
|
|
|
|
|
debug.printMessage(debug.LEVEL_INFO, f"IndentationAudio: Line navigation - line: '{line}'", True)
|
|
|
|
|
self.check_indentation_change(obj, line)
|
|
|
|
|
else:
|
|
|
|
|
debug.printMessage(debug.LEVEL_INFO, "IndentationAudio: getTextLineAtCaret returned None", True)
|
|
|
|
|
else:
|
|
|
|
|
debug.printMessage(debug.LEVEL_INFO, "IndentationAudio: script has no getTextLineAtCaret method", True)
|
|
|
|
|
else:
|
|
|
|
|
debug.printMessage(debug.LEVEL_INFO, "IndentationAudio: No active script available", True)
|
|
|
|
|
|
|
|
|
|
except Exception as script_e:
|
|
|
|
|
debug.printMessage(debug.LEVEL_INFO, f"IndentationAudio: Error using script method: {script_e}", True)
|
|
|
|
|
|
|
|
|
|
# Fallback to direct AT-SPI access
|
|
|
|
|
try:
|
|
|
|
|
obj = event.source
|
|
|
|
|
if obj and hasattr(obj, 'queryText'):
|
|
|
|
|
text_iface = obj.queryText()
|
|
|
|
|
if text_iface:
|
|
|
|
|
# Get all text and find current line
|
|
|
|
|
full_text = text_iface.getText(0, -1)
|
|
|
|
|
caret_pos = text_iface.caretOffset
|
|
|
|
|
|
|
|
|
|
if full_text:
|
|
|
|
|
lines = full_text.split('\n')
|
|
|
|
|
char_count = 0
|
|
|
|
|
for line in lines:
|
|
|
|
|
if char_count <= caret_pos <= char_count + len(line):
|
|
|
|
|
debug.printMessage(debug.LEVEL_INFO, f"IndentationAudio: Fallback line: '{line}'", True)
|
|
|
|
|
self.check_indentation_change(obj, line)
|
|
|
|
|
break
|
|
|
|
|
char_count += len(line) + 1 # +1 for newline
|
|
|
|
|
|
|
|
|
|
except Exception as fallback_e:
|
|
|
|
|
debug.printMessage(debug.LEVEL_INFO, f"IndentationAudio: Fallback also failed: {fallback_e}", True)
|
|
|
|
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
debug.printMessage(debug.LEVEL_INFO, f"IndentationAudio: Error in caret moved handler: {e}", True)
|
|
|
|
|
|
|
|
|
|
def _disconnect_from_events(self):
|
|
|
|
|
"""Disconnect from text navigation events."""
|
|
|
|
|
try:
|
|
|
|
|
# Unregister ATSPI event listener
|
|
|
|
|
if hasattr(self, '_event_listener') and self._event_listener:
|
|
|
|
|
from gi.repository import Atspi
|
|
|
|
|
Atspi.EventListener.deregister(self._event_listener, "object:text-caret-moved")
|
|
|
|
|
self._event_listener = None
|
|
|
|
|
debug.printMessage(debug.LEVEL_INFO, "IndentationAudio: Unregistered ATSPI events", True)
|
|
|
|
|
|
|
|
|
|
# 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}")
|
|
|
|
|
debug.printMessage(debug.LEVEL_INFO, f"IndentationAudio: Error disconnecting: {e}", True)
|
|
|
|
|
|
|
|
|
|
def _monkey_patch_script_methods(self):
|
|
|
|
|
"""Monkey-patch the default script's line navigation methods."""
|
|
|
|
@ -212,11 +293,29 @@ class IndentationAudio(Plugin):
|
|
|
|
|
if hasattr(script, 'speakMessage'):
|
|
|
|
|
script.speakMessage(message)
|
|
|
|
|
|
|
|
|
|
debug.printMessage(debug.LEVEL_INFO, f"IndentationAudio: Toggled to {state}", True)
|
|
|
|
|
|
|
|
|
|
# Test the indentation detection on current line when enabled
|
|
|
|
|
if self._enabled and script:
|
|
|
|
|
try:
|
|
|
|
|
# Try to get current focus object and line text
|
|
|
|
|
from cthulhu import cthulhu_state
|
|
|
|
|
obj = cthulhu_state.locusOfFocus
|
|
|
|
|
if obj and hasattr(script, 'getTextLineAtCaret'):
|
|
|
|
|
line, caretOffset, startOffset = script.getTextLineAtCaret(obj)
|
|
|
|
|
debug.printMessage(debug.LEVEL_INFO, f"IndentationAudio: Testing with current line: '{line}'", True)
|
|
|
|
|
self.check_indentation_change(obj, line)
|
|
|
|
|
else:
|
|
|
|
|
debug.printMessage(debug.LEVEL_INFO, "IndentationAudio: Could not get focus object or getTextLineAtCaret method", True)
|
|
|
|
|
except Exception as test_e:
|
|
|
|
|
debug.printMessage(debug.LEVEL_INFO, f"IndentationAudio: Test failed: {test_e}", True)
|
|
|
|
|
|
|
|
|
|
logger.info(f"IndentationAudio: Toggled to {state}")
|
|
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
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):
|
|
|
|
@ -246,23 +345,47 @@ class IndentationAudio(Plugin):
|
|
|
|
|
|
|
|
|
|
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:
|
|
|
|
|
if not self._enabled:
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
# Calculate frequency based on new indentation level
|
|
|
|
|
frequency = min(
|
|
|
|
|
base_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
|
|
|
|
|
# Add directional audio cues
|
|
|
|
|
if new_level > old_level:
|
|
|
|
|
# Indentation increased - higher pitch
|
|
|
|
|
frequency = base_frequency + 50
|
|
|
|
|
elif new_level < old_level:
|
|
|
|
|
# Indentation decreased - lower pitch
|
|
|
|
|
frequency = max(base_frequency - 50, 100)
|
|
|
|
|
else:
|
|
|
|
|
# Same level (shouldn't happen but just in case)
|
|
|
|
|
frequency = base_frequency
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
# Create tone
|
|
|
|
|
debug.printMessage(debug.LEVEL_INFO, f"IndentationAudio: About to generate tone for level {new_level} (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)
|
|
|
|
|
for i in range(beeps):
|
|
|
|
|
print("\a", end="", flush=True)
|
|
|
|
|
debug.printMessage(debug.LEVEL_INFO, f"IndentationAudio: Sent {beeps} ASCII bell beeps", True)
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
# Use Cthulhu's proper sound system
|
|
|
|
|
try:
|
|
|
|
|
# Create a tone based on indentation level
|
|
|
|
|
duration = self._tone_duration
|
|
|
|
|
volume_multiplier = 0.7
|
|
|
|
|
|
|
|
|
|
tone = Tone(
|
|
|
|
|
duration=self._tone_duration,
|
|
|
|
|
duration=duration,
|
|
|
|
|
frequency=frequency,
|
|
|
|
|
volumeMultiplier=volume_multiplier,
|
|
|
|
|
wave=Tone.SINE_WAVE
|
|
|
|
@ -270,18 +393,28 @@ 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_msg = f"IndentationAudio: Played tone - Level: {new_level}, Freq: {frequency}Hz"
|
|
|
|
|
logger.debug(debug_msg)
|
|
|
|
|
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)
|
|
|
|
|
for i in range(beeps):
|
|
|
|
|
print("\a", end="", flush=True)
|
|
|
|
|
debug.printMessage(debug.LEVEL_INFO, f"IndentationAudio: Used fallback ASCII bell ({beeps} beeps)", True)
|
|
|
|
|
|
|
|
|
|
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)
|
|
|
|
|
|
|
|
|
|
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.
|
|
|
|
|
"""
|
|
|
|
|
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:
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
@ -298,15 +431,18 @@ class IndentationAudio(Plugin):
|
|
|
|
|
# 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)
|
|
|
|
|
|
|
|
|
|
# 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)
|
|
|
|
|
debug.printMessage(debug.LEVEL_INFO, debug_msg, True)
|
|
|
|
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
logger.error(f"IndentationAudio: Error checking indentation change: {e}")
|
|
|
|
|
debug.printMessage(debug.LEVEL_INFO, f"IndentationAudio: Exception in check_indentation_change: {e}", True)
|
|
|
|
|
|
|
|
|
|
def is_enabled(self):
|
|
|
|
|
"""Return whether the plugin is currently enabled."""
|
|
|
|
|