From 06cd376cd45691f381a91a0110939591939b06bd Mon Sep 17 00:00:00 2001 From: Storm Dragon Date: Tue, 13 Jan 2026 07:49:51 -0500 Subject: [PATCH] First round of bug fixes and plugin capability extensions. Creating a preferences tab should no longer require editing Cthulhu itself. --- README.md | 8 + src/cthulhu/cthulhu.py | 13 +- src/cthulhu/cthulhu_gui_prefs.py | 178 ++++++++++++++- src/cthulhu/plugin.py | 8 + src/cthulhu/plugins/AIAssistant/plugin.info | 4 +- src/cthulhu/plugins/AIAssistant/plugin.py | 211 ++++++++++++++++++ .../plugins/IndentationAudio/plugin.info | 4 +- .../plugins/IndentationAudio/plugin.py | 164 +++++++++++++- src/cthulhu/plugins/OCR/README.md | 6 +- src/cthulhu/plugins/PluginManager/plugin.py | 9 + src/cthulhu/plugins/hello_world/README.md | 11 +- src/cthulhu/settings.py | 6 +- 12 files changed, 607 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index 48c3ee6..faa9cfb 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,7 @@ toolkit, OpenOffice/LibreOffice, Gecko, WebKitGtk, and KDE Qt toolkit. - **Extensible architecture**: Plugin system using pluggy framework - **Hot-reloadable plugins**: Add functionality without restarting - **Community plugins**: User and system plugin directories +- **Plugin preferences**: Plugins can provide preferences pages that appear only when the plugin is active ### Remote Control - **D-Bus interface**: External control via D-Bus service @@ -83,6 +84,13 @@ The `PluginSystemManager` module provides **session-only** plugin control (no pr - `SetPluginActive` (parameterized) - `RescanPlugins` +### Plugin Preferences Pages + +Plugins can add their own Preferences tab without modifying Cthulhu core code. +Implement `getPreferencesGUI()` to return a Gtk widget (or `(widget, label)`), +and `getPreferencesFromGUI()` to return a settings dict. The tab appears only +when the plugin is active. + ### More Documentation See `README-REMOTE-CONTROLLER.md` and `REMOTE-CONTROLLER-COMMANDS.md` for the full D-Bus API diff --git a/src/cthulhu/cthulhu.py b/src/cthulhu/cthulhu.py index 2f180b9..a95fadf 100644 --- a/src/cthulhu/cthulhu.py +++ b/src/cthulhu/cthulhu.py @@ -470,7 +470,18 @@ def loadUserSettings(script=None, inputEvent=None, skipReloadMessage=False): cthulhuApp.getSignalManager().emitSignal('load-setting-begin') # NOW load plugins after script system is ready - activePlugins = list(_settingsManager.getSetting('activePlugins')) + activePluginsSetting = _settingsManager.getSetting('activePlugins') or [] + debug.printMessage( + debug.LEVEL_INFO, + f"CTHULHU: Active plugins defaults: {settings.activePlugins}", + True, + ) + debug.printMessage( + debug.LEVEL_INFO, + f"CTHULHU: Active plugins from settings manager: {activePluginsSetting}", + True, + ) + activePlugins = list(activePluginsSetting) debug.printMessage(debug.LEVEL_INFO, f'CTHULHU: Loading active plugins: {activePlugins}', True) cthulhuApp.getPluginSystemManager().setActivePlugins(activePlugins) diff --git a/src/cthulhu/cthulhu_gui_prefs.py b/src/cthulhu/cthulhu_gui_prefs.py index 6feb971..a9925df 100644 --- a/src/cthulhu/cthulhu_gui_prefs.py +++ b/src/cthulhu/cthulhu_gui_prefs.py @@ -155,6 +155,9 @@ class CthulhuSetupGUI(cthulhu_gtkbuilder.GtkBuilderWrapper): self._plugin_update_progress = None self._plugin_update_status = None self._plugin_update_in_progress = False + self._plugin_tabs = {} + self._plugin_tabs_cached = False + self._dynamic_plugin_tabs = {} self.screenHeight = None self.screenWidth = None self.speechFamiliesChoice = None @@ -396,6 +399,9 @@ class CthulhuSetupGUI(cthulhu_gtkbuilder.GtkBuilderWrapper): label = Gtk.Label(label=AXObject.get_name(self.script.app)) self.get_widget("notebook").append_page(appPage, label) + self._cache_plugin_tabs() + self._update_plugin_tabs() + self._refresh_dynamic_plugin_tabs() self._initGUIState() self._initSoundThemeState() @@ -799,6 +805,7 @@ class CthulhuSetupGUI(cthulhu_gtkbuilder.GtkBuilderWrapper): path = self._plugin_model.get_path(new_iter) self._plugin_treeview.expand_to_path(path) selection.select_path(path) + self._update_plugin_tabs() def _get_active_plugins_from_ui(self): existing_plugins = list(self.prefsDict.get("activePlugins", settings.activePlugins) or []) @@ -835,6 +842,147 @@ class CthulhuSetupGUI(cthulhu_gtkbuilder.GtkBuilderWrapper): self._plugin_sources_original = list(plugin_sources) self._populate_plugin_list() + self._update_plugin_tabs(active_plugins) + + def _get_active_plugin_infos(self): + manager = cthulhu.cthulhuApp.getPluginSystemManager() + if not manager: + return [] + return [info for info in manager.plugins if info.loaded and info.instance] + + def _get_plugin_preferences_page(self, plugin_info): + plugin_instance = plugin_info.instance + if not plugin_instance: + return None + + if hasattr(plugin_instance, "getPreferencesGUI"): + page = plugin_instance.getPreferencesGUI() + elif hasattr(plugin_instance, "get_preferences_gui"): + page = plugin_instance.get_preferences_gui() + else: + return None + + if not page: + return None + + label = plugin_info.get_name() or plugin_info.get_module_name() + if isinstance(page, (list, tuple)) and len(page) == 2: + page, label = page + + if isinstance(label, str): + label_widget = Gtk.Label(label=label) + else: + label_widget = label + + return page, label_widget + + def _refresh_dynamic_plugin_tabs(self): + notebook = self.get_widget("notebook") + if not notebook: + return + + for tab in list(self._dynamic_plugin_tabs.values()): + page = tab.get("page") + if page: + page_num = notebook.page_num(page) + if page_num != -1: + notebook.remove_page(page_num) + + self._dynamic_plugin_tabs = {} + + plugin_infos = self._get_active_plugin_infos() + for plugin_info in sorted(plugin_infos, key=lambda item: (item.get_name() or item.get_module_name()).lower()): + result = self._get_plugin_preferences_page(plugin_info) + if not result: + continue + + page, label = result + if not page or not label: + continue + + notebook.append_page(page, label) + page.show_all() + label.show() + self._dynamic_plugin_tabs[plugin_info.get_module_name()] = { + "page": page, + "label": label, + } + + for plugin_name in self._dynamic_plugin_tabs: + tab = self._plugin_tabs.get(plugin_name) + if not tab: + continue + page = tab.get("page") + if not page: + continue + page_num = notebook.page_num(page) + if page_num != -1: + notebook.remove_page(page_num) + + def _cache_plugin_tabs(self): + if self._plugin_tabs_cached: + return + + notebook = self.get_widget("notebook") + if not notebook: + return + + tab_specs = [ + ("AIAssistant", "aiPage", "aiTabLabel"), + ("OCR", "ocrGrid", "ocrTabLabel"), + ] + + for plugin_name, page_id, label_id in tab_specs: + page = self.get_widget(page_id) + label = self.get_widget(label_id) + if not page or not label: + continue + position = notebook.page_num(page) + self._plugin_tabs[plugin_name] = { + "page": page, + "label": label, + "position": position, + } + + self._plugin_tabs_cached = True + + def _get_active_plugins_for_tabs(self): + if self._plugin_iters: + return self._get_active_plugins_from_ui() + return list(self.prefsDict.get("activePlugins", settings.activePlugins) or []) + + @staticmethod + def _plugin_active(active_plugins, names): + active_lower = {name.lower() for name in active_plugins} + return any(name.lower() in active_lower for name in names) + + def _update_plugin_tabs(self, active_plugins=None): + if not self._plugin_tabs: + return + + notebook = self.get_widget("notebook") + if not notebook: + return + + if active_plugins is None: + active_plugins = self._get_active_plugins_for_tabs() + + for plugin_name, tab in self._plugin_tabs.items(): + if plugin_name in self._dynamic_plugin_tabs: + continue + page = tab["page"] + label = tab["label"] + position = tab["position"] + page_num = notebook.page_num(page) + is_active = self._plugin_active(active_plugins, [plugin_name]) + + if is_active and page_num == -1: + insert_pos = min(max(position, 0), notebook.get_n_pages()) + notebook.insert_page(page, label, insert_pos) + page.show_all() + label.show() + elif not is_active and page_num != -1: + notebook.remove_page(page_num) def _getACSSForVoiceType(self, voiceType): """Return the ACSS value for the given voice type. @@ -864,11 +1012,31 @@ class CthulhuSetupGUI(cthulhu_gtkbuilder.GtkBuilderWrapper): pronunciationDict = self.getModelDict(self.pronunciationModel) keyBindingsDict = self.getKeyBindingsModelDict(self.keyBindingsModel) self.prefsDict.update(self.script.getPreferencesFromGUI()) + self.prefsDict.update(self._get_plugin_preferences_from_gui()) _settingsManager.saveSettings(self.script, self.prefsDict, pronunciationDict, keyBindingsDict) + def _get_plugin_preferences_from_gui(self): + preferences = {} + for plugin_info in self._get_active_plugin_infos(): + plugin_instance = plugin_info.instance + if not plugin_instance: + continue + + if hasattr(plugin_instance, "getPreferencesFromGUI"): + plugin_prefs = plugin_instance.getPreferencesFromGUI() + elif hasattr(plugin_instance, "get_preferences_from_gui"): + plugin_prefs = plugin_instance.get_preferences_from_gui() + else: + continue + + if isinstance(plugin_prefs, dict): + preferences.update(plugin_prefs) + + return preferences + def _getKeyValueForVoiceType(self, voiceType, key, useDefault=True): """Look for the value of the given key in the voice dictionary for the given voice type. @@ -2283,9 +2451,13 @@ class CthulhuSetupGUI(cthulhu_gtkbuilder.GtkBuilderWrapper): if self.script.app: self.get_widget('profilesFrame').set_sensitive(False) + active_plugins = self._get_active_plugins_for_tabs() + self._update_plugin_tabs(active_plugins) + # AI Assistant settings # - self._initAIState() + if self._plugin_active(active_plugins, ["AIAssistant"]): + self._initAIState() # Indentation settings # @@ -2293,7 +2465,8 @@ class CthulhuSetupGUI(cthulhu_gtkbuilder.GtkBuilderWrapper): # OCR Plugin settings # - self._initOCRState() + if self._plugin_active(active_plugins, ["OCR"]): + self._initOCRState() def __initProfileCombo(self): """Adding available profiles and setting active as the active one""" @@ -4149,6 +4322,7 @@ class CthulhuSetupGUI(cthulhu_gtkbuilder.GtkBuilderWrapper): self._apply_plugin_changes() self.writeUserPreferences() cthulhu.loadUserSettings(self.script) + self._refresh_dynamic_plugin_tabs() braille.checkBrailleSetting() self._initSpeechState() self._populateKeyBindings() diff --git a/src/cthulhu/plugin.py b/src/cthulhu/plugin.py index bb35bc4..8063233 100644 --- a/src/cthulhu/plugin.py +++ b/src/cthulhu/plugin.py @@ -87,6 +87,14 @@ class Plugin: """Get keybindings for this plugin. Override in subclasses.""" return self._bindings + def getPreferencesGUI(self): + """Return a Gtk widget for plugin preferences, or None if not provided.""" + return None + + def getPreferencesFromGUI(self): + """Return a dict of plugin preferences from the GUI.""" + return {} + def registerGestureByString(self, function, name, gestureString, learnModeEnabled=True): """Register a gesture by string.""" if self.app: diff --git a/src/cthulhu/plugins/AIAssistant/plugin.info b/src/cthulhu/plugins/AIAssistant/plugin.info index f9c5206..d954059 100644 --- a/src/cthulhu/plugins/AIAssistant/plugin.info +++ b/src/cthulhu/plugins/AIAssistant/plugin.info @@ -3,5 +3,5 @@ Name = AI Assistant Module = AIAssistant Description = AI-powered accessibility assistant for analyzing screens and taking actions Authors = Stormux -Version = 1.0.0 -Category = Accessibility \ No newline at end of file +Version = 2.0.0 +Category = Accessibility diff --git a/src/cthulhu/plugins/AIAssistant/plugin.py b/src/cthulhu/plugins/AIAssistant/plugin.py index 6bc3a39..f471ca9 100644 --- a/src/cthulhu/plugins/AIAssistant/plugin.py +++ b/src/cthulhu/plugins/AIAssistant/plugin.py @@ -69,6 +69,8 @@ class AIAssistant(Plugin): # Pre-captured screen data (to avoid capturing dialog itself) self._current_screen_data = None + self._prefs_grid = None + self._prefs_widgets = {} @cthulhu_hookimpl def activate(self, plugin=None): @@ -128,6 +130,215 @@ class AIAssistant(Plugin): self._unregister_keybindings() self._enabled = False + + def getPreferencesGUI(self): + if not self._prefs_grid: + self._prefs_grid = self._build_preferences_gui() + self._load_preferences_into_widgets() + return self._prefs_grid, "AI Assistant" + + def getPreferencesFromGUI(self): + if not self._prefs_widgets: + return {} + + provider_values = self._prefs_widgets.get("provider_values", []) + quality_values = self._prefs_widgets.get("quality_values", []) + provider_index = self._prefs_widgets["provider_combo"].get_active() + quality_index = self._prefs_widgets["quality_combo"].get_active() + + if provider_index < 0 or provider_index >= len(provider_values): + provider = settings.aiProvider + else: + provider = provider_values[provider_index] + + if quality_index < 0 or quality_index >= len(quality_values): + screenshot_quality = settings.aiScreenshotQuality + else: + screenshot_quality = quality_values[quality_index] + + return { + "aiAssistantEnabled": self._prefs_widgets["enable_check"].get_active(), + "aiProvider": provider, + "aiApiKeyFile": self._prefs_widgets["api_key_entry"].get_text().strip(), + "aiOllamaModel": self._prefs_widgets["ollama_model_entry"].get_text().strip(), + "aiOllamaEndpoint": self._prefs_widgets["ollama_endpoint_entry"].get_text().strip(), + "aiConfirmationRequired": self._prefs_widgets["confirmation_check"].get_active(), + "aiScreenshotQuality": screenshot_quality, + } + + def _build_preferences_gui(self): + grid = Gtk.Grid( + row_spacing=6, + column_spacing=12, + margin_left=12, + margin_right=12, + margin_top=12, + margin_bottom=12, + ) + + row = 0 + + enable_check = Gtk.CheckButton(label="Enable AI Assistant") + enable_check.connect("toggled", self._on_ai_enabled_toggled) + grid.attach(enable_check, 0, row, 2, 1) + row += 1 + + provider_label = Gtk.Label(label="_Provider:") + provider_label.set_use_underline(True) + provider_label.set_halign(Gtk.Align.START) + + provider_combo = Gtk.ComboBoxText() + provider_combo.set_hexpand(True) + provider_values = [ + settings.AI_PROVIDER_CLAUDE_CODE, + settings.AI_PROVIDER_CODEX, + settings.AI_PROVIDER_GEMINI, + settings.AI_PROVIDER_OLLAMA, + ] + provider_labels = ["Claude Code", "Codex", "Gemini", "Ollama"] + for label in provider_labels: + provider_combo.append_text(label) + provider_combo.connect("changed", self._on_provider_changed) + provider_label.set_mnemonic_widget(provider_combo) + + grid.attach(provider_label, 0, row, 1, 1) + grid.attach(provider_combo, 1, row, 1, 1) + row += 1 + + api_key_label = Gtk.Label(label="API _Key File:") + api_key_label.set_use_underline(True) + api_key_label.set_halign(Gtk.Align.START) + api_key_entry = Gtk.Entry() + api_key_entry.set_hexpand(True) + api_key_label.set_mnemonic_widget(api_key_entry) + + grid.attach(api_key_label, 0, row, 1, 1) + grid.attach(api_key_entry, 1, row, 1, 1) + row += 1 + + ollama_model_label = Gtk.Label(label="_Ollama Model:") + ollama_model_label.set_use_underline(True) + ollama_model_label.set_halign(Gtk.Align.START) + ollama_model_entry = Gtk.Entry() + ollama_model_entry.set_hexpand(True) + ollama_model_label.set_mnemonic_widget(ollama_model_entry) + + grid.attach(ollama_model_label, 0, row, 1, 1) + grid.attach(ollama_model_entry, 1, row, 1, 1) + row += 1 + + ollama_endpoint_label = Gtk.Label(label="Ollama _Endpoint:") + ollama_endpoint_label.set_use_underline(True) + ollama_endpoint_label.set_halign(Gtk.Align.START) + ollama_endpoint_entry = Gtk.Entry() + ollama_endpoint_entry.set_hexpand(True) + ollama_endpoint_label.set_mnemonic_widget(ollama_endpoint_entry) + + grid.attach(ollama_endpoint_label, 0, row, 1, 1) + grid.attach(ollama_endpoint_entry, 1, row, 1, 1) + row += 1 + + confirmation_check = Gtk.CheckButton(label="Require confirmation before actions") + grid.attach(confirmation_check, 0, row, 2, 1) + row += 1 + + quality_label = Gtk.Label(label="Screenshot _Quality:") + quality_label.set_use_underline(True) + quality_label.set_halign(Gtk.Align.START) + quality_combo = Gtk.ComboBoxText() + quality_combo.set_hexpand(True) + quality_values = [ + settings.AI_SCREENSHOT_QUALITY_LOW, + settings.AI_SCREENSHOT_QUALITY_MEDIUM, + settings.AI_SCREENSHOT_QUALITY_HIGH, + ] + quality_labels = ["Low", "Medium", "High"] + for label in quality_labels: + quality_combo.append_text(label) + quality_label.set_mnemonic_widget(quality_combo) + + grid.attach(quality_label, 0, row, 1, 1) + grid.attach(quality_combo, 1, row, 1, 1) + + self._prefs_widgets = { + "enable_check": enable_check, + "provider_combo": provider_combo, + "provider_values": provider_values, + "api_key_entry": api_key_entry, + "ollama_model_entry": ollama_model_entry, + "ollama_endpoint_entry": ollama_endpoint_entry, + "confirmation_check": confirmation_check, + "quality_combo": quality_combo, + "quality_values": quality_values, + } + + return grid + + def _load_preferences_into_widgets(self): + if not self._prefs_widgets: + return + + enabled = self._settings_manager.getSetting("aiAssistantEnabled") + if enabled is None: + enabled = settings.aiAssistantEnabled + self._prefs_widgets["enable_check"].set_active(enabled) + + provider = self._settings_manager.getSetting("aiProvider") or settings.aiProvider + provider_values = self._prefs_widgets.get("provider_values", []) + try: + provider_index = provider_values.index(provider) + except ValueError: + provider_index = 0 + self._prefs_widgets["provider_combo"].set_active(provider_index) + + api_key_file = self._settings_manager.getSetting("aiApiKeyFile") or settings.aiApiKeyFile + self._prefs_widgets["api_key_entry"].set_text(api_key_file or "") + + ollama_model = self._settings_manager.getSetting("aiOllamaModel") or settings.aiOllamaModel + self._prefs_widgets["ollama_model_entry"].set_text(ollama_model or "") + + ollama_endpoint = self._settings_manager.getSetting("aiOllamaEndpoint") or settings.aiOllamaEndpoint + self._prefs_widgets["ollama_endpoint_entry"].set_text(ollama_endpoint or "") + + confirmation_required = self._settings_manager.getSetting("aiConfirmationRequired") + if confirmation_required is None: + confirmation_required = settings.aiConfirmationRequired + self._prefs_widgets["confirmation_check"].set_active(confirmation_required) + + screenshot_quality = self._settings_manager.getSetting("aiScreenshotQuality") or settings.aiScreenshotQuality + quality_values = self._prefs_widgets.get("quality_values", []) + try: + quality_index = quality_values.index(screenshot_quality) + except ValueError: + quality_index = 0 + self._prefs_widgets["quality_combo"].set_active(quality_index) + + self._update_preferences_sensitivity() + + def _on_ai_enabled_toggled(self, widget): + self._update_preferences_sensitivity() + + def _on_provider_changed(self, widget): + self._update_preferences_sensitivity() + + def _update_preferences_sensitivity(self): + if not self._prefs_widgets: + return + + enabled = self._prefs_widgets["enable_check"].get_active() + provider_values = self._prefs_widgets.get("provider_values", []) + provider_index = self._prefs_widgets["provider_combo"].get_active() + provider = provider_values[provider_index] if 0 <= provider_index < len(provider_values) else settings.aiProvider + + is_gemini = provider == settings.AI_PROVIDER_GEMINI + is_ollama = provider == settings.AI_PROVIDER_OLLAMA + + self._prefs_widgets["provider_combo"].set_sensitive(enabled) + self._prefs_widgets["api_key_entry"].set_sensitive(enabled and is_gemini) + self._prefs_widgets["ollama_model_entry"].set_sensitive(enabled and is_ollama) + self._prefs_widgets["ollama_endpoint_entry"].set_sensitive(enabled and is_ollama) + self._prefs_widgets["confirmation_check"].set_sensitive(enabled) + self._prefs_widgets["quality_combo"].set_sensitive(enabled) def refresh_settings(self): """Refresh plugin settings and reinitialize provider. Called when settings change.""" diff --git a/src/cthulhu/plugins/IndentationAudio/plugin.info b/src/cthulhu/plugins/IndentationAudio/plugin.info index b3e61ec..6f724e2 100644 --- a/src/cthulhu/plugins/IndentationAudio/plugin.info +++ b/src/cthulhu/plugins/IndentationAudio/plugin.info @@ -1,8 +1,8 @@ name = IndentationAudio -version = 1.0.0 +version = 2.0.0 description = Provides audio feedback for indentation level changes when navigating code or text authors = Stormux website = https://git.stormux.org/storm/cthulhu copyright = Copyright 2025 builtin = false -hidden = false \ No newline at end of file +hidden = false diff --git a/src/cthulhu/plugins/IndentationAudio/plugin.py b/src/cthulhu/plugins/IndentationAudio/plugin.py index afb2c3a..1669f7f 100644 --- a/src/cthulhu/plugins/IndentationAudio/plugin.py +++ b/src/cthulhu/plugins/IndentationAudio/plugin.py @@ -12,7 +12,7 @@ import logging import math import re -from gi.repository import GLib +from gi.repository import GLib, Gtk from cthulhu.plugin import Plugin, cthulhu_hookimpl from cthulhu import debug @@ -56,6 +56,8 @@ class IndentationAudio(Plugin): self._saved_speech_indentation = None self._activated = False + self._prefs_grid = None + self._prefs_widgets = {} debug.printMessage(debug.LEVEL_INFO, "IndentationAudio: Plugin initialized", True) @cthulhu_hookimpl @@ -86,6 +88,7 @@ class IndentationAudio(Plugin): # Connect to text caret movement events self._connect_to_events() + self._enabled = True self._activated = True debug.printMessage(debug.LEVEL_INFO, "IndentationAudio: Plugin activated successfully", True) return True @@ -109,6 +112,7 @@ class IndentationAudio(Plugin): # Clear tracking data self._last_indentation_data.clear() + self._enabled = False self._activated = False debug.printMessage(debug.LEVEL_INFO, "IndentationAudio: Plugin deactivated successfully", True) return True @@ -116,6 +120,164 @@ class IndentationAudio(Plugin): except Exception as e: debug.printMessage(debug.LEVEL_INFO, f"IndentationAudio: ERROR deactivating plugin: {e}", True) return False + + def getPreferencesGUI(self): + if not self._prefs_grid: + self._prefs_grid = self._build_preferences_gui() + self._load_preferences_into_widgets() + return self._prefs_grid, "Indentation Audio" + + def getPreferencesFromGUI(self): + if not self._prefs_widgets: + return {} + + audio_unit = settings.INDENTATION_UNIT_COLUMNS + if self._prefs_widgets["audio_unit_levels"].get_active(): + audio_unit = settings.INDENTATION_UNIT_LEVELS + + return { + "indentationAudioUnit": audio_unit, + "indentationAudioBaseFrequency": self._prefs_widgets["base_frequency_spin"].get_value_as_int(), + "indentationAudioStepFrequency": self._prefs_widgets["step_frequency_spin"].get_value_as_int(), + "indentationAudioMaxFrequency": self._prefs_widgets["max_frequency_spin"].get_value_as_int(), + "indentationAudioDuration": float(self._prefs_widgets["duration_spin"].get_value()), + "indentationAudioVolume": float(self._prefs_widgets["volume_spin"].get_value()), + } + + def _build_preferences_gui(self): + grid = Gtk.Grid( + row_spacing=6, + column_spacing=12, + margin_left=12, + margin_right=12, + margin_top=12, + margin_bottom=12, + ) + + row = 0 + + description = Gtk.Label(label="Configure audio feedback for indentation changes.") + description.set_line_wrap(True) + description.set_halign(Gtk.Align.START) + grid.attach(description, 0, row, 2, 1) + row += 1 + + audio_unit_label = Gtk.Label(label="Audio _Unit:") + audio_unit_label.set_use_underline(True) + audio_unit_label.set_halign(Gtk.Align.START) + + audio_unit_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=4) + audio_unit_levels = Gtk.RadioButton(label="Levels") + audio_unit_columns = Gtk.RadioButton.new_with_label_from_widget(audio_unit_levels, "Columns") + audio_unit_box.pack_start(audio_unit_levels, False, False, 0) + audio_unit_box.pack_start(audio_unit_columns, False, False, 0) + + audio_unit_label.set_mnemonic_widget(audio_unit_levels) + grid.attach(audio_unit_label, 0, row, 1, 1) + grid.attach(audio_unit_box, 1, row, 1, 1) + row += 1 + + base_adjust = Gtk.Adjustment(200, 50, 2000, 10, 50, 0) + step_adjust = Gtk.Adjustment(80, 1, 500, 5, 20, 0) + max_adjust = Gtk.Adjustment(1200, 200, 5000, 50, 100, 0) + duration_adjust = Gtk.Adjustment(0.15, 0.01, 2, 0.01, 0.1, 0) + volume_adjust = Gtk.Adjustment(0.7, 0, 1, 0.05, 0.1, 0) + + base_frequency_spin, row = self._add_spin_row( + grid, + row, + "Base _Frequency (Hz):", + base_adjust, + 0, + ) + step_frequency_spin, row = self._add_spin_row( + grid, + row, + "_Step per Unit (Hz):", + step_adjust, + 0, + ) + max_frequency_spin, row = self._add_spin_row( + grid, + row, + "_Maximum Frequency (Hz):", + max_adjust, + 0, + ) + duration_spin, row = self._add_spin_row( + grid, + row, + "Tone _Duration (sec):", + duration_adjust, + 2, + ) + volume_spin, row = self._add_spin_row( + grid, + row, + "_Volume Multiplier:", + volume_adjust, + 2, + ) + + self._prefs_widgets = { + "audio_unit_levels": audio_unit_levels, + "audio_unit_columns": audio_unit_columns, + "base_frequency_spin": base_frequency_spin, + "step_frequency_spin": step_frequency_spin, + "max_frequency_spin": max_frequency_spin, + "duration_spin": duration_spin, + "volume_spin": volume_spin, + } + + return grid + + def _add_spin_row(self, grid, row, label_text, adjustment, digits): + label = Gtk.Label(label=label_text) + label.set_use_underline(True) + label.set_halign(Gtk.Align.START) + spin = Gtk.SpinButton(adjustment=adjustment, climb_rate=1, numeric=True) + if digits: + spin.set_digits(digits) + label.set_mnemonic_widget(spin) + + grid.attach(label, 0, row, 1, 1) + grid.attach(spin, 1, row, 1, 1) + return spin, row + 1 + + def _load_preferences_into_widgets(self): + if not self._prefs_widgets: + return + + audio_unit = _settingsManager.getSetting("indentationAudioUnit") or settings.indentationAudioUnit + if audio_unit == settings.INDENTATION_UNIT_LEVELS: + self._prefs_widgets["audio_unit_levels"].set_active(True) + else: + self._prefs_widgets["audio_unit_columns"].set_active(True) + + base_frequency = _settingsManager.getSetting("indentationAudioBaseFrequency") + if base_frequency is None: + base_frequency = settings.indentationAudioBaseFrequency + self._prefs_widgets["base_frequency_spin"].set_value(base_frequency) + + step_frequency = _settingsManager.getSetting("indentationAudioStepFrequency") + if step_frequency is None: + step_frequency = settings.indentationAudioStepFrequency + self._prefs_widgets["step_frequency_spin"].set_value(step_frequency) + + max_frequency = _settingsManager.getSetting("indentationAudioMaxFrequency") + if max_frequency is None: + max_frequency = settings.indentationAudioMaxFrequency + self._prefs_widgets["max_frequency_spin"].set_value(max_frequency) + + duration_value = _settingsManager.getSetting("indentationAudioDuration") + if duration_value is None: + duration_value = settings.indentationAudioDuration + self._prefs_widgets["duration_spin"].set_value(duration_value) + + volume_value = _settingsManager.getSetting("indentationAudioVolume") + if volume_value is None: + volume_value = settings.indentationAudioVolume + self._prefs_widgets["volume_spin"].set_value(volume_value) def _register_keybinding(self): """Register the Cthulhu+I keybinding for toggling the plugin.""" diff --git a/src/cthulhu/plugins/OCR/README.md b/src/cthulhu/plugins/OCR/README.md index 36d75a6..e88b580 100644 --- a/src/cthulhu/plugins/OCR/README.md +++ b/src/cthulhu/plugins/OCR/README.md @@ -84,7 +84,7 @@ To add support for other languages, install additional Tesseract language packs: 1. **Enable the Plugin**: The OCR plugin is enabled by default in Cthulhu. If disabled, you can enable it through: - Cthulhu Preferences → Plugins → Check "OCR" - - Or ensure `'OCR'` is in the `activePlugins` list in settings.py + - Or add `OCR` to your `activePlugins` preference 2. **Basic OCR Workflow**: - Navigate to content you want to OCR @@ -115,7 +115,7 @@ To add support for other languages, install additional Tesseract language packs: Access comprehensive OCR settings through Cthulhu Preferences: 1. **Open Cthulhu Preferences**: `~/.local/bin/cthulhu -s` -2. **Navigate to OCR Tab**: Use keyboard navigation to find the OCR settings tab +2. **Navigate to OCR Tab**: Use keyboard navigation to find the OCR settings tab (only shown when the plugin is active) 3. **Configure Settings**: Adjust all OCR parameters through the accessible interface ### Available Settings @@ -318,4 +318,4 @@ For issues, questions, or contributions: --- -*Part of the Cthulhu Screen Reader project - Making the desktop accessible for everyone.* \ No newline at end of file +*Part of the Cthulhu Screen Reader project - Making the desktop accessible for everyone.* diff --git a/src/cthulhu/plugins/PluginManager/plugin.py b/src/cthulhu/plugins/PluginManager/plugin.py index cb913cd..3433bfc 100644 --- a/src/cthulhu/plugins/PluginManager/plugin.py +++ b/src/cthulhu/plugins/PluginManager/plugin.py @@ -494,6 +494,15 @@ class PluginManager(Plugin): debug.printMessage(debug.LEVEL_INFO, f"PluginManager: Updated active plugins: {active_plugins}", True) + try: + from cthulhu import plugin_system_manager + manager = plugin_system_manager.getManager() + if manager: + manager.setActivePlugins(active_plugins) + debug.printMessage(debug.LEVEL_INFO, "PluginManager: Applied active plugin changes", True) + except Exception as apply_error: + debug.printMessage(debug.LEVEL_INFO, f"PluginManager: Failed to apply plugin changes: {apply_error}", True) + except Exception as error: logger.error(f"PluginManager: Error toggling plugin {plugin_name}: {error}") debug.printMessage(debug.LEVEL_INFO, f"PluginManager: Error toggling {plugin_name}: {error}", True) diff --git a/src/cthulhu/plugins/hello_world/README.md b/src/cthulhu/plugins/hello_world/README.md index 2bd1a89..ba11cf6 100644 --- a/src/cthulhu/plugins/hello_world/README.md +++ b/src/cthulhu/plugins/hello_world/README.md @@ -30,4 +30,13 @@ pip install pluggy ## Usage -The plugin will be automatically loaded when Cthulhu starts if it's listed in the activePlugins setting. \ No newline at end of file +Enable the plugin in Cthulhu Preferences → Plugins to load it at startup. You can also add it to your +`activePlugins` preference. + +## Preferences Pages (Optional) + +Plugins can add their own Preferences tab by implementing: +- `getPreferencesGUI()` → returns a Gtk widget (or `(widget, label)`). +- `getPreferencesFromGUI()` → returns a dict of settings to save. + +Tabs are only shown when the plugin is active. diff --git a/src/cthulhu/settings.py b/src/cthulhu/settings.py index e47f30a..d912296 100644 --- a/src/cthulhu/settings.py +++ b/src/cthulhu/settings.py @@ -494,12 +494,12 @@ presentChatRoomLast = False presentLiveRegionFromInactiveTab = False # Plugins -activePlugins = ['AIAssistant', 'DisplayVersion', 'OCR', 'PluginManager', 'HelloCthulhu', 'ByeCthulhu', 'IndentationAudio', 'WindowTitleReader'] +activePlugins = ['DisplayVersion', 'OCR', 'PluginManager', 'HelloCthulhu', 'ByeCthulhu', 'WindowTitleReader'] pluginSources = [] # AI Assistant settings (disabled by default for opt-in behavior) -aiAssistantEnabled = True -aiProvider = AI_PROVIDER_CLAUDE_CODE +aiAssistantEnabled = False +aiProvider = AI_PROVIDER_OLLAMA aiApiKeyFile = "" aiOllamaModel = "llama3.2-vision" aiOllamaEndpoint = "http://localhost:11434"