diff --git a/src/cthulhu/cthulhu_gui_prefs.py b/src/cthulhu/cthulhu_gui_prefs.py index 27c0987..6feb971 100644 --- a/src/cthulhu/cthulhu_gui_prefs.py +++ b/src/cthulhu/cthulhu_gui_prefs.py @@ -106,6 +106,10 @@ if louis and not tablesdir: DATE_FORMAT_ABBREVIATED_MDY, DATE_FORMAT_ABBREVIATED_YMD) = list(range(16)) class CthulhuSetupGUI(cthulhu_gtkbuilder.GtkBuilderWrapper): + PLUGIN_COL_ENABLED = 0 + PLUGIN_COL_DISPLAY = 1 + PLUGIN_COL_CAN_TOGGLE = 2 + PLUGIN_COL_NAME = 3 def __init__(self, fileName, windowName, prefsDict): """Initialize the Cthulhu configuration GUI. @@ -135,8 +139,11 @@ class CthulhuSetupGUI(cthulhu_gtkbuilder.GtkBuilderWrapper): self.planeCellRendererText = None self.pronunciationModel = None self.pronunciationView = None - self._plugin_checkboxes = {} - self._plugin_listbox = None + self._plugin_treeview = None + self._plugin_model = None + self._plugin_iters = {} + self._plugin_enabled_iter = None + self._plugin_disabled_iter = None self._plugin_sources = [] self._plugin_sources_entry = None self._plugin_sources_listbox = None @@ -420,9 +427,41 @@ class CthulhuSetupGUI(cthulhu_gtkbuilder.GtkBuilderWrapper): pluginsScrolled = Gtk.ScrolledWindow() pluginsScrolled.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC) pluginsScrolled.set_size_request(-1, 200) - self._plugin_listbox = Gtk.ListBox() - self._plugin_listbox.set_selection_mode(Gtk.SelectionMode.NONE) - pluginsScrolled.add(self._plugin_listbox) + self._plugin_model = Gtk.TreeStore( + GObject.TYPE_BOOLEAN, # enabled + GObject.TYPE_STRING, # display text + GObject.TYPE_BOOLEAN, # can toggle + GObject.TYPE_STRING, # plugin name + ) + self._plugin_treeview = Gtk.TreeView(model=self._plugin_model) + self._plugin_treeview.set_headers_visible(True) + self._plugin_treeview.set_enable_search(False) + self._plugin_treeview.connect("key-press-event", self._on_plugin_tree_key_press) + self._plugin_treeview.connect("row-activated", self._on_plugin_tree_row_activated) + + toggle_renderer = Gtk.CellRendererToggle() + toggle_renderer.set_activatable(True) + toggle_renderer.connect("toggled", self._on_plugin_tree_toggled) + toggle_column = Gtk.TreeViewColumn( + "Enabled", + toggle_renderer, + active=self.PLUGIN_COL_ENABLED, + activatable=self.PLUGIN_COL_CAN_TOGGLE + ) + toggle_column.add_attribute(toggle_renderer, "visible", self.PLUGIN_COL_CAN_TOGGLE) + self._plugin_treeview.append_column(toggle_column) + + text_renderer = Gtk.CellRendererText() + text_renderer.set_property("ellipsize", Pango.EllipsizeMode.END) + text_column = Gtk.TreeViewColumn( + "Plugin", + text_renderer, + text=self.PLUGIN_COL_DISPLAY + ) + text_column.set_expand(True) + self._plugin_treeview.append_column(text_column) + + pluginsScrolled.add(self._plugin_treeview) pluginsFrameBox.pack_start(pluginsScrolled, True, True, 0) pluginsPage.pack_start(pluginsFrame, True, True, 0) @@ -496,12 +535,12 @@ class CthulhuSetupGUI(cthulhu_gtkbuilder.GtkBuilderWrapper): listbox.remove(child) def _populate_plugin_list(self): - if not self._plugin_listbox: + if not self._plugin_treeview: return try: - self._clear_listbox(self._plugin_listbox) - self._plugin_checkboxes.clear() + self._plugin_model.clear() + self._plugin_iters = {} self._available_plugins = set() self._plugin_canonical_map = {} self._plugin_group_map = {} @@ -526,60 +565,62 @@ class CthulhuSetupGUI(cthulhu_gtkbuilder.GtkBuilderWrapper): canonical_counts[canonical] = canonical_counts.get(canonical, 0) + 1 if info.builtin: canonical_builtins[canonical] = True + + self._plugin_enabled_iter = self._plugin_model.append( + None, + [False, "Enabled plugins", False, ""] + ) + self._plugin_disabled_iter = self._plugin_model.append( + None, + [False, "Disabled plugins", False, ""] + ) + for plugin_info in sorted(plugin_infos, key=lambda item: (item.get_name() or item.get_module_name()).lower()): plugin_name = plugin_info.get_module_name() canonical_name = plugin_info.get_canonical_name() if plugin_info.hidden or canonical_name == "PluginManager": continue - row = Gtk.ListBoxRow() - row.set_activatable(False) - hbox = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=10) - hbox.set_border_width(5) + self._plugin_canonical_map[plugin_name] = canonical_name - checkbox = Gtk.CheckButton() is_active = ( plugin_name in active_plugins or plugin_name.lower() in active_plugins_lower or (plugin_info.preferred_alias and (canonical_name in active_plugins or canonical_name.lower() in active_plugins_lower)) or plugin_info.builtin ) + + can_toggle = True if canonical_builtins.get(canonical_name) and not plugin_info.builtin: is_active = False - checkbox.set_active(is_active) - if plugin_info.builtin: - checkbox.set_sensitive(False) - elif canonical_builtins.get(canonical_name): - checkbox.set_sensitive(False) - checkbox.connect("toggled", self._on_plugin_checkbox_toggled, plugin_name) + can_toggle = False - display_name = GLib.markup_escape_text(plugin_info.get_name() or plugin_name) - info_text = f"{display_name}" + if plugin_info.builtin: + can_toggle = False + + display_name = plugin_info.get_name() or plugin_name + info_text = display_name description = plugin_info.get_description() if description: - info_text += f"\n{GLib.markup_escape_text(description)}" + info_text += f" - {description}" version = plugin_info.get_version() if version: - info_text += f" (v{GLib.markup_escape_text(version)})" + info_text += f" (v{version})" if canonical_counts.get(canonical_name, 0) > 1: - info_text += f"\nSource: {GLib.markup_escape_text(plugin_info.get_source_label())}" + info_text += f" - Source: {plugin_info.get_source_label()}" if canonical_builtins.get(canonical_name) and not plugin_info.builtin: - info_text += "\nDisabled because a builtin plugin uses this name." + info_text += " - Disabled because a builtin plugin uses this name." - label = Gtk.Label() - label.set_markup(info_text) - label.set_halign(Gtk.Align.START) - label.set_line_wrap(True) - - hbox.pack_start(checkbox, False, False, 0) - hbox.pack_start(label, True, True, 0) - row.add(hbox) - self._plugin_listbox.add(row) - self._plugin_checkboxes[plugin_name] = checkbox - self._plugin_canonical_map[plugin_name] = canonical_name + parent_iter = self._plugin_enabled_iter if is_active else self._plugin_disabled_iter + tree_iter = self._plugin_model.append( + parent_iter, + [is_active, info_text, can_toggle, plugin_name] + ) + self._plugin_iters[plugin_name] = tree_iter self._plugin_group_map.setdefault(canonical_name, []).append(plugin_name) - self._plugin_listbox.show_all() + self._plugin_treeview.collapse_all() + self._plugin_treeview.show_all() except Exception as e: debug.printMessage(debug.LEVEL_WARNING, f"PREFERENCES DIALOG: Plugin list build failed: {e}", True) @@ -685,30 +726,92 @@ class CthulhuSetupGUI(cthulhu_gtkbuilder.GtkBuilderWrapper): self._set_plugin_update_status(message) self._populate_plugin_list() - def _on_plugin_checkbox_toggled(self, checkbox, plugin_name): - if not checkbox.get_active(): + def _on_plugin_tree_toggled(self, renderer, path): + if not self._plugin_model: return - canonical_name = self._plugin_canonical_map.get(plugin_name) - if not canonical_name: + + tree_iter = self._plugin_model.get_iter(path) + if not tree_iter: return - for other_name in self._plugin_group_map.get(canonical_name, []): - if other_name == plugin_name: - continue - other_checkbox = self._plugin_checkboxes.get(other_name) - if other_checkbox and other_checkbox.get_active(): - other_checkbox.set_active(False) + + can_toggle = self._plugin_model.get_value(tree_iter, self.PLUGIN_COL_CAN_TOGGLE) + if not can_toggle: + return + + plugin_name = self._plugin_model.get_value(tree_iter, self.PLUGIN_COL_NAME) + if not plugin_name: + return + + current_active = self._plugin_model.get_value(tree_iter, self.PLUGIN_COL_ENABLED) + new_active = not current_active + + if new_active: + canonical_name = self._plugin_canonical_map.get(plugin_name) + for other_name in self._plugin_group_map.get(canonical_name, []): + if other_name == plugin_name: + continue + self._set_plugin_row_active(other_name, False) + + self._set_plugin_row_active(plugin_name, new_active) + + def _on_plugin_tree_key_press(self, widget, event): + if event.keyval != Gdk.KEY_space: + return False + + selection = self._plugin_treeview.get_selection() + model, tree_iter = selection.get_selected() + if not tree_iter: + return False + + path = model.get_path(tree_iter) + self._on_plugin_tree_toggled(None, path) + return True + + def _on_plugin_tree_row_activated(self, treeview, path, column): + self._on_plugin_tree_toggled(None, path) + + def _set_plugin_row_active(self, plugin_name, is_active): + tree_iter = self._plugin_iters.get(plugin_name) + if not tree_iter: + return + + current_active = self._plugin_model.get_value(tree_iter, self.PLUGIN_COL_ENABLED) + if current_active == is_active: + return + + can_toggle = self._plugin_model.get_value(tree_iter, self.PLUGIN_COL_CAN_TOGGLE) + display_text = self._plugin_model.get_value(tree_iter, self.PLUGIN_COL_DISPLAY) + + selection = self._plugin_treeview.get_selection() + model, selected_iter = selection.get_selected() + was_selected = selected_iter == tree_iter + + self._plugin_model.remove(tree_iter) + + parent_iter = self._plugin_enabled_iter if is_active else self._plugin_disabled_iter + new_iter = self._plugin_model.append( + parent_iter, + [is_active, display_text, can_toggle, plugin_name] + ) + self._plugin_iters[plugin_name] = new_iter + + if was_selected: + path = self._plugin_model.get_path(new_iter) + self._plugin_treeview.expand_to_path(path) + selection.select_path(path) def _get_active_plugins_from_ui(self): existing_plugins = list(self.prefsDict.get("activePlugins", settings.activePlugins) or []) preserved_plugins = [ name for name in existing_plugins - if name not in self._plugin_checkboxes and name in self._available_plugins + if name not in self._plugin_iters and name in self._available_plugins ] selected_plugins = [] for canonical_name, plugin_names in self._plugin_group_map.items(): active_in_group = [ name for name in plugin_names - if self._plugin_checkboxes.get(name) and self._plugin_checkboxes[name].get_active() + if self._plugin_iters.get(name) + and self._plugin_model.get_value(self._plugin_iters[name], self.PLUGIN_COL_ENABLED) ] if active_in_group: selected_plugins.append(active_in_group[-1]) diff --git a/src/cthulhu/input_event.py b/src/cthulhu/input_event.py index 276a429..4da6fb8 100644 --- a/src/cthulhu/input_event.py +++ b/src/cthulhu/input_event.py @@ -876,9 +876,25 @@ class KeyboardEvent(InputEvent): return method.__func__ == self._handler.function + def _should_interrupt_presentation_on_press(self): + if not settings.gameMode: + return True + + if cthulhu_state.bypassNextCommand and not self.is_modifier_key(): + return False + + if self._handler or self._consumer: + return True + + if self.isCthulhuModifier(): + return True + + return False + def _present(self, inputEvent=None): if self.is_pressed_key(): - self._script.presentationInterrupt() + if self._should_interrupt_presentation_on_press(): + self._script.presentationInterrupt() if self._script.learnModePresenter.is_active(): return False diff --git a/src/cthulhu/messages.py b/src/cthulhu/messages.py index 7294133..26d8466 100644 --- a/src/cthulhu/messages.py +++ b/src/cthulhu/messages.py @@ -2333,6 +2333,12 @@ STOP_CTHULHU = _("Cthulhu lurks beneath the waves.") SLEEP_MODE_ENABLED_FOR = _("Sleep mode enabled for %s") SLEEP_MODE_DISABLED_FOR = _("Sleep mode disabled for %s") +# Translators: This message is presented to the user when game mode is enabled. +GAME_MODE_ENABLED = _("Game mode enabled") + +# Translators: This message is presented to the user when game mode is disabled. +GAME_MODE_DISABLED = _("Game mode disabled") + # Translators: This message means speech synthesis is not installed or working. SPEECH_UNAVAILABLE = _("Speech is unavailable.") diff --git a/src/cthulhu/plugin_system_manager.py b/src/cthulhu/plugin_system_manager.py index 1f751c5..a6a27ed 100644 --- a/src/cthulhu/plugin_system_manager.py +++ b/src/cthulhu/plugin_system_manager.py @@ -34,6 +34,14 @@ logger = logging.getLogger(__name__) if PLUGIN_DEBUG: logger.setLevel(logging.DEBUG) +LEGACY_PLUGIN_NAME_ALIASES = { + "ocrdesktop": "OCR", +} + +LEGACY_PLUGIN_DIR_ALIASES = { + "OCRDesktop": "OCR", +} + _manager = None def getManager(): @@ -450,6 +458,18 @@ class PluginSystemManager: return False canonical_name = os.path.basename(plugin_dir) + canonical_override = LEGACY_PLUGIN_DIR_ALIASES.get(canonical_name) + if canonical_override: + if canonical_override in self._plugin_name_index: + logger.info( + f"Skipping deprecated plugin directory {canonical_name} because " + f"{canonical_override} is already registered." + ) + return True + logger.info( + f"Registering deprecated plugin directory {canonical_name} as {canonical_override}." + ) + canonical_name = canonical_override origin, source_id = self._get_origin_info(plugin_dir) module_name = self._make_unique_plugin_id(canonical_name, source_id) if canonical_name != module_name: @@ -623,6 +643,25 @@ class PluginSystemManager: logger.info(f"PLUGIN SYSTEM: setActivePlugins called with: {activePlugins}") logger.info(f"Setting active plugins: {activePlugins}") + original_active = list(activePlugins or []) + normalized_requested = [] + for name in original_active: + if not name: + continue + name_lower = name.lower() + alias = LEGACY_PLUGIN_NAME_ALIASES.get(name_lower) + if alias: + logger.info(f"Mapping legacy plugin name {name} to {alias}") + name = alias + normalized_requested.append(name) + activePlugins = normalized_requested + if activePlugins != original_active: + try: + from . import settings as settings_module + settings_module.activePlugins = list(activePlugins) + except Exception: + logger.debug("Unable to normalize settings.activePlugins for legacy plugin aliases.") + # Make sure we have scanned for plugins first if not self._plugins: logger.info("No plugins found, rescanning...") diff --git a/src/cthulhu/plugins/GameMode/__init__.py b/src/cthulhu/plugins/GameMode/__init__.py new file mode 100644 index 0000000..cb0c944 --- /dev/null +++ b/src/cthulhu/plugins/GameMode/__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. + +"""GameMode plugin package.""" + +from .plugin import GameMode + +__all__ = ['GameMode'] diff --git a/src/cthulhu/plugins/GameMode/meson.build b/src/cthulhu/plugins/GameMode/meson.build new file mode 100644 index 0000000..4b80c49 --- /dev/null +++ b/src/cthulhu/plugins/GameMode/meson.build @@ -0,0 +1,14 @@ +gamemode_python_sources = files([ + '__init__.py', + 'plugin.py' +]) + +python3.install_sources( + gamemode_python_sources, + subdir: 'cthulhu/plugins/GameMode' +) + +install_data( + 'plugin.info', + install_dir: python3.get_install_dir() / 'cthulhu' / 'plugins' / 'GameMode' +) diff --git a/src/cthulhu/plugins/GameMode/plugin.info b/src/cthulhu/plugins/GameMode/plugin.info new file mode 100644 index 0000000..753c0d4 --- /dev/null +++ b/src/cthulhu/plugins/GameMode/plugin.info @@ -0,0 +1,8 @@ +name = GameMode +version = 1.0.0 +description = Reduces speech interruptions while holding keys +authors = Stormux +website = https://git.stormux.org/storm/cthulhu +copyright = Copyright 2025 +builtin = false +hidden = false diff --git a/src/cthulhu/plugins/GameMode/plugin.py b/src/cthulhu/plugins/GameMode/plugin.py new file mode 100644 index 0000000..d89a43e --- /dev/null +++ b/src/cthulhu/plugins/GameMode/plugin.py @@ -0,0 +1,97 @@ +#!/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. + +"""GameMode plugin for Cthulhu - reduce speech interruption while gaming.""" + +from cthulhu.plugin import Plugin, cthulhu_hookimpl +from cthulhu import debug +from cthulhu import messages +from cthulhu import settings_manager + +settingsManager = settings_manager.getManager() + + +class GameMode(Plugin): + """Plugin that reduces speech interruption while holding keys.""" + + def __init__(self, *args, **kwargs): + """Initialize the GameMode plugin.""" + super().__init__(*args, **kwargs) + self._activated = False + self._kbBinding = None + + debug.printMessage(debug.LEVEL_INFO, "GameMode: Plugin initialized", True) + + @cthulhu_hookimpl + def activate(self, plugin=None): + """Activate the GameMode plugin.""" + if plugin is not None and plugin is not self: + return + + if self._activated: + debug.printMessage(debug.LEVEL_INFO, "GameMode: Already activated, skipping", True) + return + + try: + debug.printMessage(debug.LEVEL_INFO, "GameMode: Plugin activation starting", True) + self._register_keybinding() + self._activated = True + debug.printMessage(debug.LEVEL_INFO, "GameMode: Plugin activated successfully", True) + return True + except Exception as error: + debug.printMessage(debug.LEVEL_INFO, f"GameMode: ERROR activating plugin: {error}", True) + return False + + @cthulhu_hookimpl + def deactivate(self, plugin=None): + """Deactivate the GameMode plugin.""" + if plugin is not None and plugin is not self: + return + + try: + debug.printMessage(debug.LEVEL_INFO, "GameMode: Plugin deactivation starting", True) + settingsManager.setSetting('gameMode', False) + self._activated = False + debug.printMessage(debug.LEVEL_INFO, "GameMode: Plugin deactivated successfully", True) + return True + except Exception as error: + debug.printMessage(debug.LEVEL_INFO, f"GameMode: ERROR deactivating plugin: {error}", True) + return False + + def _register_keybinding(self): + """Register the Cthulhu+Control+G keybinding for toggling game mode.""" + try: + if not self.app: + debug.printMessage(debug.LEVEL_INFO, "GameMode: No app reference for keybinding", True) + return + + gestureString = "kb:cthulhu+control+g" + description = "Toggle game mode" + self._kbBinding = self.registerGestureByString( + self._toggle_game_mode, + description, + gestureString + ) + if self._kbBinding: + debug.printMessage(debug.LEVEL_INFO, f"GameMode: Registered keybinding {gestureString}", True) + else: + debug.printMessage(debug.LEVEL_INFO, f"GameMode: Failed to register keybinding {gestureString}", True) + except Exception as error: + debug.printMessage(debug.LEVEL_INFO, f"GameMode: Error registering keybinding: {error}", True) + + def _toggle_game_mode(self, script, inputEvent=None): + """Toggle game mode on or off.""" + currentValue = bool(settingsManager.getSetting('gameMode')) + newValue = not currentValue + settingsManager.setSetting('gameMode', newValue) + + message = messages.GAME_MODE_ENABLED if newValue else messages.GAME_MODE_DISABLED + if script: + script.presentMessage(message) + return True diff --git a/src/cthulhu/plugins/PluginManager/plugin.py b/src/cthulhu/plugins/PluginManager/plugin.py index f10ce4a..f336471 100644 --- a/src/cthulhu/plugins/PluginManager/plugin.py +++ b/src/cthulhu/plugins/PluginManager/plugin.py @@ -16,7 +16,7 @@ from pathlib import Path import gi gi.require_version('Gtk', '3.0') -from gi.repository import Gtk, GLib +from gi.repository import Gtk, Gdk, Pango from cthulhu.plugin import Plugin, cthulhu_hookimpl from cthulhu import debug @@ -28,13 +28,19 @@ _settingsManager = settings_manager.getManager() class PluginManager(Plugin): """Plugin that provides a GUI interface for managing other plugins.""" + + PLUGIN_COL_ENABLED = 0 + PLUGIN_COL_DISPLAY = 1 + PLUGIN_COL_CAN_TOGGLE = 2 + PLUGIN_COL_NAME = 3 def __init__(self, *args, **kwargs): """Initialize the PluginManager plugin.""" super().__init__(*args, **kwargs) self._kb_binding = None self._dialog = None - self._plugin_checkboxes = {} + self._plugin_treeview = None + self._plugin_model = None self._activated = False debug.printMessage(debug.LEVEL_INFO, "PluginManager: Plugin initialized", True) @@ -163,11 +169,37 @@ class PluginManager(Plugin): scrolled = Gtk.ScrolledWindow() scrolled.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC) scrolled.set_size_request(-1, 200) - - # Create list box for plugins - self._plugin_listbox = Gtk.ListBox() - self._plugin_listbox.set_selection_mode(Gtk.SelectionMode.NONE) - scrolled.add(self._plugin_listbox) + + self._plugin_model = Gtk.TreeStore(bool, str, bool, str) + self._plugin_treeview = Gtk.TreeView(model=self._plugin_model) + self._plugin_treeview.set_headers_visible(True) + self._plugin_treeview.set_enable_search(False) + self._plugin_treeview.connect("key-press-event", self._on_plugin_list_key_press) + self._plugin_treeview.connect("row-activated", self._on_plugin_row_activated) + + toggle_renderer = Gtk.CellRendererToggle() + toggle_renderer.set_activatable(True) + toggle_renderer.connect("toggled", self._on_plugin_toggled) + toggle_column = Gtk.TreeViewColumn( + "Enabled", + toggle_renderer, + active=self.PLUGIN_COL_ENABLED, + activatable=self.PLUGIN_COL_CAN_TOGGLE + ) + toggle_column.add_attribute(toggle_renderer, "visible", self.PLUGIN_COL_CAN_TOGGLE) + self._plugin_treeview.append_column(toggle_column) + + text_renderer = Gtk.CellRendererText() + text_renderer.set_property("ellipsize", Pango.EllipsizeMode.END) + text_column = Gtk.TreeViewColumn( + "Plugin", + text_renderer, + text=self.PLUGIN_COL_DISPLAY + ) + text_column.set_expand(True) + self._plugin_treeview.append_column(text_column) + + scrolled.add(self._plugin_treeview) content_area.pack_start(scrolled, True, True, 0) @@ -197,49 +229,41 @@ class PluginManager(Plugin): debug.printMessage(debug.LEVEL_INFO, f"PluginManager: Found {len(available_plugins)} plugins", True) - # Clear existing checkboxes - self._plugin_checkboxes.clear() - + # Clear existing model rows + self._plugin_model.clear() + # Add each plugin as a checkbox (except PluginManager itself) + enabled_iter = self._plugin_model.append( + None, + [False, "Enabled plugins", False, ""] + ) + disabled_iter = self._plugin_model.append( + None, + [False, "Disabled plugins", False, ""] + ) + for plugin_name, plugin_info in sorted(available_plugins.items()): # Skip PluginManager to prevent users from disabling plugin management if plugin_name == "PluginManager": continue - # Create row container - row = Gtk.ListBoxRow() - row.set_activatable(False) - - # Create horizontal box for checkbox and info - hbox = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=10) - hbox.set_border_width(5) - - # Create checkbox - checkbox = Gtk.CheckButton() - checkbox.set_active(plugin_name in active_plugins) - checkbox.connect("toggled", self._on_plugin_toggled, plugin_name) - - # Create plugin info label - info_text = f"{plugin_info.get('name', plugin_name)}" - if plugin_info.get('description'): - info_text += f"\n{plugin_info['description']}" - if plugin_info.get('version'): - info_text += f" (v{plugin_info['version']})" - - label = Gtk.Label() - label.set_markup(info_text) - label.set_halign(Gtk.Align.START) - label.set_line_wrap(True) - - # Pack widgets - hbox.pack_start(checkbox, False, False, 0) - hbox.pack_start(label, True, True, 0) - - row.add(hbox) - self._plugin_listbox.add(row) - - # Store checkbox reference - self._plugin_checkboxes[plugin_name] = checkbox + display_name = plugin_info.get('name', plugin_name) + display_text = display_name + description = plugin_info.get('description') + if description: + display_text += f" - {description}" + version = plugin_info.get('version') + if version: + display_text += f" (v{version})" + + is_active = plugin_name in active_plugins + parent_iter = enabled_iter if is_active else disabled_iter + self._plugin_model.append( + parent_iter, + [is_active, display_text, True, plugin_name] + ) + + self._plugin_treeview.collapse_all() except Exception as e: logger.error(f"PluginManager: Error populating plugin list: {e}") @@ -344,51 +368,120 @@ class PluginManager(Plugin): except Exception as e: logger.error(f"PluginManager: Error scanning directory {directory}: {e}") - def _on_plugin_toggled(self, checkbox, plugin_name): - """Handle plugin checkbox toggle.""" + def _on_plugin_toggled(self, renderer, path): + """Handle plugin toggle via TreeView.""" + try: + tree_iter = self._plugin_model.get_iter(path) + if not tree_iter: + return + + can_toggle = self._plugin_model.get_value(tree_iter, self.PLUGIN_COL_CAN_TOGGLE) + if not can_toggle: + return + + is_active = self._plugin_model.get_value(tree_iter, self.PLUGIN_COL_ENABLED) + plugin_name = self._plugin_model.get_value(tree_iter, self.PLUGIN_COL_NAME) + new_active = not is_active + self._plugin_model.set_value(tree_iter, self.PLUGIN_COL_ENABLED, new_active) + self._set_plugin_active(plugin_name, new_active) + self._rebuild_plugin_groups() + except Exception as error: + logger.error(f"PluginManager: Error toggling plugin at path {path}: {error}") + debug.printMessage(debug.LEVEL_INFO, f"PluginManager: Error toggling plugin at path {path}: {error}", True) + + def _on_plugin_list_key_press(self, widget, event): + """Toggle plugin on Space while keeping Tab navigation outside the list.""" + if event.keyval != Gdk.KEY_space: + return False + + selection = self._plugin_treeview.get_selection() + model, tree_iter = selection.get_selected() + if not tree_iter: + return False + + path = model.get_path(tree_iter) + self._on_plugin_toggled(None, path) + return True + + def _on_plugin_row_activated(self, treeview, path, column): + """Toggle plugin when activating a row.""" + self._on_plugin_toggled(None, path) + + def _rebuild_plugin_groups(self): + """Rebuild the tree so enabled/disabled groups stay accurate.""" + if not self._plugin_model: + return + + selection = self._plugin_treeview.get_selection() + model, tree_iter = selection.get_selected() + selected_name = None + if tree_iter: + selected_name = model.get_value(tree_iter, self.PLUGIN_COL_NAME) + + self._populate_plugin_list() + + if selected_name: + self._select_plugin_row(selected_name) + + def _select_plugin_row(self, plugin_name): + """Select the row for the given plugin name.""" + def _walk(model, tree_iter): + while tree_iter: + name = model.get_value(tree_iter, self.PLUGIN_COL_NAME) + if name == plugin_name: + path = model.get_path(tree_iter) + self._plugin_treeview.expand_to_path(path) + self._plugin_treeview.get_selection().select_path(path) + return True + if model.iter_has_child(tree_iter): + child = model.iter_children(tree_iter) + if _walk(model, child): + return True + tree_iter = model.iter_next(tree_iter) + return False + + root = self._plugin_model.get_iter_first() + _walk(self._plugin_model, root) + + def _set_plugin_active(self, plugin_name, is_active): + """Update settings to enable or disable a plugin.""" try: - is_active = checkbox.get_active() debug.printMessage(debug.LEVEL_INFO, f"PluginManager: Plugin {plugin_name} toggled to {'active' if is_active else 'inactive'}", True) - + # Get current active plugins active_plugins = _settingsManager.getSetting('activePlugins') or [] - active_plugins = list(active_plugins) # Make a copy - - # Update the list + active_plugins = list(active_plugins) + if is_active and plugin_name not in active_plugins: active_plugins.append(plugin_name) elif not is_active and plugin_name in active_plugins: active_plugins.remove(plugin_name) - - # Save updated settings + _settingsManager.setSetting('activePlugins', active_plugins) - - # Save to disk using the backend directly + try: - # Get current general settings current_general = _settingsManager.getGeneralSettings() current_general['activePlugins'] = active_plugins - - # Save using the backend + backend = _settingsManager._backend if backend: backend.saveDefaultSettings( current_general, - _settingsManager.getPronunciations(), + _settingsManager.getPronunciations(), _settingsManager.getKeybindings() ) debug.printMessage(debug.LEVEL_INFO, "PluginManager: Settings saved to backend", True) else: debug.printMessage(debug.LEVEL_INFO, "PluginManager: No backend available for saving", True) - - except Exception as save_e: - debug.printMessage(debug.LEVEL_INFO, f"PluginManager: Error saving via backend: {save_e}", True) - + + except Exception as save_error: + debug.printMessage(debug.LEVEL_INFO, f"PluginManager: Error saving via backend: {save_error}", True) + debug.printMessage(debug.LEVEL_INFO, f"PluginManager: Updated active plugins: {active_plugins}", True) - - except Exception as e: - logger.error(f"PluginManager: Error toggling plugin {plugin_name}: {e}") - debug.printMessage(debug.LEVEL_INFO, f"PluginManager: Error toggling {plugin_name}: {e}", 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) def _on_dialog_response(self, dialog, response_id): diff --git a/src/cthulhu/plugins/meson.build b/src/cthulhu/plugins/meson.build index 73c5d97..707506c 100644 --- a/src/cthulhu/plugins/meson.build +++ b/src/cthulhu/plugins/meson.build @@ -4,6 +4,7 @@ subdir('ByeCthulhu') subdir('Clipboard') subdir('DisplayVersion') subdir('HelloCthulhu') +subdir('GameMode') subdir('IndentationAudio') subdir('OCR') subdir('PluginManager') diff --git a/src/cthulhu/scripts/default.py b/src/cthulhu/scripts/default.py index 5f66569..264e751 100644 --- a/src/cthulhu/scripts/default.py +++ b/src/cthulhu/scripts/default.py @@ -824,6 +824,7 @@ class Script(script.Script): sleepModeManager.toggleSleepMode(self) return True + def bypassNextCommand(self, inputEvent=None): """Causes the next keyboard command to be ignored by Cthulhu and passed along to the current application. diff --git a/src/cthulhu/settings.py b/src/cthulhu/settings.py index 65916e6..f0ae224 100644 --- a/src/cthulhu/settings.py +++ b/src/cthulhu/settings.py @@ -64,6 +64,7 @@ userCustomizableSettings = [ "enableEchoByWord", "enableEchoBySentence", "enableKeyEcho", + "gameMode", "enableAlphabeticKeys", "enableNumericKeys", "enablePunctuationKeys", @@ -317,6 +318,7 @@ verbalizePunctuationStyle = PUNCTUATION_STYLE_MOST speechVerbosityLevel = VERBOSITY_LEVEL_VERBOSE messagesAreDetailed = True enablePauseBreaks = True +gameMode = False speakDescription = True speakContextBlockquote = True speakContextPanel = True