diff --git a/src/cthulhu/cthulhu_gui_prefs.py b/src/cthulhu/cthulhu_gui_prefs.py index 18ab399..0a1d36f 100644 --- a/src/cthulhu/cthulhu_gui_prefs.py +++ b/src/cthulhu/cthulhu_gui_prefs.py @@ -619,7 +619,7 @@ class CthulhuSetupGUI(cthulhu_gtkbuilder.GtkBuilderWrapper): 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": + if plugin_info.hidden: continue self._plugin_canonical_map[plugin_name] = canonical_name @@ -3911,6 +3911,11 @@ print(json.dumps(result)) combobox.set_model(self.hardwareDeviceModel) self._setHardwareDeviceChoice(saved_device) + @staticmethod + def _isHardwareSpeechFactory(factory): + moduleName = getattr(factory, "__name__", "") + return moduleName.split(".")[-1] == "hardwarefactory" + def _setHardwareDeviceChoice(self, device_name): """Set the active item in the hardware device combo box. @@ -3941,9 +3946,7 @@ print(json.dumps(result)) is_hardware = False if self.speechSystemsChoice: try: - is_hardware = ( - self.speechSystemsChoice.__name__ == "hardwarefactory" - ) + is_hardware = self._isHardwareSpeechFactory(self.speechSystemsChoice) except Exception: pass @@ -5052,7 +5055,7 @@ print(json.dumps(result)) # Save hardware speech device setting when hardware factory is active if self.speechSystemsChoice and \ - self.speechSystemsChoice.__name__ == "hardwarefactory": + self._isHardwareSpeechFactory(self.speechSystemsChoice): if self.hardwareDeviceChoice is not None: self.prefsDict["hardwareSpeechDevice"] = self.hardwareDeviceChoice else: diff --git a/src/cthulhu/plugin_system_manager.py b/src/cthulhu/plugin_system_manager.py index 3a1b625..5d58366 100644 --- a/src/cthulhu/plugin_system_manager.py +++ b/src/cthulhu/plugin_system_manager.py @@ -36,6 +36,10 @@ LEGACY_PLUGIN_NAME_ALIASES: Dict[str, str] = { "ocrdesktop": "OCR", } +OBSOLETE_PLUGIN_NAMES = { + "pluginmanager", +} + LEGACY_PLUGIN_DIR_ALIASES: Dict[str, str] = { "OCRDesktop": "OCR", } @@ -721,6 +725,9 @@ class PluginSystemManager: if not name: continue name_lower = name.lower() + if name_lower in OBSOLETE_PLUGIN_NAMES: + logger.info(f"Dropping obsolete plugin name {name}") + continue alias = LEGACY_PLUGIN_NAME_ALIASES.get(name_lower) if alias: logger.info(f"Mapping legacy plugin name {name} to {alias}") diff --git a/src/cthulhu/plugins/PluginManager/__init__.py b/src/cthulhu/plugins/PluginManager/__init__.py deleted file mode 100644 index a5dbd0a..0000000 --- a/src/cthulhu/plugins/PluginManager/__init__.py +++ /dev/null @@ -1,14 +0,0 @@ -#!/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. - -"""PluginManager plugin package.""" - -from .plugin import PluginManager - -__all__ = ['PluginManager'] \ No newline at end of file diff --git a/src/cthulhu/plugins/PluginManager/meson.build b/src/cthulhu/plugins/PluginManager/meson.build deleted file mode 100644 index d951770..0000000 --- a/src/cthulhu/plugins/PluginManager/meson.build +++ /dev/null @@ -1,14 +0,0 @@ -pluginmanager_python_sources = files([ - '__init__.py', - 'plugin.py' -]) - -python3.install_sources( - pluginmanager_python_sources, - subdir: 'cthulhu/plugins/PluginManager' -) - -install_data( - 'plugin.info', - install_dir: python3.get_install_dir() / 'cthulhu' / 'plugins' / 'PluginManager' -) \ No newline at end of file diff --git a/src/cthulhu/plugins/PluginManager/plugin.info b/src/cthulhu/plugins/PluginManager/plugin.info deleted file mode 100644 index 115f79e..0000000 --- a/src/cthulhu/plugins/PluginManager/plugin.info +++ /dev/null @@ -1,8 +0,0 @@ -name = PluginManager -version = 1.0.0 -description = GUI interface for managing Cthulhu plugins - enable/disable plugins with checkboxes -authors = Stormux -website = https://git.stormux.org/storm/cthulhu -copyright = Copyright 2025 -builtin = false -hidden = false \ No newline at end of file diff --git a/src/cthulhu/plugins/PluginManager/plugin.py b/src/cthulhu/plugins/PluginManager/plugin.py deleted file mode 100644 index a45c58a..0000000 --- a/src/cthulhu/plugins/PluginManager/plugin.py +++ /dev/null @@ -1,505 +0,0 @@ -#!/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. - -"""PluginManager plugin for Cthulhu - GUI interface for managing plugins.""" - -import logging -import os -import configparser -from pathlib import Path - -import gi -gi.require_version('Gtk', '3.0') -from gi.repository import Gtk, Gdk, Pango - -from cthulhu.plugin import Plugin, cthulhu_hookimpl -from cthulhu import cthulhu -from cthulhu import debug - -logger = logging.getLogger(__name__) - - -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_treeview = None - self._plugin_model = None - self._activated = False - - debug.printMessage(debug.LEVEL_INFO, "PluginManager: Plugin initialized", True) - - @cthulhu_hookimpl - def activate(self, plugin=None): - """Activate the PluginManager plugin.""" - if plugin is not None and plugin is not self: - return - - # Prevent duplicate activation - if self._activated: - debug.printMessage(debug.LEVEL_INFO, "PluginManager: Already activated, skipping", True) - return - - try: - debug.printMessage(debug.LEVEL_INFO, "PluginManager: Plugin activation starting", True) - - self._activated = True - debug.printMessage(debug.LEVEL_INFO, "PluginManager: Plugin activated successfully", True) - return True - - except Exception as e: - debug.printMessage(debug.LEVEL_INFO, f"PluginManager: ERROR activating plugin: {e}", True) - return False - - @cthulhu_hookimpl - def deactivate(self, plugin=None): - """Deactivate the PluginManager plugin.""" - if plugin is not None and plugin is not self: - return - - try: - debug.printMessage(debug.LEVEL_INFO, "PluginManager: Plugin deactivation starting", True) - - # Close dialog if open - if self._dialog: - self._dialog.destroy() - self._dialog = None - - self._activated = False - debug.printMessage(debug.LEVEL_INFO, "PluginManager: Plugin deactivated successfully", True) - return True - - except Exception as e: - debug.printMessage(debug.LEVEL_INFO, f"PluginManager: ERROR deactivating plugin: {e}", True) - return False - - def _register_keybinding(self): - """Register the Cthulhu+Shift+P keybinding for opening plugin manager.""" - try: - if not self.app: - debug.printMessage(debug.LEVEL_INFO, "PluginManager: ERROR - No app reference available for keybinding", True) - return - - # Register Cthulhu+Shift+P keybinding - gesture_string = "kb:cthulhu+shift+p" - description = "Open plugin manager" - - self._kb_binding = self.registerGestureByString( - self._open_plugin_manager, - description, - gesture_string - ) - - if self._kb_binding: - debug.printMessage(debug.LEVEL_INFO, f"PluginManager: Registered keybinding {gesture_string}", True) - else: - debug.printMessage(debug.LEVEL_INFO, f"PluginManager: ERROR - Failed to register keybinding {gesture_string}", True) - - except Exception as e: - debug.printMessage(debug.LEVEL_INFO, f"PluginManager: ERROR registering keybinding: {e}", True) - - def _open_plugin_manager(self, script, inputEvent=None): - """Open the plugin manager dialog.""" - try: - debug.printMessage(debug.LEVEL_INFO, "PluginManager: Opening plugin manager dialog", True) - - # Close existing dialog if open - if self._dialog: - self._dialog.destroy() - - # Create new dialog - self._create_dialog() - - return True - - except Exception as e: - logger.error(f"PluginManager: Error opening plugin manager: {e}") - debug.printMessage(debug.LEVEL_INFO, f"PluginManager: Error opening dialog: {e}", True) - return False - - def _create_dialog(self): - """Create and show the plugin manager dialog.""" - try: - # Create dialog window - self._dialog = Gtk.Dialog( - title="Cthulhu Plugin Manager", - parent=None, - flags=Gtk.DialogFlags.MODAL | Gtk.DialogFlags.DESTROY_WITH_PARENT - ) - - # Set dialog properties - self._dialog.set_default_size(400, 300) - self._dialog.set_border_width(10) - - # Add buttons - self._dialog.add_button("Close", Gtk.ResponseType.CLOSE) - - # Create main content area - content_area = self._dialog.get_content_area() - - # Add title label - title_label = Gtk.Label() - title_label.set_markup("Available Plugins") - title_label.set_halign(Gtk.Align.CENTER) - content_area.pack_start(title_label, False, False, 10) - - # Add info label about PluginManager - info_label = Gtk.Label() - info_label.set_markup("Note:To apply changes, restart Cthulhu") - info_label.set_halign(Gtk.Align.CENTER) - content_area.pack_start(info_label, False, False, 5) - - # Create scrolled window for plugin list - scrolled = Gtk.ScrolledWindow() - scrolled.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC) - scrolled.set_size_request(-1, 200) - - 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) - - # Populate plugin list - self._populate_plugin_list() - - # Connect signals - self._dialog.connect("response", self._on_dialog_response) - - # Show dialog - self._dialog.show_all() - - debug.printMessage(debug.LEVEL_INFO, "PluginManager: Dialog created and shown", True) - - except Exception as e: - logger.error(f"PluginManager: Error creating dialog: {e}") - debug.printMessage(debug.LEVEL_INFO, f"PluginManager: Error creating dialog: {e}", True) - - def _populate_plugin_list(self): - """Populate the plugin list with available plugins.""" - try: - # Get available plugins - available_plugins = self._discover_plugins() - - # Get currently active plugins - active_plugins = cthulhu.cthulhuApp.settingsManager.getSetting('activePlugins') or [] - - debug.printMessage(debug.LEVEL_INFO, f"PluginManager: Found {len(available_plugins)} plugins", True) - - # 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 - - 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}") - debug.printMessage(debug.LEVEL_INFO, f"PluginManager: Error populating list: {e}", True) - - def _discover_plugins(self): - """Discover all available plugins.""" - plugins = {} - - try: - # Get plugin system manager - from cthulhu import plugin_system_manager - - # Use existing plugin manager to get plugins - manager = plugin_system_manager.getManager() - if manager: - manager.rescanPlugins() - - for plugin_info in manager.plugins: - plugin_name = plugin_info.get_module_name() - plugins[plugin_name] = { - 'name': plugin_info.get_name(), - 'description': plugin_info.get_description(), - 'version': plugin_info.get_version(), - 'path': plugin_info.get_module_dir() - } - else: - # Fallback: manually scan plugin directories - plugins = self._manual_plugin_discovery() - - except Exception as e: - logger.error(f"PluginManager: Error in plugin discovery: {e}") - debug.printMessage(debug.LEVEL_INFO, f"PluginManager: Plugin discovery error: {e}", True) - # Fallback to manual discovery - plugins = self._manual_plugin_discovery() - - return plugins - - def _manual_plugin_discovery(self): - """Manually discover plugins by scanning directories.""" - plugins = {} - - try: - # Get plugin directories - from cthulhu.plugin_system_manager import PluginType - - system_dir = PluginType.SYSTEM.get_root_dir() - user_dir = PluginType.USER.get_root_dir() - - for plugin_dir in [system_dir, user_dir]: - if os.path.exists(plugin_dir): - self._scan_plugin_directory(plugin_dir, plugins) - - except Exception as e: - logger.error(f"PluginManager: Error in manual discovery: {e}") - debug.printMessage(debug.LEVEL_INFO, f"PluginManager: Manual discovery error: {e}", True) - - return plugins - - def _scan_plugin_directory(self, directory, plugins): - """Scan a directory for plugins.""" - try: - for item in os.listdir(directory): - plugin_path = os.path.join(directory, item) - if os.path.isdir(plugin_path): - # Check for plugin.py and plugin.info - plugin_py = os.path.join(plugin_path, "plugin.py") - plugin_info_file = os.path.join(plugin_path, "plugin.info") - - if os.path.exists(plugin_py): - plugin_info = {'name': item, 'description': '', 'version': ''} - - # Try to read plugin.info - if os.path.exists(plugin_info_file): - try: - config = configparser.ConfigParser() - config.read(plugin_info_file) - - # Handle both INI-style and simple key=value format - if config.sections(): - # INI-style format - for section in config.sections(): - for key, value in config[section].items(): - if key.lower() in ['description', 'version']: - plugin_info[key.lower()] = value - else: - # Simple key=value format - with open(plugin_info_file, 'r') as f: - for line in f: - line = line.strip() - if '=' in line and not line.startswith('#'): - key, value = line.split('=', 1) - key = key.strip().lower() - value = value.strip() - if key in ['description', 'version']: - plugin_info[key] = value - except Exception as info_e: - debug.printMessage(debug.LEVEL_INFO, f"PluginManager: Error reading {plugin_info_file}: {info_e}", True) - - plugins[item] = plugin_info - - except Exception as e: - logger.error(f"PluginManager: Error scanning directory {directory}: {e}") - - 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: - 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 = cthulhu.cthulhuApp.settingsManager.getSetting('activePlugins') or [] - 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) - - cthulhu.cthulhuApp.settingsManager.setSetting('activePlugins', active_plugins) - if hasattr(cthulhu.cthulhuApp.settingsManager, "general") and isinstance(cthulhu.cthulhuApp.settingsManager.general, dict): - cthulhu.cthulhuApp.settingsManager.general['activePlugins'] = active_plugins - - try: - active_profile = cthulhu.cthulhuApp.settingsManager.getSetting('activeProfile') - if isinstance(active_profile, (list, tuple)) and len(active_profile) > 1: - profile_name = active_profile[1] - else: - profile_name = cthulhu.cthulhuApp.settingsManager.profile or 'default' - - current_general = cthulhu.cthulhuApp.settingsManager.getGeneralSettings(profile_name) or {} - current_general['activePlugins'] = active_plugins - - cthulhu.cthulhuApp.settingsManager.profile = profile_name - cthulhu.cthulhuApp.settingsManager.saveProfileSettings(current_general) - debug.printMessage(debug.LEVEL_INFO, f"PluginManager: Settings saved via settings manager (profile {profile_name})", True) - - except Exception as save_error: - debug.printMessage(debug.LEVEL_INFO, f"PluginManager: Error saving plugin state: {save_error}", True) - - 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) - - - def _on_dialog_response(self, dialog, response_id): - """Handle dialog response (close button clicked).""" - try: - if response_id == Gtk.ResponseType.CLOSE: - dialog.destroy() - self._dialog = None - debug.printMessage(debug.LEVEL_INFO, "PluginManager: Dialog closed", True) - - except Exception as e: - logger.error(f"PluginManager: Error handling dialog response: {e}") diff --git a/src/cthulhu/plugins/meson.build b/src/cthulhu/plugins/meson.build index d4f7682..e3e0b6a 100644 --- a/src/cthulhu/plugins/meson.build +++ b/src/cthulhu/plugins/meson.build @@ -8,7 +8,6 @@ subdir('HelloCthulhu') subdir('GameMode') subdir('nvda2cthulhu') subdir('OCR') -subdir('PluginManager') subdir('SpeechHistory') subdir('SimplePluginSystem') subdir('hello_world') diff --git a/src/cthulhu/scripts/apps/steamwebhelper/script.py b/src/cthulhu/scripts/apps/steamwebhelper/script.py index c4e5d21..f77532a 100644 --- a/src/cthulhu/scripts/apps/steamwebhelper/script.py +++ b/src/cthulhu/scripts/apps/steamwebhelper/script.py @@ -164,6 +164,16 @@ class Script(Chromium.Script): super().onTextInserted(event) + def onCaretMoved(self, event): + """Callback for object:text-caret-moved accessibility events.""" + + if self._isSteamLoadingSpinnerCaretEvent(event): + msg = "STEAM: Ignoring caret event from transient loading spinner" + debug.printMessage(debug.LEVEL_INFO, msg, True) + return True + + return super().onCaretMoved(event) + def onSelectionChanged(self, event): """Callback for object:selection-changed accessibility events.""" @@ -193,6 +203,34 @@ class Script(Chromium.Script): debug.printMessage(debug.LEVEL_INFO, msg, True) return True + def _isSteamLoadingSpinnerCaretEvent(self, event) -> bool: + if not (event and event.type.startswith("object:text-caret-moved")): + return False + + source = getattr(event, "source", None) + if not source or not self.utilities.inDocumentContent(source): + return False + + if AXObject.get_name(source): + return False + + try: + children = list(AXObject.iter_children(source)) + except Exception: + return False + + if not children: + return False + + for child in children: + if not AXUtilities.is_image_or_canvas(child): + continue + name = AXObject.get_name(child) or "" + if name.strip().casefold() == "steam spinner": + return True + + return False + def _trySteamButtonActivation(self, keyboardEvent) -> bool: if keyboardEvent.event_string not in ["Return", "KP_Enter"]: return False diff --git a/src/cthulhu/scripts/web/script.py b/src/cthulhu/scripts/web/script.py index 5232aff..14d47af 100644 --- a/src/cthulhu/scripts/web/script.py +++ b/src/cthulhu/scripts/web/script.py @@ -1758,7 +1758,8 @@ class Script(default.Script): if not document: msg = "WEB: Locus of focus changed to non-document obj" self._madeFindAnnouncement = False - self._inFocusMode = False + if not self._focusModeIsSticky: + self._inFocusMode = False self._setNavigationSuspended(True, "focus left document content") debug.printMessage(debug.LEVEL_INFO, msg, True) oldDocument = self.utilities.getTopLevelDocumentForObject(oldFocus) @@ -1779,6 +1780,19 @@ class Script(default.Script): if self._navSuspended: self._setNavigationSuspended(False, "focus entered document content") + if self._focusModeIsSticky and not self._inFocusMode: + msg = "WEB: Restoring sticky focus mode after returning to document content" + debug.printMessage(debug.LEVEL_INFO, msg, True) + self._inFocusMode = True + self.presentMessage(messages.MODE_FOCUS_IS_STICKY) + self.refreshKeyGrabs() + elif self._browseModeIsSticky and self._inFocusMode: + msg = "WEB: Restoring sticky browse mode after returning to document content" + debug.printMessage(debug.LEVEL_INFO, msg, True) + self._inFocusMode = False + self.presentMessage(messages.MODE_BROWSE_IS_STICKY) + self.refreshKeyGrabs() + if self.flatReviewPresenter.is_active(): self.flatReviewPresenter.quit() @@ -2551,6 +2565,7 @@ class Script(default.Script): if self._browseModeIsSticky: msg = "WEB: Web app descendant claimed focus, but browse mode is sticky" debug.printMessage(debug.LEVEL_INFO, msg, True) + return True elif AXUtilities.is_tool_tip(event.source) \ and AXObject.find_ancestor(cthulhu_state.locusOfFocus, lambda x: x == event.source): msg = "WEB: Event believed to be side effect of tooltip navigation." diff --git a/src/cthulhu/settings.py b/src/cthulhu/settings.py index 323245b..d6bcb92 100644 --- a/src/cthulhu/settings.py +++ b/src/cthulhu/settings.py @@ -497,7 +497,6 @@ presentLiveRegionFromInactiveTab = False # Plugins activePlugins = [ - 'PluginManager', 'ByeCthulhu', 'Clipboard', 'HelloCthulhu', diff --git a/tests/test_plugin_system_manager_regressions.py b/tests/test_plugin_system_manager_regressions.py index 554ee67..fa65cd0 100644 --- a/tests/test_plugin_system_manager_regressions.py +++ b/tests/test_plugin_system_manager_regressions.py @@ -137,6 +137,21 @@ class PluginSystemManagerRegressionTests(unittest.TestCase): self.assertEqual(instance.deactivation_count, 1) self.assertEqual(plugin_info.instance, None) + @mock.patch("cthulhu.plugin_system_manager.dbus_service.get_remote_controller") + def test_obsolete_plugin_manager_active_entry_is_dropped(self, remote_controller): + remote_controller.return_value = mock.Mock() + + with tempfile.TemporaryDirectory() as temp_dir: + manager = self._create_manager() + plugin_info = self._create_plugin_info(temp_dir, "OCR") + manager._plugins["OCR"] = plugin_info + manager._plugin_name_index["OCR"] = ["OCR"] + + with mock.patch.object(manager, "syncAllPluginsActive"): + manager.setActivePlugins(["PluginManager", "OCR"]) + + self.assertEqual(manager.getActivePlugins(), ["OCR"]) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_steam_selection_regressions.py b/tests/test_steam_selection_regressions.py index 4127375..49d24e0 100644 --- a/tests/test_steam_selection_regressions.py +++ b/tests/test_steam_selection_regressions.py @@ -96,6 +96,45 @@ class SteamReturnActivationTests(unittest.TestCase): class SteamVirtualizedListMutationTests(unittest.TestCase): + def test_caret_moved_ignores_transient_loading_spinner(self): + testScript = steam_script.Script.__new__(steam_script.Script) + source = object() + spinner = object() + event = mock.Mock(type="object:text-caret-moved", source=source) + + testScript.utilities = mock.Mock() + testScript.utilities.inDocumentContent.return_value = True + + with ( + mock.patch.object(steam_script.AXObject, "get_name", side_effect=["", "Steam Spinner"]), + mock.patch.object(steam_script.AXObject, "iter_children", return_value=iter([spinner])), + mock.patch.object(steam_script.AXUtilities, "is_image_or_canvas", return_value=True), + mock.patch.object(steam_script.debug, "printMessage"), + mock.patch.object(steam_script.Chromium.Script, "onCaretMoved") as chromiumCaretMoved, + ): + self.assertTrue(testScript.onCaretMoved(event)) + + chromiumCaretMoved.assert_not_called() + + def test_caret_moved_defers_to_chromium_without_loading_spinner(self): + testScript = steam_script.Script.__new__(steam_script.Script) + source = object() + child = object() + event = mock.Mock(type="object:text-caret-moved", source=source) + + testScript.utilities = mock.Mock() + testScript.utilities.inDocumentContent.return_value = True + + with ( + mock.patch.object(steam_script.AXObject, "get_name", side_effect=["", "Not A Spinner"]), + mock.patch.object(steam_script.AXObject, "iter_children", return_value=iter([child])), + mock.patch.object(steam_script.AXUtilities, "is_image_or_canvas", return_value=True), + mock.patch.object(steam_script.Chromium.Script, "onCaretMoved", return_value="handled") as chromiumCaretMoved, + ): + self.assertEqual(testScript.onCaretMoved(event), "handled") + + chromiumCaretMoved.assert_called_once_with(event) + def test_children_added_skips_generic_web_cache_dump_for_virtualized_list_churn(self): testScript = steam_script.Script.__new__(steam_script.Script) source = object() diff --git a/tests/test_toml_backend_legacy_schema_regressions.py b/tests/test_toml_backend_legacy_schema_regressions.py index 0fc97ba..267b130 100644 --- a/tests/test_toml_backend_legacy_schema_regressions.py +++ b/tests/test_toml_backend_legacy_schema_regressions.py @@ -39,7 +39,7 @@ color-calculation-max = 3 copy-to-clipboard = false [profiles.default.plugins] -active-plugins = ["PluginManager", "OCR"] +active-plugins = ["Clipboard", "OCR"] plugin-sources = [] """ @@ -64,7 +64,7 @@ class LegacyTomlSchemaMigrationTests(unittest.TestCase): settings.GENERAL_KEYBOARD_LAYOUT_DESKTOP, ) self.assertEqual(general["cthulhuModifierKeys"], settings.DESKTOP_MODIFIER_KEYS) - self.assertEqual(general["activePlugins"], ["PluginManager", "OCR"]) + self.assertEqual(general["activePlugins"], ["Clipboard", "OCR"]) self.assertEqual(general["aiProvider"], settings.AI_PROVIDER_OLLAMA) self.assertFalse(general["aiAssistantEnabled"]) self.assertEqual(general["ocrLanguageCode"], "eng") @@ -82,7 +82,7 @@ class LegacyTomlSchemaMigrationTests(unittest.TestCase): savedSettings = settingsPath.read_text(encoding="utf-8") self.assertIn('profile = ["Default", "default"]', savedSettings) - self.assertIn('activePlugins = ["PluginManager", "OCR"]', savedSettings) + self.assertIn('activePlugins = ["Clipboard", "OCR"]', savedSettings) self.assertNotIn("format-version = 2", savedSettings) self.assertNotIn("[profiles.default.metadata]", savedSettings) diff --git a/tests/test_web_input_regressions.py b/tests/test_web_input_regressions.py index 9fa762b..e46bd5c 100644 --- a/tests/test_web_input_regressions.py +++ b/tests/test_web_input_regressions.py @@ -34,14 +34,33 @@ class WebKeyGrabRegressionTests(unittest.TestCase): testScript._lastMouseButtonContext = ("old", 7) testScript._madeFindAnnouncement = True testScript._inFocusMode = True + testScript._focusModeIsSticky = False + testScript._browseModeIsSticky = False testScript._navSuspended = False testScript.removeKeyGrabs = mock.Mock() testScript.refreshKeyGrabs = mock.Mock() testScript._setNavigationSuspended = mock.Mock() + testScript.presentMessage = mock.Mock() testScript.utilities = mock.Mock() testScript.utilities.isZombie.return_value = False testScript.utilities.isDocument.return_value = False testScript.utilities.getTopLevelDocumentForObject.return_value = None + testScript.utilities.inFindContainer.return_value = False + testScript.utilities.getCaretContext.return_value = (None, -1) + testScript.utilities.queryNonEmptyText.return_value = None + testScript.utilities.isContentEditableWithEmbeddedObjects.return_value = False + testScript.utilities.isAnchor.return_value = False + testScript.utilities.lastInputEventWasPageNav.return_value = False + testScript.utilities.isFocusedWithMathChild.return_value = False + testScript.utilities.caretMovedToSamePageFragment.return_value = False + testScript.utilities.lastInputEventWasLineNav.return_value = False + testScript.utilities.shouldInterruptForLocusOfFocusChange.return_value = False + testScript.flatReviewPresenter = mock.Mock() + testScript.flatReviewPresenter.is_active.return_value = False + testScript.updateBraille = mock.Mock() + testScript.speechGenerator = mock.Mock() + testScript.speechGenerator.generateSpeech.return_value = [] + testScript._saveFocusedObjectInfo = mock.Mock() return testScript def test_window_deactivate_does_not_drop_key_grabs(self): @@ -99,6 +118,44 @@ class WebKeyGrabRegressionTests(unittest.TestCase): self.assertFalse(result) testScript.refreshKeyGrabs.assert_called_once_with() + def test_non_document_focus_preserves_sticky_focus_mode(self): + testScript = self._make_partial_script() + testScript._focusModeIsSticky = True + oldFocus = object() + newFocus = object() + + with mock.patch("cthulhu.scripts.web.script.AXObject.is_dead", return_value=False): + result = web_script.Script.locus_of_focus_changed(testScript, None, oldFocus, newFocus) + + self.assertFalse(result) + self.assertTrue(testScript._inFocusMode) + + def test_document_focus_restores_sticky_focus_after_suspension(self): + testScript = self._make_partial_script() + testScript._inFocusMode = False + testScript._focusModeIsSticky = True + testScript._navSuspended = True + oldFocus = object() + newFocus = object() + document = object() + testScript.utilities.getTopLevelDocumentForObject.side_effect = ( + lambda obj: document if obj is newFocus else None + ) + + with ( + mock.patch("cthulhu.scripts.web.script.AXObject.is_dead", return_value=False), + mock.patch("cthulhu.scripts.web.script.AXUtilities.is_unknown_or_redundant", return_value=False), + mock.patch("cthulhu.scripts.web.script.AXUtilities.is_heading", return_value=False), + mock.patch("cthulhu.scripts.web.script.speech.speak"), + mock.patch("cthulhu.scripts.web.script.cthulhu.emitRegionChanged"), + ): + result = web_script.Script.locus_of_focus_changed(testScript, None, oldFocus, newFocus) + + self.assertTrue(result) + self.assertTrue(testScript._inFocusMode) + testScript.presentMessage.assert_called_with(messages.MODE_FOCUS_IS_STICKY) + testScript.refreshKeyGrabs.assert_called_once_with() + class WebActiveWindowRegressionTests(unittest.TestCase): def test_sanity_check_recovers_missing_script_app_from_active_window(self): @@ -395,6 +452,23 @@ class WebDynamicContentRecoveryRegressionTests(unittest.TestCase): setLocusOfFocus.assert_called_once_with(event, source, False) testScript.utilities.setCaretContext.assert_called_once_with(source, 0) + def test_browse_mode_sticky_blocks_web_app_descendant_focus_claim(self): + testScript = self._make_dynamic_script() + source = object() + event = mock.Mock(detail1=1, source=source) + testScript._browseModeIsSticky = True + testScript.utilities.getDocumentForObject.return_value = "document" + testScript.utilities.isWebAppDescendant.return_value = True + + with ( + mock.patch.object(web_script.cthulhu_state, "locusOfFocus", object()), + mock.patch.object(web_script.cthulhu, "setLocusOfFocus") as setLocusOfFocus, + ): + result = web_script.Script.onFocusedChanged(testScript, event) + + self.assertTrue(result) + setLocusOfFocus.assert_not_called() + class WebContextReplicantRegressionTests(unittest.TestCase): def test_same_object_replicant_can_recover_before_old_focus_is_dead(self):