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