Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| dfef847fb5 | |||
| b08f7095cb | |||
| 85d30d331a | |||
| bbef7473ee |
@@ -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:
|
||||
|
||||
@@ -477,28 +477,49 @@ class InputEventManager:
|
||||
return None
|
||||
|
||||
self._log_active_x11_window_for_xterm_check(window)
|
||||
sawIdentifier = False
|
||||
for attrName in ("get_class_group_name", "get_class_instance_name", "get_name"):
|
||||
if self._identifier_is_xterm(self._safe_call(window, attrName)):
|
||||
value = self._safe_call(window, attrName)
|
||||
if self._identifier_is_xterm(value):
|
||||
return True
|
||||
if isinstance(value, str) and value.strip():
|
||||
sawIdentifier = True
|
||||
|
||||
classGroup = self._safe_call(window, "get_class_group")
|
||||
if classGroup is not None:
|
||||
|
||||
for attrName in ("get_name", "get_res_class"):
|
||||
if self._identifier_is_xterm(self._safe_call(classGroup, attrName)):
|
||||
value = self._safe_call(classGroup, attrName)
|
||||
if self._identifier_is_xterm(value):
|
||||
return True
|
||||
if isinstance(value, str) and value.strip():
|
||||
sawIdentifier = True
|
||||
|
||||
try:
|
||||
pid = int(window.get_pid())
|
||||
except Exception:
|
||||
pid = -1
|
||||
if pid < 1:
|
||||
if not sawIdentifier:
|
||||
msg = (
|
||||
"INPUT EVENT MANAGER: XTerm matcher cannot identify active X11 window; "
|
||||
"no usable identifiers or pid."
|
||||
)
|
||||
debug.print_message(debug.LEVEL_INFO, msg, True)
|
||||
return None
|
||||
return False
|
||||
|
||||
try:
|
||||
with open(f"/proc/{pid}/cmdline", "rb") as cmdlineFile:
|
||||
executable = cmdlineFile.read().split(b"\0", 1)[0].decode(errors="ignore")
|
||||
except OSError:
|
||||
if not sawIdentifier:
|
||||
msg = (
|
||||
"INPUT EVENT MANAGER: XTerm matcher cannot identify active X11 window; "
|
||||
"pid disappeared before cmdline lookup."
|
||||
)
|
||||
debug.print_message(debug.LEVEL_INFO, msg, True)
|
||||
return None
|
||||
return False
|
||||
|
||||
return self._identifier_is_xterm(executable)
|
||||
@@ -590,6 +611,20 @@ class InputEventManager:
|
||||
|
||||
return result
|
||||
|
||||
def _clear_stale_atspi_context(self, manager: Any) -> None:
|
||||
"""Clears cached input context after X11 positively contradicts AT-SPI."""
|
||||
|
||||
reason = "active X11 window not found in AT-SPI"
|
||||
msg = (
|
||||
"INPUT EVENT MANAGER: Clearing stale AT-SPI focus context; "
|
||||
"X11 focus moved to an untracked window."
|
||||
)
|
||||
debug.print_message(debug.LEVEL_INFO, msg, True)
|
||||
script_manager.get_manager().set_active_script(None, reason)
|
||||
manager.clear_state(reason)
|
||||
self._last_input_event = None
|
||||
self._last_non_modifier_key_event = None
|
||||
|
||||
@staticmethod
|
||||
def _get_active_script_app() -> Optional[Atspi.Accessible]:
|
||||
"""Returns the app for the active script, if one is available."""
|
||||
@@ -621,15 +656,15 @@ class InputEventManager:
|
||||
) -> bool:
|
||||
"""Returns True when XTerm is active and Cthulhu lacks matching AT-SPI context."""
|
||||
|
||||
if pendingFocus is not None:
|
||||
msg = "INPUT EVENT MANAGER: XTerm matcher false; pending focus exists."
|
||||
debug.print_message(debug.LEVEL_INFO, msg, True)
|
||||
return False
|
||||
match = self._active_x11_window_xterm_match()
|
||||
tokens = ["INPUT EVENT MANAGER: XTerm matcher returned", match]
|
||||
debug.print_tokens(debug.LEVEL_INFO, tokens, True)
|
||||
if match is True:
|
||||
return True
|
||||
if pendingFocus is not None:
|
||||
msg = "INPUT EVENT MANAGER: XTerm matcher false; pending focus exists."
|
||||
debug.print_message(debug.LEVEL_INFO, msg, True)
|
||||
return False
|
||||
if match is None and self._scriptWithSuspendedGrabsForXterm is not None:
|
||||
msg = (
|
||||
"INPUT EVENT MANAGER: Keeping XTerm key-grab suspension; "
|
||||
@@ -743,6 +778,11 @@ class InputEventManager:
|
||||
manager.set_active_window(window)
|
||||
else:
|
||||
focus_window = self._get_top_level_window(pendingFocus or manager.get_locus_of_focus())
|
||||
staleContext = focus_window or window or self._get_active_script_app()
|
||||
if self._active_x11_window_differs_from(staleContext):
|
||||
self._clear_stale_atspi_context(manager)
|
||||
return False
|
||||
|
||||
if focus_window is not None:
|
||||
window = focus_window
|
||||
tokens = [
|
||||
@@ -755,15 +795,6 @@ class InputEventManager:
|
||||
# One example: Brave's popup menus live in frames which lack the active
|
||||
# state. Failing to revalidate the window on a key press is inconclusive;
|
||||
# do not wipe out the last known window and focus state.
|
||||
focus = pendingFocus or manager.get_locus_of_focus()
|
||||
staleContext = window or self._get_active_script_app()
|
||||
if self._active_x11_window_differs_from(staleContext):
|
||||
msg = (
|
||||
"INPUT EVENT MANAGER: X11 focus moved to an untracked window; "
|
||||
"preserving Cthulhu key handling."
|
||||
)
|
||||
debug.print_message(debug.LEVEL_INFO, msg, True)
|
||||
|
||||
tokens = [
|
||||
"WARNING:",
|
||||
window,
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -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']
|
||||
@@ -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'
|
||||
)
|
||||
@@ -1,8 +0,0 @@
|
||||
name = PluginManager
|
||||
version = 1.0.0
|
||||
description = GUI interface for managing Cthulhu plugins - enable/disable plugins with checkboxes
|
||||
authors = Stormux <storm_dragon@stormux.org>
|
||||
website = https://git.stormux.org/storm/cthulhu
|
||||
copyright = Copyright 2025
|
||||
builtin = false
|
||||
hidden = false
|
||||
@@ -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("<b>Available Plugins</b>")
|
||||
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("<i>Note:To apply changes, restart Cthulhu</i>")
|
||||
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}")
|
||||
@@ -8,7 +8,6 @@ subdir('HelloCthulhu')
|
||||
subdir('GameMode')
|
||||
subdir('nvda2cthulhu')
|
||||
subdir('OCR')
|
||||
subdir('PluginManager')
|
||||
subdir('SpeechHistory')
|
||||
subdir('SimplePluginSystem')
|
||||
subdir('hello_world')
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -497,7 +497,6 @@ presentLiveRegionFromInactiveTab = False
|
||||
|
||||
# Plugins
|
||||
activePlugins = [
|
||||
'PluginManager',
|
||||
'ByeCthulhu',
|
||||
'Clipboard',
|
||||
'HelloCthulhu',
|
||||
|
||||
@@ -20,7 +20,7 @@ class InputEventManagerX11FocusRegressionTests(unittest.TestCase):
|
||||
):
|
||||
self.assertTrue(manager._active_x11_window_differs_from(cachedWindow))
|
||||
|
||||
def test_keyboard_event_preserves_key_handling_when_unknown_x11_window_is_not_xterm(self):
|
||||
def test_keyboard_event_clears_stale_context_before_recovering_old_focus_window(self):
|
||||
manager = input_event_manager.InputEventManager()
|
||||
staleWindow = object()
|
||||
staleFocus = object()
|
||||
@@ -30,6 +30,8 @@ class InputEventManagerX11FocusRegressionTests(unittest.TestCase):
|
||||
scriptManager = mock.Mock()
|
||||
keyboardEvent = mock.Mock()
|
||||
keyboardEvent.is_modifier_key.return_value = False
|
||||
manager._last_input_event = object()
|
||||
manager._last_non_modifier_key_event = object()
|
||||
|
||||
with (
|
||||
mock.patch.object(input_event_manager.cthulhu_state, "capturingKeys", False),
|
||||
@@ -38,7 +40,8 @@ class InputEventManagerX11FocusRegressionTests(unittest.TestCase):
|
||||
mock.patch.object(input_event_manager.input_event, "KeyboardEvent", return_value=keyboardEvent),
|
||||
mock.patch.object(input_event_manager.AXUtilities, "can_be_active_window", return_value=False),
|
||||
mock.patch.object(input_event_manager.AXUtilities, "find_active_window", return_value=None),
|
||||
mock.patch.object(manager, "_get_top_level_window", return_value=None),
|
||||
mock.patch.object(manager, "_find_active_x11_atspi_window", return_value=None),
|
||||
mock.patch.object(manager, "_get_top_level_window", return_value=staleWindow),
|
||||
mock.patch.object(manager, "_should_pass_through_for_active_xterm", return_value=False),
|
||||
mock.patch.object(manager, "_active_x11_window_differs_from", return_value=True),
|
||||
mock.patch.object(manager, "last_event_was_keyboard", return_value=False),
|
||||
@@ -53,12 +56,18 @@ class InputEventManagerX11FocusRegressionTests(unittest.TestCase):
|
||||
"Return",
|
||||
)
|
||||
|
||||
self.assertTrue(result)
|
||||
scriptManager.set_active_script.assert_not_called()
|
||||
focusManager.clear_state.assert_not_called()
|
||||
keyboardEvent.process.assert_called_once_with()
|
||||
self.assertFalse(result)
|
||||
scriptManager.set_active_script.assert_called_once_with(
|
||||
None,
|
||||
"active X11 window not found in AT-SPI",
|
||||
)
|
||||
focusManager.clear_state.assert_called_once_with("active X11 window not found in AT-SPI")
|
||||
focusManager.set_active_window.assert_not_called()
|
||||
keyboardEvent.process.assert_not_called()
|
||||
self.assertIsNone(manager._last_input_event)
|
||||
self.assertIsNone(manager._last_non_modifier_key_event)
|
||||
|
||||
def test_keyboard_event_uses_active_script_app_when_cached_window_is_missing(self):
|
||||
def test_keyboard_event_clears_stale_script_when_cached_context_is_missing(self):
|
||||
manager = input_event_manager.InputEventManager()
|
||||
staleApp = object()
|
||||
staleScript = mock.Mock(app=staleApp)
|
||||
@@ -77,6 +86,7 @@ class InputEventManagerX11FocusRegressionTests(unittest.TestCase):
|
||||
mock.patch.object(input_event_manager.input_event, "KeyboardEvent", return_value=keyboardEvent),
|
||||
mock.patch.object(input_event_manager.AXUtilities, "can_be_active_window", return_value=False),
|
||||
mock.patch.object(input_event_manager.AXUtilities, "find_active_window", return_value=None),
|
||||
mock.patch.object(manager, "_find_active_x11_atspi_window", return_value=None),
|
||||
mock.patch.object(manager, "_get_top_level_window", return_value=None),
|
||||
mock.patch.object(manager, "_should_pass_through_for_active_xterm", return_value=False),
|
||||
mock.patch.object(manager, "_active_x11_window_differs_from", return_value=True) as differs,
|
||||
@@ -92,8 +102,51 @@ class InputEventManagerX11FocusRegressionTests(unittest.TestCase):
|
||||
"KP_Page_Up",
|
||||
)
|
||||
|
||||
self.assertTrue(result)
|
||||
self.assertFalse(result)
|
||||
differs.assert_called_once_with(staleApp)
|
||||
scriptManager.set_active_script.assert_called_once_with(
|
||||
None,
|
||||
"active X11 window not found in AT-SPI",
|
||||
)
|
||||
focusManager.clear_state.assert_called_once_with("active X11 window not found in AT-SPI")
|
||||
keyboardEvent.process.assert_not_called()
|
||||
|
||||
def test_keyboard_event_recovers_focus_window_when_x11_does_not_contradict_it(self):
|
||||
manager = input_event_manager.InputEventManager()
|
||||
cachedWindow = object()
|
||||
staleFocus = object()
|
||||
focusManager = mock.Mock()
|
||||
focusManager.get_active_window.return_value = cachedWindow
|
||||
focusManager.get_locus_of_focus.return_value = staleFocus
|
||||
scriptManager = mock.Mock()
|
||||
keyboardEvent = mock.Mock()
|
||||
keyboardEvent.is_modifier_key.return_value = False
|
||||
|
||||
with (
|
||||
mock.patch.object(input_event_manager.cthulhu_state, "capturingKeys", False),
|
||||
mock.patch.object(input_event_manager.focus_manager, "get_manager", return_value=focusManager),
|
||||
mock.patch.object(input_event_manager.script_manager, "get_manager", return_value=scriptManager),
|
||||
mock.patch.object(input_event_manager.input_event, "KeyboardEvent", return_value=keyboardEvent),
|
||||
mock.patch.object(input_event_manager.AXUtilities, "can_be_active_window", return_value=False),
|
||||
mock.patch.object(input_event_manager.AXUtilities, "find_active_window", return_value=None),
|
||||
mock.patch.object(manager, "_find_active_x11_atspi_window", return_value=None),
|
||||
mock.patch.object(manager, "_get_top_level_window", return_value=cachedWindow),
|
||||
mock.patch.object(manager, "_should_pass_through_for_active_xterm", return_value=False),
|
||||
mock.patch.object(manager, "_active_x11_window_differs_from", return_value=False),
|
||||
mock.patch.object(manager, "last_event_was_keyboard", return_value=False),
|
||||
mock.patch.object(input_event_manager.debug, "print_message"),
|
||||
):
|
||||
result = manager.process_keyboard_event(
|
||||
mock.Mock(),
|
||||
True,
|
||||
36,
|
||||
65293,
|
||||
0,
|
||||
"Return",
|
||||
)
|
||||
|
||||
self.assertTrue(result)
|
||||
focusManager.set_active_window.assert_called_once_with(cachedWindow)
|
||||
scriptManager.set_active_script.assert_not_called()
|
||||
focusManager.clear_state.assert_not_called()
|
||||
keyboardEvent.process.assert_called_once_with()
|
||||
@@ -226,6 +279,52 @@ class InputEventManagerX11FocusRegressionTests(unittest.TestCase):
|
||||
activeScript.addKeyGrabs.assert_not_called()
|
||||
keyboardEvent.process.assert_not_called()
|
||||
|
||||
def test_xterm_pass_through_ignores_stale_pending_self_hosted_focus(self):
|
||||
manager = input_event_manager.InputEventManager()
|
||||
pendingFocus = object()
|
||||
focusManager = mock.Mock()
|
||||
focusManager.get_active_window.return_value = None
|
||||
focusManager.get_locus_of_focus.return_value = None
|
||||
scriptManager = mock.Mock()
|
||||
activeScript = mock.Mock(app=None)
|
||||
scriptManager.get_active_script.return_value = activeScript
|
||||
keyboardEvent = mock.Mock()
|
||||
|
||||
with (
|
||||
mock.patch.object(input_event_manager.cthulhu_state, "capturingKeys", False),
|
||||
mock.patch.object(input_event_manager.cthulhu_state, "pendingSelfHostedFocus", pendingFocus),
|
||||
mock.patch.object(input_event_manager.focus_manager, "get_manager", return_value=focusManager),
|
||||
mock.patch.object(input_event_manager.script_manager, "get_manager", return_value=scriptManager),
|
||||
mock.patch.object(input_event_manager.input_event, "KeyboardEvent", return_value=keyboardEvent),
|
||||
mock.patch.object(manager, "_active_x11_window_xterm_match", return_value=True),
|
||||
mock.patch.object(input_event_manager.debug, "print_message"),
|
||||
mock.patch.object(input_event_manager.debug, "print_tokens"),
|
||||
):
|
||||
result = manager.process_keyboard_event(
|
||||
mock.Mock(),
|
||||
True,
|
||||
90,
|
||||
65438,
|
||||
0,
|
||||
"KP_Insert",
|
||||
)
|
||||
|
||||
self.assertFalse(result)
|
||||
activeScript.removeKeyGrabs.assert_called_once_with()
|
||||
keyboardEvent.process.assert_not_called()
|
||||
|
||||
def test_pending_self_hosted_focus_blocks_unknown_xterm_match(self):
|
||||
manager = input_event_manager.InputEventManager()
|
||||
|
||||
with (
|
||||
mock.patch.object(manager, "_active_x11_window_xterm_match", return_value=None),
|
||||
mock.patch.object(input_event_manager.debug, "print_message"),
|
||||
mock.patch.object(input_event_manager.debug, "print_tokens"),
|
||||
):
|
||||
result = manager._should_pass_through_for_active_xterm(None, None, object())
|
||||
|
||||
self.assertFalse(result)
|
||||
|
||||
def test_xterm_grabs_restore_when_active_window_is_positively_not_xterm(self):
|
||||
manager = input_event_manager.InputEventManager()
|
||||
focusManager = mock.Mock()
|
||||
@@ -264,6 +363,24 @@ class InputEventManagerX11FocusRegressionTests(unittest.TestCase):
|
||||
activeScript.addKeyGrabs.assert_called_once_with()
|
||||
keyboardEvent.process.assert_called_once_with()
|
||||
|
||||
def test_unidentified_active_x11_window_is_unknown_not_non_xterm(self):
|
||||
manager = input_event_manager.InputEventManager()
|
||||
window = mock.Mock()
|
||||
window.get_class_group_name.return_value = None
|
||||
window.get_class_instance_name.return_value = None
|
||||
window.get_name.return_value = None
|
||||
window.get_class_group.return_value = None
|
||||
window.get_pid.return_value = -1
|
||||
|
||||
with (
|
||||
mock.patch.object(manager, "_get_active_x11_window", return_value=window),
|
||||
mock.patch.object(input_event_manager.debug, "print_message"),
|
||||
mock.patch.object(input_event_manager.debug, "print_tokens"),
|
||||
):
|
||||
result = manager._active_x11_window_xterm_match()
|
||||
|
||||
self.assertIsNone(result)
|
||||
|
||||
def test_finds_focused_atspi_window_for_active_x11_pid(self):
|
||||
manager = input_event_manager.InputEventManager()
|
||||
app = object()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user