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