7 Commits

Author SHA1 Message Date
Storm Dragon 5044455582 Had codex fix up tests. 2026-07-19 15:22:32 -04:00
Storm Dragon 3d44f6d50b Lots of work on the web side of things. More coming. 2026-07-19 15:00:25 -04:00
Storm Dragon 3e97c1ce33 Web fixes. 2026-07-18 15:10:43 -04:00
Storm Dragon 394d814ad0 updated PKGBUILDs. 2026-07-18 14:24:42 -04:00
Storm Dragon 006612c1da Merge branch 'testing' 2026-07-18 02:55:04 -04:00
Storm Dragon 49f0d3c612 Tests for the new plugins dialogue updates. 2026-07-18 02:54:53 -04:00
Storm Dragon 8e496ac606 Redesigned the plugins interface. 2026-07-18 00:19:08 -04:00
25 changed files with 2180 additions and 573 deletions
+21 -44
View File
@@ -1,23 +1,24 @@
# Maintainer: Storm Dragon <storm_dragon@stormux.org> # Maintainer: Storm Dragon <storm_dragon@stormux.org>
# shellcheck shell=bash disable=SC2034,SC2154,SC2164
pkgname=cthulhu-git pkgname=cthulhu-git
_pkgname=cthulhu _pkgname=cthulhu
pkgver=2026.05.25.r407.gc39f231 pkgver=2026.05.25.r407.gc39f231
pkgrel=1 pkgrel=2
pkgdesc="Desktop-agnostic screen reader with plugin system, forked from Orca" pkgdesc="Desktop-agnostic screen reader with plugin system, forked from Orca"
url="https://git.stormux.org/storm/cthulhu" url="https://git.stormux.org/storm/cthulhu"
arch=(any) arch=(any)
license=(LGPL) license=(LGPL-2.1-or-later)
provides=("${_pkgname}") provides=("${_pkgname}=${pkgver}")
conflicts=("${_pkgname}") conflicts=("${_pkgname}")
depends=( depends=(
# Core AT-SPI accessibility # Core AT-SPI accessibility
at-spi2-core at-spi2-core
glib2
gobject-introspection-runtime gobject-introspection-runtime
python-gobject python-gobject
python-cairo python-cairo
gtk3 gtk3
python-pywayland
# Audio and speech # Audio and speech
speech-dispatcher speech-dispatcher
gstreamer gstreamer
@@ -25,75 +26,51 @@ depends=(
gst-plugins-good # pulsesink, more decoders gst-plugins-good # pulsesink, more decoders
# Braille support # Braille support
brltty python-brltty
liblouis liblouis
# Plugin system and D-Bus remote control # Plugin system and D-Bus remote control
python-pluggy python-pluggy
python-tomlkit python-tomlkit
python-dasbus python-dasbus
python-msgpack
# AI Assistant dependencies (for screenshots, HTTP requests, and actions) python-tornado
python-requests python-xlib
python-pyautogui
# Desktop integration # Desktop integration
hicolor-icon-theme hicolor-icon-theme
libwnck3 libwnck3
pango pango
# System utilities # System utilities
python
python-setproctitle python-setproctitle
socat # for self-voicing feature
xorg-xkbcomp xorg-xkbcomp
xorg-xmodmap
) )
optdepends=( optdepends=(
'espeak-ng: Alternative TTS engine' 'git: Install and update third-party plugin sources'
'festival: Alternative TTS engine' 'python-pywayland: Wayland shared workspace backend'
'flite: Lightweight TTS engine' 'piper-tts: Piper speech backend'
'espeak: Legacy TTS engine' 'socat: Send text to the Self Voice socket from the command line'
# AI Assistant providers (optional) # AI Assistant providers (optional)
'python-requests: AI Assistant plugin and HTTP provider support'
'python-pyautogui: AI Assistant mouse and keyboard actions'
'claude-code: Claude AI provider support' 'claude-code: Claude AI provider support'
'openai-codex: ChatGPT AI provider support' 'openai-codex: OpenAI Codex provider support'
'gemini-cli: Gemini AI provider support' 'gemini-cli: Gemini AI provider support'
'ollama: Local AI model support' 'ollama: Local AI model support'
# OCR plugin dependencies (optional) # OCR plugin dependencies (optional)
'python-pillow: Image processing for OCR and AI Assistant' 'python-pillow: Image processing for OCR'
'python-pytesseract: Python wrapper for Tesseract OCR engine' 'python-pytesseract: Python wrapper for Tesseract OCR engine'
'python-pdf2image: PDF to image conversion for OCR'
'python-scipy: Scientific computing for OCR color analysis'
'python-webcolors: Color name lookup for OCR text decoration'
'tesseract: OCR engine for text recognition' 'tesseract: OCR engine for text recognition'
'tesseract-data-eng: English language data for Tesseract'
# nvda2cthulhu plugin (optional)
'python-msgpack: Msgpack decoding for nvda2cthulhu'
'python-tornado: WebSocket server for nvda2cthulhu'
# Window Title Reader plugin (optional)
'python-xlib: X11 access for Wine window title plugin'
) )
makedepends=( makedepends=(
git git
meson meson
ninja
python-build
python-installer
python-wheel
)
install=cthulhu.install
source=(
"${_pkgname}::git+https://git.stormux.org/storm/${_pkgname}.git#branch=master"
"cthulhu.install"
)
b2sums=(
'SKIP'
'SKIP'
) )
source=("${_pkgname}::git+https://git.stormux.org/storm/${_pkgname}.git#branch=master")
sha512sums=('SKIP')
pkgver() { pkgver() {
cd "${_pkgname}" cd "${_pkgname}"
@@ -1,11 +0,0 @@
post_install() {
gtk-update-icon-cache -q -t -f usr/share/icons/hicolor
}
post_upgrade() {
post_install
}
post_remove() {
gtk-update-icon-cache -q -t -f usr/share/icons/hicolor
}
+26 -49
View File
@@ -1,96 +1,73 @@
# Maintainer: Storm Dragon <storm_dragon@stormux.org> # Maintainer: Storm Dragon <storm_dragon@stormux.org>
# shellcheck shell=bash disable=SC2034,SC2154,SC2164
pkgname=cthulhu pkgname=cthulhu
pkgver=2026.05.25 pkgver=2026.05.25
pkgrel=1 pkgrel=2
pkgdesc="Desktop-agnostic screen reader with plugin system, forked from Orca" pkgdesc="Desktop-agnostic screen reader with plugin system, forked from Orca"
url="https://git.stormux.org/storm/cthulhu" url="https://git.stormux.org/storm/cthulhu"
arch=(any) arch=(any)
license=(LGPL) license=(LGPL-2.1-or-later)
depends=( depends=(
# Core AT-SPI accessibility # Core AT-SPI accessibility
at-spi2-core at-spi2-core
glib2
gobject-introspection-runtime gobject-introspection-runtime
python-gobject python-gobject
python-cairo python-cairo
gtk3 gtk3
python-pywayland
# Audio and speech # Audio and speech
speech-dispatcher speech-dispatcher
gstreamer gstreamer
gst-plugins-base # playbin, audiotestsrc, basic decoders gst-plugins-base # playbin, audiotestsrc, basic decoders
gst-plugins-good # pulsesink, more decoders gst-plugins-good # pulsesink, more decoders
# Braille support # Braille support
brltty python-brltty
liblouis liblouis
# Plugin system and D-Bus remote control # Plugin system and D-Bus remote control
python-pluggy python-pluggy
python-tomlkit python-tomlkit
python-dasbus python-dasbus
python-msgpack
# AI Assistant dependencies (for screenshots, HTTP requests, and actions) python-tornado
python-requests python-xlib
python-pyautogui
# Desktop integration # Desktop integration
hicolor-icon-theme hicolor-icon-theme
libwnck3 libwnck3
pango pango
# System utilities # System utilities
python
python-setproctitle python-setproctitle
socat # for self-voicing feature
xorg-xkbcomp xorg-xkbcomp
xorg-xmodmap
) )
optdepends=( optdepends=(
'espeak-ng: Alternative TTS engine' 'git: Install and update third-party plugin sources'
'festival: Alternative TTS engine' 'python-pywayland: Wayland shared workspace backend'
'flite: Lightweight TTS engine' 'piper-tts: Piper speech backend'
'espeak: Legacy TTS engine' 'socat: Send text to the Self Voice socket from the command line'
# AI Assistant providers (optional) # AI Assistant providers (optional)
'python-requests: AI Assistant plugin and HTTP provider support'
'python-pyautogui: AI Assistant mouse and keyboard actions'
'claude-code: Claude AI provider support' 'claude-code: Claude AI provider support'
'openai-codex: ChatGPT AI provider support' 'openai-codex: OpenAI Codex provider support'
'gemini-cli: Gemini AI provider support' 'gemini-cli: Gemini AI provider support'
'ollama: Local AI model support' 'ollama: Local AI model support'
# OCR plugin dependencies (optional)
'python-pillow: Image processing for OCR and AI Assistant'
'python-pytesseract: Python wrapper for Tesseract OCR engine'
'python-pdf2image: PDF to image conversion for OCR'
'python-scipy: Scientific computing for OCR color analysis'
'python-webcolors: Color name lookup for OCR text decoration'
'tesseract: OCR engine for text recognition'
'tesseract-data-eng: English language data for Tesseract'
# nvda2cthulhu plugin (optional)
'python-msgpack: Msgpack decoding for nvda2cthulhu'
'python-tornado: WebSocket server for nvda2cthulhu'
# Window Title Reader plugin (optional) # OCR plugin dependencies (optional)
'python-xlib: X11 access for Wine window title plugin' 'python-pillow: Image processing for OCR'
'python-pytesseract: Python wrapper for Tesseract OCR engine'
'tesseract: OCR engine for text recognition'
) )
makedepends=( makedepends=(
git git
meson meson
ninja
python-build
python-installer
python-wheel
)
install=cthulhu.install
source=(
"git+https://git.stormux.org/storm/cthulhu.git#tag=${pkgver}"
"cthulhu.install"
)
b2sums=(
'SKIP'
'SKIP'
) )
source=("git+https://git.stormux.org/storm/cthulhu.git#tag=${pkgver}")
sha512sums=('SKIP')
build() { build() {
cd cthulhu cd cthulhu
@@ -1,11 +0,0 @@
post_install() {
gtk-update-icon-cache -q -t -f usr/share/icons/hicolor
}
post_upgrade() {
post_install
}
post_remove() {
gtk-update-icon-cache -q -t -f usr/share/icons/hicolor
}
-4
View File
@@ -56,12 +56,8 @@ optional_modules = {
'louis': 'contracted braille', 'louis': 'contracted braille',
'speechd': 'speech output', 'speechd': 'speech output',
'dasbus': 'D-Bus remote controller', 'dasbus': 'D-Bus remote controller',
'psutil': 'system information commands',
'pywayland': 'Wayland shared workspace backend', 'pywayland': 'Wayland shared workspace backend',
'gi.repository.Wnck': 'mouse review', 'gi.repository.Wnck': 'mouse review',
'pdf2image': 'PDF processing for OCR',
'scipy': 'Scientific computing for OCR analysis',
'webcolors': 'Color name resolution for OCR',
} }
summary = {} summary = {}
+2 -2
View File
@@ -254,6 +254,8 @@ class AXUtilitiesEvent:
reason = TextEventReason.SELECT_ALL reason = TextEventReason.SELECT_ALL
elif mgr.last_event_was_primary_click_or_release(): elif mgr.last_event_was_primary_click_or_release():
reason = TextEventReason.MOUSE_PRIMARY_BUTTON reason = TextEventReason.MOUSE_PRIMARY_BUTTON
elif mgr.last_event_was_tab_navigation():
reason = TextEventReason.FOCUS_CHANGE
elif AXUtilitiesState.is_editable(obj) or AXUtilitiesRole.is_terminal(obj): elif AXUtilitiesState.is_editable(obj) or AXUtilitiesRole.is_terminal(obj):
if mgr.last_event_was_backspace(): if mgr.last_event_was_backspace():
reason = TextEventReason.BACKSPACE reason = TextEventReason.BACKSPACE
@@ -273,8 +275,6 @@ class AXUtilitiesEvent:
reason = TextEventReason.UNSPECIFIED_COMMAND reason = TextEventReason.UNSPECIFIED_COMMAND
elif mgr.last_event_was_printable_key(): elif mgr.last_event_was_printable_key():
reason = TextEventReason.TYPING reason = TextEventReason.TYPING
elif mgr.last_event_was_tab_navigation():
reason = TextEventReason.FOCUS_CHANGE
elif AXObject.find_ancestor(obj, AXUtilitiesRole.children_are_presentational): elif AXObject.find_ancestor(obj, AXUtilitiesRole.children_are_presentational):
reason = TextEventReason.UI_UPDATE reason = TextEventReason.UI_UPDATE
return reason return reason
+2 -2
View File
@@ -23,5 +23,5 @@
# Forked from Orca screen reader. # Forked from Orca screen reader.
# Cthulhu project: https://git.stormux.org/storm/cthulhu # Cthulhu project: https://git.stormux.org/storm/cthulhu
version = "2026.07.17" version = "2026.07.18"
codeName = "master" codeName = "testing"
+129 -350
View File
@@ -111,11 +111,6 @@ if louis and not tablesdir:
DATE_FORMAT_ABBREVIATED_MDY, DATE_FORMAT_ABBREVIATED_YMD) = list(range(16)) DATE_FORMAT_ABBREVIATED_MDY, DATE_FORMAT_ABBREVIATED_YMD) = list(range(16))
class CthulhuSetupGUI(cthulhu_gtkbuilder.GtkBuilderWrapper): 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): def __init__(self, fileName, windowName, prefsDict):
"""Initialize the Cthulhu configuration GUI. """Initialize the Cthulhu configuration GUI.
@@ -144,22 +139,16 @@ class CthulhuSetupGUI(cthulhu_gtkbuilder.GtkBuilderWrapper):
self.planeCellRendererText = None self.planeCellRendererText = None
self.pronunciationModel = None self.pronunciationModel = None
self.pronunciationView = None self.pronunciationView = None
self._plugin_treeview = None self._plugin_combo = None
self._plugin_model = None self._plugin_model = None
self._plugin_iters = {} self._plugin_entries = {}
self._plugin_enabled_iter = None self._plugin_enabled_button = None
self._plugin_disabled_iter = None self._plugin_disabled_button = None
self._plugin_sources = [] self._plugin_description_view = None
self._plugin_sources_entry = None self._updating_plugin_controls = False
self._plugin_sources_listbox = None
self._plugin_sources_original = []
self._available_plugins = set() self._available_plugins = set()
self._plugin_canonical_map = {} self._plugin_canonical_map = {}
self._plugin_group_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 = {}
self._plugin_tabs_cached = False self._plugin_tabs_cached = False
self._dynamic_plugin_tabs = {} self._dynamic_plugin_tabs = {}
@@ -440,148 +429,80 @@ class CthulhuSetupGUI(cthulhu_gtkbuilder.GtkBuilderWrapper):
self._initGUIState() self._initGUIState()
self._initSoundThemeState() self._initSoundThemeState()
def _initPluginsPage(self): def _initPluginsPage(self) -> None:
self._plugin_sources = list(self.prefsDict.get("pluginSources", settings.pluginSources) or [])
self._plugin_sources_original = list(self._plugin_sources)
pluginsPage = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=10) pluginsPage = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=10)
pluginsPage.set_border_width(12) 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_line_wrap(True)
infoLabel.set_halign(Gtk.Align.START) infoLabel.set_halign(Gtk.Align.START)
pluginsPage.pack_start(infoLabel, False, False, 0) pluginsPage.pack_start(infoLabel, False, False, 0)
pluginsFrame = Gtk.Frame(label="Plugins") pluginGrid = Gtk.Grid(row_spacing=10, column_spacing=10)
try: pluginLabel = Gtk.Label(label="_Plugin:")
pluginsFrame.set_label_align(0.0, 0.5) pluginLabel.set_use_underline(True)
except Exception: pluginLabel.set_halign(Gtk.Align.START)
try: self._plugin_model = Gtk.ListStore(GObject.TYPE_STRING, GObject.TYPE_STRING)
pluginsFrame.set_label_xalign(0.0) self._plugin_combo = Gtk.ComboBox.new_with_model(self._plugin_model)
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)
text_renderer = Gtk.CellRendererText() text_renderer = Gtk.CellRendererText()
text_renderer.set_property("ellipsize", Pango.EllipsizeMode.END) self._plugin_combo.pack_start(text_renderer, True)
text_column = Gtk.TreeViewColumn( self._plugin_combo.add_attribute(text_renderer, "text", 0)
"Plugin", self._plugin_combo.set_hexpand(True)
text_renderer, self._plugin_combo.connect("changed", self._on_plugin_selection_changed)
text=self.PLUGIN_COL_DISPLAY 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_enabled_button.connect("toggled", self._on_plugin_enabled_toggled)
self._plugin_treeview.append_column(text_column) 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) descriptionLabel = Gtk.Label(label="_Description:")
pluginsFrameBox.pack_start(pluginsScrolled, True, True, 0) 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) descriptionScrolled = Gtk.ScrolledWindow()
descriptionScrolled.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC)
sourcesFrame = Gtk.Frame(label="Plugin Sources") descriptionScrolled.set_size_request(-1, 180)
try: self._plugin_description_view = Gtk.TextView()
sourcesFrame.set_label_align(0.0, 0.5) self._plugin_description_view.set_editable(False)
except Exception: self._plugin_description_view.set_cursor_visible(True)
try: self._plugin_description_view.set_accepts_tab(False)
sourcesFrame.set_label_xalign(0.0) self._plugin_description_view.set_wrap_mode(Gtk.WrapMode.WORD_CHAR)
except Exception: self._plugin_description_view.get_accessible().set_name("Plugin description")
pass descriptionLabel.set_mnemonic_widget(self._plugin_description_view)
sourcesFrame.set_shadow_type(Gtk.ShadowType.NONE) descriptionScrolled.add(self._plugin_description_view)
sourcesFrameBox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=6) pluginsPage.pack_start(descriptionScrolled, True, True, 0)
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)
notebook = self.get_widget("notebook") notebook = self.get_widget("notebook")
notebook.append_page(pluginsPage, Gtk.Label(label="Plugins")) notebook.append_page(pluginsPage, Gtk.Label(label="Plugins"))
self._populate_plugin_list() self._populate_plugin_list()
self._populate_plugin_sources_list()
pluginsPage.show_all() pluginsPage.show_all()
def _clear_listbox(self, listbox): def _populate_plugin_list(self) -> None:
for child in listbox.get_children(): if not self._plugin_combo:
listbox.remove(child)
def _populate_plugin_list(self):
if not self._plugin_treeview:
return return
try: try:
selected_plugin = self._get_selected_plugin_name()
selected_index = 0
self._plugin_model.clear() self._plugin_model.clear()
self._plugin_iters = {} self._plugin_entries = {}
self._available_plugins = set() self._available_plugins = set()
self._plugin_canonical_map = {} self._plugin_canonical_map = {}
self._plugin_group_map = {} self._plugin_group_map = {}
@@ -607,15 +528,6 @@ class CthulhuSetupGUI(cthulhu_gtkbuilder.GtkBuilderWrapper):
if info.builtin: if info.builtin:
canonical_builtins[canonical] = True 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()): 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() plugin_name = plugin_info.get_module_name()
canonical_name = plugin_info.get_canonical_name() canonical_name = plugin_info.get_canonical_name()
@@ -640,242 +552,109 @@ class CthulhuSetupGUI(cthulhu_gtkbuilder.GtkBuilderWrapper):
can_toggle = False can_toggle = False
display_name = plugin_info.get_name() or plugin_name display_name = plugin_info.get_name() or plugin_name
info_text = display_name
description = plugin_info.get_description() description = plugin_info.get_description()
if description:
info_text += f" - {description}"
version = plugin_info.get_version() version = plugin_info.get_version()
if 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: 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: 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 if plugin_name == selected_plugin:
tree_iter = self._plugin_model.append( selected_index = len(self._plugin_model)
parent_iter, self._plugin_model.append([display_name, plugin_name])
[is_active, info_text, can_toggle, plugin_name] self._plugin_entries[plugin_name] = {
) "active": is_active,
self._plugin_iters[plugin_name] = tree_iter "can_toggle": can_toggle,
"description": description,
}
self._plugin_group_map.setdefault(canonical_name, []).append(plugin_name) self._plugin_group_map.setdefault(canonical_name, []).append(plugin_name)
self._plugin_treeview.collapse_all() if len(self._plugin_model):
self._plugin_treeview.show_all() self._plugin_combo.set_active(selected_index)
else:
self._update_selected_plugin_controls()
except Exception as e: except Exception as e:
debug.printMessage(debug.LEVEL_WARNING, f"PREFERENCES DIALOG: Plugin list build failed: {e}", True) debug.printMessage(debug.LEVEL_WARNING, f"PREFERENCES DIALOG: Plugin list build failed: {e}", True)
def _populate_plugin_sources_list(self): def _get_selected_plugin_name(self) -> str | None:
if not self._plugin_sources_listbox: 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 return
self._clear_listbox(self._plugin_sources_listbox) if is_active:
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:
canonical_name = self._plugin_canonical_map.get(plugin_name) canonical_name = self._plugin_canonical_map.get(plugin_name)
for other_name in self._plugin_group_map.get(canonical_name, []): for other_name in self._plugin_group_map.get(canonical_name, []):
if other_name == plugin_name: if other_name != plugin_name:
continue self._plugin_entries[other_name]["active"] = False
self._set_plugin_row_active(other_name, False)
self._set_plugin_row_active(plugin_name, new_active) plugin_entry["active"] = is_active
self._update_selected_plugin_controls()
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)
self._update_plugin_tabs() 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 []) existing_plugins = list(self.prefsDict.get("activePlugins", settings.activePlugins) or [])
preserved_plugins = [ preserved_plugins = [
name for name in existing_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 = [] selected_plugins = []
for canonical_name, plugin_names in self._plugin_group_map.items(): for canonical_name, plugin_names in self._plugin_group_map.items():
active_in_group = [ active_in_group = [
name for name in plugin_names name for name in plugin_names
if self._plugin_iters.get(name) if self._plugin_entries.get(name, {}).get("active", False)
and self._plugin_model.get_value(self._plugin_iters[name], self.PLUGIN_COL_ENABLED)
] ]
if active_in_group: if active_in_group:
selected_plugins.append(active_in_group[-1]) selected_plugins.append(active_in_group[-1])
return preserved_plugins + selected_plugins return preserved_plugins + selected_plugins
def _apply_plugin_changes(self): def _apply_plugin_changes(self) -> None:
active_plugins = self._get_active_plugins_from_ui() active_plugins = self._get_active_plugins_from_ui()
plugin_sources = list(self._plugin_sources)
self.prefsDict["activePlugins"] = active_plugins 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._populate_plugin_list()
self._update_plugin_tabs(active_plugins) self._update_plugin_tabs(active_plugins)
@@ -982,7 +761,7 @@ class CthulhuSetupGUI(cthulhu_gtkbuilder.GtkBuilderWrapper):
self._plugin_tabs_cached = True self._plugin_tabs_cached = True
def _get_active_plugins_for_tabs(self): def _get_active_plugins_for_tabs(self):
if self._plugin_iters: if self._plugin_entries:
return self._get_active_plugins_from_ui() return self._get_active_plugins_from_ui()
return list(self.prefsDict.get("activePlugins", settings.activePlugins) or []) return list(self.prefsDict.get("activePlugins", settings.activePlugins) or [])
+152 -4
View File
@@ -46,6 +46,7 @@ import gi
gi.require_version("Atspi", "2.0") gi.require_version("Atspi", "2.0")
from gi.repository import Atspi from gi.repository import Atspi
from gi.repository import GLib from gi.repository import GLib
import pyatspi
from . import debug from . import debug
from . import focus_manager from . import focus_manager
@@ -61,6 +62,44 @@ from .ax_utilities_application import AXUtilitiesApplication
if TYPE_CHECKING: if TYPE_CHECKING:
from . import keybindings from . import keybindings
_X11_SHIFT_MASK = 1 << int(Atspi.ModifierType.SHIFT)
_X11_SHIFT_LOCK_MASK = 1 << int(Atspi.ModifierType.SHIFTLOCK)
_X11_NUM_LOCK_MASK = 1 << int(Atspi.ModifierType.NUMLOCK)
_X11_CONSUMABLE_MODIFIER_MASKS = (
0,
_X11_SHIFT_MASK,
_X11_SHIFT_LOCK_MASK,
_X11_SHIFT_MASK | _X11_SHIFT_LOCK_MASK,
_X11_NUM_LOCK_MASK,
_X11_SHIFT_MASK | _X11_NUM_LOCK_MASK,
_X11_SHIFT_LOCK_MASK | _X11_NUM_LOCK_MASK,
_X11_SHIFT_MASK | _X11_SHIFT_LOCK_MASK | _X11_NUM_LOCK_MASK,
)
_X11_CONSUMABLE_KEYSYMS = (
"a", "b", "c", "d", "e", "f", "g", "h", "i", "k", "l", "m",
"o", "p", "q", "r", "s", "t", "u", "v", "x", "y",
"1", "2", "3", "4", "5", "6", "comma",
)
def _get_x11_consumable_key_set() -> List[Atspi.KeyDefinition]:
"""Returns AT-SPI definitions for unmodified structural-navigation keys."""
# Importing here avoids a module-level cycle: keybindings obtains this
# manager lazily when bindings install their AT-SPI grabs.
from . import keybindings # pylint: disable=import-outside-toplevel
keycodes = sorted({
keycode
for keysym in _X11_CONSUMABLE_KEYSYMS
if (keycode := keybindings.get_keycodes(keysym)[1])
})
keySet = []
for keycode in keycodes:
definition = Atspi.KeyDefinition()
definition.keycode = keycode
keySet.append(definition)
return keySet
class InputEventManager: class InputEventManager:
"""Provides utilities for managing input events.""" """Provides utilities for managing input events."""
@@ -68,6 +107,11 @@ class InputEventManager:
self._last_input_event: Optional[input_event.InputEvent] = None self._last_input_event: Optional[input_event.InputEvent] = None
self._last_non_modifier_key_event: Optional[input_event.KeyboardEvent] = None self._last_non_modifier_key_event: Optional[input_event.KeyboardEvent] = None
self._device: Optional[Atspi.Device] = None self._device: Optional[Atspi.Device] = None
self._key_pressed_id: int = 0
self._key_released_id: int = 0
self._selective_legacy_listener_active: bool = False
self._selective_legacy_key_set: List[Atspi.KeyDefinition] = []
self._selective_legacy_keycodes: set[int] = set()
self._pointer_moved_id: int = 0 self._pointer_moved_id: int = 0
self._mapped_keycodes: List[int] = [] self._mapped_keycodes: List[int] = []
self._mapped_keysyms: List[int] = [] self._mapped_keysyms: List[int] = []
@@ -101,13 +145,56 @@ class InputEventManager:
msg = "INPUT EVENT MANAGER: Starting key watcher." msg = "INPUT EVENT MANAGER: Starting key watcher."
debug.print_message(debug.LEVEL_INFO, msg, True) debug.print_message(debug.LEVEL_INFO, msg, True)
self.activate_device().add_key_watcher(self.process_keyboard_event) device = self.activate_device()
atspiVersion = Atspi.get_version()
if atspiVersion[0] > 2 or atspiVersion[1] >= 60:
msg = "INPUT EVENT MANAGER: Using AT-SPI key signals."
debug.print_message(debug.LEVEL_INFO, msg, True)
self._key_pressed_id = device.connect("key-pressed", self._on_key_pressed)
self._key_released_id = device.connect("key-released", self._on_key_released)
if os.environ.get("XDG_SESSION_TYPE", "").lower() == "x11":
self._selective_legacy_key_set = _get_x11_consumable_key_set()
if self._selective_legacy_key_set:
self._selective_legacy_keycodes = {
definition.keycode for definition in self._selective_legacy_key_set
}
msg = "INPUT EVENT MANAGER: Adding selective consumable X11 key listener."
debug.print_message(debug.LEVEL_INFO, msg, True)
pyatspi.Registry.registerKeystrokeListener(
self._process_selective_legacy_keyboard_event,
key_set=self._selective_legacy_key_set,
mask=list(_X11_CONSUMABLE_MODIFIER_MASKS),
kind=(Atspi.EventType.KEY_PRESSED_EVENT, Atspi.EventType.KEY_RELEASED_EVENT),
synchronous=True,
preemptive=True,
)
self._selective_legacy_listener_active = True
else:
msg = "INPUT EVENT MANAGER: Using legacy AT-SPI key watcher."
debug.print_message(debug.LEVEL_INFO, msg, True)
device.add_key_watcher(self.process_keyboard_event)
def stop_key_watcher(self) -> None: def stop_key_watcher(self) -> None:
"""Starts the watcher for keyboard input events.""" """Stops the watcher for keyboard input events."""
msg = "INPUT EVENT MANAGER: Stopping key watcher." msg = "INPUT EVENT MANAGER: Stopping key watcher."
debug.print_message(debug.LEVEL_INFO, msg, True) debug.print_message(debug.LEVEL_INFO, msg, True)
if self._selective_legacy_listener_active:
pyatspi.Registry.deregisterKeystrokeListener(
self._process_selective_legacy_keyboard_event,
key_set=self._selective_legacy_key_set,
mask=list(_X11_CONSUMABLE_MODIFIER_MASKS),
kind=(Atspi.EventType.KEY_PRESSED_EVENT, Atspi.EventType.KEY_RELEASED_EVENT),
)
self._selective_legacy_listener_active = False
self._selective_legacy_key_set = []
self._selective_legacy_keycodes.clear()
if self._device is not None:
for handlerId in (self._key_pressed_id, self._key_released_id):
if handlerId:
self._device.disconnect(handlerId)
self._key_pressed_id = 0
self._key_released_id = 0
self._device = None self._device = None
def get_debug_snapshot(self) -> Dict[str, object]: def get_debug_snapshot(self) -> Dict[str, object]:
@@ -229,6 +316,11 @@ class InputEventManager:
grab_ids = [] grab_ids = []
for kd in binding.key_definitions(): for kd in binding.key_definitions():
definitionKeycode = kd.keycode or binding.keycode
if self._signal_event_uses_selective_legacy_listener(
definitionKeycode, kd.modifiers
):
continue
grab_id = self._device.add_key_grab(kd, None) grab_id = self._device.add_key_grab(kd, None)
if grab_id == 0: if grab_id == 0:
continue continue
@@ -756,6 +848,62 @@ class InputEventManager:
# pylint: disable=too-many-arguments # pylint: disable=too-many-arguments
# pylint: disable=too-many-positional-arguments # pylint: disable=too-many-positional-arguments
def _process_selective_legacy_keyboard_event(self, event: Atspi.DeviceEvent) -> bool:
"""Returns an X11 consume decision for unmodified browse-navigation keys."""
pressed = event.type == Atspi.EventType.KEY_PRESSED_EVENT
return self.process_keyboard_event(
self._device,
pressed,
event.hw_code,
event.id,
event.modifiers,
event.event_string or "",
)
def _signal_event_uses_selective_legacy_listener(self, keycode: int, modifiers: int) -> bool:
"""Returns True if the X11 compatibility listener owns this event."""
return (
self._selective_legacy_listener_active
and keycode in self._selective_legacy_keycodes
and modifiers in _X11_CONSUMABLE_MODIFIER_MASKS
)
def _on_key_pressed(
self,
device: Atspi.Device,
keycode: int,
keysym: int,
modifiers: int,
text: str,
) -> None:
"""Processes a key-pressed signal from AT-SPI 2.60 and later."""
if self._signal_event_uses_selective_legacy_listener(keycode, modifiers):
msg = "INPUT EVENT MANAGER: Deferring key-pressed signal to X11 consumable listener."
debug.print_message(debug.LEVEL_INFO, msg, True)
return
self.process_keyboard_event(device, True, keycode, keysym, modifiers, text)
def _on_key_released(
self,
device: Atspi.Device,
keycode: int,
keysym: int,
modifiers: int,
text: str,
) -> None:
"""Processes a key-released signal from AT-SPI 2.60 and later."""
if self._signal_event_uses_selective_legacy_listener(keycode, modifiers):
msg = "INPUT EVENT MANAGER: Deferring key-released signal to X11 consumable listener."
debug.print_message(debug.LEVEL_INFO, msg, True)
return
self.process_keyboard_event(device, False, keycode, keysym, modifiers, text)
def process_keyboard_event( def process_keyboard_event(
self, self,
_device: Atspi.Device, _device: Atspi.Device,
@@ -849,7 +997,7 @@ class InputEventManager:
event._finalize_initialization() event._finalize_initialization()
event.set_click_count(self._determine_keyboard_event_click_count(event)) event.set_click_count(self._determine_keyboard_event_click_count(event))
event.process() consumed = event.process()
if event.is_modifier_key(): if event.is_modifier_key():
if self.is_release_for(event, self._last_input_event): if self.is_release_for(event, self._last_input_event):
@@ -859,7 +1007,7 @@ class InputEventManager:
else: else:
self._last_non_modifier_key_event = event self._last_non_modifier_key_event = event
self._last_input_event = event self._last_input_event = event
return True return consumed
# pylint: enable=too-many-arguments # pylint: enable=too-many-arguments
# pylint: enable=too-many-positional-arguments # pylint: enable=too-many-positional-arguments
+1 -1
View File
@@ -83,7 +83,7 @@ To add support for other languages, install additional Tesseract language packs:
## Usage ## Usage
1. **Enable the Plugin**: The OCR plugin is enabled by default in Cthulhu. If disabled, you can enable it through: 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 - Or add `OCR` to your `activePlugins` preference
2. **Basic OCR Workflow**: 2. **Basic OCR Workflow**:
+1 -25
View File
@@ -47,28 +47,6 @@ try:
except ImportError: except ImportError:
PYTESSERACT_AVAILABLE = False PYTESSERACT_AVAILABLE = False
# pdf2image
try:
from pdf2image import convert_from_path
PDF2IMAGE_AVAILABLE = True
except ImportError:
PDF2IMAGE_AVAILABLE = False
# scipy
try:
from scipy.spatial import KDTree
SCIPY_AVAILABLE = True
except ImportError:
SCIPY_AVAILABLE = False
# webcolors
try:
from webcolors import CSS3_HEX_TO_NAMES
from webcolors import hex_to_rgb
WEBCOLORS_AVAILABLE = True
except ImportError:
WEBCOLORS_AVAILABLE = False
# GTK/GDK # GTK/GDK
try: try:
gi.require_version("Gtk", "3.0") gi.require_version("Gtk", "3.0")
@@ -594,9 +572,7 @@ class OCRDesktop(Plugin):
"""Get color information for OCR text (simplified version).""" """Get color information for OCR text (simplified version)."""
if not self._colorCalculation: if not self._colorCalculation:
return 'unknown' return 'unknown'
if not SCIPY_AVAILABLE or not WEBCOLORS_AVAILABLE:
return 'unknown'
# Simplified color calculation - just return "unknown" for now # Simplified color calculation - just return "unknown" for now
# Full implementation would require the color analysis from ocrdesktop # Full implementation would require the color analysis from ocrdesktop
return 'unknown' return 'unknown'
+5 -7
View File
@@ -60,6 +60,7 @@ from cthulhu.scripts import default
from cthulhu.ax_object import AXObject from cthulhu.ax_object import AXObject
from cthulhu.ax_text import AXText from cthulhu.ax_text import AXText
from cthulhu.ax_utilities import AXUtilities from cthulhu.ax_utilities import AXUtilities
from cthulhu.ax_utilities_event import AXUtilitiesEvent, TextEventReason
from .bookmarks import Bookmarks from .bookmarks import Bookmarks
from .braille_generator import BrailleGenerator from .braille_generator import BrailleGenerator
@@ -2104,6 +2105,7 @@ class Script(default.Script):
debug.printMessage(debug.LEVEL_INFO, msg, True) debug.printMessage(debug.LEVEL_INFO, msg, True)
return False return False
reason = AXUtilitiesEvent.get_text_event_reason(event)
obj, offset = self.utilities.getCaretContext(document, False, False) obj, offset = self.utilities.getCaretContext(document, False, False)
tokens = ["WEB: Context: ", obj, ", ", offset, "(focus: ", cthulhu_state.locusOfFocus, ")"] tokens = ["WEB: Context: ", obj, ", ", offset, "(focus: ", cthulhu_state.locusOfFocus, ")"]
debug.printTokens(debug.LEVEL_INFO, tokens, True) debug.printTokens(debug.LEVEL_INFO, tokens, True)
@@ -2156,14 +2158,10 @@ class Script(default.Script):
self.updateBraille(event.source) self.updateBraille(event.source)
return True return True
if self.utilities.lastInputEventWasTab(): if reason == TextEventReason.FOCUS_CHANGE:
msg = "WEB: Last input event was Tab." msg = "WEB: Event ignored: Caret moved due to focus change."
debug.printMessage(debug.LEVEL_INFO, msg, True) debug.printMessage(debug.LEVEL_INFO, msg, True)
return True
if self.utilities.isDocument(event.source):
msg = "WEB: Event ignored: Caret moved in document due to Tab."
debug.printMessage(debug.LEVEL_INFO, msg, True)
return True
if self.utilities.inFindContainer(): if self.utilities.inFindContainer():
msg = "WEB: Event handled: Presenting find results" msg = "WEB: Event handled: Presenting find results"
@@ -7,13 +7,33 @@ from unittest import mock
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src")) sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
import cthulhu as cthulhu_package
from cthulhu import cthulhu_state from cthulhu import cthulhu_state
from cthulhu import cthulhu
from cthulhu import compositor_state_adapter from cthulhu import compositor_state_adapter
from cthulhu import compositor_state_types from cthulhu import compositor_state_types
from cthulhu import compositor_state_wayland from cthulhu import compositor_state_wayland
stubCthulhu = types.ModuleType("cthulhu.cthulhu")
stubCthulhu.cthulhuApp = mock.Mock()
previousCthulhuModule = sys.modules.get("cthulhu.cthulhu")
sys.modules.setdefault("cthulhu.cthulhu", stubCthulhu)
from cthulhu import event_manager
from cthulhu.wayland_protocols import ext_workspace_v1 from cthulhu.wayland_protocols import ext_workspace_v1
if previousCthulhuModule is None:
sys.modules.pop("cthulhu.cthulhu", None)
if getattr(cthulhu_package, "cthulhu", None) is stubCthulhu:
delattr(cthulhu_package, "cthulhu")
from cthulhu import cthulhu as restoredCthulhuModule
else:
sys.modules["cthulhu.cthulhu"] = previousCthulhuModule
cthulhu_package.cthulhu = previousCthulhuModule
restoredCthulhuModule = previousCthulhuModule
event_manager.cthulhu = restoredCthulhuModule
class FakeWorkspaceBackend: class FakeWorkspaceBackend:
def __init__(self, available: bool, name: str) -> None: def __init__(self, available: bool, name: str) -> None:
@@ -313,23 +333,38 @@ class CompositorStateAdapterRegressionTests(unittest.TestCase):
adapter = mock.Mock() adapter = mock.Mock()
adapter.sync_accessible_context = mock.Mock(return_value=None) adapter.sync_accessible_context = mock.Mock(return_value=None)
listener = mock.Mock() listener = mock.Mock()
fakeAtspi = types.SimpleNamespace(
EventListener=types.SimpleNamespace(new=mock.Mock(return_value=listener)),
)
fakeCthulhu = types.SimpleNamespace(
setActiveWindow=mock.Mock(),
setLocusOfFocus=mock.Mock(),
)
window = object() window = object()
focusedObject = object() focusedObject = object()
with ( with (
mock.patch.object(cthulhu.event_manager.Atspi.EventListener, "new", return_value=listener), mock.patch.object(event_manager, "Atspi", fakeAtspi),
mock.patch.object(cthulhu.event_manager.AXUtilities, "can_be_active_window", return_value=False), mock.patch.object(event_manager.AXUtilities, "can_be_active_window", return_value=False),
mock.patch.object(cthulhu.event_manager.AXUtilities, "find_active_window", return_value=window), mock.patch.object(event_manager.AXUtilities, "find_active_window", return_value=window),
mock.patch.object(cthulhu.event_manager.AXUtilities, "get_focused_object", return_value=focusedObject), mock.patch.object(event_manager.AXUtilities, "get_focused_object", return_value=focusedObject),
mock.patch.object(cthulhu.event_manager.cthulhu, "setActiveWindow") as setActiveWindow, mock.patch.object(event_manager, "cthulhu", fakeCthulhu),
mock.patch.object(cthulhu.event_manager.cthulhu, "setLocusOfFocus") as setLocusOfFocus,
): ):
manager = cthulhu.event_manager.EventManager(mock.Mock()) manager = event_manager.EventManager(mock.Mock())
manager.set_compositor_state_adapter(adapter) manager.set_compositor_state_adapter(adapter)
manager._sync_focus_on_startup() manager._sync_focus_on_startup()
setActiveWindow.assert_called_once_with(window, alsoSetLocusOfFocus=True, notifyScript=False) fakeCthulhu.setActiveWindow.assert_called_once_with(
setLocusOfFocus.assert_called_once_with(None, focusedObject, notifyScript=True, force=True) window,
alsoSetLocusOfFocus=True,
notifyScript=False,
)
fakeCthulhu.setLocusOfFocus.assert_called_once_with(
None,
focusedObject,
notifyScript=True,
force=True,
)
adapter.sync_accessible_context.assert_called_once_with("event-manager-startup") adapter.sync_accessible_context.assert_called_once_with("event-manager-startup")
+55 -11
View File
@@ -6,18 +6,62 @@ from unittest import mock
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src")) sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
input_event_manager_stub = types.ModuleType("cthulhu.input_event_manager") import cthulhu as cthulhuPackage
input_event_manager_stub.get_manager = mock.Mock(return_value=mock.Mock())
sys.modules["cthulhu.input_event_manager"] = input_event_manager_stub
from cthulhu.plugin_system_manager import PluginSystemManager missingModule = object()
from cthulhu import cthulhu_state previousInputEventManagerModule = sys.modules.get(
from cthulhu import speech "cthulhu.input_event_manager",
from cthulhu.plugins.CthulhuRemote.connection_info import ConnectionInfo, ConnectionMode missingModule,
from cthulhu.plugins.CthulhuRemote.local_machine import LocalMachine )
from cthulhu.plugins.CthulhuRemote.plugin import CthulhuRemote previousInputEventManagerAttribute = getattr(
from cthulhu.plugins.CthulhuRemote.protocol import RemoteMessageType cthulhuPackage,
from cthulhu.plugins.CthulhuRemote.serializer import JSONSerializer "input_event_manager",
missingModule,
)
previousPluginSystemManagerModule = sys.modules.get(
"cthulhu.plugin_system_manager",
missingModule,
)
previousPluginSystemManagerAttribute = getattr(
cthulhuPackage,
"plugin_system_manager",
missingModule,
)
inputEventManagerStub = types.ModuleType("cthulhu.input_event_manager")
inputEventManagerStub.get_manager = mock.Mock(return_value=mock.Mock())
sys.modules["cthulhu.input_event_manager"] = inputEventManagerStub
cthulhuPackage.input_event_manager = inputEventManagerStub
try:
from cthulhu import plugin_system_manager
from cthulhu import cthulhu_state
from cthulhu import speech
from cthulhu.plugin_system_manager import PluginSystemManager
from cthulhu.plugins.CthulhuRemote.connection_info import ConnectionInfo, ConnectionMode
from cthulhu.plugins.CthulhuRemote.local_machine import LocalMachine
from cthulhu.plugins.CthulhuRemote.plugin import CthulhuRemote
from cthulhu.plugins.CthulhuRemote.protocol import RemoteMessageType
from cthulhu.plugins.CthulhuRemote.serializer import JSONSerializer
finally:
if previousInputEventManagerModule is missingModule:
sys.modules.pop("cthulhu.input_event_manager", None)
else:
sys.modules["cthulhu.input_event_manager"] = previousInputEventManagerModule
if previousInputEventManagerAttribute is missingModule:
delattr(cthulhuPackage, "input_event_manager")
else:
cthulhuPackage.input_event_manager = previousInputEventManagerAttribute
if previousPluginSystemManagerModule is missingModule:
sys.modules.pop("cthulhu.plugin_system_manager", None)
else:
sys.modules["cthulhu.plugin_system_manager"] = previousPluginSystemManagerModule
if previousPluginSystemManagerAttribute is missingModule:
delattr(cthulhuPackage, "plugin_system_manager")
else:
cthulhuPackage.plugin_system_manager = previousPluginSystemManagerAttribute
class CthulhuRemotePluginTests(unittest.TestCase): class CthulhuRemotePluginTests(unittest.TestCase):
+9 -3
View File
@@ -30,6 +30,7 @@ sys.modules.setdefault(
), ),
) )
from cthulhu import ax_document_selection
from cthulhu.script_utilities import Utilities from cthulhu.script_utilities import Utilities
@@ -131,9 +132,14 @@ class DocumentSelectionRegressionTests(unittest.TestCase):
stack.enter_context(mock.patch("cthulhu.script_utilities.AXText.set_selected_text", set_selected_text)) stack.enter_context(mock.patch("cthulhu.script_utilities.AXText.set_selected_text", set_selected_text))
stack.enter_context(mock.patch("cthulhu.script_utilities.AXText._get_n_selections", return_value=1)) stack.enter_context(mock.patch("cthulhu.script_utilities.AXText._get_n_selections", return_value=1))
stack.enter_context(mock.patch("cthulhu.script_utilities.AXText._remove_selection", remove_selection)) stack.enter_context(mock.patch("cthulhu.script_utilities.AXText._remove_selection", remove_selection))
stack.enter_context(mock.patch("cthulhu.script_utilities.Atspi.Document.get_text_selections", side_effect=get_text_selections)) fakeAtspi = types.SimpleNamespace(
stack.enter_context(mock.patch("cthulhu.script_utilities.Atspi.Document.set_text_selections", side_effect=set_text_selections)) Document=types.SimpleNamespace(
stack.enter_context(mock.patch("cthulhu.script_utilities.Atspi.TextSelection", side_effect=self._new_text_selection)) get_text_selections=mock.Mock(side_effect=get_text_selections),
set_text_selections=mock.Mock(side_effect=set_text_selections),
),
TextSelection=mock.Mock(side_effect=self._new_text_selection),
)
stack.enter_context(mock.patch.object(ax_document_selection, "Atspi", fakeAtspi))
stack.enter_context( stack.enter_context(
mock.patch( mock.patch(
"cthulhu.script_utilities.cthulhu.cthulhuApp", "cthulhu.script_utilities.cthulhu.cthulhuApp",
@@ -34,11 +34,12 @@ class EventManagerCompositorContextRegressionTests(unittest.TestCase):
self.addCleanup(self._restore_cthulhu_state) self.addCleanup(self._restore_cthulhu_state)
self.listener = mock.Mock() self.listener = mock.Mock()
self.listenerPatch = mock.patch.object( fakeAtspi = types.SimpleNamespace(
event_manager.Atspi.EventListener, Accessible=event_manager.Atspi.Accessible,
"new", EventListener=types.SimpleNamespace(new=mock.Mock(return_value=self.listener)),
return_value=self.listener, Role=event_manager.Atspi.Role,
) )
self.listenerPatch = mock.patch.object(event_manager, "Atspi", fakeAtspi)
self.listenerPatch.start() self.listenerPatch.start()
self.addCleanup(self.listenerPatch.stop) self.addCleanup(self.listenerPatch.stop)
@@ -27,11 +27,12 @@ class FakeEvent:
class EventManagerRelevanceGateRegressionTests(unittest.TestCase): class EventManagerRelevanceGateRegressionTests(unittest.TestCase):
def setUp(self) -> None: def setUp(self) -> None:
self.listener = mock.Mock() self.listener = mock.Mock()
self.listenerPatch = mock.patch.object( fakeAtspi = types.SimpleNamespace(
event_manager.Atspi.EventListener, Accessible=event_manager.Atspi.Accessible,
"new", EventListener=types.SimpleNamespace(new=mock.Mock(return_value=self.listener)),
return_value=self.listener, Role=event_manager.Atspi.Role,
) )
self.listenerPatch = mock.patch.object(event_manager, "Atspi", fakeAtspi)
self.listenerPatch.start() self.listenerPatch.start()
self.addCleanup(self.listenerPatch.stop) self.addCleanup(self.listenerPatch.stop)
@@ -0,0 +1,310 @@
import sys
import types
import unittest
from pathlib import Path
from unittest import mock
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
from cthulhu import input_event_manager
class FakeDevice:
def __init__(self) -> None:
self.add_key_watcher_calls = []
self.connect_calls = []
self.disconnect_calls = []
self.next_handler_id = 17
self.add_key_grab_calls = []
self.grab_ids = []
def add_key_watcher(self, callback: object, user_data: object = None) -> None:
self.add_key_watcher_calls.append((callback, user_data))
def connect(self, signalName: str, callback: object) -> int:
self.connect_calls.append((signalName, callback))
handlerId = self.next_handler_id
self.next_handler_id += 1
return handlerId
def disconnect(self, handlerId: int) -> None:
self.disconnect_calls.append(handlerId)
def add_key_grab(self, definition: object, callback: object = None) -> int:
self.add_key_grab_calls.append((definition, callback))
return self.grab_ids.pop(0)
class FakeDeviceFactory:
def __init__(self, device: FakeDevice) -> None:
self.device = device
def new(self) -> FakeDevice:
return self.device
def new_full(self, _appId: str) -> FakeDevice:
return self.device
class InputEventManagerKeyWatcherTests(unittest.TestCase):
@staticmethod
def _fake_atspi(
version: tuple[int, int, int],
device: FakeDevice,
) -> types.SimpleNamespace:
return types.SimpleNamespace(
get_version=lambda: version,
Device=FakeDeviceFactory(device),
)
def test_atspi_260_uses_key_signals_and_disconnects_them(self) -> None:
manager = input_event_manager.InputEventManager()
device = FakeDevice()
fakeAtspi = self._fake_atspi((2, 60, 5), device)
with (
mock.patch.object(input_event_manager, "Atspi", fakeAtspi),
mock.patch.dict(input_event_manager.os.environ, {"XDG_SESSION_TYPE": "wayland"}),
):
manager.start_key_watcher()
manager.stop_key_watcher()
self.assertEqual(
device.connect_calls,
[
("key-pressed", manager._on_key_pressed),
("key-released", manager._on_key_released),
],
)
self.assertEqual(device.add_key_watcher_calls, [])
self.assertEqual(device.disconnect_calls, [17, 18])
self.assertIsNone(manager.get_device())
def test_pre_atspi_260_keeps_legacy_key_watcher(self) -> None:
manager = input_event_manager.InputEventManager()
device = FakeDevice()
fakeAtspi = self._fake_atspi((2, 58, 4), device)
with (
mock.patch.object(input_event_manager, "Atspi", fakeAtspi),
mock.patch.dict(input_event_manager.os.environ, {"XDG_SESSION_TYPE": "wayland"}),
):
manager.start_key_watcher()
self.assertEqual(
device.add_key_watcher_calls,
[(manager.process_keyboard_event, None)],
)
self.assertEqual(device.connect_calls, [])
def test_key_signal_callbacks_preserve_pressed_state(self) -> None:
manager = input_event_manager.InputEventManager()
device = mock.Mock()
with mock.patch.object(manager, "process_keyboard_event") as processEvent:
manager._on_key_pressed(device, 56, 98, 0, "b")
manager._on_key_released(device, 56, 98, 0, "b")
self.assertEqual(
processEvent.call_args_list,
[
mock.call(device, True, 56, 98, 0, "b"),
mock.call(device, False, 56, 98, 0, "b"),
],
)
def test_x11_consumable_listener_includes_locking_modifier_combinations(self) -> None:
manager = input_event_manager.InputEventManager()
device = FakeDevice()
fakeAtspi = self._fake_atspi((2, 60, 5), device)
fakeAtspi.EventType = types.SimpleNamespace(KEY_PRESSED_EVENT=0, KEY_RELEASED_EVENT=1)
fakeKeySet = [types.SimpleNamespace(keycode=38), types.SimpleNamespace(keycode=56)]
with (
mock.patch.object(input_event_manager, "Atspi", fakeAtspi),
mock.patch.dict(input_event_manager.os.environ, {"XDG_SESSION_TYPE": "x11"}),
mock.patch.object(
input_event_manager,
"_get_x11_consumable_key_set",
return_value=fakeKeySet,
),
mock.patch.object(input_event_manager.pyatspi.Registry, "registerKeystrokeListener") as register,
mock.patch.object(input_event_manager.pyatspi.Registry, "deregisterKeystrokeListener") as deregister,
):
manager.start_key_watcher()
manager.stop_key_watcher()
shiftMask = 1 << int(input_event_manager.Atspi.ModifierType.SHIFT)
shiftLockMask = 1 << int(input_event_manager.Atspi.ModifierType.SHIFTLOCK)
numLockMask = 1 << int(input_event_manager.Atspi.ModifierType.NUMLOCK)
expectedMasks = [
0,
shiftMask,
shiftLockMask,
shiftMask | shiftLockMask,
numLockMask,
shiftMask | numLockMask,
shiftLockMask | numLockMask,
shiftMask | shiftLockMask | numLockMask,
]
register.assert_called_once_with(
manager._process_selective_legacy_keyboard_event,
key_set=fakeKeySet,
mask=expectedMasks,
kind=(0, 1),
synchronous=True,
preemptive=True,
)
deregister.assert_called_once_with(
manager._process_selective_legacy_keyboard_event,
key_set=fakeKeySet,
mask=expectedMasks,
kind=(0, 1),
)
self.assertEqual(
device.connect_calls,
[
("key-pressed", manager._on_key_pressed),
("key-released", manager._on_key_released),
],
)
def test_x11_consumable_keycodes_include_structural_navigation_but_not_tab(self) -> None:
keycodes = {
keysym: index + 20
for index, keysym in enumerate(input_event_manager._X11_CONSUMABLE_KEYSYMS)
}
with mock.patch("cthulhu.keybindings.get_keycodes") as getKeycodes:
getKeycodes.side_effect = lambda keysym: (0, keycodes[keysym])
result = input_event_manager._get_x11_consumable_key_set()
resultKeycodes = {definition.keycode for definition in result}
self.assertIn(keycodes["b"], resultKeycodes)
self.assertIn(keycodes["h"], resultKeycodes)
requestedKeysyms = {call.args[0] for call in getKeycodes.call_args_list}
self.assertNotIn("Tab", requestedKeysyms)
def test_x11_consumable_key_set_uses_atspi_key_definitions(self) -> None:
with mock.patch("cthulhu.keybindings.get_keycodes", return_value=(0, 38)):
keySet = input_event_manager._get_x11_consumable_key_set()
self.assertTrue(keySet)
self.assertTrue(all(isinstance(item, input_event_manager.Atspi.KeyDefinition) for item in keySet))
def test_modern_signal_defers_registered_x11_key_to_consumable_listener(self) -> None:
manager = input_event_manager.InputEventManager()
manager._selective_legacy_listener_active = True
manager._selective_legacy_keycodes = {56}
device = mock.Mock()
with mock.patch.object(manager, "process_keyboard_event") as processEvent:
manager._on_key_pressed(device, 56, 98, 0, "b")
manager._on_key_released(device, 56, 98, 0, "b")
processEvent.assert_not_called()
def test_modern_signal_defers_x11_key_with_locking_modifiers(self) -> None:
manager = input_event_manager.InputEventManager()
manager._selective_legacy_listener_active = True
manager._selective_legacy_keycodes = {56}
device = mock.Mock()
shiftMask = 1 << int(input_event_manager.Atspi.ModifierType.SHIFT)
shiftLockMask = 1 << int(input_event_manager.Atspi.ModifierType.SHIFTLOCK)
numLockMask = 1 << int(input_event_manager.Atspi.ModifierType.NUMLOCK)
lockingMasks = (
shiftLockMask,
shiftMask | shiftLockMask,
numLockMask,
shiftMask | numLockMask,
shiftLockMask | numLockMask,
shiftMask | shiftLockMask | numLockMask,
)
with mock.patch.object(manager, "process_keyboard_event") as processEvent:
for modifiers in lockingMasks:
manager._on_key_pressed(device, 56, 98, modifiers, "b")
manager._on_key_released(device, 56, 98, modifiers, "b")
processEvent.assert_not_called()
def test_modern_signal_still_processes_keys_not_owned_by_selective_listener(self) -> None:
manager = input_event_manager.InputEventManager()
manager._selective_legacy_listener_active = True
manager._selective_legacy_keycodes = {56}
device = mock.Mock()
with mock.patch.object(manager, "process_keyboard_event") as processEvent:
manager._on_key_pressed(device, 23, 65289, 0, "Tab")
manager._on_key_released(device, 23, 65289, 0, "Tab")
self.assertEqual(
processEvent.call_args_list,
[
mock.call(device, True, 23, 65289, 0, "Tab"),
mock.call(device, False, 23, 65289, 0, "Tab"),
],
)
def test_keyboard_event_returns_actual_consumption_decision(self) -> None:
manager = input_event_manager.InputEventManager()
focusManager = mock.Mock()
focusManager.get_active_window.return_value = object()
focusManager.get_locus_of_focus.return_value = object()
scriptManager = mock.Mock()
scriptManager.get_active_script.return_value = mock.Mock()
keyboardEvent = mock.Mock()
keyboardEvent.is_modifier_key.return_value = False
keyboardEvent.process.return_value = False
with (
mock.patch.object(input_event_manager.cthulhu_state, "capturingKeys", False),
mock.patch.object(input_event_manager.cthulhu_state, "pendingSelfHostedFocus", None),
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, "_should_pass_through_for_active_xterm", return_value=False),
mock.patch.object(input_event_manager.AXUtilities, "can_be_active_window", return_value=True),
mock.patch.object(manager, "last_event_was_keyboard", return_value=False),
):
result = manager.process_keyboard_event(mock.Mock(), True, 56, 98, 0, "b")
self.assertFalse(result)
keyboardEvent.process.assert_called_once_with()
def test_x11_consumable_listener_is_only_owner_of_matching_key_grabs(self) -> None:
manager = input_event_manager.InputEventManager()
device = FakeDevice()
device.grab_ids = [101, 102, 103]
manager._device = device
manager._selective_legacy_listener_active = True
manager._selective_legacy_keycodes = {56}
legacyOwned = types.SimpleNamespace(keycode=0, keysym=98, modifiers=0)
modified = types.SimpleNamespace(keycode=0, keysym=98, modifiers=4)
unrelated = types.SimpleNamespace(keycode=0, keysym=106, modifiers=0)
binding = mock.Mock()
binding.is_enabled.return_value = True
binding.is_bound.return_value = True
binding.has_grabs.return_value = False
binding.keycode = 56
binding.key_definitions.return_value = [legacyOwned, modified]
unrelatedBinding = mock.Mock()
unrelatedBinding.is_enabled.return_value = True
unrelatedBinding.is_bound.return_value = True
unrelatedBinding.has_grabs.return_value = False
unrelatedBinding.keycode = 44
unrelatedBinding.key_definitions.return_value = [unrelated]
result = manager.add_grabs_for_keybinding(binding)
unrelatedResult = manager.add_grabs_for_keybinding(unrelatedBinding)
self.assertEqual(result, [101])
self.assertEqual(unrelatedResult, [102])
self.assertEqual(
device.add_key_grab_calls,
[(modified, None), (unrelated, None)],
)
if __name__ == "__main__":
unittest.main()
@@ -2,6 +2,7 @@ import sys
import types import types
import unittest import unittest
from pathlib import Path from pathlib import Path
from typing import Any
from unittest import mock from unittest import mock
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src")) sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
@@ -133,6 +134,20 @@ class InputEventManagerPointerMonitorTests(unittest.TestCase):
class MouseReviewBackendSelectionTests(unittest.TestCase): class MouseReviewBackendSelectionTests(unittest.TestCase):
@staticmethod
def _make_atspi(
version: tuple[int, int, int],
listener: Any,
pointerMonitor: int | None = None,
) -> types.SimpleNamespace:
fakeAtspi = types.SimpleNamespace(
EventListener=types.SimpleNamespace(new=mock.Mock(return_value=listener)),
get_version=mock.Mock(return_value=version),
)
if pointerMonitor is not None:
fakeAtspi.DeviceCapability = types.SimpleNamespace(POINTER_MONITOR=pointerMonitor)
return fakeAtspi
@staticmethod @staticmethod
def _make_app(enabled=False): def _make_app(enabled=False):
app = mock.Mock() app = mock.Mock()
@@ -146,16 +161,10 @@ class MouseReviewBackendSelectionTests(unittest.TestCase):
listener = mock.Mock() listener = mock.Mock()
deviceManager = mock.Mock() deviceManager = mock.Mock()
deviceManager.enable_pointer_monitoring.return_value = True deviceManager.enable_pointer_monitoring.return_value = True
fakeAtspi = self._make_atspi((2, 60, 0), listener, pointerMonitor=8)
with ( with (
mock.patch.object(mouse_review.Atspi.EventListener, "new", return_value=listener), mock.patch.object(mouse_review, "Atspi", fakeAtspi),
mock.patch.object(mouse_review.Atspi, "get_version", return_value=(2, 60, 0)),
mock.patch.object(
mouse_review.Atspi,
"DeviceCapability",
new=types.SimpleNamespace(POINTER_MONITOR=8),
create=True,
),
mock.patch.object(mouse_review.input_event_manager, "get_manager", return_value=deviceManager), mock.patch.object(mouse_review.input_event_manager, "get_manager", return_value=deviceManager),
mock.patch.object(mouse_review, "Wnck", None), mock.patch.object(mouse_review, "Wnck", None),
): ):
@@ -170,16 +179,10 @@ class MouseReviewBackendSelectionTests(unittest.TestCase):
listener = mock.Mock() listener = mock.Mock()
deviceManager = mock.Mock() deviceManager = mock.Mock()
deviceManager.enable_pointer_monitoring.return_value = True deviceManager.enable_pointer_monitoring.return_value = True
fakeAtspi = self._make_atspi((2, 60, 0), listener, pointerMonitor=8)
with ( with (
mock.patch.object(mouse_review.Atspi.EventListener, "new", return_value=listener), mock.patch.object(mouse_review, "Atspi", fakeAtspi),
mock.patch.object(mouse_review.Atspi, "get_version", return_value=(2, 60, 0)),
mock.patch.object(
mouse_review.Atspi,
"DeviceCapability",
new=types.SimpleNamespace(POINTER_MONITOR=8),
create=True,
),
mock.patch.object(mouse_review.input_event_manager, "get_manager", return_value=deviceManager), mock.patch.object(mouse_review.input_event_manager, "get_manager", return_value=deviceManager),
mock.patch.object(mouse_review.cthulhu_state, "locusOfFocus", None), mock.patch.object(mouse_review.cthulhu_state, "locusOfFocus", None),
mock.patch.object(mouse_review, "Wnck", None), mock.patch.object(mouse_review, "Wnck", None),
@@ -202,14 +205,19 @@ class MouseReviewBackendSelectionTests(unittest.TestCase):
screen.get_active_workspace.return_value = None screen.get_active_workspace.return_value = None
fakeWnck = mock.Mock() fakeWnck = mock.Mock()
fakeWnck.Screen.get_default.return_value = screen fakeWnck.Screen.get_default.return_value = screen
fakeAtspi = self._make_atspi((2, 58, 4), listener)
fakeGdk = types.SimpleNamespace(
Display=types.SimpleNamespace(
get_default=mock.Mock(return_value=mock.Mock()),
get_default_seat=mock.Mock(return_value=seat),
),
)
with ( with (
mock.patch.object(mouse_review.Atspi.EventListener, "new", return_value=listener), mock.patch.object(mouse_review, "Atspi", fakeAtspi),
mock.patch.object(mouse_review.Atspi, "get_version", return_value=(2, 58, 4)),
mock.patch.object(mouse_review.input_event_manager, "get_manager"), mock.patch.object(mouse_review.input_event_manager, "get_manager"),
mock.patch.object(mouse_review.cthulhu_state, "locusOfFocus", None), mock.patch.object(mouse_review.cthulhu_state, "locusOfFocus", None),
mock.patch.object(mouse_review.Gdk.Display, "get_default", return_value=mock.Mock()), mock.patch.object(mouse_review, "Gdk", fakeGdk),
mock.patch.object(mouse_review.Gdk.Display, "get_default_seat", return_value=seat),
mock.patch.object(mouse_review, "Wnck", fakeWnck), mock.patch.object(mouse_review, "Wnck", fakeWnck),
): ):
reviewer = mouse_review.MouseReviewer(self._make_app(enabled=False)) reviewer = mouse_review.MouseReviewer(self._make_app(enabled=False))
@@ -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()
@@ -8,11 +8,55 @@ from unittest import mock
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src")) sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
input_event_manager_stub = types.ModuleType("cthulhu.input_event_manager") import cthulhu as cthulhuPackage
input_event_manager_stub.get_manager = mock.Mock(return_value=mock.Mock())
sys.modules["cthulhu.input_event_manager"] = input_event_manager_stub
from cthulhu.plugin_system_manager import PluginInfo, PluginSystemManager missingModule = object()
previousInputEventManagerModule = sys.modules.get(
"cthulhu.input_event_manager",
missingModule,
)
previousInputEventManagerAttribute = getattr(
cthulhuPackage,
"input_event_manager",
missingModule,
)
previousPluginSystemManagerModule = sys.modules.get(
"cthulhu.plugin_system_manager",
missingModule,
)
previousPluginSystemManagerAttribute = getattr(
cthulhuPackage,
"plugin_system_manager",
missingModule,
)
inputEventManagerStub = types.ModuleType("cthulhu.input_event_manager")
inputEventManagerStub.get_manager = mock.Mock(return_value=mock.Mock())
sys.modules["cthulhu.input_event_manager"] = inputEventManagerStub
cthulhuPackage.input_event_manager = inputEventManagerStub
try:
from cthulhu import plugin_system_manager
from cthulhu.plugin_system_manager import PluginInfo, PluginSystemManager
finally:
if previousInputEventManagerModule is missingModule:
sys.modules.pop("cthulhu.input_event_manager", None)
else:
sys.modules["cthulhu.input_event_manager"] = previousInputEventManagerModule
if previousInputEventManagerAttribute is missingModule:
delattr(cthulhuPackage, "input_event_manager")
else:
cthulhuPackage.input_event_manager = previousInputEventManagerAttribute
if previousPluginSystemManagerModule is missingModule:
sys.modules.pop("cthulhu.plugin_system_manager", None)
else:
sys.modules["cthulhu.plugin_system_manager"] = previousPluginSystemManagerModule
if previousPluginSystemManagerAttribute is missingModule:
delattr(cthulhuPackage, "plugin_system_manager")
else:
cthulhuPackage.plugin_system_manager = previousPluginSystemManagerAttribute
PLUGIN_TEMPLATE = """ PLUGIN_TEMPLATE = """
+140 -2
View File
@@ -8,6 +8,9 @@ import gi
gi.require_version("Gdk", "3.0") gi.require_version("Gdk", "3.0")
gi.require_version("Gtk", "3.0") gi.require_version("Gtk", "3.0")
gi.require_version("Atspi", "2.0")
from gi.repository import Atspi
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src")) sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
@@ -20,9 +23,62 @@ if soundGeneratorModule is not None and not hasattr(soundGeneratorModule, "Sound
from cthulhu.ax_object import AXObject from cthulhu.ax_object import AXObject
from cthulhu.ax_utilities import AXUtilities from cthulhu.ax_utilities import AXUtilities
from cthulhu import cthulhu_state
from cthulhu.scripts.apps.steamwebhelper import script as steam_script from cthulhu.scripts.apps.steamwebhelper import script as steam_script
class SteamInactiveNotificationContractTests(unittest.TestCase):
@staticmethod
def _prepare_event_script(testScript, presentIfInactive):
handler = mock.Mock()
testScript.presentIfInactive = presentIfInactive
testScript.listeners = {"object:state-changed:showing": handler}
testScript.generatorCache = {"stale": object()}
testScript.skipObjectEvent = mock.Mock(return_value=False)
return handler
def test_steam_notification_is_processed_while_script_is_inactive(self):
app = object()
def initialize_chromium(testScript, initializedApp):
testScript.app = initializedApp
testScript.presentIfInactive = False
with mock.patch.object(
steam_script.Chromium.Script,
"__init__",
new=initialize_chromium,
):
testScript = steam_script.Script(app)
handler = self._prepare_event_script(testScript, testScript.presentIfInactive)
event = mock.Mock(type="object:state-changed:showing", source=object())
with (
mock.patch.object(cthulhu_state, "activeScript", object()),
mock.patch.object(AXObject, "get_role", return_value=Atspi.Role.NOTIFICATION),
):
testScript.processObjectEvent(event)
self.assertTrue(testScript.presentIfInactive)
handler.assert_called_once_with(event)
def test_generic_chromium_notification_is_deferred_while_script_is_inactive(self):
testScript = steam_script.Chromium.Script.__new__(
steam_script.Chromium.Script
)
handler = self._prepare_event_script(testScript, presentIfInactive=False)
event = mock.Mock(type="object:state-changed:showing", source=object())
with (
mock.patch.object(cthulhu_state, "activeScript", object()),
mock.patch.object(AXObject, "get_role", return_value=Atspi.Role.NOTIFICATION),
):
testScript.processObjectEvent(event)
handler.assert_not_called()
class SteamNotificationRootTests(unittest.TestCase): class SteamNotificationRootTests(unittest.TestCase):
def test_prefers_notification_ancestor_over_live_region_descendant(self): def test_prefers_notification_ancestor_over_live_region_descendant(self):
testScript = steam_script.Script.__new__(steam_script.Script) testScript = steam_script.Script.__new__(steam_script.Script)
@@ -50,11 +106,55 @@ class SteamNotificationRootTests(unittest.TestCase):
self.assertIs(result, notification) self.assertIs(result, notification)
class SteamNotificationQueueTests(unittest.TestCase): class SteamNotificationRoutingContractTests(unittest.TestCase):
def test_merges_non_overlapping_fragments_for_same_notification(self): def test_steam_notification_override_handles_showing_before_chromium(self):
testScript = steam_script.Script.__new__(steam_script.Script) testScript = steam_script.Script.__new__(steam_script.Script)
notification = object() notification = object()
event = mock.Mock(source=notification, detail1=1)
testScript._presentSteamNotification = mock.Mock()
with (
mock.patch.object(
testScript,
"_isSteamNotification",
return_value=True,
),
mock.patch.object(
steam_script.Chromium.Script,
"onShowingChanged",
) as chromiumHandler,
):
testScript.onShowingChanged(event)
testScript._presentSteamNotification.assert_called_once_with(notification)
chromiumHandler.assert_not_called()
def test_nonsteam_showing_event_defers_to_chromium(self):
testScript = steam_script.Script.__new__(steam_script.Script)
event = mock.Mock(source=object(), detail1=1)
testScript._presentSteamNotification = mock.Mock()
with (
mock.patch.object(
testScript,
"_isSteamNotification",
return_value=False,
),
mock.patch.object(
steam_script.Chromium.Script,
"onShowingChanged",
) as chromiumHandler,
):
testScript.onShowingChanged(event)
testScript._presentSteamNotification.assert_not_called()
chromiumHandler.assert_called_once_with(event)
class SteamNotificationQueueTests(unittest.TestCase):
@staticmethod
def _make_script():
testScript = steam_script.Script.__new__(steam_script.Script)
testScript._lastSteamNotification = ("", 0.0) testScript._lastSteamNotification = ("", 0.0)
testScript._steamPendingNotification = None testScript._steamPendingNotification = None
testScript._steamRelativeTimePattern = re.compile( testScript._steamRelativeTimePattern = re.compile(
@@ -63,6 +163,11 @@ class SteamNotificationQueueTests(unittest.TestCase):
) )
testScript._resetSteamPendingTimer = mock.Mock() testScript._resetSteamPendingTimer = mock.Mock()
testScript._presentSteamNotificationTextNow = mock.Mock() testScript._presentSteamNotificationTextNow = mock.Mock()
return testScript
def test_merges_non_overlapping_fragments_for_same_notification(self):
testScript = self._make_script()
notification = object()
testScript._queueSteamNotification("username", notification) testScript._queueSteamNotification("username", notification)
testScript._queueSteamNotification("Playing: Borderlands 2", notification) testScript._queueSteamNotification("Playing: Borderlands 2", notification)
@@ -80,6 +185,39 @@ class SteamNotificationQueueTests(unittest.TestCase):
notification, notification,
) )
def test_merges_relative_timestamp_without_repeated_presentation(self):
testScript = self._make_script()
notification = object()
testScript._queueSteamNotification("Username is now online", notification)
testScript._queueSteamNotification("2 minutes ago", notification)
testScript._presentSteamNotificationTextNow.assert_not_called()
self.assertEqual(
testScript._steamPendingNotification["text"],
"Username is now online. 2 minutes ago",
)
testScript._flushSteamPendingNotification(fromTimer=True)
testScript._presentSteamNotificationTextNow.assert_called_once_with(
"Username is now online. 2 minutes ago",
notification,
)
def test_duplicate_complete_notification_is_presented_once(self):
testScript = self._make_script()
notification = object()
testScript._queueSteamNotification("Username is now online", notification)
testScript._queueSteamNotification("Username is now online", notification)
testScript._flushSteamPendingNotification(fromTimer=True)
testScript._presentSteamNotificationTextNow.assert_called_once_with(
"Username is now online",
notification,
)
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()
+32
View File
@@ -569,6 +569,38 @@ class SteamReturnActivationTests(unittest.TestCase):
self.assertEqual(testScript._steamFriendsPanelActivationTarget, (None, "", 0.0)) self.assertEqual(testScript._steamFriendsPanelActivationTarget, (None, "", 0.0))
def test_return_rejects_friends_target_after_nonsteam_x11_handoff(self):
testScript = steam_script.Script.__new__(steam_script.Script)
button = object()
keyboardEvent = mock.Mock(event_string="Return", modifiers=0)
keyboardEvent.is_pressed_key.return_value = True
testScript.utilities = mock.Mock()
testScript._steamActiveQuickAccessTab = "Friends"
testScript._steamFriendsPanelActivationTarget = (
button,
"Username Playing Terraria",
100.0,
)
testScript._steamFriendsPanelActivationTargetMaxAge = 10.0
with (
mock.patch.object(steam_script.time, "monotonic", return_value=105.0),
mock.patch.object(
steam_script.Script,
"_activeX11WindowIsSteamSelectionContext",
return_value=False,
),
mock.patch.object(testScript, "_getClickableActivationTarget", return_value=None),
mock.patch.object(
steam_script.Script,
"_performClickableAction",
) as performAction,
):
self.assertFalse(testScript.shouldConsumeKeyboardEvent(keyboardEvent, None))
performAction.assert_not_called()
class SteamVirtualizedListMutationTests(unittest.TestCase): class SteamVirtualizedListMutationTests(unittest.TestCase):
def test_name_changed_presents_friends_panel_item_after_friends_tab_activation(self): def test_name_changed_presents_friends_panel_item_after_friends_tab_activation(self):
File diff suppressed because it is too large Load Diff
+82 -5
View File
@@ -24,6 +24,7 @@ if soundGeneratorModule is not None and not hasattr(soundGeneratorModule, "Sound
soundGeneratorModule.SoundGenerator = _StubSoundGenerator soundGeneratorModule.SoundGenerator = _StubSoundGenerator
from cthulhu import messages from cthulhu import messages
from cthulhu.ax_utilities_event import AXUtilitiesEvent, TextEventReason
from cthulhu.scripts.web import script as web_script from cthulhu.scripts.web import script as web_script
from cthulhu.scripts.web import script_utilities as web_script_utilities from cthulhu.scripts.web import script_utilities as web_script_utilities
from cthulhu.scripts.toolkits.Gecko import script_utilities as gecko_script_utilities from cthulhu.scripts.toolkits.Gecko import script_utilities as gecko_script_utilities
@@ -308,7 +309,6 @@ class WebFocusSpeechInterruptionRegressionTests(unittest.TestCase):
testScript.updateBraille = mock.Mock() testScript.updateBraille = mock.Mock()
testScript.presentationInterrupt = mock.Mock() testScript.presentationInterrupt = mock.Mock()
testScript._saveFocusedObjectInfo = mock.Mock() testScript._saveFocusedObjectInfo = mock.Mock()
testScript.refreshKeyGrabs = mock.Mock()
return testScript return testScript
def test_focus_generated_speech_uses_explicit_interrupt_then_appends(self): def test_focus_generated_speech_uses_explicit_interrupt_then_appends(self):
@@ -336,6 +336,7 @@ class WebFocusSpeechInterruptionRegressionTests(unittest.TestCase):
class WebCaretMovedRegressionTests(unittest.TestCase): class WebCaretMovedRegressionTests(unittest.TestCase):
def _make_script(self): def _make_script(self):
testScript = web_script.Script.__new__(web_script.Script) testScript = web_script.Script.__new__(web_script.Script)
testScript._inFocusMode = False
testScript._lastCommandWasCaretNav = False testScript._lastCommandWasCaretNav = False
testScript._lastCommandWasStructNav = False testScript._lastCommandWasStructNav = False
testScript._lastCommandWasMouseButton = False testScript._lastCommandWasMouseButton = False
@@ -351,15 +352,87 @@ class WebCaretMovedRegressionTests(unittest.TestCase):
def test_matching_char_nav_caret_event_is_consumed_without_duplicate_presentation(self): def test_matching_char_nav_caret_event_is_consumed_without_duplicate_presentation(self):
testScript = self._make_script() testScript = self._make_script()
source = "source" source = "source"
event = mock.Mock(source=source, detail1=4) event = mock.Mock(type="object:text-caret-moved", source=source, detail1=4)
with mock.patch.object(web_script.cthulhu, "setLocusOfFocus") as setLocusOfFocus: with (
mock.patch.object(
AXUtilitiesEvent,
"get_text_event_reason",
return_value=TextEventReason.NAVIGATION_BY_CHARACTER,
),
mock.patch.object(web_script.cthulhu, "setLocusOfFocus") as setLocusOfFocus,
):
result = web_script.Script.onCaretMoved(testScript, event) result = web_script.Script.onCaretMoved(testScript, event)
self.assertTrue(result) self.assertTrue(result)
testScript.utilities.setCaretContext.assert_not_called() testScript.utilities.setCaretContext.assert_not_called()
setLocusOfFocus.assert_not_called() setLocusOfFocus.assert_not_called()
def test_focus_change_caret_event_does_not_replace_real_focus_event(self):
testScript = self._make_script()
source = object()
event = mock.Mock(
type="object:text-caret-moved",
source=source,
detail1=0,
)
testScript.utilities.getCaretContext.return_value = ("old-focus", 0)
testScript.utilities.lastInputEventWasCharNav.return_value = False
with (
mock.patch.object(
AXUtilitiesEvent,
"get_text_event_reason",
return_value=TextEventReason.FOCUS_CHANGE,
) as getReason,
mock.patch.object(web_script.cthulhu, "setLocusOfFocus") as setLocusOfFocus,
):
result = web_script.Script.onCaretMoved(testScript, event)
self.assertTrue(result)
getReason.assert_called_once_with(event)
testScript.utilities.setCaretContext.assert_not_called()
setLocusOfFocus.assert_not_called()
class TextEventReasonRegressionTests(unittest.TestCase):
def test_tab_is_classified_as_focus_change_before_editable_state(self):
oldFocus = object()
source = object()
event = mock.Mock(type="object:text-caret-moved", source=source)
inputManager = mock.Mock()
inputManager.last_event_was_caret_selection.return_value = False
inputManager.last_event_was_caret_navigation.return_value = False
inputManager.last_event_was_select_all.return_value = False
inputManager.last_event_was_primary_click_or_release.return_value = False
inputManager.last_event_was_tab_navigation.return_value = True
with (
mock.patch(
"cthulhu.input_event_manager.get_manager",
return_value=inputManager,
),
mock.patch(
"cthulhu.ax_utilities_event.focus_manager.get_manager"
) as getFocusManager,
mock.patch(
"cthulhu.ax_utilities_event.AXUtilitiesRole.is_text_input_search",
return_value=False,
),
mock.patch(
"cthulhu.ax_utilities_event.AXUtilitiesState.is_editable",
return_value=True,
) as isEditable,
):
getFocusManager.return_value.get_active_mode_and_object_of_interest.return_value = (
None,
oldFocus,
)
reason = AXUtilitiesEvent._get_caret_moved_event_reason(event)
self.assertEqual(reason, TextEventReason.FOCUS_CHANGE)
isEditable.assert_not_called()
class WebDescriptionChangeRegressionTests(unittest.TestCase): class WebDescriptionChangeRegressionTests(unittest.TestCase):
def _make_script(self): def _make_script(self):
@@ -419,6 +492,7 @@ class WebDynamicContentRecoveryRegressionTests(unittest.TestCase):
testScript.utilities.handleEventFromContextReplicant.return_value = False testScript.utilities.handleEventFromContextReplicant.return_value = False
testScript.utilities.handleEventForRemovedChild.return_value = False testScript.utilities.handleEventForRemovedChild.return_value = False
testScript.utilities.inDocumentContent.return_value = True testScript.utilities.inDocumentContent.return_value = True
testScript.refreshKeyGrabs = mock.Mock()
return testScript return testScript
def test_children_added_to_live_focus_preserves_caret_context(self): def test_children_added_to_live_focus_preserves_caret_context(self):
@@ -512,7 +586,6 @@ class WebDynamicContentRecoveryRegressionTests(unittest.TestCase):
setLocusOfFocus.assert_called_once_with(event, link, False) setLocusOfFocus.assert_called_once_with(event, link, False)
testScript.utilities.setCaretContext.assert_called_once_with(link, 0) testScript.utilities.setCaretContext.assert_called_once_with(link, 0)
testScript.utilities.searchForCaretContext.assert_not_called() testScript.utilities.searchForCaretContext.assert_not_called()
def test_browse_mode_sticky_blocks_web_app_descendant_focus_claim(self): def test_browse_mode_sticky_blocks_web_app_descendant_focus_claim(self):
testScript = self._make_dynamic_script() testScript = self._make_dynamic_script()
source = object() source = object()
@@ -610,7 +683,11 @@ class WebSelectionRegressionTests(unittest.TestCase):
testScript = self._make_script() testScript = self._make_script()
document = object() document = object()
section = object() section = object()
event = mock.Mock(source=section, detail1=-1) event = mock.Mock(
type="object:text-caret-moved",
source=section,
detail1=-1,
)
manager = mock.Mock() manager = mock.Mock()
manager.last_event_was_forward_caret_selection.return_value = True manager.last_event_was_forward_caret_selection.return_value = True
manager.last_event_was_backward_caret_selection.return_value = False manager.last_event_was_backward_caret_selection.return_value = False