diff --git a/src/cthulhu/cthulhu_gui_prefs.py b/src/cthulhu/cthulhu_gui_prefs.py index 0a1d36f..9667f7f 100644 --- a/src/cthulhu/cthulhu_gui_prefs.py +++ b/src/cthulhu/cthulhu_gui_prefs.py @@ -111,11 +111,6 @@ 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. @@ -144,22 +139,16 @@ class CthulhuSetupGUI(cthulhu_gtkbuilder.GtkBuilderWrapper): self.planeCellRendererText = None self.pronunciationModel = None self.pronunciationView = None - self._plugin_treeview = None + self._plugin_combo = 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 - self._plugin_sources_original = [] + self._plugin_entries = {} + self._plugin_enabled_button = None + self._plugin_disabled_button = None + self._plugin_description_view = None + self._updating_plugin_controls = False self._available_plugins = set() self._plugin_canonical_map = {} self._plugin_group_map = {} - self._plugin_update_button = None - 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 = {} @@ -440,148 +429,80 @@ class CthulhuSetupGUI(cthulhu_gtkbuilder.GtkBuilderWrapper): self._initGUIState() self._initSoundThemeState() - def _initPluginsPage(self): - self._plugin_sources = list(self.prefsDict.get("pluginSources", settings.pluginSources) or []) - self._plugin_sources_original = list(self._plugin_sources) - + def _initPluginsPage(self) -> None: pluginsPage = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=10) pluginsPage.set_border_width(12) - infoLabel = Gtk.Label(label="Enable or disable plugins and manage plugin sources.") + infoLabel = Gtk.Label(label="Select a plugin, then choose whether it is enabled or disabled.") infoLabel.set_line_wrap(True) infoLabel.set_halign(Gtk.Align.START) pluginsPage.pack_start(infoLabel, False, False, 0) - pluginsFrame = Gtk.Frame(label="Plugins") - try: - pluginsFrame.set_label_align(0.0, 0.5) - except Exception: - try: - pluginsFrame.set_label_xalign(0.0) - except Exception: - pass - pluginsFrame.set_shadow_type(Gtk.ShadowType.NONE) - pluginsFrameBox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=6) - pluginsFrameBox.set_border_width(6) - pluginsFrame.add(pluginsFrameBox) - - pluginsScrolled = Gtk.ScrolledWindow() - pluginsScrolled.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC) - pluginsScrolled.set_size_request(-1, 200) - 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) - + pluginGrid = Gtk.Grid(row_spacing=10, column_spacing=10) + pluginLabel = Gtk.Label(label="_Plugin:") + pluginLabel.set_use_underline(True) + pluginLabel.set_halign(Gtk.Align.START) + self._plugin_model = Gtk.ListStore(GObject.TYPE_STRING, GObject.TYPE_STRING) + self._plugin_combo = Gtk.ComboBox.new_with_model(self._plugin_model) text_renderer = Gtk.CellRendererText() - text_renderer.set_property("ellipsize", Pango.EllipsizeMode.END) - text_column = Gtk.TreeViewColumn( - "Plugin", - text_renderer, - text=self.PLUGIN_COL_DISPLAY + self._plugin_combo.pack_start(text_renderer, True) + self._plugin_combo.add_attribute(text_renderer, "text", 0) + self._plugin_combo.set_hexpand(True) + self._plugin_combo.connect("changed", self._on_plugin_selection_changed) + pluginLabel.set_mnemonic_widget(self._plugin_combo) + pluginGrid.attach(pluginLabel, 0, 0, 1, 1) + pluginGrid.attach(self._plugin_combo, 1, 0, 1, 1) + pluginsPage.pack_start(pluginGrid, False, False, 0) + + stateFrame = Gtk.Frame(label="State") + stateFrame.set_shadow_type(Gtk.ShadowType.NONE) + stateBox = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=12) + stateBox.set_border_width(6) + self._plugin_enabled_button = Gtk.RadioButton.new_with_mnemonic(None, "_Enabled") + self._plugin_disabled_button = Gtk.RadioButton.new_with_mnemonic_from_widget( + self._plugin_enabled_button, + "_Disabled", ) - text_column.set_expand(True) - self._plugin_treeview.append_column(text_column) + self._plugin_enabled_button.connect("toggled", self._on_plugin_enabled_toggled) + self._plugin_disabled_button.connect("toggled", self._on_plugin_disabled_toggled) + stateBox.pack_start(self._plugin_enabled_button, False, False, 0) + stateBox.pack_start(self._plugin_disabled_button, False, False, 0) + stateFrame.add(stateBox) + pluginsPage.pack_start(stateFrame, False, False, 0) - pluginsScrolled.add(self._plugin_treeview) - pluginsFrameBox.pack_start(pluginsScrolled, True, True, 0) + descriptionLabel = Gtk.Label(label="_Description:") + descriptionLabel.set_use_underline(True) + descriptionLabel.set_halign(Gtk.Align.START) + pluginsPage.pack_start(descriptionLabel, False, False, 0) - pluginsPage.pack_start(pluginsFrame, True, True, 0) - - sourcesFrame = Gtk.Frame(label="Plugin Sources") - try: - sourcesFrame.set_label_align(0.0, 0.5) - except Exception: - try: - sourcesFrame.set_label_xalign(0.0) - except Exception: - pass - sourcesFrame.set_shadow_type(Gtk.ShadowType.NONE) - sourcesFrameBox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=6) - sourcesFrameBox.set_border_width(6) - sourcesFrame.add(sourcesFrameBox) - - sourcesGrid = Gtk.Grid(row_spacing=6, column_spacing=6) - sourcesLabel = Gtk.Label(label="_Source URL:") - sourcesLabel.set_use_underline(True) - sourcesLabel.set_halign(Gtk.Align.START) - self._plugin_sources_entry = Gtk.Entry() - self._plugin_sources_entry.set_placeholder_text("https://example.com/repo.git") - sourcesLabel.set_mnemonic_widget(self._plugin_sources_entry) - addButton = Gtk.Button(label="Add Source") - addButton.connect("clicked", self._on_add_plugin_source) - sourcesGrid.attach(sourcesLabel, 0, 0, 1, 1) - sourcesGrid.attach(self._plugin_sources_entry, 1, 0, 1, 1) - sourcesGrid.attach(addButton, 2, 0, 1, 1) - sourcesFrameBox.pack_start(sourcesGrid, False, False, 0) - - updateRow = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6) - self._plugin_update_button = Gtk.Button(label="Update Plugins") - self._plugin_update_button.connect("clicked", self._on_update_plugins_clicked) - self._plugin_update_progress = Gtk.ProgressBar() - self._plugin_update_progress.set_show_text(True) - self._plugin_update_progress.set_text("Idle") - updateRow.pack_start(self._plugin_update_button, False, False, 0) - updateRow.pack_start(self._plugin_update_progress, True, True, 0) - sourcesFrameBox.pack_start(updateRow, False, False, 0) - - sourcesScrolled = Gtk.ScrolledWindow() - sourcesScrolled.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC) - sourcesScrolled.set_size_request(-1, 120) - self._plugin_sources_listbox = Gtk.ListBox() - self._plugin_sources_listbox.set_selection_mode(Gtk.SelectionMode.NONE) - sourcesScrolled.add(self._plugin_sources_listbox) - sourcesFrameBox.pack_start(sourcesScrolled, True, True, 0) - - sourcesNote = Gtk.Label(label="Use Update Plugins to install or update sources, then Apply or OK to save settings.") - sourcesNote.set_line_wrap(True) - sourcesNote.set_halign(Gtk.Align.START) - sourcesFrameBox.pack_start(sourcesNote, False, False, 0) - - self._plugin_update_status = Gtk.Label(label="") - self._plugin_update_status.set_line_wrap(True) - self._plugin_update_status.set_halign(Gtk.Align.START) - sourcesFrameBox.pack_start(self._plugin_update_status, False, False, 0) - - pluginsPage.pack_start(sourcesFrame, True, True, 0) + descriptionScrolled = Gtk.ScrolledWindow() + descriptionScrolled.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC) + descriptionScrolled.set_size_request(-1, 180) + self._plugin_description_view = Gtk.TextView() + self._plugin_description_view.set_editable(False) + self._plugin_description_view.set_cursor_visible(True) + self._plugin_description_view.set_accepts_tab(False) + self._plugin_description_view.set_wrap_mode(Gtk.WrapMode.WORD_CHAR) + self._plugin_description_view.get_accessible().set_name("Plugin description") + descriptionLabel.set_mnemonic_widget(self._plugin_description_view) + descriptionScrolled.add(self._plugin_description_view) + pluginsPage.pack_start(descriptionScrolled, True, True, 0) notebook = self.get_widget("notebook") notebook.append_page(pluginsPage, Gtk.Label(label="Plugins")) self._populate_plugin_list() - self._populate_plugin_sources_list() pluginsPage.show_all() - def _clear_listbox(self, listbox): - for child in listbox.get_children(): - listbox.remove(child) - - def _populate_plugin_list(self): - if not self._plugin_treeview: + def _populate_plugin_list(self) -> None: + if not self._plugin_combo: return try: + selected_plugin = self._get_selected_plugin_name() + selected_index = 0 self._plugin_model.clear() - self._plugin_iters = {} + self._plugin_entries = {} self._available_plugins = set() self._plugin_canonical_map = {} self._plugin_group_map = {} @@ -607,15 +528,6 @@ class CthulhuSetupGUI(cthulhu_gtkbuilder.GtkBuilderWrapper): 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() @@ -640,242 +552,109 @@ class CthulhuSetupGUI(cthulhu_gtkbuilder.GtkBuilderWrapper): 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" - {description}" version = plugin_info.get_version() if version: - info_text += f" (v{version})" + description = f"{description}\n\nVersion: {version}" if description else f"Version: {version}" if canonical_counts.get(canonical_name, 0) > 1: - info_text += f" - Source: {plugin_info.get_source_label()}" + source_label = plugin_info.get_source_label() + display_name += f" ({source_label})" if canonical_builtins.get(canonical_name) and not plugin_info.builtin: - info_text += " - Disabled because a builtin plugin uses this name." + description += "\n\nThis copy cannot be enabled because a built-in plugin uses the same name." + if plugin_info.builtin: + description += "\n\nThis built-in plugin is always enabled." - 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 + if plugin_name == selected_plugin: + selected_index = len(self._plugin_model) + self._plugin_model.append([display_name, plugin_name]) + self._plugin_entries[plugin_name] = { + "active": is_active, + "can_toggle": can_toggle, + "description": description, + } self._plugin_group_map.setdefault(canonical_name, []).append(plugin_name) - self._plugin_treeview.collapse_all() - self._plugin_treeview.show_all() + if len(self._plugin_model): + self._plugin_combo.set_active(selected_index) + else: + self._update_selected_plugin_controls() except Exception as e: debug.printMessage(debug.LEVEL_WARNING, f"PREFERENCES DIALOG: Plugin list build failed: {e}", True) - def _populate_plugin_sources_list(self): - if not self._plugin_sources_listbox: + def _get_selected_plugin_name(self) -> str | None: + if not self._plugin_combo: + return None + tree_iter = self._plugin_combo.get_active_iter() + if tree_iter is None: + return None + return self._plugin_model.get_value(tree_iter, 1) + + def _on_plugin_selection_changed(self, widget: Gtk.ComboBox) -> None: + self._update_selected_plugin_controls() + + def _update_selected_plugin_controls(self) -> None: + plugin_name = self._get_selected_plugin_name() + plugin_entry = self._plugin_entries.get(plugin_name, {}) + is_active = bool(plugin_entry.get("active", False)) + can_toggle = bool(plugin_entry.get("can_toggle", False)) + + self._updating_plugin_controls = True + try: + self._plugin_enabled_button.set_active(is_active) + self._plugin_disabled_button.set_active(not is_active) + self._plugin_enabled_button.set_sensitive(can_toggle) + self._plugin_disabled_button.set_sensitive(can_toggle) + description = str(plugin_entry.get("description", "")) + self._plugin_description_view.get_buffer().set_text(description) + self._plugin_description_view.get_buffer().place_cursor( + self._plugin_description_view.get_buffer().get_start_iter() + ) + finally: + self._updating_plugin_controls = False + + def _on_plugin_enabled_toggled(self, widget: Gtk.RadioButton) -> None: + if widget.get_active() and not self._updating_plugin_controls: + self._set_plugin_active(self._get_selected_plugin_name(), True) + + def _on_plugin_disabled_toggled(self, widget: Gtk.RadioButton) -> None: + if widget.get_active() and not self._updating_plugin_controls: + self._set_plugin_active(self._get_selected_plugin_name(), False) + + def _set_plugin_active(self, plugin_name: str | None, is_active: bool) -> None: + plugin_entry = self._plugin_entries.get(plugin_name) + if not plugin_entry or not plugin_entry.get("can_toggle", False): + self._update_selected_plugin_controls() return - self._clear_listbox(self._plugin_sources_listbox) - - for source in self._plugin_sources: - row = Gtk.ListBoxRow() - row.set_activatable(False) - hbox = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=10) - hbox.set_border_width(5) - - label = Gtk.Label(label=source) - label.set_halign(Gtk.Align.START) - label.set_line_wrap(True) - - remove_button = Gtk.Button(label="Remove") - remove_button.connect("clicked", self._on_remove_plugin_source, source, row) - - hbox.pack_start(label, True, True, 0) - hbox.pack_start(remove_button, False, False, 0) - row.add(hbox) - self._plugin_sources_listbox.add(row) - - self._plugin_sources_listbox.show_all() - - def _on_add_plugin_source(self, widget): - if not self._plugin_sources_entry: - return - source = self._plugin_sources_entry.get_text().strip() - if not source: - return - if source in self._plugin_sources: - return - self._plugin_sources.append(source) - self._plugin_sources_entry.set_text("") - self._populate_plugin_sources_list() - - def _on_remove_plugin_source(self, widget, source, row): - if source in self._plugin_sources: - self._plugin_sources.remove(source) - if self._plugin_sources_listbox and row: - self._plugin_sources_listbox.remove(row) - - def _on_update_plugins_clicked(self, widget): - if self._plugin_update_in_progress: - return - - sources = list(self._plugin_sources) - if not sources: - self._set_plugin_update_status("No sources to update.", done=True) - return - - self._plugin_update_in_progress = True - if self._plugin_update_button: - self._plugin_update_button.set_sensitive(False) - self._set_plugin_update_progress(0, len(sources), "Starting updates...") - - def _run_updates(): - manager = cthulhu.cthulhuApp.getPluginSystemManager() - if not manager: - GLib.idle_add(self._finish_plugin_updates, "Plugin manager unavailable.") - return - - def _progress_callback(index, total, message): - GLib.idle_add(self._set_plugin_update_progress, index, total, message) - - manager.syncPluginSources(sources, progress_callback=_progress_callback) - try: - manager.rescanPlugins() - except Exception as e: - GLib.idle_add(self._finish_plugin_updates, f"Update finished with errors: {e}") - return - - GLib.idle_add(self._finish_plugin_updates, "Update complete.") - - thread = threading.Thread(target=_run_updates, daemon=True) - thread.start() - - def _set_plugin_update_progress(self, index, total, message): - if not self._plugin_update_progress: - return - fraction = 0.0 if total <= 0 else min(1.0, float(index) / float(total)) - self._plugin_update_progress.set_fraction(fraction) - self._plugin_update_progress.set_text(message) - self._set_plugin_update_status(message) - - def _set_plugin_update_status(self, message, done=False): - if self._plugin_update_status: - self._plugin_update_status.set_text(message) - if done and self._plugin_update_progress: - self._plugin_update_progress.set_fraction(0.0) - self._plugin_update_progress.set_text("Idle") - - def _finish_plugin_updates(self, message): - self._plugin_update_in_progress = False - if self._plugin_update_button: - self._plugin_update_button.set_sensitive(True) - if message: - self._set_plugin_update_status(message) - self._populate_plugin_list() - - def _on_plugin_tree_toggled(self, renderer, path): - if not self._plugin_model: - return - - 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 - - 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: + if is_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) + if other_name != plugin_name: + self._plugin_entries[other_name]["active"] = 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) + plugin_entry["active"] = is_active + self._update_selected_plugin_controls() self._update_plugin_tabs() - def _get_active_plugins_from_ui(self): + def _get_active_plugins_from_ui(self) -> list[str]: existing_plugins = list(self.prefsDict.get("activePlugins", settings.activePlugins) or []) preserved_plugins = [ name for name in existing_plugins - if name not in self._plugin_iters and name in self._available_plugins + if name not in self._plugin_entries 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_iters.get(name) - and self._plugin_model.get_value(self._plugin_iters[name], self.PLUGIN_COL_ENABLED) + if self._plugin_entries.get(name, {}).get("active", False) ] if active_in_group: selected_plugins.append(active_in_group[-1]) return preserved_plugins + selected_plugins - def _apply_plugin_changes(self): + def _apply_plugin_changes(self) -> None: active_plugins = self._get_active_plugins_from_ui() - plugin_sources = list(self._plugin_sources) - self.prefsDict["activePlugins"] = active_plugins - self.prefsDict["pluginSources"] = plugin_sources - - removed_sources = [source for source in self._plugin_sources_original if source not in plugin_sources] - manager = cthulhu.cthulhuApp.getPluginSystemManager() - if manager: - try: - manager.removePluginSources(removed_sources) - manager.rescanPlugins() - except Exception as e: - debug.printMessage(debug.LEVEL_WARNING, f"PREFERENCES DIALOG: Plugin sync failed: {e}", True) - - self._plugin_sources_original = list(plugin_sources) self._populate_plugin_list() self._update_plugin_tabs(active_plugins) @@ -982,7 +761,7 @@ class CthulhuSetupGUI(cthulhu_gtkbuilder.GtkBuilderWrapper): self._plugin_tabs_cached = True def _get_active_plugins_for_tabs(self): - if self._plugin_iters: + if self._plugin_entries: return self._get_active_plugins_from_ui() return list(self.prefsDict.get("activePlugins", settings.activePlugins) or []) diff --git a/src/cthulhu/plugins/OCR/README.md b/src/cthulhu/plugins/OCR/README.md index 8378d1b..0c0d007 100644 --- a/src/cthulhu/plugins/OCR/README.md +++ b/src/cthulhu/plugins/OCR/README.md @@ -83,7 +83,7 @@ To add support for other languages, install additional Tesseract language packs: ## Usage 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" + - Cthulhu Preferences → Plugins → Select "OCR Desktop" and choose "Enabled" - Or add `OCR` to your `activePlugins` preference 2. **Basic OCR Workflow**: diff --git a/tests/test_plugin_preferences_regressions.py b/tests/test_plugin_preferences_regressions.py new file mode 100644 index 0000000..3b67415 --- /dev/null +++ b/tests/test_plugin_preferences_regressions.py @@ -0,0 +1,82 @@ +#!/usr/bin/env python3 + +"""Regression tests for the plugin controls in Preferences.""" + +import unittest +from unittest import mock + +from cthulhu import cthulhu # Import first to satisfy the application's initialization order. +from cthulhu import cthulhu_gui_prefs + + +class PluginPreferencesRegressionTests(unittest.TestCase): + """Keep plugin state changes independent from their presentation widgets.""" + + @staticmethod + def _create_gui(): + gui = cthulhu_gui_prefs.CthulhuSetupGUI.__new__( + cthulhu_gui_prefs.CthulhuSetupGUI + ) + gui.prefsDict = { + "activePlugins": ["HiddenPlugin", "SourceOne"], + "pluginSources": ["https://example.com/plugins.git"], + } + gui._available_plugins = {"HiddenPlugin", "SourceOne", "SourceTwo", "Required"} + gui._plugin_entries = { + "SourceOne": {"active": True, "can_toggle": True}, + "SourceTwo": {"active": False, "can_toggle": True}, + "Required": {"active": True, "can_toggle": False}, + } + gui._plugin_canonical_map = { + "SourceOne": "SharedPlugin", + "SourceTwo": "SharedPlugin", + "Required": "Required", + } + gui._plugin_group_map = { + "SharedPlugin": ["SourceOne", "SourceTwo"], + "Required": ["Required"], + } + gui._update_selected_plugin_controls = mock.Mock() + gui._update_plugin_tabs = mock.Mock() + return gui + + def test_active_plugins_include_hidden_and_selected_plugins(self): + gui = self._create_gui() + + self.assertEqual( + gui._get_active_plugins_from_ui(), + ["HiddenPlugin", "SourceOne", "Required"], + ) + + def test_enabling_plugin_disables_other_copy_with_same_name(self): + gui = self._create_gui() + + gui._set_plugin_active("SourceTwo", True) + + self.assertFalse(gui._plugin_entries["SourceOne"]["active"]) + self.assertTrue(gui._plugin_entries["SourceTwo"]["active"]) + gui._update_selected_plugin_controls.assert_called_once_with() + gui._update_plugin_tabs.assert_called_once_with() + + def test_required_plugin_cannot_be_disabled(self): + gui = self._create_gui() + + gui._set_plugin_active("Required", False) + + self.assertTrue(gui._plugin_entries["Required"]["active"]) + gui._update_plugin_tabs.assert_not_called() + + def test_apply_does_not_modify_plugin_sources(self): + gui = self._create_gui() + gui._populate_plugin_list = mock.Mock() + + gui._apply_plugin_changes() + + self.assertEqual( + gui.prefsDict["pluginSources"], + ["https://example.com/plugins.git"], + ) + + +if __name__ == "__main__": + unittest.main()