Compare commits

..

40 Commits

Author SHA1 Message Date
Storm Dragon
d36b664319 Merge branch 'testing'
Plugins are in a much better state now, mostly working. The exception is, plugins that create a keyboard shortcut don't actually bind the shortcut. That one is turning out to be a lot harder to fix than I originally thought.
2025-04-05 16:32:17 -04:00
Storm Dragon
02be96aa69 Try to fix clipboard and simple plugins. 2025-04-04 18:04:58 -04:00
Storm Dragon
48575ab6cd Removed the old plugin manager. It didn't work anyway and needs to be rewritten. 2025-04-04 17:28:31 -04:00
Storm Dragon
2c28021ed4 Removed the old plugin manager. It didn't work anyway and needs to be rewritten. 2025-04-04 17:27:30 -04:00
Storm Dragon
8a79725df8 Update clipboard plugin to new pluggy format. 2025-04-04 17:08:18 -04:00
Storm Dragon
1b4c4916e3 Hopefully fixed an error in simple plugin system. 2025-04-04 16:33:53 -04:00
Storm Dragon
35a83327ac Convert simple plugin plugin to new plugin format. Hmm, gotta get in a couple more... plugin plugin plugin! lol 2025-04-04 16:25:28 -04:00
Storm Dragon
c712bea421 Attempt to fix keybindings not working from plugins. 2025-04-04 16:03:35 -04:00
Storm Dragon
815d39fc3f Remove a couple plugins that were not being used and won't be ported over. If needed, they can be rewritten later. 2025-04-04 14:54:18 -04:00
Storm Dragon
231d74efa0 Try to fix repeating welcome message. 2025-04-04 14:32:03 -04:00
Storm Dragon
7876a18c12 Working on plugin conversion. 2025-04-04 14:19:09 -04:00
Storm Dragon
0b7cf681c3 Forgot to update the configure.ac file. 2025-04-04 02:48:43 -04:00
Storm Dragon
4b8ebcb599 Removed the Date plugin it was causing traceback. 2025-04-04 02:46:01 -04:00
Storm Dragon
d6a373c726 Fixed some errors with plugins. 2025-04-03 20:46:11 -04:00
Storm Dragon
dfe20fca30 More work on pluggy. 2025-04-03 20:38:27 -04:00
Storm Dragon
3f7d60763d Merge branch 'testing'
Plugins are currently broken as Cthulhu moves over to pluggy. Libpeas and pygobject no longer play nicely together after latest updates. I really did not want to make a new release yet, because it is not ready, but a screen reader that at least reads instead of crashing at launch is better than nothing.
2025-04-03 20:17:14 -04:00
Storm Dragon
084d4fe85f Attempt to get pluggy working. 2025-04-03 20:10:54 -04:00
Storm Dragon
6bbe6e47fc Simplified the plugin code. Hopefully it at least somewhat works better now. 2025-04-03 14:00:06 -04:00
Storm Dragon
312476bbed A simple test plugin added for testing. 2025-03-27 23:23:01 -04:00
Storm Dragon
0005d5ec71 Activate the plugin in settings.py. 2025-03-26 01:45:58 -04:00
Storm Dragon
88ad9833d2 Forgot to update configure.ac. 2025-03-26 01:26:22 -04:00
Storm Dragon
654f1acc21 Start updating plugins. 2025-03-26 01:21:28 -04:00
Storm Dragon
dfb53fff89 And another line I forgot to remove. 2025-03-25 20:18:57 -04:00
Storm Dragon
9cdb9f74e5 Decided to make the api helper part of the plugin_system_manager file. Forgot to remove the import from cthulhu.py. 2025-03-25 20:15:54 -04:00
Storm Dragon
41dae26d90 Fixed a message to be more clear. 2025-03-25 20:02:06 -04:00
Storm Dragon
edc1cbf7af Continue work on switching to pluggy for plugins. 2025-03-25 19:59:50 -04:00
Storm Dragon
d3d268004b Start migration to pluggy for plugins. 2025-03-25 19:41:57 -04:00
Didier Spaier
a8e16fcf01 Toggle screen reader keyboard shortcut for desktops relying on glib-2.0/gio such as Mate. 2024-12-23 14:29:10 -05:00
Storm Dragon
6bbf3d0e67 Merge branch 'testing' latest changes merged. 2024-12-22 19:04:57 -05:00
Storm Dragon
cbe3424e29 Fix the version.py file in preparation for merging. 2024-12-22 19:04:39 -05:00
Storm Dragon
1cf566c37c Error out if libpeas is not found during build. 2024-12-22 18:59:48 -05:00
Storm Dragon
936153f307 Fixed typo in keybinding. 2024-12-22 17:00:36 -05:00
Storm Dragon
29f8697a9f Fix missing method in default.py for scripts. 2024-12-22 15:16:18 -05:00
Storm Dragon
ba735c554b Try to fix sleep mode so it is actually detected. 2024-12-22 14:49:31 -05:00
Storm Dragon
1c9ca14272 Attempt to back port sleepmode. 2024-12-22 14:22:56 -05:00
Storm Dragon
d6af91bf42 Added version to active plugins. Reverted keybind to cthulhu+shift+v 2024-12-19 09:44:01 -05:00
Storm Dragon
5dd872535d Temperarily change the keyboard short fo version to cthulhu+x. 2024-12-19 09:30:59 -05:00
Storm Dragon
8e9ea3af5a Attempt to fix the cthulhu+shift+v keybinding. 2024-12-19 08:37:45 -05:00
Storm Dragon
a40087cd20 Update configure.ac. 2024-12-19 08:29:11 -05:00
Storm Dragon
35acddeb8f Added Display Version plugin, cthul+shift=V. 2024-12-19 08:20:21 -05:00
84 changed files with 2191 additions and 3217 deletions

View File

@ -31,7 +31,7 @@ PKG_CHECK_MODULES([PYGOBJECT], [pygobject-3.0 >= pygobject_required_version])
PKG_CHECK_MODULES([ATSPI2], [atspi-2 >= atspi_required_version]) PKG_CHECK_MODULES([ATSPI2], [atspi-2 >= atspi_required_version])
PKG_CHECK_MODULES([ATKBRIDGE], [atk-bridge-2.0 >= atkbridge_required_version]) PKG_CHECK_MODULES([ATKBRIDGE], [atk-bridge-2.0 >= atkbridge_required_version])
PKG_CHECK_MODULES([GSTREAMER], [gstreamer-1.0], [gstreamer="yes"], [gstreamer="no"]) PKG_CHECK_MODULES([GSTREAMER], [gstreamer-1.0], [gstreamer="yes"], [gstreamer="no"])
PKG_CHECK_MODULES([LIBPEAS], [libpeas-1.0], [libpeas="yes"], [libpeas="no"]) PKG_CHECK_MODULES([LIBPEAS], [libpeas-1.0])
dnl Needed programs dnl Needed programs
AC_PROG_INSTALL AC_PROG_INSTALL
@ -46,6 +46,7 @@ AM_PATH_PYTHON(3.3)
AM_CHECK_PYMOD(gi,,,[AC_MSG_ERROR(Could not find python module: gi)]) AM_CHECK_PYMOD(gi,,,[AC_MSG_ERROR(Could not find python module: gi)])
AM_CHECK_PYMOD(json,,,[AC_MSG_ERROR(Could not find python module: json)]) AM_CHECK_PYMOD(json,,,[AC_MSG_ERROR(Could not find python module: json)])
AM_CHECK_PYMOD(pluggy,,[pluggy_available="yes"],[pluggy_available="no"])
AM_CHECK_PYMOD(brlapi,,[brlapi_available="yes"],[brlapi_available="no"]) AM_CHECK_PYMOD(brlapi,,[brlapi_available="yes"],[brlapi_available="no"])
AM_CHECK_PYMOD(speechd,,[speechd_available="yes"],[speechd_available="no"]) AM_CHECK_PYMOD(speechd,,[speechd_available="yes"],[speechd_available="no"])
AC_ARG_WITH([liblouis], AC_ARG_WITH([liblouis],
@ -110,6 +111,7 @@ src/cthulhu/scripts/apps/SeaMonkey/Makefile
src/cthulhu/scripts/apps/smuxi-frontend-gnome/Makefile src/cthulhu/scripts/apps/smuxi-frontend-gnome/Makefile
src/cthulhu/scripts/apps/Thunderbird/Makefile src/cthulhu/scripts/apps/Thunderbird/Makefile
src/cthulhu/scripts/apps/xfwm4/Makefile src/cthulhu/scripts/apps/xfwm4/Makefile
src/cthulhu/scripts/sleepmode/Makefile
src/cthulhu/scripts/switcher/Makefile src/cthulhu/scripts/switcher/Makefile
src/cthulhu/scripts/terminal/Makefile src/cthulhu/scripts/terminal/Makefile
src/cthulhu/scripts/web/Makefile src/cthulhu/scripts/web/Makefile
@ -125,14 +127,10 @@ src/cthulhu/scripts/toolkits/gtk/Makefile
src/cthulhu/plugins/Makefile src/cthulhu/plugins/Makefile
src/cthulhu/plugins/ByeCthulhu/Makefile src/cthulhu/plugins/ByeCthulhu/Makefile
src/cthulhu/plugins/HelloCthulhu/Makefile src/cthulhu/plugins/HelloCthulhu/Makefile
src/cthulhu/plugins/PluginManager/Makefile
src/cthulhu/plugins/Clipboard/Makefile src/cthulhu/plugins/Clipboard/Makefile
src/cthulhu/plugins/HelloWorld/Makefile src/cthulhu/plugins/DisplayVersion/Makefile
src/cthulhu/plugins/CapsLockHack/Makefile src/cthulhu/plugins/hello_world/Makefile
src/cthulhu/plugins/SelfVoice/Makefile src/cthulhu/plugins/self_voice/Makefile
src/cthulhu/plugins/Date/Makefile
src/cthulhu/plugins/Time/Makefile
src/cthulhu/plugins/MouseReview/Makefile
src/cthulhu/plugins/SimplePluginSystem/Makefile src/cthulhu/plugins/SimplePluginSystem/Makefile
src/cthulhu/backends/Makefile src/cthulhu/backends/Makefile
src/cthulhu/cthulhu_bin.py src/cthulhu/cthulhu_bin.py
@ -166,6 +164,7 @@ if test "$have_libpeas" = "no"; then
fi fi
echo echo
echo Use pluggy: $pluggy_available
echo Use speech-dispatcher: $speechd_available echo Use speech-dispatcher: $speechd_available
echo Use brltty: $brlapi_available echo Use brltty: $brlapi_available
echo Use liblouis: $louis_available echo Use liblouis: $louis_available

View File

@ -0,0 +1,20 @@
<?xml version="1.0" encoding="UTF-8"?>
<schemalist gettext-domain="slint">
<schema id="toggle.screenreader" path="/toggle/screenreader/">
<key name="binding" type="s">
<default>'&lt;Shift&gt;&lt;Alt&gt;v'</default>
<summary>Keybinding</summary>
<description>Keybinding associated to toggle screen reader.</description>
</key>
<key name="action" type="s">
<default>'/opt/I38/scripts/toggle_screenreader.sh 1>/dev/null'</default>
<summary>Command</summary>
<description>Command to toggle the screen reader between orca and cthulhu.</description>
</key>
<key name="name" type="s">
<default>'Toggle screenreader'</default>
<summary>Name</summary>
<description>Description associated to toggle screen reader.</description>
</key>
</schema>
</schemalist>

View File

@ -460,6 +460,9 @@ TOGGLE_SPEECH = _("Toggle the silencing of speech")
# This string describes that command. # This string describes that command.
TOGGLE_SPEECH_VERBOSITY = _("Toggle speech verbosity level") TOGGLE_SPEECH_VERBOSITY = _("Toggle speech verbosity level")
# Translators: this string is associated with the keyboard shortcut to toggle sleepmode
TOGGLE_SLEEP_MODE = _("Toggle sleep mode")
# Translators: this string is associated with the keyboard shortcut to quit # Translators: this string is associated with the keyboard shortcut to quit
# Cthulhu. # Cthulhu.
QUIT_CTHULHU = _("Quit the screen reader") QUIT_CTHULHU = _("Quit the screen reader")

View File

@ -57,6 +57,9 @@ keymap = (
("BackSpace", defaultModifierMask, CTHULHU_MODIFIER_MASK, ("BackSpace", defaultModifierMask, CTHULHU_MODIFIER_MASK,
"bypassNextCommandHandler"), "bypassNextCommandHandler"),
("q", defaultModifierMask, CTHULHU_CTRL_ALT_MODIFIER_MASK, CTHULHU_SHIFT_MODIFIER_MASK,
"toggleSleepModeHandler"),
("q", defaultModifierMask, CTHULHU_MODIFIER_MASK, ("q", defaultModifierMask, CTHULHU_MODIFIER_MASK,
"shutdownHandler"), "shutdownHandler"),

View File

@ -34,6 +34,108 @@ __copyright__ = "Copyright (c) 2004-2009 Sun Microsystems Inc." \
__license__ = "LGPL" __license__ = "LGPL"
import faulthandler import faulthandler
class APIHelper:
"""Helper class for plugin API interactions, including keybindings."""
def __init__(self, app):
"""Initialize the APIHelper.
Arguments:
- app: the Cthulhu application
"""
self.app = app
self._gestureBindings = {}
def registerGestureByString(self, function, name, gestureString,
inputEventType='default', normalizer='cthulhu',
learnModeEnabled=True, contextName=None):
"""Register a gesture by string.
Arguments:
- function: the function to call when the gesture is performed
- name: a human-readable name for this gesture
- gestureString: string representation of the gesture (e.g., 'kb:cthulhu+z')
- inputEventType: the type of input event
- normalizer: the normalizer to use
- learnModeEnabled: whether this should be available in learn mode
- contextName: the context for this gesture (e.g., plugin name)
Returns the binding ID or None if registration failed
"""
if not gestureString.startswith("kb:"):
return None
# Extract the key portion from the gesture string
key = gestureString.split(":", 1)[1]
# Handle Cthulhu modifier specially
if "cthulhu+" in key.lower():
from . import keybindings
key_parts = key.lower().split("+")
# Determine appropriate modifier mask
modifiers = keybindings.CTHULHU_MODIFIER_MASK
# Extract the final key (without modifiers)
final_key = key_parts[-1]
# Check for additional modifiers
if "shift" in key_parts:
modifiers = keybindings.CTHULHU_SHIFT_MODIFIER_MASK
elif "ctrl" in key_parts or "control" in key_parts:
modifiers = keybindings.CTHULHU_CTRL_MODIFIER_MASK
elif "alt" in key_parts:
modifiers = keybindings.CTHULHU_ALT_MODIFIER_MASK
# Create a keybinding handler
class GestureHandler:
def __init__(self, function, description):
self.function = function
self.description = description
def __call__(self, script, inputEvent):
return self.function(script, inputEvent)
handler = GestureHandler(function, name)
# Register the binding with the active script
from . import cthulhu_state
if cthulhu_state.activeScript:
bindings = cthulhu_state.activeScript.getKeyBindings()
binding = keybindings.KeyBinding(
final_key,
keybindings.defaultModifierMask,
modifiers,
handler)
bindings.add(binding)
# Store binding for later reference
if contextName not in self._gestureBindings:
self._gestureBindings[contextName] = []
self._gestureBindings[contextName].append(binding)
return binding
return None
def unregisterShortcut(self, binding, contextName=None):
"""Unregister a previously registered shortcut.
Arguments:
- binding: the binding to unregister
- contextName: the context for this gesture
"""
# Remove from script's keybindings
from . import cthulhu_state
if cthulhu_state.activeScript:
bindings = cthulhu_state.activeScript.getKeyBindings()
bindings.remove(binding)
# Remove from our tracking
if contextName in self._gestureBindings:
if binding in self._gestureBindings[contextName]:
self._gestureBindings[contextName].remove(binding)
import gi import gi
import importlib import importlib
import os import os
@ -74,7 +176,7 @@ from .ax_object import AXObject
from .ax_utilities import AXUtilities from .ax_utilities import AXUtilities
from .input_event import BrailleEvent from .input_event import BrailleEvent
from . import cmdnames from . import cmdnames
from . import plugin_system_manager from . import plugin_system_manager # This will now be your pluggy-based implementation
from . import guilabels from . import guilabels
from . import acss from . import acss
from . import text_attribute_names from . import text_attribute_names
@ -920,7 +1022,6 @@ class Cthulhu(GObject.Object):
GObject.Object.__init__(self) GObject.Object.__init__(self)
# add members # add members
self.resourceManager = resource_manager.ResourceManager(self) self.resourceManager = resource_manager.ResourceManager(self)
self.APIHelper = plugin_system_manager.APIHelper(self)
self.eventManager = _eventManager self.eventManager = _eventManager
self.settingsManager = _settingsManager self.settingsManager = _settingsManager
self.scriptManager = _scriptManager self.scriptManager = _scriptManager
@ -928,8 +1029,11 @@ class Cthulhu(GObject.Object):
self.dynamicApiManager = dynamic_api_manager.DynamicApiManager(self) self.dynamicApiManager = dynamic_api_manager.DynamicApiManager(self)
self.translationManager = translation_manager.TranslationManager(self) self.translationManager = translation_manager.TranslationManager(self)
self.debugManager = debug self.debugManager = debug
self.APIHelper = APIHelper(self)
self.createCompatAPI() self.createCompatAPI()
self.pluginSystemManager = plugin_system_manager.PluginSystemManager(self) self.pluginSystemManager = plugin_system_manager.PluginSystemManager(self)
# Scan for available plugins at startup
self.pluginSystemManager.rescanPlugins()
def getAPIHelper(self): def getAPIHelper(self):
return self.APIHelper return self.APIHelper
def getPluginSystemManager(self): def getPluginSystemManager(self):
@ -987,6 +1091,7 @@ class Cthulhu(GObject.Object):
# cthulhu lets say, special compat handling.... # cthulhu lets say, special compat handling....
self.getDynamicApiManager().registerAPI('EmitRegionChanged', emitRegionChanged) self.getDynamicApiManager().registerAPI('EmitRegionChanged', emitRegionChanged)
self.getDynamicApiManager().registerAPI('LoadUserSettings', loadUserSettings) self.getDynamicApiManager().registerAPI('LoadUserSettings', loadUserSettings)
self.getDynamicApiManager().registerAPI('APIHelper', self.APIHelper)
cthulhuApp = Cthulhu() cthulhuApp = Cthulhu()

View File

@ -23,5 +23,5 @@
# Fork of Orca Screen Reader (GNOME) # Fork of Orca Screen Reader (GNOME)
# Original source: https://gitlab.gnome.org/GNOME/orca # Original source: https://gitlab.gnome.org/GNOME/orca
version = "2024.12.18" version = "2025.04.04"
codeName = "stable" codeName = "testing"

View File

@ -193,7 +193,7 @@ class CthulhuSetupGUI(cthulhu_gtkbuilder.GtkBuilderWrapper):
# ***** Key Bindings treeview initialization ***** # ***** Key Bindings treeview initialization *****
self.keyBindView = self.get_widget("keyBindingsTreeview") self.keyBindView = self.get_widget("keyBindingsTreeview")
if self.keyBindView.get_columns(): if self.keyBindView.get_columns():
for column in self.keyBindView.get_columns(): for column in self.keyBindView.get_columns():
self.keyBindView.remove_column(column) self.keyBindView.remove_column(column)
@ -337,7 +337,7 @@ class CthulhuSetupGUI(cthulhu_gtkbuilder.GtkBuilderWrapper):
column.set_resizable(True) column.set_resizable(True)
column.set_sort_column_id(EDITABLE) column.set_sort_column_id(EDITABLE)
self.keyBindView.append_column(column) self.keyBindView.append_column(column)
# Populates the treeview with all the keybindings: # Populates the treeview with all the keybindings:
# #
self._populateKeyBindings() self._populateKeyBindings()
@ -582,7 +582,7 @@ class CthulhuSetupGUI(cthulhu_gtkbuilder.GtkBuilderWrapper):
self.get_widget("rateScale").set_value(rate) self.get_widget("rateScale").set_value(rate)
else: else:
self.get_widget("rateScale").set_value(50.0) self.get_widget("rateScale").set_value(50.0)
pitch = self._getPitchForVoiceType(voiceType) pitch = self._getPitchForVoiceType(voiceType)
if pitch is not None: if pitch is not None:
self.get_widget("pitchScale").set_value(pitch) self.get_widget("pitchScale").set_value(pitch)
@ -1150,7 +1150,7 @@ class CthulhuSetupGUI(cthulhu_gtkbuilder.GtkBuilderWrapper):
grid = self.get_widget('flashMessageDurationGrid') grid = self.get_widget('flashMessageDurationGrid')
grid.set_sensitive(not checkbox.get_active()) grid.set_sensitive(not checkbox.get_active())
self.prefsDict["flashIsPersistent"] = checkbox.get_active() self.prefsDict["flashIsPersistent"] = checkbox.get_active()
def textAttributeSpokenToggled(self, cell, path, model): def textAttributeSpokenToggled(self, cell, path, model):
"""The user has toggled the state of one of the text attribute """The user has toggled the state of one of the text attribute
checkboxes to be spoken. Update our model to reflect this, then checkboxes to be spoken. Update our model to reflect this, then
@ -1596,7 +1596,7 @@ class CthulhuSetupGUI(cthulhu_gtkbuilder.GtkBuilderWrapper):
elif dateFormat == messages.DATE_FORMAT_ABBREVIATED_YMD: elif dateFormat == messages.DATE_FORMAT_ABBREVIATED_YMD:
indexdate = DATE_FORMAT_ABBREVIATED_YMD indexdate = DATE_FORMAT_ABBREVIATED_YMD
combobox2.set_active (indexdate) combobox2.set_active (indexdate)
combobox3 = self.get_widget("timeFormatCombo") combobox3 = self.get_widget("timeFormatCombo")
self.populateComboBox(combobox3, self.populateComboBox(combobox3,
[sdtime(messages.TIME_FORMAT_LOCALE, ltime()), [sdtime(messages.TIME_FORMAT_LOCALE, ltime()),
@ -1757,7 +1757,7 @@ class CthulhuSetupGUI(cthulhu_gtkbuilder.GtkBuilderWrapper):
prefs["enableEchoByWord"]) prefs["enableEchoByWord"])
self.get_widget("enableEchoBySentenceCheckButton").set_active( \ self.get_widget("enableEchoBySentenceCheckButton").set_active( \
prefs["enableEchoBySentence"]) prefs["enableEchoBySentence"])
# Text attributes pane. # Text attributes pane.
# #
self._createTextAttributesTreeView() self._createTextAttributesTreeView()
@ -1785,7 +1785,7 @@ class CthulhuSetupGUI(cthulhu_gtkbuilder.GtkBuilderWrapper):
self.get_widget("generalDesktopButton").set_active(True) self.get_widget("generalDesktopButton").set_active(True)
else: else:
self.get_widget("generalLaptopButton").set_active(True) self.get_widget("generalLaptopButton").set_active(True)
combobox = self.get_widget("sayAllStyle") combobox = self.get_widget("sayAllStyle")
self.populateComboBox(combobox, [guilabels.SAY_ALL_STYLE_LINE, self.populateComboBox(combobox, [guilabels.SAY_ALL_STYLE_LINE,
guilabels.SAY_ALL_STYLE_SENTENCE]) guilabels.SAY_ALL_STYLE_SENTENCE])
@ -2748,7 +2748,7 @@ class CthulhuSetupGUI(cthulhu_gtkbuilder.GtkBuilderWrapper):
elif dateFormatCombo == DATE_FORMAT_ABBREVIATED_YMD: elif dateFormatCombo == DATE_FORMAT_ABBREVIATED_YMD:
newFormat = messages.DATE_FORMAT_ABBREVIATED_YMD newFormat = messages.DATE_FORMAT_ABBREVIATED_YMD
self.prefsDict["presentDateFormat"] = newFormat self.prefsDict["presentDateFormat"] = newFormat
def timeFormatChanged(self, widget): def timeFormatChanged(self, widget):
"""Signal handler for the "changed" signal for the timeFormat """Signal handler for the "changed" signal for the timeFormat
GtkComboBox widget. Set the 'timeFormat' preference to the GtkComboBox widget. Set the 'timeFormat' preference to the

View File

@ -66,7 +66,7 @@ class DynamicApiManager():
def getAPI(self, key, application = '', fallback = True): def getAPI(self, key, application = '', fallback = True):
# get dynamic API # get dynamic API
api = None api = None
try: try:
api = self.api[application][key] api = self.api[application][key]
return api return api
@ -83,5 +83,5 @@ class DynamicApiManager():
api = self.api[application][''] api = self.api[application]['']
except: except:
print('API Key: "{}/{}" not found,'.format(application, key)) print('API Key: "{}/{}" not found,'.format(application, key))
return api return api

View File

@ -44,7 +44,7 @@ CTHULHU_SHIFT_MODIFIER_MASK = keybindings.CTHULHU_SHIFT_MODIFIER_MASK
CTHULHU_CTRL_MODIFIER_MASK = keybindings.CTHULHU_CTRL_MODIFIER_MASK CTHULHU_CTRL_MODIFIER_MASK = keybindings.CTHULHU_CTRL_MODIFIER_MASK
keymap = ( keymap = (
("9", defaultModifierMask, CTHULHU_MODIFIER_MASK, ("9", defaultModifierMask, CTHULHU_MODIFIER_MASK,
"routePointerToItemHandler"), "routePointerToItemHandler"),

View File

@ -2299,7 +2299,12 @@ SPOKEN_ELLIPSIS = _(" dot dot dot")
START_CTHULHU = _("Cthulhu welcomes you.") START_CTHULHU = _("Cthulhu welcomes you.")
# Translators: This message is presented to the user when Cthulhu is quit. # Translators: This message is presented to the user when Cthulhu is quit.
STOP_CTHULHU = _("Cthulhu lerks beneath the waves.") STOP_CTHULHU = _("Cthulhu lurks beneath the waves.")
# Sleep Mode
# Translators: This message is presented to the user when Cthulhu enters or leaves sleep mode.
SLEEP_MODE_ENABLED_FOR = _("Sleep mode enabled for %s")
SLEEP_MODE_DISABLED_FOR = _("Sleep mode disabled for %s")
# Translators: This message means speech synthesis is not installed or working. # Translators: This message means speech synthesis is not installed or working.
SPEECH_UNAVAILABLE = _("Speech is unavailable.") SPEECH_UNAVAILABLE = _("Speech is unavailable.")

View File

@ -1,185 +1,92 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
#
# Copyright (c) 2024 Stormux # Copyright (c) 2024 Stormux
# Copyright (c) 2010-2012 The Orca Team
# Copyright (c) 2012 Igalia, S.L.
# Copyright (c) 2005-2010 Sun Microsystems Inc.
# #
# This library is free software; you can redistribute it and/or # This library is free software; you can redistribute it and/or
# modify it under the terms of the GNU Lesser General Public # modify it under the terms of the GNU Lesser General Public
# License as published by the Free Software Foundation; either # License as published by the Free Software Foundation; either
# version 2.1 of the License, or (at your option) any later version. # version 2.1 of the License, or (at your option) any later version.
#
# This library is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
# Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public
# License along with this library; if not, write to the
# Free Software Foundation, Inc., Franklin Street, Fifth Floor,
# Boston MA 02110-1301 USA.
#
# Fork of Orca Screen Reader (GNOME)
# Original source: https://gitlab.gnome.org/GNOME/orca
import os, inspect """Base class for Cthulhu plugins using pluggy."""
import gi
from gi.repository import GObject
class Plugin(): import os
#__gtype_name__ = 'BasePlugin' import logging
def __init__(self, *args, **kwargs): # Import pluggy for hook specifications
self.API = None try:
self.pluginInfo = None import pluggy
self.moduleDir = '' cthulhu_hookimpl = pluggy.HookimplMarker("cthulhu")
self.hidden = False PLUGGY_AVAILABLE = True
self.moduleName = '' logging.getLogger(__name__).info("Successfully imported pluggy")
except ImportError:
# Fallback if pluggy is not available
def cthulhu_hookimpl(func=None, **kwargs):
"""Fallback decorator when pluggy is not available.
This is a no-op decorator that returns the original function.
It allows the code to continue working without pluggy, though
plugins will be disabled.
"""
if func is None:
return lambda f: f
return func
PLUGGY_AVAILABLE = False
logging.getLogger(__name__).warning("Pluggy not available, plugins will be disabled")
import traceback
logging.getLogger(__name__).debug(traceback.format_exc())
logger = logging.getLogger(__name__)
class Plugin:
"""Base class for Cthulhu plugins."""
def __init__(self):
"""Initialize the plugin with default attributes."""
self.app = None
self.plugin_info = None
self.module_name = ''
self.name = '' self.name = ''
self.version = '' self.version = ''
self.website = ''
self.authors = []
self.buildIn = False
self.description = '' self.description = ''
self.iconName = ''
self.copyright = '' def set_app(self, app):
self.dependencies = False """Set the application reference."""
self.helpUri = ''
self.dataDir = ''
self.translationContext = None
def setApp(self, app):
self.app = app self.app = app
self.dynamicApiManager = app.getDynamicApiManager()
self.signalManager = app.getSignalManager()
def getApp(self): def set_plugin_info(self, plugin_info):
return self.app """Set plugin information and extract relevant attributes."""
def setPluginInfo(self, pluginInfo): self.plugin_info = plugin_info
self.pluginInfo = pluginInfo if plugin_info:
self.updatePluginInfoAttributes() self.module_name = getattr(plugin_info, 'module_name', '')
def getPluginInfo(self): self.name = getattr(plugin_info, 'name', '')
return self.pluginInfo self.version = getattr(plugin_info, 'version', '')
def updatePluginInfoAttributes(self): self.description = getattr(plugin_info, 'description', '')
self.moduleDir = ''
self.hidden = False @cthulhu_hookimpl
self.moduleName = '' def activate(self, plugin=None):
self.name = '' """Activate the plugin. Override this in subclasses."""
self.version = '' if plugin is not None and plugin is not self:
self.website = ''
self.authors = []
self.buildIn = False
self.description = ''
self.iconName = ''
self.copyright = ''
self.dependencies = False
self.helpUri = ''
self.dataDir = ''
pluginInfo = self.getPluginInfo()
if pluginInfo == None:
return return
self.moduleName = self.getApp().getPluginSystemManager().getPluginModuleName(pluginInfo) logger.info(f"Activating plugin: {self.name}")
self.name = self.getApp().getPluginSystemManager().getPluginName(pluginInfo)
self.version = self.getApp().getPluginSystemManager().getPluginVersion(pluginInfo)
self.moduleDir = self.getApp().getPluginSystemManager().getPluginModuleDir(pluginInfo)
self.buildIn = self.getApp().getPluginSystemManager().isPluginBuildIn(pluginInfo)
self.description = self.getApp().getPluginSystemManager().getPluginDescription(pluginInfo)
self.hidden = self.getApp().getPluginSystemManager().isPluginHidden(pluginInfo)
self.website = self.getApp().getPluginSystemManager().getPluginWebsite(pluginInfo)
self.authors = self.getApp().getPluginSystemManager().getPluginAuthors(pluginInfo)
self.iconName = self.getApp().getPluginSystemManager().getPluginIconName(pluginInfo)
self.copyright = self.getApp().getPluginSystemManager().getPluginCopyright(pluginInfo)
self.dependencies = self.getApp().getPluginSystemManager().getPluginDependencies(pluginInfo)
#settings = self.getApp().getPluginSystemManager().getPluginSettings(pluginInfo) @cthulhu_hookimpl
#hasDependencies = self.getApp().getPluginSystemManager().hasPluginDependency(pluginInfo) def deactivate(self, plugin=None):
"""Deactivate the plugin. Override this in subclasses."""
if plugin is not None and plugin is not self:
return
logger.info(f"Deactivating plugin: {self.name}")
#externalData = self.getApp().getPluginSystemManager().getPluginExternalData(pluginInfo) def registerGestureByString(self, function, name, gestureString, learnModeEnabled=True):
self.helpUri = self.getApp().getPluginSystemManager().getPlugingetHelpUri(pluginInfo) """Register a gesture by string."""
self.dataDir = self.getApp().getPluginSystemManager().getPluginDataDir(pluginInfo) if self.app:
self.updateTranslationContext() api_helper = self.app.getAPIHelper()
if api_helper:
def updateTranslationContext(self, domain = None, localeDir = None, language = None, fallbackToCthulhuTranslation = True): return api_helper.registerGestureByString(
self.translationContext = None function,
useLocaleDir = '{}/locale/'.format(self.getModuleDir()) name,
if localeDir: gestureString,
if os.path.isdir(localeDir): 'default',
useLocaleDir = localeDir 'cthulhu',
useName = self.getModuleName() learnModeEnabled,
useDomain = useName contextName=self.module_name
if domain: )
useDomain = domain
useLanguage = None
if language:
useLanguage = language
self.translationContext = self.getApp().getTranslationManager().initTranslation(useName, domain=useDomain, localeDir=useLocaleDir, language=useLanguage, fallbackToCthulhuTranslation=fallbackToCthulhuTranslation)
# Point _ to the translation object in the globals namespace of the caller frame
try:
callerFrame = inspect.currentframe().f_back
# Install our gettext and ngettext function to the upper frame
callerFrame.f_globals['_'] = self.translationContext.gettext
callerFrame.f_globals['ngettext'] = self.translationContext.ngettext
finally:
del callerFrame # Avoid reference problems with frames (per python docs)
def getTranslationContext(self):
return self.translationContext
def isPluginBuildIn(self):
return self.buildIn
def isPluginHidden(self):
return self.hidden
def getAuthors(self):
return self.authors
def getCopyright(self):
return self.copyright
def getDataDir(self):
return self.dataDir
def getDependencies(self):
return self.dependencies
def getDescription(self):
return self.description
def getgetHelpUri(self):
return self.helpUri
def getIconName(self):
return self.iconName
def getModuleDir(self):
return self.moduleDir
def getModuleName(self):
return self.moduleName
def getName(self):
return self.name
def getVersion(self):
return self.version
def getWebsite(self):
return self.website
def getSetting(key):
#self.getModuleName())
return None return None
def setSetting(key, value):
#self.getModuleName())
pass
def registerGestureByString(self, function, name, gestureString, learnModeEnabled = True):
keybinding = self.getApp().getAPIHelper().registerGestureByString(function, name, gestureString, 'default', 'cthulhu', learnModeEnabled, contextName = self.getModuleName())
return keybinding
def unregisterShortcut(self, function, name, gestureString, learnModeEnabled = True):
ok = self.getApp().getAPIHelper().unregisterShortcut(keybinding, contextName = self.getModuleName())
return ok
def registerSignal(self, signalName, signalFlag = GObject.SignalFlags.RUN_LAST, closure = GObject.TYPE_NONE, accumulator=()):
ok = self.signalManager.registerSignal(signalName, signalFlag, closure, accumulator, contextName = self.getModuleName())
return ok
def unregisterSignal(self, signalName):
# how to unregister?
pass
def connectSignal(self, signalName, function, param = None):
signalID = self.signalManager.connectSignal(signalName, function, param, contextName = self.getModuleName())
return signalID
def disconnectSignalByFunction(self, function):
# need get mapped function
mappedFunction = function
self.signalManager.disconnectSignalByFunction(mappedFunction, contextName = self.getModuleName())
def registerAPI(self, key, value, application = ''):
ok = self.dynamicApiManager.registerAPI(key, value, application = application, contextName = self.getModuleName())
return ok
def unregisterAPI(self, key, application = ''):
self.dynamicApiManager.unregisterAPI(key, application = application, contextName = self.getModuleName())

View File

@ -1,544 +1,469 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
#
# Copyright (c) 2024 Stormux # Copyright (c) 2024 Stormux
# Copyright (c) 2010-2012 The Orca Team
# Copyright (c) 2012 Igalia, S.L.
# Copyright (c) 2005-2010 Sun Microsystems Inc.
# #
# This library is free software; you can redistribute it and/or # This library is free software; you can redistribute it and/or
# modify it under the terms of the GNU Lesser General Public # modify it under the terms of the GNU Lesser General Public
# License as published by the Free Software Foundation; either # License as published by the Free Software Foundation; either
# version 2.1 of the License, or (at your option) any later version. # version 2.1 of the License, or (at your option) any later version.
#
# This library is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
# Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public
# License along with this library; if not, write to the
# Free Software Foundation, Inc., Franklin Street, Fifth Floor,
# Boston MA 02110-1301 USA.
#
# Fork of Orca Screen Reader (GNOME)
# Original source: https://gitlab.gnome.org/GNOME/orca
"""PluginManager for loading cthulhu plugins.""" """Plugin System Manager for Cthulhu using pluggy."""
import os, inspect, sys, tarfile, shutil
import os
import inspect
import importlib.util
import logging
from enum import IntEnum from enum import IntEnum
version = sys.version[:3] # we only need major.minor version. # Import pluggy if available
if version in ["3.3","3.4"]: try:
from importlib.machinery import SourceFileLoader import pluggy
else: # Python 3.5+, no support for python < 3.3. PLUGGY_AVAILABLE = True
import importlib.util except ImportError:
PLUGGY_AVAILABLE = False
logging.getLogger(__name__).info("Pluggy not available, plugins will be disabled")
import gi # Set to True for more detailed plugin loading debug info
gi.require_version('Peas', '1.0') PLUGIN_DEBUG = True
from gi.repository import GObject
from gi.repository import Peas
gi.require_version('Atspi', '2.0') logger = logging.getLogger(__name__)
from gi.repository import Atspi if PLUGIN_DEBUG:
logger.setLevel(logging.DEBUG)
from cthulhu import resource_manager
class API(GObject.GObject):
"""Interface that gives access to all the objects of Cthulhu."""
def __init__(self, app):
GObject.GObject.__init__(self)
self.app = app
class PluginType(IntEnum): class PluginType(IntEnum):
"""Types of plugins we support, depending on their directory location.""" """Types of plugins we support."""
# pylint: disable=comparison-with-callable,inconsistent-return-statements,no-else-return
# SYSTEM: provides system wide plugins
SYSTEM = 1 SYSTEM = 1
# USER: provides per user plugin
USER = 2 USER = 2
def __str__(self):
if self.value == PluginType.SYSTEM:
return _("System plugin")
elif self.value == PluginType.USER:
return _("User plugin")
def get_root_dir(self): def get_root_dir(self):
"""Returns the directory where this type of plugins can be found.""" """Returns the directory where this type of plugins can be found."""
if self.value == PluginType.SYSTEM: if self.value == PluginType.SYSTEM:
return os.path.dirname(os.path.realpath(os.path.abspath(inspect.getfile(inspect.currentframe())))) + '/plugins' current_file = inspect.getfile(inspect.currentframe())
current_dir = os.path.dirname(os.path.realpath(os.path.abspath(current_file)))
return os.path.join(current_dir, 'plugins')
elif self.value == PluginType.USER: elif self.value == PluginType.USER:
return os.path.expanduser('~') + '/.local/share/cthulhu/plugins' return os.path.expanduser('~/.local/share/cthulhu/plugins')
class PluginSystemManager(): class PluginInfo:
"""Cthulhu Plugin Manager to handle a set of plugins. """Information about a plugin."""
Attributes:
DEFAULT_LOADERS (tuple): Default loaders used by the plugin manager. For def __init__(self, name, module_name, module_dir, metadata=None):
possible values see self.name = name
https://developer.gnome.org/libpeas/stable/PeasEngine.html#peas-engine-enable-loader self.module_name = module_name
""" self.module_dir = module_dir
DEFAULT_LOADERS = ("python3", ) self.metadata = metadata or {}
self.builtin = False
self.hidden = False
self.module = None
self.instance = None
self.loaded = False
def get_module_name(self):
return self.module_name
def get_name(self):
return self.metadata.get('name', self.name)
def get_version(self):
return self.metadata.get('version', '0.0.0')
def get_description(self):
return self.metadata.get('description', '')
def get_module_dir(self):
return self.module_dir
class PluginSystemManager:
"""Cthulhu Plugin Manager using pluggy."""
def __init__(self, app): def __init__(self, app):
self.app = app self.app = app
self.engine = Peas.Engine.get_default() logger.info("Initializing PluginSystemManager")
for loader in self.DEFAULT_LOADERS: # Initialize plugin manager
self.engine.enable_loader(loader) if PLUGGY_AVAILABLE:
logger.info("Pluggy is available, setting up plugin manager")
self.plugin_manager = pluggy.PluginManager("cthulhu")
self._setupPluginsDir() # Define hook specifications
self._setupExtensionSet() hook_spec = pluggy.HookspecMarker("cthulhu")
class CthulhuHookSpecs:
@hook_spec
def activate(self, plugin=None):
"""Called when the plugin is activated."""
pass
@hook_spec
def deactivate(self, plugin=None):
"""Called when the plugin is deactivated."""
pass
logger.info("Adding hook specifications to plugin manager")
self.plugin_manager.add_hookspecs(CthulhuHookSpecs)
else:
logger.warning("Pluggy is not available, plugins will be disabled")
self.plugin_manager = None
# Plugin storage
self._plugins = {} # module_name -> PluginInfo
self._active_plugins = []
# Create plugin directories
self._setup_plugin_dirs()
if self.app: # Log available plugins directory paths
self.gsettingsManager = self.app.getSettingsManager() logger.info(f"System plugins directory: {PluginType.SYSTEM.get_root_dir()}")
# settings else: logger.info(f"User plugins directory: {PluginType.USER.get_root_dir()}")
# settings self.gsettingsManager = gsettings_manager.getSettingsManager(self.app)
self._activePlugins = [] def _setup_plugin_dirs(self):
self._ignorePluginModulePath = [] """Ensure plugin directories exist."""
os.makedirs(PluginType.SYSTEM.get_root_dir(), exist_ok=True)
os.makedirs(PluginType.USER.get_root_dir(), exist_ok=True)
@property @property
def plugins(self): def plugins(self):
"""Gets the engine's plugin list.""" """Get all available plugins."""
return self.engine.get_plugin_list() return list(self._plugins.values())
@classmethod
def getPluginType(cls, pluginInfo):
"""Gets the PluginType for the specified Peas.PluginInfo."""
paths = [pluginInfo.get_data_dir(), PluginType.SYSTEM.get_root_dir()]
if os.path.commonprefix(paths) == PluginType.SYSTEM.get_root_dir():
return PluginType.SYSTEM
return PluginType.USER
def getExtension(self, pluginInfo):
if not pluginInfo:
return None
return self.extension_set.get_extension(pluginInfo)
def rescanPlugins(self):
self.engine.garbage_collect()
self.engine.rescan_plugins()
def getApp(self): def getApp(self):
return self.app return self.app
def getPluginInfoByName(self, pluginName, pluginType=PluginType.USER):
"""Gets the plugin info for the specified plugin name. def rescanPlugins(self):
Args: """Scan for plugins in the plugin directories."""
pluginName (str): The name from the .plugin file of the module. old_plugins = self._plugins.copy()
Returns: self._plugins = {}
Peas.PluginInfo: The plugin info if it exists. Otherwise, `None`.
""" # Scan system and user plugins
for pluginInfo in self.plugins: self._scan_plugins_in_directory(PluginType.SYSTEM.get_root_dir())
if pluginInfo.get_module_name() == pluginName and PluginSystemManager.getPluginType(pluginInfo) == pluginType: self._scan_plugins_in_directory(PluginType.USER.get_root_dir())
return pluginInfo
return None # Preserve state for already loaded plugins
for name, old_info in old_plugins.items():
if name in self._plugins and old_info.loaded:
self._plugins[name].loaded = True
self._plugins[name].instance = old_info.instance
self._plugins[name].module = old_info.module
def _scan_plugins_in_directory(self, directory):
"""Scan for plugins in a directory."""
if not os.path.exists(directory) or not os.path.isdir(directory):
logger.warning(f"Plugin directory not found or not a directory: {directory}")
return
logger.info(f"Scanning for plugins in directory: {directory}")
for item in os.listdir(directory):
plugin_dir = os.path.join(directory, item)
if not os.path.isdir(plugin_dir):
continue
# Check for the traditional structure first (plugin.py & plugin.info)
plugin_file = os.path.join(plugin_dir, "plugin.py")
metadata_file = os.path.join(plugin_dir, "plugin.info")
# Fall back to [PluginName].py if plugin.py doesn't exist
if not os.path.isfile(plugin_file):
alternative_plugin_file = os.path.join(plugin_dir, f"{item}.py")
if os.path.isfile(alternative_plugin_file):
plugin_file = alternative_plugin_file
logger.info(f"Using alternative plugin file: {alternative_plugin_file}")
# Check if we have any valid plugin file
if os.path.isfile(plugin_file):
# Extract plugin info
module_name = os.path.basename(plugin_dir)
logger.info(f"Found plugin: {module_name} in {plugin_dir}")
metadata = self._load_plugin_metadata(metadata_file)
plugin_info = PluginInfo(
metadata.get('name', module_name),
module_name,
plugin_dir,
metadata
)
# Check if it's a built-in or hidden plugin
plugin_info.builtin = metadata.get('builtin', 'false').lower() == 'true'
plugin_info.hidden = metadata.get('hidden', 'false').lower() == 'true'
logger.info(f"Adding plugin to registry: {module_name}")
self._plugins[module_name] = plugin_info
else:
logger.warning(f"No plugin file found in directory: {plugin_dir}")
def _load_plugin_metadata(self, metadata_file):
"""Load plugin metadata from a file."""
metadata = {}
if os.path.isfile(metadata_file):
try:
with open(metadata_file, 'r') as f:
for line in f:
line = line.strip()
if not line or line.startswith('#'):
continue
if '=' in line:
key, value = line.split('=', 1)
metadata[key.strip()] = value.strip()
except Exception as e:
logger.error(f"Error loading plugin metadata: {e}")
return metadata
def getActivePlugins(self): def getActivePlugins(self):
return self._activePlugins """Get the list of active plugin names."""
return self._active_plugins
def setActivePlugins(self, activePlugins): def setActivePlugins(self, activePlugins):
self._activePlugins = activePlugins """Set active plugins and sync their state."""
logger.info(f"Setting active plugins: {activePlugins}")
# Make sure we have scanned for plugins first
if not self._plugins:
logger.info("No plugins found, rescanning...")
self.rescanPlugins()
self._active_plugins = activePlugins
# Log active vs available plugins
available_plugins = [p.get_module_name() for p in self.plugins]
logger.info(f"Available plugins: {available_plugins}")
logger.info(f"Active plugins: {self._active_plugins}")
# Find missing plugins
missing_plugins = [p for p in self._active_plugins if p not in available_plugins]
if missing_plugins:
logger.warning(f"Active plugins not found: {missing_plugins}")
self.syncAllPluginsActive() self.syncAllPluginsActive()
def isPluginBuildIn(self, pluginInfo):
return pluginInfo.is_builtin()
def isPluginHidden(self, pluginInfo):
return pluginInfo.is_hidden()
def getPluginAuthors(self, pluginInfo):
return pluginInfo.get_authors()
def getPluginCopyright(self, pluginInfo):
return pluginInfo.get_copyright()
def getPluginDataDir(self, pluginInfo):
return pluginInfo.get_data_dir()
def getPluginDependencies(self, pluginInfo):
return pluginInfo.get_dependencies()
def getPluginDescription(self, pluginInfo):
return pluginInfo.get_description()
def getPlugingetHelpUri(self, pluginInfo):
return pluginInfo.get_help_uri()
def getPluginIconName(self, pluginInfo):
return pluginInfo.get_icon_name()
def getPluginModuleDir(self, pluginInfo):
return pluginInfo.get_module_dir()
def getPluginModuleName(self, pluginInfo):
return pluginInfo.get_module_name()
def getPluginName(self, pluginInfo):
return pluginInfo.get_name()
def getPluginSettings(self, pluginInfo):
return pluginInfo.get_settings()
def getPluginVersion(self, pluginInfo):
return pluginInfo.get_version()
def getPluginWebsite(self, pluginInfo):
return pluginInfo.get_website()
# has_dependency and get_external_data seems broken-> takes exactly 2 arguments (1 given) but documentation doesnt say any parameter
#def hasPluginDependency(self, pluginInfo):
# return pluginInfo.has_dependency()
#def getPluginExternalData(self, pluginInfo):
# return pluginInfo.get_external_data()
def isPluginAvailable(self, pluginInfo):
try:
return pluginInfo.is_available()
except:
return False
def isPluginLoaded(self, pluginInfo):
try:
return pluginInfo.is_loaded()
except:
return False
def getIgnoredPlugins(self):
return self._ignorePluginModulePath
def setIgnoredPlugins(self, pluginModulePath, ignored):
if pluginModulePath.endswith('/'):
pluginModulePath = pluginModulePath[:-1]
if ignored:
if not pluginModulePath in self.getIgnoredPlugins():
self._ignorePluginModulePath.append(pluginModulePath)
else:
if pluginModulePath in self.getIgnoredPlugins():
self._ignorePluginModulePath.remove(pluginModulePath)
def setPluginActive(self, pluginInfo, active): def setPluginActive(self, pluginInfo, active):
if self.isPluginBuildIn(pluginInfo): """Set the active state of a plugin."""
if pluginInfo.builtin:
active = True active = True
pluginName = self.getPluginModuleName(pluginInfo)
pluginName = pluginInfo.get_module_name()
if active: if active:
if not pluginName in self.getActivePlugins(): if pluginName not in self.getActivePlugins():
if self.loadPlugin(pluginInfo): if self.loadPlugin(pluginInfo):
self._activePlugins.append(pluginName ) self._active_plugins.append(pluginName)
else: else:
if pluginName in self.getActivePlugins(): if pluginName in self.getActivePlugins():
if self.unloadPlugin(pluginInfo): if self.unloadPlugin(pluginInfo):
self._activePlugins.remove(pluginName ) self._active_plugins.remove(pluginName)
def isPluginActive(self, pluginInfo): def isPluginActive(self, pluginInfo):
if self.isPluginBuildIn(pluginInfo): """Check if a plugin is active."""
module_name = pluginInfo.get_module_name()
# Builtin plugins are always active
if pluginInfo.builtin:
logger.debug(f"Plugin {module_name} is builtin, active by default")
return True return True
if self.isPluginLoaded(pluginInfo):
# If a plugin is already loaded, it's active
if pluginInfo.loaded:
logger.debug(f"Plugin {module_name} is already loaded, considered active")
return True return True
active_plugin_names = self.getActivePlugins()
return self.getPluginModuleName(pluginInfo) in active_plugin_names
def syncAllPluginsActive(self, ForceAllPlugins=False):
self.unloadAllPlugins(ForceAllPlugins)
self.loadAllPlugins(ForceAllPlugins)
def loadAllPlugins(self, ForceAllPlugins=False): # Check case-insensitive match in active plugins list
"""Loads plugins from settings.""" active_plugins = self.getActivePlugins()
# Try exact match first
if module_name in active_plugins:
logger.debug(f"Plugin {module_name} found in active plugins list")
return True
# Try case-insensitive match
module_name_lower = module_name.lower()
is_active = any(plugin.lower() == module_name_lower for plugin in active_plugins)
if is_active:
logger.debug(f"Plugin {module_name} found in active plugins list (case-insensitive match)")
else:
logger.debug(f"Plugin {module_name} not found in active plugins list")
return is_active
def syncAllPluginsActive(self):
"""Sync the active state of all plugins."""
logger.info("Syncing active state of all plugins")
# Log plugin status before syncing
if PLUGIN_DEBUG:
for pluginInfo in self.plugins:
is_active = self.isPluginActive(pluginInfo)
is_loaded = pluginInfo.loaded
logger.debug(f"Plugin {pluginInfo.get_module_name()}: active={is_active}, loaded={is_loaded}")
# First unload inactive plugins
for pluginInfo in self.plugins: for pluginInfo in self.plugins:
if self.isPluginActive(pluginInfo) or ForceAllPlugins: if not self.isPluginActive(pluginInfo) and pluginInfo.loaded:
self.loadPlugin(pluginInfo) logger.info(f"Unloading inactive plugin: {pluginInfo.get_module_name()}")
def loadPlugin(self, pluginInfo):
resourceManager = self.getApp().getResourceManager()
moduleName = pluginInfo.get_module_name()
try:
if pluginInfo not in self.plugins:
print("Plugin missing: {}".format(moduleName))
return False
resourceManager.addResourceContext(moduleName)
self.engine.load_plugin(pluginInfo)
except Exception as e:
print('loadPlugin:',e)
return False
return True
def unloadAllPlugins(self, ForceAllPlugins=False):
"""Loads plugins from settings."""
for pluginInfo in self.plugins:
if not self.isPluginActive(pluginInfo) or ForceAllPlugins:
self.unloadPlugin(pluginInfo) self.unloadPlugin(pluginInfo)
# Then load active plugins
for pluginInfo in self.plugins:
if self.isPluginActive(pluginInfo) and not pluginInfo.loaded:
logger.info(f"Loading active plugin: {pluginInfo.get_module_name()}")
result = self.loadPlugin(pluginInfo)
logger.info(f"Plugin {pluginInfo.get_module_name()} load result: {result}")
# Log final plugin status
active_plugins = [p.get_module_name() for p in self.plugins if p.loaded]
logger.info(f"Active plugins after sync: {active_plugins}")
inactive_plugins = [p.get_module_name() for p in self.plugins if not p.loaded]
logger.info(f"Inactive plugins after sync: {inactive_plugins}")
def loadPlugin(self, pluginInfo):
"""Load a plugin."""
# Skip if pluggy is not available
if not PLUGGY_AVAILABLE:
logger.info(f"Skipping plugin {pluginInfo.get_name()}: pluggy not available")
return False
module_name = pluginInfo.get_module_name()
logger.info(f"Attempting to load plugin: {module_name}")
try:
# Already loaded?
if pluginInfo.loaded:
logger.info(f"Plugin {module_name} already loaded, skipping")
return True
# Try to find the plugin file
module_name = pluginInfo.get_module_name()
plugin_dir = pluginInfo.get_module_dir()
# Check for plugin.py first (standard format)
plugin_file = os.path.join(plugin_dir, "plugin.py")
# Fall back to [PluginName].py if plugin.py doesn't exist
if not os.path.exists(plugin_file):
alternative_plugin_file = os.path.join(plugin_dir, f"{module_name}.py")
if os.path.exists(alternative_plugin_file):
plugin_file = alternative_plugin_file
logger.info(f"Using alternative plugin file: {alternative_plugin_file}")
if not os.path.exists(plugin_file):
logger.error(f"Plugin file not found: {plugin_file}")
return False
logger.info(f"Loading plugin from: {plugin_file}")
spec = importlib.util.spec_from_file_location(module_name, plugin_file)
if spec is None:
logger.error(f"Failed to create spec for plugin: {module_name}")
return False
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
pluginInfo.module = module
# Find Plugin class
plugin_class = None
for attr_name in dir(module):
attr = getattr(module, attr_name)
if (inspect.isclass(attr) and
attr.__module__ == module.__name__ and
hasattr(attr, 'activate')):
plugin_class = attr
logger.info(f"Found plugin class: {attr.__name__} in {module_name}")
break
if not plugin_class:
logger.error(f"No plugin class found in {module_name}")
return False
# Create and initialize plugin instance
logger.info(f"Creating instance of plugin class: {plugin_class.__name__}")
plugin_instance = plugin_class()
pluginInfo.instance = plugin_instance
# Ensure plugins have a reference to the app
plugin_instance.app = self.getApp()
logger.info(f"Set app reference for plugin: {module_name}")
if hasattr(plugin_instance, 'set_app'):
plugin_instance.set_app(self.getApp())
logger.info(f"Called set_app() for plugin: {module_name}")
if hasattr(plugin_instance, 'set_plugin_info'):
plugin_instance.set_plugin_info(pluginInfo)
logger.info(f"Called set_plugin_info() for plugin: {module_name}")
# Register with pluggy and activate
if self.plugin_manager is None:
logger.error(f"Plugin manager is None when loading {module_name}")
return False
logger.info(f"Registering plugin with pluggy: {module_name}")
self.plugin_manager.register(plugin_instance)
try:
logger.info(f"Activating plugin: {module_name}")
self.plugin_manager.hook.activate(plugin=plugin_instance)
except Exception as e:
logger.error(f"Error activating plugin {module_name}: {e}")
import traceback
logger.error(traceback.format_exc())
return False
pluginInfo.loaded = True
logger.info(f"Successfully loaded plugin: {module_name}")
return True
except Exception as e:
logger.error(f"Failed to load plugin {module_name}: {e}")
import traceback
logger.error(traceback.format_exc())
return False
def unloadPlugin(self, pluginInfo): def unloadPlugin(self, pluginInfo):
resourceManager = self.getApp().getResourceManager() """Unload a plugin."""
moduleName = pluginInfo.get_module_name() # Skip if pluggy is not available
try: if not PLUGGY_AVAILABLE:
if pluginInfo not in self.plugins:
print("Plugin missing: {}".format(moduleName))
return False
if self.isPluginBuildIn(pluginInfo):
return False
self.engine.unload_plugin(pluginInfo)
self.getApp().getResourceManager().removeResourceContext(moduleName)
self.engine.garbage_collect()
except Exception as e:
print('unloadPlugin:',e)
return False return False
return True
def installPlugin(self, pluginFilePath, pluginType=PluginType.USER):
if not self.isValidPluginFile(pluginFilePath):
return False
pluginFolder = pluginType.get_root_dir()
if not pluginFolder.endswith('/'):
pluginFolder += '/'
if not os.path.exists(pluginFolder):
os.mkdir(pluginFolder)
else:
if not os.path.isdir(pluginFolder):
return False
try:
with tarfile.open(pluginFilePath) as tar:
tar.extractall(path=pluginFolder)
except Exception as e:
print(e)
pluginModulePath = self.getModuleDirByPluginFile(pluginFilePath)
if pluginModulePath != '':
pluginModulePath = pluginFolder + pluginModulePath
self.setIgnoredPlugins(pluginModulePath[:-1], False) # without ending /
print('install', pluginFilePath)
self.callPackageTriggers(pluginModulePath, 'onPostInstall')
self.rescanPlugins()
return True if pluginInfo.builtin:
def getModuleDirByPluginFile(self, pluginFilePath):
if not isinstance(pluginFilePath, str):
return ''
if pluginFilePath == '':
return ''
if not os.path.exists(pluginFilePath):
return ''
try:
with tarfile.open(pluginFilePath) as tar:
tarMembers = tar.getmembers()
for tarMember in tarMembers:
if tarMember.isdir():
return tarMember.name
except Exception as e:
print(e)
return ''
def isValidPluginFile(self, pluginFilePath):
if not isinstance(pluginFilePath, str):
return False return False
if pluginFilePath == '':
return False
if not os.path.exists(pluginFilePath):
return False
pluginFolder = ''
pluginFileExists = False
packageFileExists = False
try:
with tarfile.open(pluginFilePath) as tar:
tarMembers = tar.getmembers()
for tarMember in tarMembers:
if tarMember.isdir():
if pluginFolder == '':
pluginFolder = tarMember.name
if tarMember.isfile():
if tarMember.name.endswith('.plugin'):
pluginFileExists = True
if tarMember.name.endswith('package.py'):
pluginFileExists = True
if not tarMember.name.startswith(pluginFolder):
return False
except Exception as e:
print(e)
return False
return pluginFileExists
def uninstallPlugin(self, pluginInfo):
if self.isPluginBuildIn(pluginInfo):
return False
# do we want to allow removing system plugins?
if PluginSystemManager.getPluginType(pluginInfo) == PluginType.SYSTEM:
return False
pluginFolder = pluginInfo.get_data_dir()
if not pluginFolder.endswith('/'):
pluginFolder += '/'
if not os.path.isdir(pluginFolder):
return False
if self.isPluginActive(pluginInfo):
self.setPluginActive(pluginInfo, False)
SettingsManager = self.app.getSettingsManager()
# TODO SettingsManager.set_settings_value_list('active-plugins', self.getActivePlugins())
self.callPackageTriggers(pluginFolder, 'onPreUninstall')
try:
shutil.rmtree(pluginFolder, ignore_errors=True)
except Exception as e:
print(e)
return False
self.setIgnoredPlugins(pluginFolder, True)
self.rescanPlugins()
return True module_name = pluginInfo.get_module_name()
def callPackageTriggers(self, pluginPath, trigger):
if not os.path.exists(pluginPath): try:
# Not loaded?
if not pluginInfo.loaded:
return True
# Deactivate plugin
plugin_instance = pluginInfo.instance
if plugin_instance:
try:
self.plugin_manager.hook.deactivate(plugin=plugin_instance)
except Exception as e:
logger.error(f"Error deactivating plugin {module_name}: {e}")
# Unregister from pluggy
self.plugin_manager.unregister(plugin_instance)
# Clean up
pluginInfo.instance = None
pluginInfo.loaded = False
logger.info(f"Unloaded plugin: {module_name}")
return True
except Exception as e:
logger.error(f"Failed to unload plugin {module_name}: {e}")
return False
def unloadAllPlugins(self, ForceAllPlugins=False):
"""Unload all plugins."""
if not PLUGGY_AVAILABLE:
return return
if not pluginPath.endswith('/'):
pluginPath += '/'
packageModulePath = pluginPath + 'package.py'
if not os.path.isfile(packageModulePath):
return
if not os.access(packageModulePath, os.R_OK):
return
package = self.getApp().getAPIHelper().importModule('package', packageModulePath)
if trigger == 'onPostInstall': for pluginInfo in self.plugins:
try: if ForceAllPlugins or pluginInfo.loaded:
package.onPostInstall(pluginPath, self.getApp()) self.unloadPlugin(pluginInfo)
except Exception as e:
print(e)
elif trigger == 'onPreUninstall':
try:
package.onPreUninstall(pluginPath, self.getApp())
except Exception as e:
print(e)
def _setupExtensionSet(self):
plugin_iface = API(self.getApp())
self.extension_set = Peas.ExtensionSet.new(self.engine,
Peas.Activatable,
["object"],
[plugin_iface])
self.extension_set.connect("extension-removed",
self.__extensionRemoved)
self.extension_set.connect("extension-added",
self.__extensionAdded)
def _setupPluginsDir(self):
system_plugins_dir = PluginType.SYSTEM.get_root_dir()
user_plugins_dir = PluginType.USER.get_root_dir()
if os.path.exists(user_plugins_dir):
self.engine.add_search_path(user_plugins_dir)
if os.path.exists(system_plugins_dir):
self.engine.add_search_path(system_plugins_dir)
def __extensionRemoved(self, unusedSet, pluginInfo, extension):
extension.deactivate()
def __extensionAdded(self, unusedSet, pluginInfo, extension):
extension.setApp(self.getApp())
extension.setPluginInfo(pluginInfo)
extension.activate()
def __loadedPlugins(self, engine, unusedSet):
"""Handles the changing of the loaded plugin list."""
self.getApp().settings.ActivePlugins = engine.get_property("loaded-plugins")
class APIHelper():
def __init__(self, app):
self.app = app
self.cthulhuKeyBindings = None
'''
_pluginAPIManager.seCthulhuAPI('Logger', _logger)
_pluginAPIManager.setCthulhuAPI('SettingsManager', _settingsManager)
_pluginAPIManager.setCthulhuAPI('ScriptManager', _scriptManager)
_pluginAPIManager.setCthulhuAPI('EventManager', _eventManager)
_pluginAPIManager.setCthulhuAPI('Speech', speech)
_pluginAPIManager.setCthulhuAPI('Sound', sound)
_pluginAPIManager.setCthulhuAPI('Braille', braille)
_pluginAPIManager.setCthulhuAPI('Debug', debug)
_pluginAPIManager.setCthulhuAPI('Messages', messages)
_pluginAPIManager.setCthulhuAPI('MouseReview', mouse_review)
_pluginAPIManager.setCthulhuAPI('NotificationMessages', notification_messages)
_pluginAPIManager.setCthulhuAPI('CthulhuState', cthulhu_state)
_pluginAPIManager.setCthulhuAPI('CthulhuPlatform', cthulhu_platform)
_pluginAPIManager.setCthulhuAPI('Settings', settings)
_pluginAPIManager.setCthulhuAPI('Keybindings', keybindings)
'''
def outputMessage(self, Message, interrupt=False):
settings = self.app.getDynamicApiManager().getAPI('Settings')
braille = self.app.getDynamicApiManager().getAPI('Braille')
speech = self.app.getDynamicApiManager().getAPI('Speech')
if speech != None:
if (settings.enableSpeech):
if interrupt:
speech.cancel()
if Message != '':
speech.speak(Message)
if braille != None:
if (settings.enableBraille):
braille.displayMessage(Message)
def createInputEventHandler(self, function, name, learnModeEnabled=True):
EventManager = self.app.getDynamicApiManager().getAPI('EventManager')
newInputEventHandler = EventManager.input_event.InputEventHandler(function, name, learnModeEnabled)
return newInputEventHandler
def registerGestureByString(self, function, name, gestureString, profile, application, learnModeEnabled = True, contextName = None):
gestureList = gestureString.split(',')
registeredGestures = []
for gesture in gestureList:
if gesture.startswith('kb:'):
shortcutString = gesture[3:]
registuredGesture = self.registerShortcutByString(function, name, shortcutString, profile, application, learnModeEnabled, contextName=contextName)
if registuredGesture:
registeredGestures.append(registuredGesture)
return registeredGestures
def registerShortcutByString(self, function, name, shortcutString, profile, application, learnModeEnabled = True, contextName = None):
keybindings = self.app.getDynamicApiManager().getAPI('Keybindings')
settings = self.app.getDynamicApiManager().getAPI('Settings')
resourceManager = self.app.getResourceManager()
clickCount = 0
cthulhuKey = False
shiftKey = False
ctrlKey = False
altKey = False
key = ''
shortcutList = shortcutString.split('+')
for shortcutElement in shortcutList:
shortcutElementLower = shortcutElement.lower()
if shortcutElementLower == 'press':
clickCount += 1
elif shortcutElement == 'cthulhu':
cthulhuKey = True
elif shortcutElementLower == 'shift':
shiftKey = True
elif shortcutElementLower == 'control':
ctrlKey = True
elif shortcutElementLower == 'alt':
altKey = True
else:
key = shortcutElementLower
if clickCount == 0:
clickCount = 1
if self.cthulhuKeyBindings == None:
self.cthulhuKeyBindings = keybindings.KeyBindings()
tryFunction = resource_manager.TryFunction(function)
newInputEventHandler = self.createInputEventHandler(tryFunction.runInputEvent, name, learnModeEnabled)
currModifierMask = keybindings.NO_MODIFIER_MASK
if cthulhuKey:
currModifierMask = currModifierMask | 1 << keybindings.MODIFIER_CTHULHU
if shiftKey:
currModifierMask = currModifierMask | 1 << Atspi.ModifierType.SHIFT
if altKey:
currModifierMask = currModifierMask | 1 << Atspi.ModifierType.ALT
if ctrlKey:
currModifierMask = currModifierMask | 1 << Atspi.ModifierType.CONTROL
newKeyBinding = keybindings.KeyBinding(key, keybindings.defaultModifierMask, currModifierMask, newInputEventHandler, clickCount)
self.cthulhuKeyBindings.add(newKeyBinding)
settings.keyBindingsMap["default"] = self.cthulhuKeyBindings
if contextName:
resourceContext = resourceManager.getResourceContext(contextName)
if resourceContext:
resourceEntry = resource_manager.ResourceEntry('keyboard', newKeyBinding, function, tryFunction, shortcutString)
resourceContext.addGesture(profile, application, newKeyBinding, resourceEntry)
return newKeyBinding
def unregisterShortcut(self, KeyBindingToRemove, contextName = None):
ok = False
keybindings = self.app.getDynamicApiManager().getAPI('Keybindings')
settings = self.app.getDynamicApiManager().getAPI('Settings')
resourceManager = self.app.getResourceManager()
if self.cthulhuKeyBindings == None:
self.cthulhuKeyBindings = keybindings.KeyBindings()
try:
self.cthulhuKeyBindings.remove(KeyBindingToRemove)
settings.keyBindingsMap["default"] = self.cthulhuKeyBindings
ok = True
except KeyError:
pass
if contextName:
resourceContext = resourceManager.getResourceContext(contextName)
if resourceContext:
resourceContext.removeGesture(KeyBindingToRemove)
return ok
def importModule(self, moduleName, moduleLocation):
if version in ["3.3","3.4"]:
return SourceFileLoader(moduleName, moduleLocation).load_module()
else:
spec = importlib.util.spec_from_file_location(moduleName, moduleLocation)
driver_mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(driver_mod)
return driver_mod

View File

@ -1,6 +0,0 @@
[Plugin]
Module=ByeCthulhu
Loader=python3
Name=Stop announcement for cthulhu
Description=Test plugin for cthulhu
Authors=Chrys chrys@linux-a11y.org

View File

@ -1,52 +0,0 @@
#!/usr/bin/env python3
#
# Copyright (c) 2024 Stormux
# Copyright (c) 2010-2012 The Orca Team
# Copyright (c) 2012 Igalia, S.L.
# Copyright (c) 2005-2010 Sun Microsystems Inc.
#
# This library is free software; you can redistribute it and/or
# modify it under the terms of the GNU Lesser General Public
# License as published by the Free Software Foundation; either
# version 2.1 of the License, or (at your option) any later version.
#
# This library is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
# Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public
# License along with this library; if not, write to the
# Free Software Foundation, Inc., Franklin Street, Fifth Floor,
# Boston MA 02110-1301 USA.
#
# Fork of Orca Screen Reader (GNOME)
# Original source: https://gitlab.gnome.org/GNOME/orca
from cthulhu import plugin
import gi
gi.require_version('Peas', '1.0')
from gi.repository import GObject
from gi.repository import Peas
import time
class ByeCthulhu(GObject.Object, Peas.Activatable, plugin.Plugin):
#__gtype_name__ = 'ByeCthulhu'
object = GObject.Property(type=GObject.Object)
def __init__(self):
plugin.Plugin.__init__(self)
def do_activate(self):
API = self.object
self.connectSignal("stop-application-completed", self.process)
def do_deactivate(self):
API = self.object
def do_update_state(self):
API = self.object
def process(self, app):
messages = app.getDynamicApiManager().getAPI('Messages')
activeScript = app.getDynamicApiManager().getAPI('CthulhuState').activeScript
activeScript.presentationInterrupt()
activeScript.presentMessage(messages.STOP_CTHULHU, resetStyles=False)

View File

@ -1,7 +1,7 @@
cthulhu_python_PYTHON = \ cthulhu_python_PYTHON = \
__init__.py \ __init__.py \
ByeCthulhu.plugin \ plugin.info \
ByeCthulhu.py plugin.py
cthulhu_pythondir=$(pkgpythondir)/plugins/ByeCthulhu cthulhu_pythondir=$(pkgpythondir)/plugins/ByeCthulhu

View File

@ -0,0 +1,8 @@
name = Bye Cthulhu
version = 1.0.0
description = Says goodbye when Cthulhu is shutting down
authors = Stormux <storm_dragon@stormux.org>
website = https://stormux.org
copyright = Copyright 2025
builtin = false
hidden = false

View File

@ -0,0 +1,75 @@
#!/usr/bin/env python3
#
# Copyright (c) 2025 Stormux
#
# This library is free software; you can redistribute it and/or
# modify it under the terms of the GNU Lesser General Public
# License as published by the Free Software Foundation; either
# version 2.1 of the License, or (at your option) any later version.
"""Bye Cthulhu plugin for Cthulhu."""
import logging
import time
from cthulhu.plugin import Plugin, cthulhu_hookimpl
logger = logging.getLogger(__name__)
class ByeCthulhu(Plugin):
"""Plugin that speaks a goodbye message when Cthulhu is shutting down."""
def __init__(self, *args, **kwargs):
"""Initialize the plugin."""
super().__init__(*args, **kwargs)
logger.info("ByeCthulhu plugin initialized")
self._signal_handler_id = None
@cthulhu_hookimpl
def activate(self, plugin=None):
"""Activate the plugin."""
# Skip if this activation call isn't for us
if plugin is not None and plugin is not self:
return
logger.info("Activating ByeCthulhu plugin")
try:
# Connect to the stop-application-completed signal
signal_manager = self.app.getSignalManager()
self._signal_handler_id = signal_manager.connectSignal(
"stop-application-completed",
self.process,
"default" # Add profile parameter
)
except Exception as e:
logger.error(f"Error activating ByeCthulhu plugin: {e}")
@cthulhu_hookimpl
def deactivate(self, plugin=None):
"""Deactivate the plugin."""
# Skip if this deactivation call isn't for us
if plugin is not None and plugin is not self:
return
logger.info("Deactivating ByeCthulhu plugin")
try:
# Disconnect signal if we have an ID
if self._signal_handler_id is not None:
signal_manager = self.app.getSignalManager()
# Use disconnectSignalByFunction instead since disconnectSignal doesn't exist
signal_manager.disconnectSignalByFunction(
self.process
)
self._signal_handler_id = None
except Exception as e:
logger.error(f"Error deactivating ByeCthulhu plugin: {e}")
def process(self, app):
"""Process the stop-application-completed signal."""
try:
messages = app.getDynamicApiManager().getAPI('Messages')
state = app.getDynamicApiManager().getAPI('CthulhuState')
if state.activeScript:
state.activeScript.presentationInterrupt()
state.activeScript.presentMessage(messages.STOP_CTHULHU, resetStyles=False)
except Exception as e:
logger.error(f"Error in ByeCthulhu process: {e}")

View File

@ -1,6 +0,0 @@
[Plugin]
Module=CapsLockHack
Loader=python3
Name=Caps Lock Hack
Description=Fix Capslock sometimes switch on / off when its used as modifier
Authors=Chrys chrys@linux-a11y.org

View File

@ -1,144 +0,0 @@
#!/usr/bin/env python3
#
# Copyright (c) 2024 Stormux
# Copyright (c) 2010-2012 The Orca Team
# Copyright (c) 2012 Igalia, S.L.
# Copyright (c) 2005-2010 Sun Microsystems Inc.
#
# This library is free software; you can redistribute it and/or
# modify it under the terms of the GNU Lesser General Public
# License as published by the Free Software Foundation; either
# version 2.1 of the License, or (at your option) any later version.
#
# This library is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
# Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public
# License along with this library; if not, write to the
# Free Software Foundation, Inc., Franklin Street, Fifth Floor,
# Boston MA 02110-1301 USA.
#
# Fork of Orca Screen Reader (GNOME)
# Original source: https://gitlab.gnome.org/GNOME/orca
from cthulhu import plugin
import gi
gi.require_version('Peas', '1.0')
from gi.repository import GObject
from gi.repository import Peas
from threading import Thread, Lock
import subprocess, time, re, os
class CapsLockHack(GObject.Object, Peas.Activatable, plugin.Plugin):
__gtype_name__ = 'CapsLockHack'
object = GObject.Property(type=GObject.Object)
def __init__(self):
plugin.Plugin.__init__(self)
self.lock = Lock()
self.active = False
self.workerThread = Thread(target=self.worker)
def do_activate(self):
API = self.object
"""Enable or disable use of the caps lock key as an Cthulhu modifier key."""
self.interpretCapsLineProg = re.compile(
r'^\s*interpret\s+Caps[_+]Lock[_+]AnyOfOrNone\s*\(all\)\s*{\s*$', re.I)
self.normalCapsLineProg = re.compile(
r'^\s*action\s*=\s*LockMods\s*\(\s*modifiers\s*=\s*Lock\s*\)\s*;\s*$', re.I)
self.interpretShiftLineProg = re.compile(
r'^\s*interpret\s+Shift[_+]Lock[_+]AnyOf\s*\(\s*Shift\s*\+\s*Lock\s*\)\s*{\s*$', re.I)
self.normalShiftLineProg = re.compile(
r'^\s*action\s*=\s*LockMods\s*\(\s*modifiers\s*=\s*Shift\s*\)\s*;\s*$', re.I)
self.disabledModLineProg = re.compile(
r'^\s*action\s*=\s*NoAction\s*\(\s*\)\s*;\s*$', re.I)
self.normalCapsLine = ' action= LockMods(modifiers=Lock);'
self.normalShiftLine = ' action= LockMods(modifiers=Shift);'
self.disabledModLine = ' action= NoAction();'
self.activateWorker()
def do_deactivate(self):
API = self.object
self.deactivateWorker()
def do_update_state(self):
API = self.object
def deactivateWorker(self):
with self.lock:
self.active = False
self.workerThread.join()
def activateWorker(self):
with self.lock:
self.active = True
self.workerThread.start()
def isActive(self):
with self.lock:
return self.active
def worker(self):
"""Makes an Cthulhu-specific Xmodmap so that the keys behave as we
need them to do. This is especially the case for the Cthulhu modifier.
"""
API = self.object
capsLockCleared = False
settings = API.app.getDynamicApiManager().getAPI('Settings')
time.sleep(3)
while self.isActive():
if "Caps_Lock" in settings.cthulhuModifierKeys \
or "Shift_Lock" in settings.cthulhuModifierKeys:
self.setCapsLockAsCthulhuModifier(True)
capsLockCleared = True
elif capsLockCleared:
self.setCapsLockAsCthulhuModifier(False)
capsLockCleared = False
time.sleep(1)
def setCapsLockAsCthulhuModifier(self, enable):
originalXmodmap = None
lines = None
try:
originalXmodmap = subprocess.check_output(['xkbcomp', os.environ['DISPLAY'], '-'])
lines = originalXmodmap.decode('UTF-8').split('\n')
except:
return
foundCapsInterpretSection = False
foundShiftInterpretSection = False
modified = False
for i, line in enumerate(lines):
if not foundCapsInterpretSection and not foundShiftInterpretSection:
if self.interpretCapsLineProg.match(line):
foundCapsInterpretSection = True
elif self.interpretShiftLineProg.match(line):
foundShiftInterpretSection = True
elif foundCapsInterpretSection:
if enable:
if self.normalCapsLineProg.match(line):
lines[i] = self.disabledModLine
modified = True
else:
if self.disabledModLineProg.match(line):
lines[i] = self.normalCapsLine
modified = True
if line.find('}'):
foundCapsInterpretSection = False
else: # foundShiftInterpretSection
if enable:
if self.normalShiftLineProg.match(line):
lines[i] = self.disabledModLine
modified = True
else:
if self.disabledModLineProg.match(line):
lines[i] = self.normalShiftLine
modified = True
if line.find('}'):
foundShiftInterpretSection = False
if modified:
newXmodMap = bytes('\n'.join(lines), 'UTF-8')
self.setXmodmap(newXmodMap)
def setXmodmap(self, xkbmap):
"""Set the keyboard map using xkbcomp."""
try:
p = subprocess.Popen(['xkbcomp', '-w0', '-', os.environ['DISPLAY']],
stdin=subprocess.PIPE, stdout=None, stderr=None)
p.communicate(xkbmap)
except:
pass

View File

@ -1,7 +0,0 @@
cthulhu_python_PYTHON = \
__init__.py \
CapsLockHack.plugin \
CapsLockHack.py
cthulhu_pythondir=$(pkgpythondir)/plugins/CapsLockHack

View File

@ -1,6 +0,0 @@
[Plugin]
Module=Clipboard
Loader=python3
Name=Clipboard
Description=Present the content of the current clipboard
Authors=Chrys chrys@linux-a11y.org

View File

@ -1,101 +0,0 @@
#!/usr/bin/env python3
#
# Copyright (c) 2024 Stormux
# Copyright (c) 2010-2012 The Orca Team
# Copyright (c) 2012 Igalia, S.L.
# Copyright (c) 2005-2010 Sun Microsystems Inc.
#
# This library is free software; you can redistribute it and/or
# modify it under the terms of the GNU Lesser General Public
# License as published by the Free Software Foundation; either
# version 2.1 of the License, or (at your option) any later version.
#
# This library is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
# Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public
# License along with this library; if not, write to the
# Free Software Foundation, Inc., Franklin Street, Fifth Floor,
# Boston MA 02110-1301 USA.
#
# Fork of Orca Screen Reader (GNOME)
# Original source: https://gitlab.gnome.org/GNOME/orca
from cthulhu import plugin
import gi, os
gi.require_version('Peas', '1.0')
from gi.repository import GObject
from gi.repository import Peas
gi.require_version("Gtk", "3.0")
from gi.repository import Gtk, Gdk
class Clipboard(GObject.Object, Peas.Activatable, plugin.Plugin):
#__gtype_name__ = 'Clipboard'
object = GObject.Property(type=GObject.Object)
def __init__(self):
plugin.Plugin.__init__(self)
def do_activate(self):
API = self.object
self.registerGestureByString(self.speakClipboard, _('clipboard'), 'kb:cthulhu+c')
def do_deactivate(self):
API = self.object
def do_update_state(self):
API = self.object
def speakClipboard(self, script=None, inputEvent=None):
API = self.object
Message = self.getClipboard()
API.app.getDynamicApiManager().getAPI('CthulhuState').activeScript.presentMessage(Message, resetStyles=False)
return True
def getClipboard(self):
Message = ""
FoundClipboardContent = False
# Get Clipboard
ClipboardObj = Gtk.Clipboard.get(Gdk.SELECTION_CLIPBOARD)
ClipboardText = ClipboardObj.wait_for_text()
ClipboardImage = ClipboardObj.wait_for_image()
ClipboardURI = ClipboardObj.wait_for_uris()
if (ClipboardText != None):
FoundClipboardContent = True
if (ClipboardObj.wait_is_uris_available()):
noOfObjects = 0
noOfFolder = 0
noOfFiles = 0
noOfDisks = 0
noOfLinks = 0
for Uri in ClipboardURI:
if Uri == '':
continue
noOfObjects += 1
uriWithoutProtocoll = Uri[Uri.find('://') + 3:]
Message += " " + Uri[Uri.rfind('/') + 1:] + " "
if (os.path.isdir(uriWithoutProtocoll)):
noOfFolder += 1
Message = Message + _("Folder") #Folder
if (os.path.isfile(uriWithoutProtocoll)):
noOfFiles += 1
Message = Message + _("File") #File
if (os.path.ismount(uriWithoutProtocoll)):
noOfDisks += 1
Message = Message + _("Disk") #Mountpoint
if (os.path.islink(uriWithoutProtocoll)):
noOfLinks += 1
Message = Message + _("Link") #Link
if (noOfObjects > 1):
Message = str(noOfObjects) + _(" Objects in clipboard ") + Message # X Objects in Clipboard Object Object
else:
Message = str(noOfObjects) + _(" Object in clipboard ") + Message # 1 Object in Clipboard Object
else:
Message = _("Text in clipboard ") + ClipboardText # Text in Clipboard
if (ClipboardImage != None):
FoundClipboardContent = True
Message = _("The clipboard contains a image") # Image is in Clipboard
if (not FoundClipboardContent):
Message = _("The clipboard is empty")
return Message

View File

@ -1,7 +1,7 @@
cthulhu_python_PYTHON = \ cthulhu_python_PYTHON = \
__init__.py \ __init__.py \
Clipboard.plugin \ plugin.info \
Clipboard.py plugin.py
cthulhu_pythondir=$(pkgpythondir)/plugins/Clipboard cthulhu_pythondir=$(pkgpythondir)/plugins/Clipboard

View File

@ -0,0 +1,7 @@
[Plugin]
Name = Clipboard
Module = Clipboard
Description = Present the content of the current clipboard
Authors = Storm Dragon <storm_dragon@stormux.org>
Version = 1.0
Category = Utilities

View File

@ -0,0 +1,193 @@
#!/usr/bin/env python3
#
# Copyright (c) 2024 Stormux
# Copyright (c) 2010-2012 The Orca Team
# Copyright (c) 2012 Igalia, S.L.
# Copyright (c) 2005-2010 Sun Microsystems Inc.
#
# This library is free software; you can redistribute it and/or
# modify it under the terms of the GNU Lesser General Public
# License as published by the Free Software Foundation; either
# version 2.1 of the License, or (at your option) any later version.
#
# This library is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
# Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public
# License along with this library; if not, write to the
# Free Software Foundation, Inc., Franklin Street, Fifth Floor,
# Boston MA 02110-1301 USA.
#
# Fork of Orca Screen Reader (GNOME)
# Original source: https://gitlab.gnome.org/GNOME/orca
"""Clipboard plugin for Cthulhu."""
import os
import logging
import gettext
import gi
gi.require_version("Gtk", "3.0")
from gi.repository import Gtk, Gdk
from cthulhu.plugin import Plugin, cthulhu_hookimpl
# Set up translation function
_ = gettext.gettext
logger = logging.getLogger(__name__)
class Clipboard(Plugin):
"""Plugin to read the clipboard contents."""
def __init__(self, *args, **kwargs):
"""Initialize the plugin."""
super().__init__(*args, **kwargs)
logger.info("Clipboard plugin initialized")
self._signal_handler_id = None
@cthulhu_hookimpl
def activate(self, plugin=None):
"""Activate the plugin."""
# Skip if this activation call isn't for us
if plugin is not None and plugin is not self:
return
logger.info("Activating Clipboard plugin")
try:
# Register keyboard shortcut
self.registerGestureByString(self.speakClipboard, _('clipboard'), 'kb:cthulhu+shift+c')
logger.debug("Registered shortcut for clipboard")
except Exception as e:
logger.error(f"Error activating Clipboard plugin: {e}")
@cthulhu_hookimpl
def deactivate(self, plugin=None):
"""Deactivate the plugin."""
# Skip if this deactivation call isn't for us
if plugin is not None and plugin is not self:
return
logger.info("Deactivating Clipboard plugin")
try:
# Unregister keyboard shortcut
if self.app:
api_helper = self.app.getAPIHelper()
if api_helper and hasattr(api_helper, 'unregisterShortcut'):
api_helper.unregisterShortcut('kb:cthulhu+shift+c')
logger.debug("Unregistered clipboard shortcut")
except Exception as e:
logger.error(f"Error deactivating Clipboard plugin: {e}")
"""Activate the plugin."""
# Skip if this activation call isn't for us
if plugin is not None and plugin is not self:
return
logger.info("Activating Clipboard plugin")
try:
# Register keyboard shortcut
self.registerGestureByString(self.speakClipboard, _('clipboard'), 'kb:cthulhu+shift+c')
logger.debug("Registered shortcut for clipboard")
except Exception as e:
logger.error(f"Error activating Clipboard plugin: {e}")
@cthulhu_hookimpl
def deactivate(self, plugin=None):
"""Deactivate the plugin."""
# Skip if this deactivation call isn't for us
if plugin is not None and plugin is not self:
return
logger.info("Deactivating Clipboard plugin")
try:
# Unregister keyboard shortcut
self.unregisterGestureByString('kb:cthulhu+shift+c')
logger.debug("Unregistered clipboard shortcut")
except Exception as e:
logger.error(f"Error deactivating Clipboard plugin: {e}")
def speakClipboard(self, script=None, inputEvent=None):
"""Present the contents of the clipboard."""
try:
message = self.getClipboard()
state = self.app.getDynamicApiManager().getAPI('CthulhuState')
if state and state.activeScript:
state.activeScript.presentMessage(message, resetStyles=False)
logger.debug("Presented clipboard contents")
else:
logger.warning("Could not present clipboard: no active script")
return True
except Exception as e:
logger.error(f"Error in speakClipboard: {e}")
return False
def getClipboard(self):
"""Get the contents of the clipboard."""
try:
message = ""
found_clipboard_content = False
# Get Clipboard
clipboard_obj = Gtk.Clipboard.get(Gdk.SELECTION_CLIPBOARD)
clipboard_text = clipboard_obj.wait_for_text()
clipboard_image = clipboard_obj.wait_for_image()
clipboard_uri = clipboard_obj.wait_for_uris()
if clipboard_text is not None:
found_clipboard_content = True
if clipboard_obj.wait_is_uris_available():
no_of_objects = 0
no_of_folder = 0
no_of_files = 0
no_of_disks = 0
no_of_links = 0
for uri in clipboard_uri:
if uri == '':
continue
no_of_objects += 1
uri_without_protocol = uri[uri.find('://') + 3:]
message += " " + uri[uri.rfind('/') + 1:] + " "
if os.path.isdir(uri_without_protocol):
no_of_folder += 1
message = message + _("Folder")
if os.path.isfile(uri_without_protocol):
no_of_files += 1
message = message + _("File")
if os.path.ismount(uri_without_protocol):
no_of_disks += 1
message = message + _("Disk")
if os.path.islink(uri_without_protocol):
no_of_links += 1
message = message + _("Link")
if no_of_objects > 1:
message = str(no_of_objects) + _(" Objects in clipboard ") + message
else:
message = str(no_of_objects) + _(" Object in clipboard ") + message
else:
message = _("Text in clipboard ") + clipboard_text
if clipboard_image is not None:
found_clipboard_content = True
message = _("The clipboard contains a image")
if not found_clipboard_content:
message = _("The clipboard is empty")
return message
except Exception as e:
logger.error(f"Error getting clipboard content: {e}")
return _("Error accessing clipboard")

View File

@ -1,6 +0,0 @@
[Plugin]
Module=Date
Loader=python3
Name=Date
Description=Present the current date
Authors=Chrys chrys@linux-a11y.org

View File

@ -1,58 +0,0 @@
#!/usr/bin/env python3
#
# Copyright (c) 2024 Stormux
# Copyright (c) 2010-2012 The Orca Team
# Copyright (c) 2012 Igalia, S.L.
# Copyright (c) 2005-2010 Sun Microsystems Inc.
#
# This library is free software; you can redistribute it and/or
# modify it under the terms of the GNU Lesser General Public
# License as published by the Free Software Foundation; either
# version 2.1 of the License, or (at your option) any later version.
#
# This library is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
# Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public
# License along with this library; if not, write to the
# Free Software Foundation, Inc., Franklin Street, Fifth Floor,
# Boston MA 02110-1301 USA.
#
# Fork of Orca Screen Reader (GNOME)
# Original source: https://gitlab.gnome.org/GNOME/orca
from cthulhu import plugin
import gi, time
gi.require_version('Peas', '1.0')
from gi.repository import GObject
from gi.repository import Peas
class Date(GObject.Object, Peas.Activatable, plugin.Plugin):
#__gtype_name__ = 'Date'
object = GObject.Property(type=GObject.Object)
def __init__(self):
plugin.Plugin.__init__(self)
def do_activate(self):
API = self.object
self.connectSignal("setup-inputeventhandlers-completed", self.setupCompatBinding)
def setupCompatBinding(self, app):
cmdnames = app.getDynamicApiManager().getAPI('Cmdnames')
inputEventHandlers = app.getDynamicApiManager().getAPI('inputEventHandlers')
inputEventHandlers['presentDateHandler'] = app.getAPIHelper().createInputEventHandler(self.presentDate, cmdnames.PRESENT_CURRENT_DATE)
def do_deactivate(self):
API = self.object
inputEventHandlers = API.app.getDynamicApiManager().getAPI('inputEventHandlers')
del inputEventHandlers['presentDateHandler']
def presentDate(self, script=None, inputEvent=None):
""" Presents the current time. """
API = self.object
settings_manager = API.app.getDynamicApiManager().getAPI('SettingsManager')
_settingsManager = settings_manager.getManager()
dateFormat = _settingsManager.getSetting('presentDateFormat')
message = time.strftime(dateFormat, time.localtime())
API.app.getDynamicApiManager().getAPI('CthulhuState').activeScript.presentMessage(message, resetStyles=False)
return True

View File

@ -1,7 +0,0 @@
cthulhu_python_PYTHON = \
__init__.py \
Date.plugin \
Date.py
cthulhu_pythondir=$(pkgpythondir)/plugins/Date

View File

@ -0,0 +1,7 @@
cthulhu_python_PYTHON = \
__init__.py \
plugin.info \
plugin.py
cthulhu_pythondir=$(pkgpythondir)/plugins/DisplayVersion

View File

@ -0,0 +1,8 @@
name = Display Version
version = 1.0.0
description = Announces the Cthulhu version with Cthulhu+Shift+V
authors = Stormux <storm_dragon@stormux.org>
website = https://stormux.org
copyright = Copyright 2025
builtin = false
hidden = false

View File

@ -0,0 +1,68 @@
#!/usr/bin/env python3
#
# Copyright (c) 2025 Stormux
#
# This library is free software; you can redistribute it and/or
# modify it under the terms of the GNU Lesser General Public
# License as published by the Free Software Foundation; either
# version 2.1 of the License, or (at your option) any later version.
"""Display Version plugin for Cthulhu."""
import logging
from cthulhu.plugin import Plugin, cthulhu_hookimpl
from cthulhu import cthulhuVersion
logger = logging.getLogger(__name__)
class DisplayVersion(Plugin):
"""Plugin that announces the current Cthulhu version."""
def __init__(self, *args, **kwargs):
"""Initialize the plugin."""
super().__init__(*args, **kwargs)
logger.info("DisplayVersion plugin initialized")
self._kb_binding = None
@cthulhu_hookimpl
def activate(self, plugin=None):
"""Activate the plugin."""
# Skip if this activation call isn't for us
if plugin is not None and plugin is not self:
return
try:
logger.info("Activating DisplayVersion plugin")
# Register keyboard shortcut
self._kb_binding = self.registerGestureByString(
self.speakText,
f'Cthulhu screen reader version {cthulhuVersion.version}-{cthulhuVersion.codeName}',
'kb:cthulhu+shift+v'
)
except Exception as e:
logger.error(f"Error activating DisplayVersion plugin: {e}")
@cthulhu_hookimpl
def deactivate(self, plugin=None):
"""Deactivate the plugin."""
# Skip if this deactivation call isn't for us
if plugin is not None and plugin is not self:
return
logger.info("Deactivating DisplayVersion plugin")
def speakText(self, script=None, inputEvent=None):
"""Speak the Cthulhu version when shortcut is pressed."""
try:
if self.app:
state = self.app.getDynamicApiManager().getAPI('CthulhuState')
if state.activeScript:
state.activeScript.presentMessage(
f'Cthulhu screen reader version {cthulhuVersion.version}-{cthulhuVersion.codeName}',
resetStyles=False
)
return True
except Exception as e:
logger.error(f"Error in DisplayVersion speakText: {e}")
return False

View File

@ -1,6 +0,0 @@
[Plugin]
Module=HelloCthulhu
Loader=python3
Name=Cthulhu say hello
Description=startup announcement for Cthulhu
Authors=Chrys chrys@linux-a11y.org

View File

@ -1,48 +0,0 @@
#!/usr/bin/env python3
#
# Copyright (c) 2024 Stormux
# Copyright (c) 2010-2012 The Orca Team
# Copyright (c) 2012 Igalia, S.L.
# Copyright (c) 2005-2010 Sun Microsystems Inc.
#
# This library is free software; you can redistribute it and/or
# modify it under the terms of the GNU Lesser General Public
# License as published by the Free Software Foundation; either
# version 2.1 of the License, or (at your option) any later version.
#
# This library is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
# Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public
# License along with this library; if not, write to the
# Free Software Foundation, Inc., Franklin Street, Fifth Floor,
# Boston MA 02110-1301 USA.
#
# Fork of Orca Screen Reader (GNOME)
# Original source: https://gitlab.gnome.org/GNOME/orca
from cthulhu import plugin
import gi
gi.require_version('Peas', '1.0')
from gi.repository import GObject
from gi.repository import Peas
class HelloCthulhu(GObject.Object, Peas.Activatable, plugin.Plugin):
#__gtype_name__ = 'HelloCthulhu'
object = GObject.Property(type=GObject.Object)
def __init__(self):
plugin.Plugin.__init__(self)
def do_activate(self):
API = self.object
self.connectSignal("start-application-completed", self.process)
def do_deactivate(self):
API = self.object
def do_update_state(self):
API = self.object
def process(self, app):
messages = app.getDynamicApiManager().getAPI('Messages')
app.getDynamicApiManager().getAPI('CthulhuState').activeScript.presentMessage(messages.START_CTHULHU, resetStyles=False)

View File

@ -1,7 +1,7 @@
cthulhu_python_PYTHON = \ cthulhu_python_PYTHON = \
__init__.py \ __init__.py \
HelloCthulhu.plugin \ plugin.info \
HelloCthulhu.py plugin.py
cthulhu_pythondir=$(pkgpythondir)/plugins/HelloCthulhu cthulhu_pythondir=$(pkgpythondir)/plugins/HelloCthulhu

View File

@ -0,0 +1,7 @@
[Plugin]
Name = Cthulhu say hello
Module = HelloCthulhu
Description = Startup announcement for Cthulhu
Authors = Storm Dragon <storm_dragon@stormux.org>
Version = 1.0
Category = Interaction

View File

@ -0,0 +1,113 @@
#!/usr/bin/env python3
#
# Copyright (c) 2024 Stormux
#
# This library is free software; you can redistribute it and/or
# modify it under the terms of the GNU Lesser General Public
# License as published by the Free Software Foundation; either
# version 2.1 of the License, or (at your option) any later version.
#
# This library is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
# Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public
# License along with this library; if not, write to the
# Free Software Foundation, Inc., Franklin Street, Fifth Floor,
# Boston MA 02110-1301 USA.
#
"""Hello Cthulhu plugin for Cthulhu."""
import logging
import weakref
from cthulhu.plugin import Plugin, cthulhu_hookimpl
logger = logging.getLogger(__name__)
# Class-level variable to track if the greeting has been presented
# This ensures the greeting is only shown once even if multiple instances exist
_greeting_shown = False
class HelloCthulhu(Plugin):
"""Plugin that speaks a welcome message when Cthulhu starts up."""
def __init__(self, *args, **kwargs):
"""Initialize the plugin."""
super().__init__(*args, **kwargs)
logger.info("HelloCthulhu plugin initialized")
self._signal_handler_id = None
self._is_connected = False
@cthulhu_hookimpl
def activate(self, plugin=None):
"""Activate the plugin."""
# Skip if this activation call isn't for us
if plugin is not None and plugin is not self:
return
logger.info("Activating HelloCthulhu plugin")
try:
# Only connect the signal if we haven't already
if not self._is_connected:
signal_manager = self.app.getSignalManager()
self._signal_handler_id = signal_manager.connectSignal(
"start-application-completed",
self.process,
"default" # Add profile parameter
)
self._is_connected = True
logger.debug("Connected to start-application-completed signal")
except Exception as e:
logger.error(f"Error activating HelloCthulhu plugin: {e}")
@cthulhu_hookimpl
def deactivate(self, plugin=None):
"""Deactivate the plugin."""
# Skip if this deactivation call isn't for us
if plugin is not None and plugin is not self:
return
logger.info("Deactivating HelloCthulhu plugin")
try:
# Only disconnect if we're connected
if self._is_connected and self._signal_handler_id is not None:
signal_manager = self.app.getSignalManager()
signal_manager.disconnectSignalByFunction(
self.process
)
self._signal_handler_id = None
self._is_connected = False
logger.debug("Disconnected from start-application-completed signal")
except Exception as e:
logger.error(f"Error deactivating HelloCthulhu plugin: {e}")
def process(self, app):
"""Process the start-application-completed signal."""
global _greeting_shown
# Only present the message if it hasn't been shown yet
if _greeting_shown:
logger.debug("Greeting already shown, skipping")
return
try:
messages = app.getDynamicApiManager().getAPI('Messages')
state = app.getDynamicApiManager().getAPI('CthulhuState')
if state.activeScript:
state.activeScript.presentMessage(messages.START_CTHULHU, resetStyles=False)
_greeting_shown = True
logger.info("Greeting message presented")
# Disconnect the signal after presenting the message
if self._is_connected and self._signal_handler_id is not None:
signal_manager = app.getSignalManager()
signal_manager.disconnectSignalByFunction(
self.process
)
self._signal_handler_id = None
self._is_connected = False
logger.debug("Disconnected signal after presenting greeting")
except Exception as e:
logger.error(f"Error in HelloCthulhu process: {e}")

View File

@ -1,6 +0,0 @@
[Plugin]
Module=HelloWorld
Loader=python3
Name=Hello World (python3)
Description=Test plugin for cthulhu
Authors=Chrys chrys@linux-a11y.org

View File

@ -1,49 +0,0 @@
#!/usr/bin/env python3
#
# Copyright (c) 2024 Stormux
# Copyright (c) 2010-2012 The Orca Team
# Copyright (c) 2012 Igalia, S.L.
# Copyright (c) 2005-2010 Sun Microsystems Inc.
#
# This library is free software; you can redistribute it and/or
# modify it under the terms of the GNU Lesser General Public
# License as published by the Free Software Foundation; either
# version 2.1 of the License, or (at your option) any later version.
#
# This library is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
# Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public
# License along with this library; if not, write to the
# Free Software Foundation, Inc., Franklin Street, Fifth Floor,
# Boston MA 02110-1301 USA.
#
# Fork of Orca Screen Reader (GNOME)
# Original source: https://gitlab.gnome.org/GNOME/orca
from cthulhu import plugin
import gi
gi.require_version('Peas', '1.0')
from gi.repository import GObject
from gi.repository import Peas
class HelloWorld(GObject.Object, Peas.Activatable, plugin.Plugin):
__gtype_name__ = 'helloworld'
object = GObject.Property(type=GObject.Object)
def __init__(self):
plugin.Plugin.__init__(self)
def do_activate(self):
API = self.object
self.registerGestureByString(self.speakTest, _('hello world'), 'kb:cthulhu+z')
print('activate hello world plugin')
def do_deactivate(self):
API = self.object
print('deactivate hello world plugin')
def speakTest(self, script=None, inputEvent=None):
API = self.object
API.app.getDynamicApiManager().getAPI('CthulhuState').activeScript.presentMessage('hello world', resetStyles=False)
return True

View File

@ -1,7 +0,0 @@
cthulhu_python_PYTHON = \
__init__.py \
HelloWorld.plugin \
HelloWorld.py
cthulhu_pythondir=$(pkgpythondir)/plugins/HelloWorld

View File

@ -1,4 +1,4 @@
SUBDIRS = Clipboard HelloWorld SelfVoice Time MouseReview Date ByeCthulhu HelloCthulhu PluginManager CapsLockHack SimplePluginSystem SUBDIRS = Clipboard DisplayVersion hello_world self_voice ByeCthulhu HelloCthulhu SimplePluginSystem
cthulhu_pythondir=$(pkgpythondir)/plugins cthulhu_pythondir=$(pkgpythondir)/plugins

View File

@ -1,7 +0,0 @@
cthulhu_python_PYTHON = \
__init__.py \
MouseReview.plugin \
MouseReview.py
cthulhu_pythondir=$(pkgpythondir)/plugins/MouseReview

View File

@ -1,6 +0,0 @@
[Plugin]
Module=MouseReview
Loader=python3
Name=Mouse Review
Description=Review whats below the mouse coursor
Authors=Chrys chrys@linux-a11y.org

View File

@ -1,759 +0,0 @@
#!/usr/bin/env python3
#
# Copyright (c) 2024 Stormux
# Copyright (c) 2010-2012 The Orca Team
# Copyright (c) 2012 Igalia, S.L.
# Copyright (c) 2005-2010 Sun Microsystems Inc.
#
# This library is free software; you can redistribute it and/or
# modify it under the terms of the GNU Lesser General Public
# License as published by the Free Software Foundation; either
# version 2.1 of the License, or (at your option) any later version.
#
# This library is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
# Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public
# License along with this library; if not, write to the
# Free Software Foundation, Inc., Franklin Street, Fifth Floor,
# Boston MA 02110-1301 USA.
#
# Fork of Orca Screen Reader (GNOME)
# Original source: https://gitlab.gnome.org/GNOME/orca
"""Mouse review mode."""
__id__ = "$Id$"
__version__ = "$Revision$"
__date__ = "$Date$"
__copyright__ = "Copyright (c) 2008 Eitan Isaacson" \
"Copyright (c) 2016 Igalia, S.L."
__license__ = "LGPL"
from cthulhu import plugin
import gi, math, time
gi.require_version('Peas', '1.0')
from gi.repository import GObject
from gi.repository import Peas
gi.require_version("Atspi", "2.0")
from gi.repository import Atspi
from gi.repository import Gdk
try:
gi.require_version("Wnck", "3.0")
from gi.repository import Wnck
_mouseReviewCapable = True
except Exception:
_mouseReviewCapable = False
# compatibility layer, see MouseReview.do_activate
debug = None
event_manager = None
cthulhu = None
cthulhu_state = None
script_manager = None
settings_manager = None
speech = None
messages = None
cmdnames = None
emitRegionChanged = None
_scriptManager = None
_settingsManager = None
AXObject = None
AXUtilities = None
keybindings = None
input_event = None
class MouseReview(GObject.Object, Peas.Activatable, plugin.Plugin):
#__gtype_name__ = 'MouseReview'
object = GObject.Property(type=GObject.Object)
def __init__(self):
plugin.Plugin.__init__(self)
def do_activate(self):
API = self.object
global _mouseReviewCapable
if not _mouseReviewCapable:
return
global debug
global event_manager
global cthulhu_state
global script_manager
global settings_manager
global speech
global _scriptManager
global _settingsManager
global emitRegionChanged
global messages
global cmdnames
global AXObject
global AXUtilities
global keybindings
global input_event
debug= API.app.getDynamicApiManager().getAPI('Debug')
event_manager = API.app.getDynamicApiManager().getAPI('EventManager')
messages = API.app.getDynamicApiManager().getAPI('Messages')
cmdnames = API.app.getDynamicApiManager().getAPI('Cmdnames')
cthulhu_state = API.app.getDynamicApiManager().getAPI('CthulhuState')
script_manager = API.app.getDynamicApiManager().getAPI('ScriptManager')
settings_manager = API.app.getDynamicApiManager().getAPI('SettingsManager')
speech = API.app.getDynamicApiManager().getAPI('Speech')
emitRegionChanged = API.app.getDynamicApiManager().getAPI('EmitRegionChanged')
_scriptManager = script_manager.getManager()
_settingsManager = settings_manager.getManager()
AXObject = API.app.getDynamicApiManager().getAPI('AXObject')
AXUtilities = API.app.getDynamicApiManager().getAPI('AXUtilities')
keybindings = API.app.getDynamicApiManager().getAPI('Keybindings')
input_event = API.app.getDynamicApiManager().getAPI('InputEvent')
mouse_review = MouseReviewer()
self.registerAPI('MouseReview', mouse_review)
self.Initialize(API.app)
self.connectSignal("setup-inputeventhandlers-completed", self.setupCompatBinding)
self.connectSignal("load-setting-completed", self.Initialize)
def do_deactivate(self):
API = self.object
global _mouseReviewCapable
if not _mouseReviewCapable:
return
mouse_review = API.app.getDynamicApiManager().getAPI('MouseReview')
mouse_review.deactivate()
def do_update_state(self):
API = self.object
def setupCompatBinding(self, app):
API = self.object
mouse_review = API.app.getDynamicApiManager().getAPI('MouseReview')
cmdnames = API.app.getDynamicApiManager().getAPI('Cmdnames')
inputEventHandlers = API.app.getDynamicApiManager().getAPI('inputEventHandlers')
inputEventHandlers['toggleMouseReviewHandler'] = API.app.getAPIHelper().createInputEventHandler(mouse_review.toggle, cmdnames.MOUSE_REVIEW_TOGGLE)
def Initialize(self, app):
mouse_review = app.getDynamicApiManager().getAPI('MouseReview')
settings_manager = app.getDynamicApiManager().getAPI('SettingsManager')
_settingsManager = settings_manager.getManager()
if _settingsManager.getSetting('enableMouseReview'):
mouse_review.activate()
else:
mouse_review.deactivate()
class _StringContext:
"""The textual information associated with an _ItemContext."""
def __init__(self, obj, script=None, string="", start=0, end=0):
"""Initialize the _StringContext.
Arguments:
- string: The human-consumable string
- obj: The accessible object associated with this string
- start: The start offset with respect to entire text, if one exists
- end: The end offset with respect to the entire text, if one exists
- script: The script associated with the accessible object
"""
self._obj = obj
self._script = script
self._string = string
self._start = start
self._end = end
self._boundingBox = 0, 0, 0, 0
if script:
self._boundingBox = script.utilities.getTextBoundingBox(obj, start, end)
def __eq__(self, other):
return other is not None \
and self._obj == other._obj \
and self._string == other._string \
and self._start == other._start \
and self._end == other._end
def isSubstringOf(self, other):
"""Returns True if this is a substring of other."""
if other is None:
return False
if not (self._obj and other._obj):
return False
thisBox = self.getBoundingBox()
if thisBox == (0, 0, 0, 0):
return False
otherBox = other.getBoundingBox()
if otherBox == (0, 0, 0, 0):
return False
# We get various and sundry results for the bounding box if the implementor
# included newline characters as part of the word or line at offset. Try to
# detect this and adjust the bounding boxes before getting the intersection.
if thisBox[3] != otherBox[3] and self._obj == other._obj:
thisNewLineCount = self._string.count("\n")
if thisNewLineCount and thisBox[3] / thisNewLineCount == otherBox[3]:
thisBox = *thisBox[0:3], otherBox[3]
if self._script.utilities.intersection(thisBox, otherBox) != thisBox:
return False
if not (self._string and self._string.strip() in other._string):
return False
msg = f"MOUSE REVIEW: '{self._string}' is substring of '{other._string}'"
debug.println(debug.LEVEL_INFO, msg, True)
return True
def getBoundingBox(self):
"""Returns the bounding box associated with this context's range."""
return self._boundingBox
def getString(self):
"""Returns the string associated with this context."""
return self._string
def present(self):
"""Presents this context to the user."""
if not self._script:
msg = "MOUSE REVIEW: Not presenting due to lack of script"
debug.println(debug.LEVEL_INFO, msg, True)
return False
if not self._string:
msg = "MOUSE REVIEW: Not presenting due to lack of string"
debug.println(debug.LEVEL_INFO, msg, True)
return False
voice = self._script.speechGenerator.voice(obj=self._obj, string=self._string)
string = self._script.utilities.adjustForRepeats(self._string)
# TODO
#cthulhu.emitRegionChanged(self._obj, self._start, self._end, cthulhu.MOUSE_REVIEW)
emitRegionChanged(self._obj, self._start, self._end, "mouse-review")
self._script.speakMessage(string, voice=voice, interrupt=False)
self._script.displayBrailleMessage(self._string, -1)
return True
class _ItemContext:
"""Holds all the information of the item at a specified point."""
def __init__(self, x=0, y=0, obj=None, boundary=None, frame=None, script=None):
"""Initialize the _ItemContext.
Arguments:
- x: The X coordinate
- y: The Y coordinate
- obj: The accessible object of interest at that coordinate
- boundary: The accessible-text boundary type
- frame: The containing accessible object (often a top-level window)
- script: The script associated with the accessible object
"""
self._x = x
self._y = y
self._obj = obj
self._boundary = boundary
self._frame = frame
self._script = script
self._string = self._getStringContext()
self._time = time.time()
self._boundingBox = 0, 0, 0, 0
if script:
self._boundingBox = script.utilities.getBoundingBox(obj)
def __eq__(self, other):
return other is not None \
and self._frame == other._frame \
and self._obj == other._obj \
and self._string == other._string
def _treatAsDuplicate(self, prior):
if self._obj != prior._obj or self._frame != prior._frame:
msg = "MOUSE REVIEW: Not a duplicate: different objects"
debug.println(debug.LEVEL_INFO, msg, True)
return False
if self.getString() and prior.getString() and not self._isSubstringOf(prior):
msg = "MOUSE REVIEW: Not a duplicate: not a substring of"
debug.println(debug.LEVEL_INFO, msg, True)
return False
if self._x == prior._x and self._y == prior._y:
msg = "MOUSE REVIEW: Treating as duplicate: mouse didn't move"
debug.println(debug.LEVEL_INFO, msg, True)
return True
interval = self._time - prior._time
if interval > 0.5:
msg = f"MOUSE REVIEW: Not a duplicate: was {interval:.2f}s ago"
debug.println(debug.LEVEL_INFO, msg, True)
return False
msg = "MOUSE REVIEW: Treating as duplicate"
debug.println(debug.LEVEL_INFO, msg, True)
return True
def _treatAsSingleObject(self):
if not AXObject.supports_text(self._obj):
return True
if not self._obj.queryText().characterCount:
return True
return False
def _getStringContext(self):
"""Returns the _StringContext associated with the specified point."""
if not (self._script and self._obj):
return _StringContext(self._obj)
if self._treatAsSingleObject():
return _StringContext(self._obj, self._script)
string, start, end = self._script.utilities.textAtPoint(
self._obj, self._x, self._y, boundary=self._boundary)
if string:
string = self._script.utilities.expandEOCs(self._obj, start, end)
return _StringContext(self._obj, self._script, string, start, end)
def _getContainer(self):
roles = [Atspi.Role.DIALOG,
Atspi.Role.FRAME,
Atspi.Role.LAYERED_PANE,
Atspi.Role.MENU,
Atspi.Role.PAGE_TAB,
Atspi.Role.TOOL_BAR,
Atspi.Role.WINDOW]
return AXObject.find_ancestor(self._obj, lambda x: AXObject.get_role(x) in roles)
def _isSubstringOf(self, other):
"""Returns True if this is a substring of other."""
return self._string.isSubstringOf(other._string)
def getObject(self):
"""Returns the accessible object associated with this context."""
return self._obj
def getBoundingBox(self):
"""Returns the bounding box associated with this context."""
x, y, width, height = self._string.getBoundingBox()
if not (width or height):
return self._boundingBox
return x, y, width, height
def getString(self):
"""Returns the string associated with this context."""
return self._string.getString()
def getTime(self):
"""Returns the time associated with this context."""
return self._time
def _isInlineChild(self, prior):
if not self._obj or not prior._obj:
return False
if AXObject.get_parent(prior._obj) != self._obj:
return False
if self._treatAsSingleObject():
return False
return AXUtilities.is_link(prior._obj)
def present(self, prior):
"""Presents this context to the user."""
if self == prior or self._treatAsDuplicate(prior):
msg = "MOUSE REVIEW: Not presenting due to no change"
debug.println(debug.LEVEL_INFO, msg, True)
return False
interrupt = self._obj and self._obj != prior._obj \
or math.sqrt((self._x - prior._x)**2 + (self._y - prior._y)**2) > 25
if interrupt:
self._script.presentationInterrupt()
if self._frame and self._frame != prior._frame:
self._script.presentObject(self._frame,
alreadyFocused=True,
inMouseReview=True,
interrupt=True)
if self._script.utilities.containsOnlyEOCs(self._obj):
msg = "MOUSE REVIEW: Not presenting object which contains only EOCs"
debug.println(debug.LEVEL_INFO, msg, True)
return False
if self._obj and self._obj != prior._obj and not self._isInlineChild(prior):
priorObj = prior._obj or self._getContainer()
# TODO
#cthulhu.emitRegionChanged(self._obj, mode=cthulhu.MOUSE_REVIEW)
emitRegionChanged(self._obj, mode="mouse-review")
self._script.presentObject(self._obj, priorObj=priorObj, inMouseReview=True)
if self._string.getString() == AXObject.get_name(self._obj):
return True
if not self._script.utilities.isEditableTextArea(self._obj):
return True
if AXUtilities.is_table_cell(self._obj) \
and self._string.getString() == self._script.utilities.displayedText(self._obj):
return True
if self._string != prior._string and self._string.present():
return True
return True
class MouseReviewer:
"""Main class for the mouse-review feature."""
def __init__(self):
self._active = _settingsManager.getSetting("enableMouseReview")
self._currentMouseOver = _ItemContext()
self._pointer = None
self._workspace = None
self._windows = []
self._all_windows = []
self._handlerIds = {}
self._eventListener = Atspi.EventListener.new(self._listener)
self.inMouseEvent = False
self._handlers = self._setup_handlers()
self._bindings = self._setup_bindings()
if not _mouseReviewCapable:
msg = "MOUSE REVIEW ERROR: Wnck is not available"
debug.println(debug.LEVEL_INFO, msg, True)
return
display = Gdk.Display.get_default()
try:
seat = Gdk.Display.get_default_seat(display)
self._pointer = seat.get_pointer()
except AttributeError:
msg = "MOUSE REVIEW ERROR: Gtk+ 3.20 is not available"
debug.println(debug.LEVEL_INFO, msg, True)
return
except Exception:
msg = "MOUSE REVIEW ERROR: Exception getting pointer for default seat."
debug.println(debug.LEVEL_INFO, msg, True)
return
if not self._pointer:
msg = "MOUSE REVIEW ERROR: No pointer for default seat."
debug.println(debug.LEVEL_INFO, msg, True)
return
if not self._active:
return
self.activate()
def get_bindings(self):
"""Returns the mouse-review keybindings."""
return self._bindings
def get_handlers(self):
"""Returns the mouse-review handlers."""
return self._handlers
def _setup_handlers(self):
"""Sets up and returns the mouse-review input event handlers."""
handlers = {}
handlers["toggleMouseReviewHandler"] = \
input_event.InputEventHandler(
self.toggle,
cmdnames.MOUSE_REVIEW_TOGGLE)
return handlers
def _setup_bindings(self):
"""Sets up and returns the mouse-review key bindings."""
bindings = keybindings.KeyBindings()
bindings.add(
keybindings.KeyBinding(
"",
keybindings.defaultModifierMask,
keybindings.NO_MODIFIER_MASK,
self._handlers.get("toggleMouseReviewHandler")))
return bindings
def activate(self):
"""Activates mouse review."""
if not _mouseReviewCapable:
msg = "MOUSE REVIEW ERROR: Wnck is not available"
debug.println(debug.LEVEL_INFO, msg, True)
return
# Set up the initial object as the one with the focus to avoid
# presenting irrelevant info the first time.
obj = cthulhu_state.locusOfFocus
script = None
frame = None
if obj:
script = _scriptManager.getScript(AXObject.get_application(obj), obj)
if script:
frame = script.utilities.topLevelObject(obj)
self._currentMouseOver = _ItemContext(obj=obj, frame=frame, script=script)
self._eventListener.register("mouse:abs")
screen = Wnck.Screen.get_default()
if screen:
# On first startup windows and workspace are likely to be None,
# but the signals we connect to will get emitted when proper values
# become available; but in case we got disabled and re-enabled we
# have to get the initial values manually.
stacked = screen.get_windows_stacked()
if stacked:
stacked.reverse()
self._all_windows = stacked
self._workspace = screen.get_active_workspace()
if self._workspace:
self._update_workspace_windows()
i = screen.connect("window-stacking-changed", self._on_stacking_changed)
self._handlerIds[i] = screen
i = screen.connect("active-workspace-changed", self._on_workspace_changed)
self._handlerIds[i] = screen
self._active = True
def deactivate(self):
"""Deactivates mouse review."""
self._eventListener.deregister("mouse:abs")
for key, value in self._handlerIds.items():
value.disconnect(key)
self._handlerIds = {}
self._workspace = None
self._windows = []
self._all_windows = []
self._active = False
def getCurrentItem(self):
"""Returns the accessible object being reviewed."""
if not _mouseReviewCapable:
return None
if not self._active:
return None
obj = self._currentMouseOver.getObject()
if time.time() - self._currentMouseOver.getTime() > 0.1:
msg = f"MOUSE REVIEW: Treating {obj} as stale"
debug.println(debug.LEVEL_INFO, msg, True)
return None
return obj
def toggle(self, script=None, event=None):
"""Toggle mouse reviewing on or off."""
if not _mouseReviewCapable:
return
self._active = not self._active
_settingsManager.setSetting("enableMouseReview", self._active)
if not self._active:
self.deactivate()
msg = messages.MOUSE_REVIEW_DISABLED
else:
self.activate()
msg = messages.MOUSE_REVIEW_ENABLED
if cthulhu_state.activeScript:
cthulhu_state.activeScript.presentMessage(msg)
def _update_workspace_windows(self):
self._windows = [w for w in self._all_windows
if w.is_on_workspace(self._workspace)]
def _on_stacking_changed(self, screen):
"""Callback for Wnck's window-stacking-changed signal."""
stacked = screen.get_windows_stacked()
stacked.reverse()
self._all_windows = stacked
if self._workspace:
self._update_workspace_windows()
def _on_workspace_changed(self, screen, prev_ws=None):
"""Callback for Wnck's active-workspace-changed signal."""
self._workspace = screen.get_active_workspace()
self._update_workspace_windows()
def _contains_point(self, obj, x, y, coordType=None):
if coordType is None:
coordType = Atspi.CoordType.SCREEN
try:
return obj.queryComponent().contains(x, y, coordType)
except Exception:
return False
def _has_bounds(self, obj, bounds, coordType=None):
"""Returns True if the bounding box of obj is bounds."""
if coordType is None:
coordType = Atspi.CoordType.SCREEN
try:
extents = obj.queryComponent().getExtents(coordType)
except Exception:
return False
return list(extents) == list(bounds)
def _accessible_window_at_point(self, pX, pY):
"""Returns the accessible window at the specified coordinates."""
window = None
for w in self._windows:
if w.is_minimized():
continue
x, y, width, height = w.get_geometry()
if x <= pX <= x + width and y <= pY <= y + height:
window = w
break
if not window:
return None
windowApp = window.get_application()
if not windowApp:
return None
app = AXUtilities.get_application_with_pid(windowApp.get_pid())
if not app:
return None
candidates = [o for o in AXObject.iter_children(
app, lambda x: self._contains_point(x, pX, pY))]
if len(candidates) == 1:
return candidates[0]
name = window.get_name()
matches = [o for o in candidates if AXObject.get_name(o) == name]
if len(matches) == 1:
return matches[0]
bbox = window.get_client_window_geometry()
matches = [o for o in candidates if self._has_bounds(o, bbox)]
if len(matches) == 1:
return matches[0]
return None
def _on_mouse_moved(self, event):
"""Callback for mouse:abs events."""
screen, pX, pY = self._pointer.get_position()
window = self._accessible_window_at_point(pX, pY)
msg = "MOUSE REVIEW: Window at (%i, %i) is %s" % (pX, pY, window)
debug.println(debug.LEVEL_INFO, msg, True)
if not window:
return
script = _scriptManager.getScript(AXObject.get_application(window))
if not script:
return
if script.utilities.isDead(cthulhu_state.locusOfFocus):
menu = None
elif AXUtilities.is_menu(cthulhu_state.locusOfFocus):
menu = cthulhu_state.locusOfFocus
else:
menu = AXObject.find_ancestor(cthulhu_state.locusOfFocus, AXUtilities.is_menu)
screen, nowX, nowY = self._pointer.get_position()
if (pX, pY) != (nowX, nowY):
msg = "MOUSE REVIEW: Pointer moved again: (%i, %i)" % (nowX, nowY)
debug.println(debug.LEVEL_INFO, msg, True)
return
obj = script.utilities.descendantAtPoint(menu, pX, pY) \
or script.utilities.descendantAtPoint(window, pX, pY)
msg = "MOUSE REVIEW: Object at (%i, %i) is %s" % (pX, pY, obj)
debug.println(debug.LEVEL_INFO, msg, True)
script = _scriptManager.getScript(AXObject.get_application(window), obj)
if menu and obj and not AXObject.find_ancestor(obj, AXUtilities.is_menu):
if script.utilities.intersectingRegion(obj, menu) != (0, 0, 0, 0):
msg = f"MOUSE REVIEW: {obj} believed to be under {menu}"
debug.println(debug.LEVEL_INFO, msg, True)
return
objDocument = script.utilities.getTopLevelDocumentForObject(obj)
if objDocument and script.utilities.inDocumentContent():
document = script.utilities.activeDocument()
if document != objDocument:
msg = f"MOUSE REVIEW: {obj} is not in active document {document}"
debug.println(debug.LEVEL_INFO, msg, True)
return
screen, nowX, nowY = self._pointer.get_position()
if (pX, pY) != (nowX, nowY):
msg = "MOUSE REVIEW: Pointer moved again: (%i, %i)" % (nowX, nowY)
debug.println(debug.LEVEL_INFO, msg, True)
return
boundary = None
x, y, width, height = self._currentMouseOver.getBoundingBox()
if y <= pY <= y + height and self._currentMouseOver.getString():
boundary = Atspi.TextBoundaryType.WORD_START
elif obj == self._currentMouseOver.getObject():
boundary = Atspi.TextBoundaryType.LINE_START
elif AXUtilities.is_selectable(obj):
boundary = Atspi.TextBoundaryType.LINE_START
elif script.utilities.isMultiParagraphObject(obj):
boundary = Atspi.TextBoundaryType.LINE_START
new = _ItemContext(pX, pY, obj, boundary, window, script)
if new.present(self._currentMouseOver):
self._currentMouseOver = new
def _listener(self, event):
"""Generic listener, mainly to output debugging info."""
startTime = time.time()
msg = f"\nvvvvv PROCESS OBJECT EVENT {event.type} vvvvv"
debug.println(debug.LEVEL_INFO, msg, False)
if event.type.startswith("mouse:abs"):
self.inMouseEvent = True
self._on_mouse_moved(event)
self.inMouseEvent = False
msg = f"TOTAL PROCESSING TIME: {time.time() - startTime:.4f}\n"
msg += f"^^^^^ PROCESS OBJECT EVENT {event.type} ^^^^^\n"
debug.println(debug.LEVEL_INFO, msg, False)

View File

@ -1,25 +0,0 @@
#!/usr/bin/env python3
#
# Copyright (c) 2024 Stormux
# Copyright (c) 2010-2012 The Orca Team
# Copyright (c) 2012 Igalia, S.L.
# Copyright (c) 2005-2010 Sun Microsystems Inc.
#
# This library is free software; you can redistribute it and/or
# modify it under the terms of the GNU Lesser General Public
# License as published by the Free Software Foundation; either
# version 2.1 of the License, or (at your option) any later version.
#
# This library is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
# Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public
# License along with this library; if not, write to the
# Free Software Foundation, Inc., Franklin Street, Fifth Floor,
# Boston MA 02110-1301 USA.
#
# Fork of Orca Screen Reader (GNOME)
# Original source: https://gitlab.gnome.org/GNOME/orca

View File

@ -1,8 +0,0 @@
cthulhu_python_PYTHON = \
__init__.py \
PluginManager.plugin \
PluginManager.py \
PluginManagerUi.py
cthulhu_pythondir=$(pkgpythondir)/plugins/PluginManager

View File

@ -1,14 +0,0 @@
[Plugin]
Module=PluginManager
Loader=python3
Name=Plugin Manager
Description=Activate and Deactivate plugins
Authors=Chrys chrys@linux-a11y.org
Website=
Version=1.0
Copyright=
Builtin=true
Hidden=true
Depends=
Icon=
Help=

View File

@ -1,61 +0,0 @@
#!/usr/bin/env python3
#
# Copyright (c) 2024 Stormux
# Copyright (c) 2010-2012 The Orca Team
# Copyright (c) 2012 Igalia, S.L.
# Copyright (c) 2005-2010 Sun Microsystems Inc.
#
# This library is free software; you can redistribute it and/or
# modify it under the terms of the GNU Lesser General Public
# License as published by the Free Software Foundation; either
# version 2.1 of the License, or (at your option) any later version.
#
# This library is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
# Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public
# License along with this library; if not, write to the
# Free Software Foundation, Inc., Franklin Street, Fifth Floor,
# Boston MA 02110-1301 USA.
#
# Fork of Orca Screen Reader (GNOME)
# Original source: https://gitlab.gnome.org/GNOME/orca
from cthulhu import plugin
import gi
gi.require_version('Peas', '1.0')
from gi.repository import GObject
from gi.repository import Peas
import PluginManagerUi
class PluginManager(GObject.Object, Peas.Activatable, plugin.Plugin):
#__gtype_name__ = 'PluginManager'
object = GObject.Property(type=GObject.Object)
def __init__(self):
plugin.Plugin.__init__(self)
self.pluginManagerUi = None
def do_activate(self):
API = self.object
self.registerGestureByString(self.startPluginManagerUi, _('plugin manager'), 'kb:cthulhu+e')
def do_deactivate(self):
API = self.object
def startPluginManagerUi(self, script=None, inputEvent=None):
self.showUI()
return True
def showUI(self):
API = self.object
if self.pluginManagerUi == None:
self.pluginManagerUi = PluginManagerUi.PluginManagerUi(API.app)
self.pluginManagerUi.setTranslationContext(self.getTranslationContext())
self.pluginManagerUi.createUI()
self.pluginManagerUi.run()
self.pluginManagerUi = None
else:
self.pluginManagerUi.present()

View File

@ -1,307 +0,0 @@
#!/usr/bin/env python3
#
# Copyright (c) 2024 Stormux
# Copyright (c) 2010-2012 The Orca Team
# Copyright (c) 2012 Igalia, S.L.
# Copyright (c) 2005-2010 Sun Microsystems Inc.
#
# This library is free software; you can redistribute it and/or
# modify it under the terms of the GNU Lesser General Public
# License as published by the Free Software Foundation; either
# version 2.1 of the License, or (at your option) any later version.
#
# This library is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
# Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public
# License along with this library; if not, write to the
# Free Software Foundation, Inc., Franklin Street, Fifth Floor,
# Boston MA 02110-1301 USA.
#
# Fork of Orca Screen Reader (GNOME)
# Original source: https://gitlab.gnome.org/GNOME/orca
import gi
gi.require_version("Gtk", "3.0")
from gi.repository import Gtk, Gdk
class PluginManagerUi(Gtk.ApplicationWindow):
def __init__(self, app, *args, **kwargs):
super().__init__(*args, **kwargs, title=_("Cthulhu Plugin Manager"))
self.app = app
self.translationContext = None
self.connect("destroy", self._onCancelButtonClicked)
self.connect('key-press-event', self._onKeyPressWindow)
def createUI(self):
self.set_default_size(650, 650)
self.set_position(Gtk.WindowPosition.CENTER_ALWAYS)
# pluginInfo (object) = 0
# name (str) = 1
# active (bool) = 2
# buildIn (bool) = 3
# dataDir (str) = 4
# moduleDir (str) = 5
# dependencies (object) = 6
# moduleName (str) = 7
# description (str) = 8
# authors (object) = 9
# website (str) = 10
# copyright (str) = 11
# version (str) = 12
# helpUri (str) = 13
# iconName (str) = 14
self.listStore = Gtk.ListStore(object,str, bool, bool, str, str,object,str,str,object,str,str,str,str,str)
self.treeView = Gtk.TreeView(model=self.listStore)
self.treeView.connect("row-activated", self._rowActivated)
self.treeView.connect('key-press-event', self._onKeyPressTreeView)
self.rendererText = Gtk.CellRendererText()
self.columnText = Gtk.TreeViewColumn(_("Name"), self.rendererText, text=1)
self.treeView.append_column(self.columnText)
self.rendererToggle = Gtk.CellRendererToggle()
self.rendererToggle.connect("toggled", self._onCellToggled)
self.columnToggle = Gtk.TreeViewColumn(_("Active"), self.rendererToggle, active=2)
self.treeView.append_column(self.columnToggle)
self.buttomBox = Gtk.Box(spacing=6)
self.mainVBox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=6)
self.mainVBox.pack_start(self.treeView, True, True, 0)
self.mainVBox.pack_start(self.buttomBox, False, True, 0)
self.add(self.mainVBox)
self.oKButton = Gtk.Button.new_with_mnemonic(_("_Details"))
self.oKButton.connect("clicked", self._onDetailsButtonClicked)
self.buttomBox.pack_start(self.oKButton, True, True, 0)
self.oKButton = Gtk.Button.new_with_mnemonic(_("_OK"))
self.oKButton.connect("clicked", self._onOkButtonClicked)
self.buttomBox.pack_start(self.oKButton, True, True, 0)
self.applyButton = Gtk.Button.new_with_mnemonic(_("_Apply"))
self.applyButton.connect("clicked", self._onApplyButtonClicked)
self.buttomBox.pack_start(self.applyButton, True, True, 0)
self.applyButton = Gtk.Button.new_with_mnemonic(_("_Install"))
self.applyButton.connect("clicked", self._onInstallButtonClicked)
self.buttomBox.pack_start(self.applyButton, True, True, 0)
self.applyButton = Gtk.Button.new_with_mnemonic(_("_Uninstall"))
self.applyButton.connect("clicked", self._onUninstallButtonClicked)
self.buttomBox.pack_start(self.applyButton, True, True, 0)
self.cancelButton = Gtk.Button.new_with_mnemonic(_("_Cancel"))
self.cancelButton.connect("clicked", self._onCancelButtonClicked)
self.buttomBox.pack_start(self.cancelButton, True, True, 0)
def setTranslationContext(self, translationContext):
self.translationContext = translationContext
global _
_ = translationContext.gettext
def closeWindow(self):
Gtk.main_quit()
def uninstallPlugin(self):
selection = self.treeView.get_selection()
model, list_iter = selection.get_selected()
try:
if model.get_value(list_iter,0):
pluginInfo = model.get_value(list_iter,0)
pluginName = self.app.getPluginSystemManager().getPluginName(pluginInfo)
dialog = Gtk.MessageDialog(None,
Gtk.DialogFlags.MODAL,
type=Gtk.MessageType.INFO,
buttons=Gtk.ButtonsType.YES_NO)
dialog.set_markup("<b>%s</b>" % _('Remove Plugin {}?').format(pluginName))
dialog.format_secondary_markup(_('Do you really want to remove Plugin {}?').format(pluginName))
response = dialog.run()
dialog.destroy()
if response != Gtk.ResponseType.YES:
return
self.app.getPluginSystemManager().uninstallPlugin(model.get_value(list_iter,0))
self.refreshPluginList()
except:
pass
def installPlugin(self):
ok, filePath = self.chooseFile()
if not ok:
return
self.app.getPluginSystemManager().installPlugin(filePath)
self.refreshPluginList()
def _onKeyPressWindow(self, _, event):
_, key_val = event.get_keyval()
if key_val == Gdk.KEY_Escape:
self.closeWindow()
def _onKeyPressTreeView(self, _, event):
_, key_val = event.get_keyval()
if key_val == Gdk.KEY_Return:
self.applySettings()
self.closeWindow()
if key_val == Gdk.KEY_Escape:
self.closeWindow()
# CTRL + Q
#modifiers = event.get_state()
#if modifiers == Gdk.ModifierType.CONTROL_MASK | Gdk.ModifierType.MOD2_MASK:
# if key_val == Gdk.KEY_q:
# self._on_scan()
def applySettings(self):
for row in self.listStore:
pluginInfo = row[0]
isActive = row[2]
self.app.getPluginSystemManager().setPluginActive(pluginInfo, isActive)
gsettingsManager = self.app.getGsettingsManager()
gsettingsManager.set_settings_value_list('active-plugins', self.app.getPluginSystemManager().getActivePlugins())
self.app.getPluginSystemManager().syncAllPluginsActive()
self.refreshPluginList()
def _rowActivated(self, tree_view, path, column):
print('rowActivated')
def showDetails(self):
selection = self.treeView.get_selection()
model, list_iter = selection.get_selected()
try:
if model.get_value(list_iter,0):
pluginInfo = model.get_value(list_iter,0)
name = self.app.getPluginSystemManager().getPluginName(pluginInfo)
description = self.app.getPluginSystemManager().getPluginDescription(pluginInfo)
authors = self.app.getPluginSystemManager().getPluginAuthors(pluginInfo)
website =self.app.getPluginSystemManager().getPluginWebsite(pluginInfo)
copyright = self.app.getPluginSystemManager().getPluginCopyright(pluginInfo)
license = '' #self.app.getPluginSystemManager().getPluginName(pluginInfo)
version = self.app.getPluginSystemManager().getPluginVersion(pluginInfo)
dialog = Gtk.AboutDialog(self)
dialog.set_authors(authors)
dialog.set_website(website)
dialog.set_copyright(copyright)
dialog.set_license(license)
dialog.set_version(version)
dialog.set_program_name(name)
dialog.set_comments(description)
dialog.run()
dialog.destroy()
except:
pass
def _onDetailsButtonClicked(self, widget):
self.showDetails()
def _onOkButtonClicked(self, widget):
self.applySettings()
self.closeWindow()
def _onApplyButtonClicked(self, widget):
self.applySettings()
def _onInstallButtonClicked(self, widget):
self.installPlugin()
def _onUninstallButtonClicked(self, widget):
self.uninstallPlugin()
def _onCancelButtonClicked(self, widget):
self.closeWindow()
def refreshPluginList(self):
self.clearPluginList()
pluginList = self.app.getPluginSystemManager().plugins
for pluginInfo in pluginList:
self.addPlugin(pluginInfo)
def clearPluginList(self):
self.listStore.clear()
def addPlugin(self, pluginInfo):
ignoredPlugins = self.app.getPluginSystemManager().getIgnoredPlugins()
moduleDir = self.app.getPluginSystemManager().getPluginModuleDir(pluginInfo)
if moduleDir in ignoredPlugins:
return
hidden = self.app.getPluginSystemManager().isPluginHidden(pluginInfo)
if hidden:
return
moduleName = self.app.getPluginSystemManager().getPluginModuleName(pluginInfo)
name = self.app.getPluginSystemManager().getPluginName(pluginInfo)
version = self.app.getPluginSystemManager().getPluginVersion(pluginInfo)
website = self.app.getPluginSystemManager().getPluginWebsite(pluginInfo)
authors = self.app.getPluginSystemManager().getPluginAuthors(pluginInfo)
buildIn = self.app.getPluginSystemManager().isPluginBuildIn(pluginInfo)
description = self.app.getPluginSystemManager().getPluginDescription(pluginInfo)
iconName = self.app.getPluginSystemManager().getPluginIconName(pluginInfo)
copyright = self.app.getPluginSystemManager().getPluginCopyright(pluginInfo)
dependencies = self.app.getPluginSystemManager().getPluginDependencies(pluginInfo)
#settings = self.app.getPluginSystemManager().getPluginSettings(pluginInfo)
#hasDependencies = self.app.getPluginSystemManager().hasPluginDependency(pluginInfo)
loaded = self.app.getPluginSystemManager().isPluginLoaded(pluginInfo)
available = self.app.getPluginSystemManager().isPluginAvailable(pluginInfo)
active = self.app.getPluginSystemManager().isPluginActive(pluginInfo)
#externalData = self.app.getPluginSystemManager().getPluginExternalData(pluginInfo)
helpUri = self.app.getPluginSystemManager().getPlugingetHelpUri(pluginInfo)
dataDir = self.app.getPluginSystemManager().getPluginDataDir(pluginInfo)
# pluginInfo (object) = 0
# name (str) = 1
# active (bool) = 2
# buildIn (bool) = 3
# dataDir (str) = 4
# moduleDir (str) = 5
# dependencies (object) = 6
# moduleName (str) = 7
# description (str) = 8
# authors (object) = 9
# website (str) = 10
# copyright (str) = 11
# version (str) = 12
# helpUri (str) = 13
# iconName (str) = 14
self.listStore.append([pluginInfo, name, active, buildIn, dataDir, moduleDir, dependencies, moduleName, description, authors, website, copyright, version, helpUri, iconName])
def chooseFile(self):
dialog = Gtk.FileChooserDialog(
title=_("Please choose a file"), parent=self, action=Gtk.FileChooserAction.OPEN
)
dialog.add_buttons(
Gtk.STOCK_CANCEL,
Gtk.ResponseType.CANCEL,
Gtk.STOCK_OPEN,
Gtk.ResponseType.OK,
)
filter_plugin = Gtk.FileFilter()
filter_plugin.set_name(_("Plugin Archive"))
filter_plugin.add_mime_type("application/gzip")
dialog.add_filter(filter_plugin)
response = dialog.run()
filePath = ''
ok = False
if response == Gtk.ResponseType.OK:
ok = True
filePath = dialog.get_filename()
dialog.destroy()
return ok, filePath
def _onCellToggled(self, widget, path):
self.listStore[path][2] = not self.listStore[path][2]
def present(self):
cthulhu_state = self.app.getDynamicApiManager().getAPI('CthulhuState')
ts = 0
try:
ts = cthulhu_state.lastInputEvent.timestamp
except:
pass
if ts == 0:
ts = Gtk.get_current_event_time()
self.present_with_time(ts)
def run(self):
self.refreshPluginList()
self.present()
self.show_all()
Gtk.main()
self.destroy()

View File

@ -1,107 +0,0 @@
#!/usr/bin/env python3
#
# Copyright (c) 2024 Stormux
# Copyright (c) 2010-2012 The Orca Team
# Copyright (c) 2012 Igalia, S.L.
# Copyright (c) 2005-2010 Sun Microsystems Inc.
#
# This library is free software; you can redistribute it and/or
# modify it under the terms of the GNU Lesser General Public
# License as published by the Free Software Foundation; either
# version 2.1 of the License, or (at your option) any later version.
#
# This library is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
# Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public
# License along with this library; if not, write to the
# Free Software Foundation, Inc., Franklin Street, Fifth Floor,
# Boston MA 02110-1301 USA.
#
# Fork of Orca Screen Reader (GNOME)
# Original source: https://gitlab.gnome.org/GNOME/orca
import gi
gi.require_version('Gtk', '3.0')
from gi.repository import Gtk
class ListBoxRowWithData(Gtk.ListBoxRow):
def __init__(self, data):
super(Gtk.ListBoxRow, self).__init__()
self.data = data
self.add(Gtk.Label(label=data))
class PluginManagerUi(Gtk.Window):
def __init__(self):
Gtk.Window.__init__(self)
self.pluginList = []
self.set_default_size(200, -1)
self.connect("destroy", Gtk.main_quit)
self.listBox = Gtk.ListBox()
self.buttomBox = Gtk.Box(spacing=6)
self.mainVBox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=20)
self.mainVBox.pack_start(self.listBox, True, True, 0)
self.mainVBox.pack_start(self.buttomBox, True, True, 0)
self.add(self.mainVBox)
self.oKButton = Gtk.Button(label="OK")
self.oKButton.connect("clicked", self.on_oKButton_clicked)
self.buttomBox.pack_start(self.oKButton, True, True, 0)
self.applyButton = Gtk.Button(label="Apply")
self.applyButton.connect("clicked", self.on_applyButton_clicked)
self.buttomBox.pack_start(self.applyButton, True, True, 0)
self.cancelButton = Gtk.Button(label="Cancel")
self.cancelButton.connect("clicked", self.on_cancelButton_clicked)
self.buttomBox.pack_start(self.cancelButton, True, True, 0)
self.listBox.connect("row-activated", self.on_row_activated)
def on_row_activated(self, listBox, listboxrow):
print("Row %i activated" % (listboxrow.get_index()))
def on_oKButton_clicked(self, widget):
print("OK")
def on_applyButton_clicked(self, widget):
print("Apply")
def on_cancelButton_clicked(self, widget):
print("Cancel")
def addPlugin(self, Name, Active, Description = ''):
self.pluginList.append([Name, Active, Description])
def run(self):
for plugin in self.pluginList:
print(plugin)
box = Gtk.Box(spacing=10)
pluginNameLabel = Gtk.Label(plugin[0])
#pluginActiveCheckButton = Gtk.CheckButton(label="_Active", use_underline=True)
#pluginActiveCheckButton.set_active(plugin[1])
pluginActiveSwitch = Gtk.Switch()
pluginActiveSwitch.set_active(plugin[1])
pluginDescriptionLabel = Gtk.Label(plugin[2])
box.pack_start(pluginNameLabel, True, True, 0)
box.pack_start(pluginActiveSwitch, True, True, 0)
box.pack_start(pluginDescriptionLabel, True, True, 0)
self.listBox.add(box)
self.show_all()
Gtk.main()
if __name__ == "__main__":
ui = PluginManagerUi()
ui.addPlugin('plugin1', True, 'bla')
ui.addPlugin('plugin2', True, 'bla')
ui.addPlugin('plugin3', True, 'bla')
ui.run()

View File

@ -1,117 +0,0 @@
#!/usr/bin/env python3
#
# Copyright (c) 2024 Stormux
# Copyright (c) 2010-2012 The Orca Team
# Copyright (c) 2012 Igalia, S.L.
# Copyright (c) 2005-2010 Sun Microsystems Inc.
#
# This library is free software; you can redistribute it and/or
# modify it under the terms of the GNU Lesser General Public
# License as published by the Free Software Foundation; either
# version 2.1 of the License, or (at your option) any later version.
#
# This library is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
# Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public
# License along with this library; if not, write to the
# Free Software Foundation, Inc., Franklin Street, Fifth Floor,
# Boston MA 02110-1301 USA.
#
# Fork of Orca Screen Reader (GNOME)
# Original source: https://gitlab.gnome.org/GNOME/orca
import gi
gi.require_version("Gtk", "3.0")
from gi.repository import Gtk
class ListBoxRowWithData(Gtk.ListBoxRow):
def __init__(self, data):
super(Gtk.ListBoxRow, self).__init__()
self.data = data
self.add(Gtk.Label(label=data))
class ListBoxWindow(Gtk.Window):
def __init__(self):
Gtk.Window.__init__(self, title="ListBox Demo")
self.set_border_width(10)
box_outer = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=6)
self.add(box_outer)
listbox = Gtk.ListBox()
listbox.set_selection_mode(Gtk.SelectionMode.NONE)
box_outer.pack_start(listbox, True, True, 0)
row = Gtk.ListBoxRow()
hbox = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=50)
row.add(hbox)
vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
hbox.pack_start(vbox, True, True, 0)
label1 = Gtk.Label(label="Automatic Date & Time", xalign=0)
label2 = Gtk.Label(label="Requires internet access", xalign=0)
vbox.pack_start(label1, True, True, 0)
vbox.pack_start(label2, True, True, 0)
switch = Gtk.Switch()
switch.props.valign = Gtk.Align.CENTER
hbox.pack_start(switch, False, True, 0)
listbox.add(row)
row = Gtk.ListBoxRow()
hbox = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=50)
row.add(hbox)
label = Gtk.Label(label="Enable Automatic Update", xalign=0)
check = Gtk.CheckButton()
hbox.pack_start(label, True, True, 0)
hbox.pack_start(check, False, True, 0)
listbox.add(row)
row = Gtk.ListBoxRow()
hbox = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=50)
row.add(hbox)
label = Gtk.Label(label="Date Format", xalign=0)
combo = Gtk.ComboBoxText()
combo.insert(0, "0", "24-hour")
combo.insert(1, "1", "AM/PM")
hbox.pack_start(label, True, True, 0)
hbox.pack_start(combo, False, True, 0)
listbox.add(row)
listbox_2 = Gtk.ListBox()
items = "This is a sorted ListBox Fail".split()
for item in items:
listbox_2.add(ListBoxRowWithData(item))
def sort_func(row_1, row_2, data, notify_destroy):
return row_1.data.lower() > row_2.data.lower()
def filter_func(row, data, notify_destroy):
return False if row.data == "Fail" else True
listbox_2.set_sort_func(sort_func, None, False)
listbox_2.set_filter_func(filter_func, None, False)
def on_row_activated(listbox_widget, row):
print(row.data)
listbox_2.connect("row-activated", on_row_activated)
box_outer.pack_start(listbox_2, True, True, 0)
listbox_2.show_all()
win = ListBoxWindow()
win.connect("destroy", Gtk.main_quit)
win.show_all()
Gtk.main()

View File

@ -1,25 +0,0 @@
#!/usr/bin/env python3
#
# Copyright (c) 2024 Stormux
# Copyright (c) 2010-2012 The Orca Team
# Copyright (c) 2012 Igalia, S.L.
# Copyright (c) 2005-2010 Sun Microsystems Inc.
#
# This library is free software; you can redistribute it and/or
# modify it under the terms of the GNU Lesser General Public
# License as published by the Free Software Foundation; either
# version 2.1 of the License, or (at your option) any later version.
#
# This library is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
# Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public
# License along with this library; if not, write to the
# Free Software Foundation, Inc., Franklin Street, Fifth Floor,
# Boston MA 02110-1301 USA.
#
# Fork of Orca Screen Reader (GNOME)
# Original source: https://gitlab.gnome.org/GNOME/orca

View File

@ -1,7 +0,0 @@
cthulhu_python_PYTHON = \
__init__.py \
SelfVoice.plugin \
SelfVoice.py
cthulhu_pythondir=$(pkgpythondir)/plugins/SelfVoice

View File

@ -1,6 +0,0 @@
[Plugin]
Module=SelfVoice
Loader=python3
Name=Self Voice Plugin
Description=use cthulhu text / braile from using unix sockets
Authors=Chrys chrys@linux-a11y.org

View File

@ -1,135 +0,0 @@
#!/usr/bin/env python3
#
# Copyright (c) 2024 Stormux
# Copyright (c) 2010-2012 The Orca Team
# Copyright (c) 2012 Igalia, S.L.
# Copyright (c) 2005-2010 Sun Microsystems Inc.
#
# This library is free software; you can redistribute it and/or
# modify it under the terms of the GNU Lesser General Public
# License as published by the Free Software Foundation; either
# version 2.1 of the License, or (at your option) any later version.
#
# This library is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
# Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public
# License along with this library; if not, write to the
# Free Software Foundation, Inc., Franklin Street, Fifth Floor,
# Boston MA 02110-1301 USA.
#
# Fork of Orca Screen Reader (GNOME)
# Original source: https://gitlab.gnome.org/GNOME/orca
from cthulhu import plugin
import gi
gi.require_version('Peas', '1.0')
from gi.repository import GObject
from gi.repository import Peas
import select, socket, os, os.path
from threading import Thread, Lock
APPEND_CODE = '<#APPEND#>'
PERSISTENT_CODE = '<#PERSISTENT#>'
class SelfVoice(GObject.Object, Peas.Activatable, plugin.Plugin):
__gtype_name__ = 'SelfVoice'
object = GObject.Property(type=GObject.Object)
def __init__(self):
plugin.Plugin.__init__(self)
self.lock = Lock()
self.active = False
self.voiceThread = Thread(target=self.voiceWorker)
def do_activate(self):
API = self.object
self.activateWorker()
def do_deactivate(self):
API = self.object
self.deactivateWorker()
def do_update_state(self):
API = self.object
def deactivateWorker(self):
with self.lock:
self.active = False
self.voiceThread.join()
def activateWorker(self):
with self.lock:
self.active = True
self.voiceThread.start()
def isActive(self):
with self.lock:
return self.active
def outputMessage(self, Message):
# Prepare
API = self.object
append = Message.startswith(APPEND_CODE)
if append:
Message = Message[len(APPEND_CODE):]
if Message.endswith(PERSISTENT_CODE):
Message = Message[:len(Message)-len(PERSISTENT_CODE)]
API.app.getAPIHelper().outputMessage(Message, not append)
else:
script_manager = API.app.getDynamicApiManager().getAPI('ScriptManager')
scriptManager = script_manager.getManager()
scriptManager.getDefaultScript().presentMessage(Message, resetStyles=False)
return
try:
settings = API.app.getDynamicApiManager().getAPI('Settings')
braille = API.app.getDynamicApiManager().getAPI('Braille')
speech = API.app.getDynamicApiManager().getAPI('Speech')
# Speak
if speech != None:
if (settings.enableSpeech):
if not append:
speech.cancel()
if Message != '':
speech.speak(Message)
# Braille
if braille != None:
if (settings.enableBraille):
braille.displayMessage(Message)
except e as Exception:
print(e)
def voiceWorker(self):
socketFile = '/tmp/cthulhu.sock'
# for testing purposes
#socketFile = '/tmp/cthulhu-plugin.sock'
if os.path.exists(socketFile):
os.unlink(socketFile)
cthulhuSock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
cthulhuSock.bind(socketFile)
os.chmod(socketFile, 0o222)
cthulhuSock.listen(1)
while self.isActive():
# Check if the client is still connected and if data is available:
try:
r, _, _ = select.select([cthulhuSock], [], [], 0.8)
except select.error:
break
if r == []:
continue
if cthulhuSock in r:
client_sock, client_addr = cthulhuSock.accept()
try:
rawdata = client_sock.recv(8129)
data = rawdata.decode("utf-8").rstrip().lstrip()
self.outputMessage(data)
except:
pass
try:
client_sock.close()
except:
pass
if cthulhuSock:
cthulhuSock.close()
cthulhuSock = None
if os.path.exists(socketFile):
os.unlink(socketFile)

View File

@ -1,25 +0,0 @@
#!/usr/bin/env python3
#
# Copyright (c) 2024 Stormux
# Copyright (c) 2010-2012 The Orca Team
# Copyright (c) 2012 Igalia, S.L.
# Copyright (c) 2005-2010 Sun Microsystems Inc.
#
# This library is free software; you can redistribute it and/or
# modify it under the terms of the GNU Lesser General Public
# License as published by the Free Software Foundation; either
# version 2.1 of the License, or (at your option) any later version.
#
# This library is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
# Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public
# License along with this library; if not, write to the
# Free Software Foundation, Inc., Franklin Street, Fifth Floor,
# Boston MA 02110-1301 USA.
#
# Fork of Orca Screen Reader (GNOME)
# Original source: https://gitlab.gnome.org/GNOME/orca

View File

@ -1,7 +1,7 @@
cthulhu_python_PYTHON = \ cthulhu_python_PYTHON = \
__init__.py \ __init__.py \
SimplePluginSystem.plugin \ plugin.info \
SimplePluginSystem.py plugin.py
cthulhu_pythondir=$(pkgpythondir)/plugins/SimplePluginSystem cthulhu_pythondir=$(pkgpythondir)/plugins/SimplePluginSystem

View File

@ -1,11 +0,0 @@
[Plugin]
Module=SimplePluginSystem
Loader=python3
Name=Simple Plugin System
Description=Simple plugin system implementation for Cthulhu
Authors=Chrys <chrys@linux-a11y.org>;Storm Dragon <storm_dragon@stormux.org>
Copyright=Copyright Â2024 Chrys, Storm Dragon
Website=https://git.stormux.org/storm/cthulhu
Version=1.0
Builtin=true

View File

@ -1,291 +0,0 @@
#!/usr/bin/env python3
#
# Copyright (c) 2024 Stormux
# Copyright (c) 2010-2012 The Orca Team
# Copyright (c) 2012 Igalia, S.L.
# Copyright (c) 2005-2010 Sun Microsystems Inc.
#
# This library is free software; you can redistribute it and/or
# modify it under the terms of the GNU Lesser General Public
# License as published by the Free Software Foundation; either
# version 2.1 of the License, or (at your option) any later version.
#
# This library is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
# Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public
# License along with this library; if not, write to the
# Free Software Foundation, Inc., Franklin Street, Fifth Floor,
# Boston MA 02110-1301 USA.
#
# Fork of Orca Screen Reader (GNOME)
# Original source: https://gitlab.gnome.org/GNOME/orca
from cthulhu import plugin
from gi.repository import GObject, Peas
import glob
import os
import importlib.util
import random
import string
import _thread
from subprocess import Popen, PIPE
settings = None
speech = None
braille = None
input_event = None
def outputMessage( Message):
if (settings.enableSpeech):
speech.speak(Message)
if (settings.enableBraille):
braille.displayMessage(Message)
class SimplePluginSystem(GObject.Object, Peas.Activatable, plugin.Plugin):
__gtype_name__ = 'SimplePluginSystem'
object = GObject.Property(type=GObject.Object)
def __init__(self):
plugin.Plugin.__init__(self)
self.plugin_list = []
self.loaded = False
self.plugin_repo = os.path.expanduser('~') + "/.local/share/cthulhu/simple-plugins-enabled/"
def do_activate(self):
API = self.object
global settings
global speech
global braille
global input_event
settings = API.app.getDynamicApiManager().getAPI('Settings')
speech = API.app.getDynamicApiManager().getAPI('Speech')
braille = API.app.getDynamicApiManager().getAPI('Braille')
input_event = API.app.getDynamicApiManager().getAPI('InputEvent')
"""Required method for plugins"""
if not self.loaded:
self.load_plugins()
def do_deactivate(self):
"""Required method for plugins"""
# Remove all registered keybindings
for plugin in self.plugin_list:
self.unregisterShortcut(plugin['function'], plugin['shortcut'])
self.loaded = False
self.plugin_list = []
def SetupShortcutAndHandle(self, currPluginSetting):
shortcut = ''
# just the modifier
if not currPluginSetting['shiftkey'] and not currPluginSetting['ctrlkey'] and not currPluginSetting['altkey']:
shortcut = 'kb:cthulhu+' + currPluginSetting['key']
# cthulhu + alt
if not currPluginSetting['shiftkey'] and not currPluginSetting['ctrlkey'] and currPluginSetting['altkey']:
shortcut = 'kb:cthulhu+alt+' + currPluginSetting['key']
# cthulhu + CTRL
if not currPluginSetting['shiftkey'] and currPluginSetting['ctrlkey'] and not currPluginSetting['altkey']:
shortcut = 'kb:cthulhu+control+' + currPluginSetting['key']
# cthulhu + alt + CTRL
if not currPluginSetting['shiftkey'] and currPluginSetting['ctrlkey'] and currPluginSetting['altkey']:
shortcut = 'kb:cthulhu+alt+control+ ' + currPluginSetting['key']
# cthulhu + shift
if currPluginSetting['shiftkey'] and not currPluginSetting['ctrlkey'] and not currPluginSetting['altkey']:
shortcut = 'kb:cthulhu+shift+' + currPluginSetting['key']
# alt + shift
if currPluginSetting['shiftkey'] and not currPluginSetting['ctrlkey'] and currPluginSetting['altkey']:
shortcut = 'kb:alt+shift+' + currPluginSetting['key']
if shortcut != '':
print(shortcut)
currPluginSetting['shortcut'] = shortcut
self.registerGestureByString(currPluginSetting['function'], _(currPluginSetting['pluginname']), shortcut)
return currPluginSetting
def id_generator(self, size=7, chars=string.ascii_letters):
return ''.join(random.choice(chars) for _ in range(size))
def initSettings(self):
currPluginSetting={
'pluginname':'',
'functionname':'',
'key':'',
'shiftkey':False,
'ctrlkey':False,
'altkey':False,
'startnotify':False,
'stopnotify':False,
'blockcall':False,
'error':False,
'exec': False,
'parameters':'',
'function':None,
'inputeventhandler':None,
'valid':False,
'supressoutput':False,
'shortcut': ''
}
return currPluginSetting
def getPluginSettings(self, filepath, currPluginSetting):
try:
currPluginSetting['file'] = filepath
fileName, fileExtension = os.path.splitext(filepath)
if (fileExtension and (fileExtension != '')): #if there is an extension
currPluginSetting['loadable'] = (fileExtension.lower() == '.py') # only python is loadable
filename = os.path.basename(filepath) #filename
filename = os.path.splitext(filename)[0] #remove extension if we have one
#remove pluginname seperated by __-__
filenamehelper = filename.split('__-__')
filename = filenamehelper[len(filenamehelper) - 1 ]
currPluginSetting['permission'] = os.access(filepath, os.X_OK )
currPluginSetting['pluginname'] = 'NoNameAvailable'
if len(filenamehelper) == 2:
currPluginSetting['pluginname'] = filenamehelper[0]
#now get shortcuts seperated by __+__
filenamehelper = filename.split('__+__')
if len([y for y in filenamehelper if 'parameters_' in y.lower()]) == 1 and\
len([y for y in filenamehelper if 'parameters_' in y.lower()][0]) > 11:
currPluginSetting['parameters'] = [y for y in filenamehelper if 'parameters_' in y.lower()][0][11:]
if len([y for y in filenamehelper if 'key_' in y.lower()]) == 1 and\
len([y for y in filenamehelper if 'key_' in y.lower()][0]) > 4 :
currPluginSetting['key'] = [y for y in filenamehelper if 'key_' in y.lower()][0][4]
if currPluginSetting['key'] == '':
settcurrPluginSetting = 'shift' in map(str.lower, filenamehelper)
currPluginSetting['ctrlkey'] = 'control' in map(str.lower, filenamehelper)
currPluginSetting['altkey'] = 'alt' in map(str.lower, filenamehelper)
currPluginSetting['startnotify'] = 'startnotify' in map(str.lower, filenamehelper)
currPluginSetting['stopnotify'] = 'stopnotify' in map(str.lower, filenamehelper)
currPluginSetting['blockcall'] = 'blockcall' in map(str.lower, filenamehelper)
currPluginSetting['error'] = 'error' in map(str.lower, filenamehelper)
currPluginSetting['supressoutput'] = 'supressoutput' in map(str.lower, filenamehelper)
currPluginSetting['exec'] = 'exec' in map(str.lower, filenamehelper)
currPluginSetting['loadmodule'] = 'loadmodule' in map(str.lower, filenamehelper)
currPluginSetting = self.readSettingsFromPlugin(currPluginSetting)
if not currPluginSetting['loadmodule']:
if not currPluginSetting['permission']: #subprocessing only works with exec permission
return self.initSettings()
if currPluginSetting['loadmodule'] and not currPluginSetting['loadable']: #sorry.. its not loadable only .py is loadable
return self.initSettings()
if (len(currPluginSetting['key']) > 1): #no shortcut
if not currPluginSetting['exec']: # and no exec -> the plugin make no sense because it isnt hooked anywhere
return self.initSettings() #so not load it (sets valid = False)
else:
currPluginSetting['key'] = '' #there is a strange key, but exec? ignore the key..
currPluginSetting['valid'] = True # we could load everything
return currPluginSetting
except:
return self.initSettings()
def readSettingsFromPlugin(self, currPluginSetting):
if not os.access(currPluginSetting['file'], os.R_OK ):
return currPluginSetting
fileName, fileExtension = os.path.splitext(currPluginSetting['file'])
if (fileExtension and (fileExtension != '')): #if there is an extension
if (fileExtension.lower() != '.py') and \
(fileExtension.lower() != '.sh'):
return currPluginSetting
else:
return currPluginSetting
with open(currPluginSetting['file'], "r") as pluginFile:
for line in pluginFile:
currPluginSetting['shiftkey'] = ('sopsproperty:shift' in line.lower().replace(" ", "")) or currPluginSetting['shiftkey']
currPluginSetting['ctrlkey'] = ('sopsproperty:control' in line.lower().replace(" ", "")) or currPluginSetting['ctrlkey']
currPluginSetting['altkey'] = ('sopsproperty:alt' in line.lower().replace(" ", "")) or currPluginSetting['altkey']
currPluginSetting['startnotify'] = ('sopsproperty:startnotify' in line.lower().replace(" ", "")) or currPluginSetting['startnotify']
currPluginSetting['stopnotify'] = ('sopsproperty:stopnotify' in line.lower().replace(" ", "")) or currPluginSetting['stopnotify']
currPluginSetting['blockcall'] = ('sopsproperty:blockcall' in line.lower().replace(" ", "")) or currPluginSetting['blockcall']
currPluginSetting['error'] = ('sopsproperty:error' in line.lower().replace(" ", "")) or currPluginSetting['error']
currPluginSetting['supressoutput'] = ('sopsproperty:supressoutput' in line.lower().replace(" ", "")) or currPluginSetting['supressoutput']
currPluginSetting['exec'] = ('sopsproperty:exec' in line.lower().replace(" ", "")) or currPluginSetting['exec']
currPluginSetting['loadmodule'] = ('sopsproperty:loadmodule' in line.lower().replace(" ", "")) or currPluginSetting['loadmodule']
return currPluginSetting
def buildPluginSubprocess(self, currPluginSetting):
currplugin = "\'\"" + currPluginSetting['file'] + "\" " + currPluginSetting['parameters'] + "\'"
pluginname = currPluginSetting['pluginname']
if currPluginSetting['blockcall']:
pluginname = "blocking " + pluginname
fun_body = "global " + currPluginSetting['functionname']+"\n"
fun_body += "def " + currPluginSetting['functionname'] + "(script=None, inputEvent=None):\n"
if currPluginSetting['startnotify']:
fun_body +=" outputMessage('start " + pluginname + "')\n"
fun_body +=" p = Popen(" + currplugin + ", stdout=PIPE, stderr=PIPE, shell=True)\n"
fun_body +=" stdout, stderr = p.communicate()\n"
fun_body +=" message = ''\n"
fun_body +=" if not " + str(currPluginSetting['supressoutput']) + " and stdout:\n"
fun_body +=" message += str(stdout, \"utf-8\")\n"
fun_body +=" if " + str(currPluginSetting['error']) + " and stderr:\n"
fun_body +=" message += ' error: ' + str(stderr, \"utf-8\")\n"
fun_body +=" outputMessage( message)\n"
if currPluginSetting['stopnotify']:
fun_body +=" outputMessage('finish " + pluginname + "')\n"
fun_body +=" return True\n\n"
fun_body += "global " + currPluginSetting['functionname']+"T\n"
fun_body +="def " + currPluginSetting['functionname'] + "T(script=None, inputEvent=None):\n"
fun_body +=" _thread.start_new_thread("+ currPluginSetting['functionname'] + ",(script, inputEvent))\n\n"
return fun_body
def buildPluginExec(self, currPluginSetting):
pluginname = currPluginSetting['pluginname']
if currPluginSetting['blockcall']:
pluginname = "blocking " + pluginname
fun_body = "global " + currPluginSetting['functionname']+"\n"
fun_body += "def " + currPluginSetting['functionname'] + "(script=None, inputEvent=None):\n"
if currPluginSetting['startnotify']:
fun_body +=" outputMessage('start " + pluginname + "')\n"
fun_body += " try:\n"
fun_body += " spec = importlib.util.spec_from_file_location(\"" + currPluginSetting['functionname'] + "\",\""+ currPluginSetting['file']+"\")\n"
fun_body += " "+currPluginSetting['functionname'] + "Module = importlib.util.module_from_spec(spec)\n"
fun_body += " spec.loader.exec_module(" + currPluginSetting['functionname'] + "Module)\n"
fun_body += " except:\n"
fun_body += " pass\n"
if currPluginSetting['error']:
fun_body += " outputMessage(\"Error while executing " + pluginname + "\")\n"
if currPluginSetting['stopnotify']:
fun_body +=" outputMessage('finish " + pluginname + "')\n"
fun_body += " return True\n\n"
fun_body += "global " + currPluginSetting['functionname']+"T\n"
fun_body +="def " + currPluginSetting['functionname'] + "T(script=None, inputEvent=None):\n"
fun_body +=" _thread.start_new_thread("+ currPluginSetting['functionname'] + ",(script, inputEvent))\n\n"
return fun_body
def getFunctionName(self, currPluginSetting):
currPluginSetting['functionname'] = ''
while currPluginSetting['functionname'] == '' or currPluginSetting['functionname'] + 'T' in globals() or currPluginSetting['functionname'] in globals():
currPluginSetting['functionname'] = self.id_generator()
return currPluginSetting
def load_plugins(self):
if not self.loaded:
self.plugin_list = glob.glob(self.plugin_repo+'*')
for currplugin in self.plugin_list:
currPluginSetting = self.initSettings()
currPluginSetting = self.getPluginSettings(currplugin, currPluginSetting)
if not currPluginSetting['valid']:
continue
currPluginSetting = self.getFunctionName(currPluginSetting)
if currPluginSetting['loadmodule']:
exec(self.buildPluginExec(currPluginSetting)) # load as python module
else:
exec(self.buildPluginSubprocess(currPluginSetting)) # run as subprocess
if currPluginSetting['blockcall']:
currPluginSetting['function'] = globals()[currPluginSetting['functionname']] # non threaded
else:
currPluginSetting['function'] = globals()[currPluginSetting['functionname']+"T"] # T = Threaded
if currPluginSetting['exec']: # exec on load if we want
currPluginSetting['function']()
if not currPluginSetting['key'] == '':
currPluginSetting = self.SetupShortcutAndHandle(currPluginSetting)
print(currPluginSetting)
self.plugin_list.append(currPluginSetting) # store in a list
self.loaded = True

View File

@ -0,0 +1,9 @@
[Plugin]
Name = Simple Plugin System
Module = SimplePluginSystem
Description = Simple plugin system implementation for Cthulhu
Authors = Storm Dragon <storm_dragon@stormux.org>
Copyright = Copyright (c) 2025 Stormux
Website = https://git.stormux.org/storm/cthulhu
Version = 1.0
Category = System

View File

@ -0,0 +1,407 @@
#!/usr/bin/env python3
#
# Copyright (c) 2024 Stormux
#
# This library is free software; you can redistribute it and/or
# modify it under the terms of the GNU Lesser General Public
# License as published by the Free Software Foundation; either
# version 2.1 of the License, or (at your option) any later version.
#
# This library is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
# Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public
# License along with this library; if not, write to the
# Free Software Foundation, Inc., Franklin Street, Fifth Floor,
# Boston MA 02110-1301 USA.
#
"""Simple Plugin System for Cthulhu."""
import glob
import os
import importlib.util
import random
import string
import _thread
import logging
from subprocess import Popen, PIPE
import gettext
from cthulhu.plugin import Plugin, cthulhu_hookimpl
# Set up translation function
_ = gettext.gettext
logger = logging.getLogger(__name__)
# Global variables for API access
settings = None
speech = None
braille = None
input_event = None
def outputMessage(Message):
"""Output a message via speech and/or braille depending on settings."""
if (settings.enableSpeech):
speech.speak(Message)
if (settings.enableBraille):
braille.displayMessage(Message)
class SimplePluginSystem(Plugin):
"""Simple plugin system implementation for Cthulhu.
This plugin allows loading and managing simple script-based plugins
from a designated directory.
"""
def __init__(self, *args, **kwargs):
"""Initialize the plugin system."""
super().__init__(*args, **kwargs)
logger.info("SimplePluginSystem plugin initialized")
self.plugin_list = []
self.loaded = False
self.plugin_repo = os.path.expanduser('~') + "/.local/share/cthulhu/simple-plugins-enabled/"
self._signal_handler_id = None
@cthulhu_hookimpl
def activate(self, plugin=None):
"""Activate the plugin system."""
# Skip if this activation call isn't for us
if plugin is not None and plugin is not self:
return
logger.info("Activating SimplePluginSystem plugin")
try:
global settings
global speech
global braille
global input_event
settings = self.app.getDynamicApiManager().getAPI('Settings')
speech = self.app.getDynamicApiManager().getAPI('Speech')
braille = self.app.getDynamicApiManager().getAPI('Braille')
input_event = self.app.getDynamicApiManager().getAPI('InputEvent')
if not self.loaded:
self.load_plugins()
except Exception as e:
logger.error(f"Error activating SimplePluginSystem plugin: {e}")
@cthulhu_hookimpl
def deactivate(self, plugin=None):
"""Deactivate the plugin system."""
# Skip if this deactivation call isn't for us
if plugin is not None and plugin is not self:
return
logger.info("Deactivating SimplePluginSystem plugin")
try:
# Remove all registered keybindings
for plugin in self.plugin_list:
self.unregisterShortcut(plugin['function'], plugin['shortcut'])
self.loaded = False
self.plugin_list = []
except Exception as e:
logger.error(f"Error deactivating SimplePluginSystem plugin: {e}")
def SetupShortcutAndHandle(self, currPluginSetting):
"""Set up keyboard shortcuts for a plugin."""
shortcut = ''
# just the modifier
if not currPluginSetting['shiftkey'] and not currPluginSetting['ctrlkey'] and not currPluginSetting['altkey']:
shortcut = 'kb:cthulhu+' + currPluginSetting['key']
# cthulhu + alt
if not currPluginSetting['shiftkey'] and not currPluginSetting['ctrlkey'] and currPluginSetting['altkey']:
shortcut = 'kb:cthulhu+alt+' + currPluginSetting['key']
# cthulhu + CTRL
if not currPluginSetting['shiftkey'] and currPluginSetting['ctrlkey'] and not currPluginSetting['altkey']:
shortcut = 'kb:cthulhu+control+' + currPluginSetting['key']
# cthulhu + alt + CTRL
if not currPluginSetting['shiftkey'] and currPluginSetting['ctrlkey'] and currPluginSetting['altkey']:
shortcut = 'kb:cthulhu+alt+control+ ' + currPluginSetting['key']
# cthulhu + shift
if currPluginSetting['shiftkey'] and not currPluginSetting['ctrlkey'] and not currPluginSetting['altkey']:
shortcut = 'kb:cthulhu+shift+' + currPluginSetting['key']
# alt + shift
if currPluginSetting['shiftkey'] and not currPluginSetting['ctrlkey'] and currPluginSetting['altkey']:
shortcut = 'kb:alt+shift+' + currPluginSetting['key']
if shortcut != '':
logger.debug(f"Registering shortcut: {shortcut}")
currPluginSetting['shortcut'] = shortcut
try:
# Try to use the translation function, fall back to plain text if it fails
plugin_name = _(currPluginSetting['pluginname'])
except Exception:
# If translation fails, use the original name
plugin_name = currPluginSetting['pluginname']
logger.warning(f"Translation failed for plugin: {currPluginSetting['pluginname']}")
self.registerGestureByString(currPluginSetting['function'], plugin_name, shortcut)
return currPluginSetting
def id_generator(self, size=7, chars=string.ascii_letters):
"""Generate a random ID string."""
return ''.join(random.choice(chars) for _ in range(size))
def initSettings(self):
"""Initialize default settings for a plugin."""
currPluginSetting = {
'pluginname': '',
'functionname': '',
'key': '',
'shiftkey': False,
'ctrlkey': False,
'altkey': False,
'startnotify': False,
'stopnotify': False,
'blockcall': False,
'error': False,
'exec': False,
'parameters': '',
'function': None,
'inputeventhandler': None,
'valid': False,
'supressoutput': False,
'shortcut': ''
}
return currPluginSetting
def getPluginSettings(self, filepath, currPluginSetting):
"""Parse plugin settings from filename and content."""
try:
currPluginSetting['file'] = filepath
fileName, fileExtension = os.path.splitext(filepath)
if (fileExtension and (fileExtension != '')): # if there is an extension
currPluginSetting['loadable'] = (fileExtension.lower() == '.py') # only python is loadable
filename = os.path.basename(filepath) # filename
filename = os.path.splitext(filename)[0] # remove extension if we have one
# remove pluginname seperated by __-__
filenamehelper = filename.split('__-__')
filename = filenamehelper[len(filenamehelper) - 1]
currPluginSetting['permission'] = os.access(filepath, os.X_OK)
currPluginSetting['pluginname'] = 'NoNameAvailable'
if len(filenamehelper) == 2:
currPluginSetting['pluginname'] = filenamehelper[0]
# now get shortcuts seperated by __+__
filenamehelper = filename.split('__+__')
if len([y for y in filenamehelper if 'parameters_' in y.lower()]) == 1 and\
len([y for y in filenamehelper if 'parameters_' in y.lower()][0]) > 11:
currPluginSetting['parameters'] = [y for y in filenamehelper if 'parameters_' in y.lower()][0][11:]
if len([y for y in filenamehelper if 'key_' in y.lower()]) == 1 and\
len([y for y in filenamehelper if 'key_' in y.lower()][0]) > 4:
currPluginSetting['key'] = [y for y in filenamehelper if 'key_' in y.lower()][0][4]
if currPluginSetting['key'] == '':
settcurrPluginSetting = 'shift' in map(str.lower, filenamehelper)
currPluginSetting['ctrlkey'] = 'control' in map(str.lower, filenamehelper)
currPluginSetting['altkey'] = 'alt' in map(str.lower, filenamehelper)
currPluginSetting['startnotify'] = 'startnotify' in map(str.lower, filenamehelper)
currPluginSetting['stopnotify'] = 'stopnotify' in map(str.lower, filenamehelper)
currPluginSetting['blockcall'] = 'blockcall' in map(str.lower, filenamehelper)
currPluginSetting['error'] = 'error' in map(str.lower, filenamehelper)
currPluginSetting['supressoutput'] = 'supressoutput' in map(str.lower, filenamehelper)
currPluginSetting['exec'] = 'exec' in map(str.lower, filenamehelper)
currPluginSetting['loadmodule'] = 'loadmodule' in map(str.lower, filenamehelper)
currPluginSetting = self.readSettingsFromPlugin(currPluginSetting)
if not currPluginSetting['loadmodule']:
if not currPluginSetting['permission']: # subprocessing only works with exec permission
return self.initSettings()
if currPluginSetting['loadmodule'] and not currPluginSetting['loadable']: # sorry.. its not loadable only .py is loadable
return self.initSettings()
if (len(currPluginSetting['key']) > 1): # no shortcut
if not currPluginSetting['exec']: # and no exec -> the plugin make no sense because it isnt hooked anywhere
return self.initSettings() # so not load it (sets valid = False)
else:
currPluginSetting['key'] = '' # there is a strange key, but exec? ignore the key..
currPluginSetting['valid'] = True # we could load everything
return currPluginSetting
except Exception as e:
logger.error(f"Error getting plugin settings: {e}")
return self.initSettings()
def readSettingsFromPlugin(self, currPluginSetting):
"""Read settings from plugin file content."""
if not os.access(currPluginSetting['file'], os.R_OK):
return currPluginSetting
fileName, fileExtension = os.path.splitext(currPluginSetting['file'])
if (fileExtension and (fileExtension != '')): # if there is an extension
if (fileExtension.lower() != '.py') and \
(fileExtension.lower() != '.sh'):
return currPluginSetting
else:
return currPluginSetting
try:
with open(currPluginSetting['file'], "r") as pluginFile:
for line in pluginFile:
currPluginSetting['shiftkey'] = ('sopsproperty:shift' in line.lower().replace(" ", "")) or currPluginSetting['shiftkey']
currPluginSetting['ctrlkey'] = ('sopsproperty:control' in line.lower().replace(" ", "")) or currPluginSetting['ctrlkey']
currPluginSetting['altkey'] = ('sopsproperty:alt' in line.lower().replace(" ", "")) or currPluginSetting['altkey']
currPluginSetting['startnotify'] = ('sopsproperty:startnotify' in line.lower().replace(" ", "")) or currPluginSetting['startnotify']
currPluginSetting['stopnotify'] = ('sopsproperty:stopnotify' in line.lower().replace(" ", "")) or currPluginSetting['stopnotify']
currPluginSetting['blockcall'] = ('sopsproperty:blockcall' in line.lower().replace(" ", "")) or currPluginSetting['blockcall']
currPluginSetting['error'] = ('sopsproperty:error' in line.lower().replace(" ", "")) or currPluginSetting['error']
currPluginSetting['supressoutput'] = ('sopsproperty:supressoutput' in line.lower().replace(" ", "")) or currPluginSetting['supressoutput']
currPluginSetting['exec'] = ('sopsproperty:exec' in line.lower().replace(" ", "")) or currPluginSetting['exec']
currPluginSetting['loadmodule'] = ('sopsproperty:loadmodule' in line.lower().replace(" ", "")) or currPluginSetting['loadmodule']
except Exception as e:
logger.error(f"Error reading plugin file: {e}")
return currPluginSetting
def buildPluginSubprocess(self, currPluginSetting):
"""Build a function to execute a plugin as a subprocess."""
currplugin = "\'\"" + currPluginSetting['file'] + "\" " + currPluginSetting['parameters'] + "\'"
pluginname = currPluginSetting['pluginname']
if currPluginSetting['blockcall']:
pluginname = "blocking " + pluginname
fun_body = "global " + currPluginSetting['functionname'] + "\n"
fun_body += "def " + currPluginSetting['functionname'] + "(script=None, inputEvent=None):\n"
if currPluginSetting['startnotify']:
fun_body += " outputMessage('start " + pluginname + "')\n"
fun_body += " p = Popen(" + currplugin + ", stdout=PIPE, stderr=PIPE, shell=True)\n"
fun_body += " stdout, stderr = p.communicate()\n"
fun_body += " message = ''\n"
fun_body += " if not " + str(currPluginSetting['supressoutput']) + " and stdout:\n"
fun_body += " message += str(stdout, \"utf-8\")\n"
fun_body += " if " + str(currPluginSetting['error']) + " and stderr:\n"
fun_body += " message += ' error: ' + str(stderr, \"utf-8\")\n"
fun_body += " outputMessage(message)\n"
if currPluginSetting['stopnotify']:
fun_body += " outputMessage('finish " + pluginname + "')\n"
fun_body += " return True\n\n"
fun_body += "global " + currPluginSetting['functionname'] + "T\n"
fun_body += "def " + currPluginSetting['functionname'] + "T(script=None, inputEvent=None):\n"
fun_body += " _thread.start_new_thread(" + currPluginSetting['functionname'] + ",(script, inputEvent))\n\n"
return fun_body
def buildPluginExec(self, currPluginSetting):
"""Build a function to execute a plugin as a Python module."""
pluginname = currPluginSetting['pluginname']
if currPluginSetting['blockcall']:
pluginname = "blocking " + pluginname
fun_body = "global " + currPluginSetting['functionname'] + "\n"
fun_body += "def " + currPluginSetting['functionname'] + "(script=None, inputEvent=None):\n"
if currPluginSetting['startnotify']:
fun_body += " outputMessage('start " + pluginname + "')\n"
fun_body += " try:\n"
fun_body += " spec = importlib.util.spec_from_file_location(\"" + currPluginSetting['functionname'] + "\",\"" + currPluginSetting['file'] + "\")\n"
fun_body += " " + currPluginSetting['functionname'] + "Module = importlib.util.module_from_spec(spec)\n"
fun_body += " spec.loader.exec_module(" + currPluginSetting['functionname'] + "Module)\n"
fun_body += " except Exception as e:\n"
fun_body += " logger.error(f\"Error executing plugin {pluginname}: {e}\")\n"
if currPluginSetting['error']:
fun_body += " outputMessage(\"Error while executing " + pluginname + "\")\n"
if currPluginSetting['stopnotify']:
fun_body += " outputMessage('finish " + pluginname + "')\n"
fun_body += " return True\n\n"
fun_body += "global " + currPluginSetting['functionname'] + "T\n"
fun_body += "def " + currPluginSetting['functionname'] + "T(script=None, inputEvent=None):\n"
fun_body += " _thread.start_new_thread(" + currPluginSetting['functionname'] + ",(script, inputEvent))\n\n"
return fun_body
def getFunctionName(self, currPluginSetting):
"""Generate a unique function name for a plugin."""
currPluginSetting['functionname'] = ''
while currPluginSetting['functionname'] == '' or currPluginSetting['functionname'] + 'T' in globals() or currPluginSetting['functionname'] in globals():
currPluginSetting['functionname'] = self.id_generator()
return currPluginSetting
def registerGestureByString(self, function, description, shortcut):
"""Register a keyboard shortcut for a function.
This is a compatibility wrapper for the new plugin system.
"""
try:
if self.app:
api_helper = self.app.getAPIHelper()
if api_helper:
api_helper.registerGestureByString(
function,
description,
shortcut,
'default',
'cthulhu',
True,
contextName=self.module_name
)
logger.debug(f"Registered shortcut {shortcut} for {description}")
return True
else:
logger.error("Could not get APIHelper")
else:
logger.error("No app reference available")
except Exception as e:
logger.error(f"Error registering shortcut {shortcut}: {e}")
def unregisterShortcut(self, function, shortcut):
"""Unregister a keyboard shortcut for a function.
This is a compatibility wrapper for the new plugin system.
"""
try:
if self.app:
api_helper = self.app.getAPIHelper()
if api_helper and hasattr(api_helper, 'unregisterShortcut'):
api_helper.unregisterShortcut(shortcut)
logger.debug(f"Unregistered shortcut {shortcut}")
return True
else:
logger.error("Could not get APIHelper or unregisterShortcut method")
else:
logger.error("No app reference available")
except Exception as e:
logger.error(f"Error unregistering shortcut {shortcut}: {e}")
def load_plugins(self):
"""Load and setup all plugins in the plugin repository."""
if not self.loaded:
try:
logger.info(f"Loading plugins from {self.plugin_repo}")
plugin_files = glob.glob(self.plugin_repo + '*')
self.plugin_list = [] # Reset the plugin list to avoid confusion
for currplugin in plugin_files:
try:
# Ensure currplugin is a valid path string
if not isinstance(currplugin, (str, bytes, os.PathLike)):
logger.error(f"Invalid plugin path: {type(currplugin)}")
continue
currPluginSetting = self.initSettings()
currPluginSetting = self.getPluginSettings(currplugin, currPluginSetting)
if not currPluginSetting['valid']:
logger.debug(f"Skipping invalid plugin: {currplugin}")
continue
currPluginSetting = self.getFunctionName(currPluginSetting)
if currPluginSetting['loadmodule']:
exec(self.buildPluginExec(currPluginSetting)) # load as python module
else:
exec(self.buildPluginSubprocess(currPluginSetting)) # run as subprocess
if currPluginSetting['blockcall']:
currPluginSetting['function'] = globals()[currPluginSetting['functionname']] # non threaded
else:
currPluginSetting['function'] = globals()[currPluginSetting['functionname'] + "T"] # T = Threaded
if currPluginSetting['exec']: # exec on load if we want
currPluginSetting['function']()
if not currPluginSetting['key'] == '':
currPluginSetting = self.SetupShortcutAndHandle(currPluginSetting)
logger.debug(f"Loaded plugin: {currPluginSetting['pluginname']}")
self.plugin_list.append(currPluginSetting) # store in a list
except Exception as e:
logger.error(f"Error loading plugin {currplugin}: {e}")
self.loaded = True
logger.info(f"Loaded {len(self.plugin_list)} plugins")
except Exception as e:
logger.error(f"Error in load_plugins: {e}")

View File

@ -1,7 +0,0 @@
cthulhu_python_PYTHON = \
__init__.py \
Time.plugin \
Time.py
cthulhu_pythondir=$(pkgpythondir)/plugins/Time

View File

@ -1,6 +0,0 @@
[Plugin]
Module=Time
Loader=python3
Name=Time
Description=Present current time
Authors=Chrys chrys@linux-a11y.org

View File

@ -1,60 +0,0 @@
#!/usr/bin/env python3
#
# Copyright (c) 2024 Stormux
# Copyright (c) 2010-2012 The Orca Team
# Copyright (c) 2012 Igalia, S.L.
# Copyright (c) 2005-2010 Sun Microsystems Inc.
#
# This library is free software; you can redistribute it and/or
# modify it under the terms of the GNU Lesser General Public
# License as published by the Free Software Foundation; either
# version 2.1 of the License, or (at your option) any later version.
#
# This library is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
# Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public
# License along with this library; if not, write to the
# Free Software Foundation, Inc., Franklin Street, Fifth Floor,
# Boston MA 02110-1301 USA.
#
# Fork of Orca Screen Reader (GNOME)
# Original source: https://gitlab.gnome.org/GNOME/orca
import gi, time
gi.require_version('Peas', '1.0')
from gi.repository import GObject
from gi.repository import Peas
from cthulhu import plugin
class Time(GObject.Object, Peas.Activatable, plugin.Plugin):
#__gtype_name__ = 'Time'
object = GObject.Property(type=GObject.Object)
def __init__(self):
plugin.Plugin.__init__(self)
def do_activate(self):
API = self.object
self.connectSignal("setup-inputeventhandlers-completed", self.setupCompatBinding)
def setupCompatBinding(self, app):
cmdnames = app.getDynamicApiManager().getAPI('Cmdnames')
inputEventHandlers = app.getDynamicApiManager().getAPI('inputEventHandlers')
inputEventHandlers['presentTimeHandler'] = app.getAPIHelper().createInputEventHandler(self.presentTime, cmdnames.PRESENT_CURRENT_TIME)
def do_deactivate(self):
API = self.object
inputEventHandlers = API.app.getDynamicApiManager().getAPI('inputEventHandlers')
del inputEventHandlers['presentTimeHandler']
def do_update_state(self):
API = self.object
def presentTime(self, script=None, inputEvent=None):
""" Presents the current time. """
API = self.object
settings_manager = API.app.getDynamicApiManager().getAPI('SettingsManager')
_settingsManager = settings_manager.getManager()
timeFormat = _settingsManager.getSetting('presentTimeFormat')
message = time.strftime(timeFormat, time.localtime())
API.app.getDynamicApiManager().getAPI('CthulhuState').activeScript.presentMessage(message, resetStyles=False)
return True

View File

@ -1,25 +0,0 @@
#!/usr/bin/env python3
#
# Copyright (c) 2024 Stormux
# Copyright (c) 2010-2012 The Orca Team
# Copyright (c) 2012 Igalia, S.L.
# Copyright (c) 2005-2010 Sun Microsystems Inc.
#
# This library is free software; you can redistribute it and/or
# modify it under the terms of the GNU Lesser General Public
# License as published by the Free Software Foundation; either
# version 2.1 of the License, or (at your option) any later version.
#
# This library is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
# Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public
# License along with this library; if not, write to the
# Free Software Foundation, Inc., Franklin Street, Fifth Floor,
# Boston MA 02110-1301 USA.
#
# Fork of Orca Screen Reader (GNOME)
# Original source: https://gitlab.gnome.org/GNOME/orca

View File

@ -0,0 +1,6 @@
cthulhu_python_PYTHON = \
__init__.py \
plugin.info \
plugin.py
cthulhu_pythondir=$(pkgpythondir)/plugins/hello_world

View File

@ -0,0 +1,33 @@
# Hello World Plugin for Cthulhu
This is a simple test plugin for the Cthulhu screen reader that demonstrates how to use the pluggy-based plugin system.
## Features
- Registers a keyboard shortcut (Cthulhu+z) that speaks "hello world" when pressed
- Demonstrates how to use the Plugin base class
- Shows how to use cthulhu_hookimpl for hook implementation
## Implementation Details
The plugin uses the following key features:
- `pluggy` for hook specification and implementation
- The `Plugin` base class from cthulhu.plugin
- The `cthulhu_hookimpl` decorator to mark functions as plugin hooks
- The `registerGestureByString` method to register keyboard shortcuts
## Structure
- `plugin.py`: The main plugin implementation
- `plugin.info`: Metadata about the plugin
## Requirements
Requires the pluggy package to be installed:
```
pip install pluggy
```
## Usage
The plugin will be automatically loaded when Cthulhu starts if it's listed in the activePlugins setting.

View File

@ -0,0 +1,8 @@
name = Hello World
version = 1.0.0
description = Test plugin for Cthulhu
authors = Storm Dragon storm_dragon@stormux.org
website = https://stormux.org
copyright = Copyright 2025
builtin = false
hidden = false

View File

@ -0,0 +1,70 @@
#!/usr/bin/env python3
#
# Copyright (c) 2025 Stormux
#
# This library is free software; you can redistribute it and/or
# modify it under the terms of the GNU Lesser General Public
# License as published by the Free Software Foundation; either
# version 2.1 of the License, or (at your option) any later version.
"""Hello World plugin for Cthulhu."""
import logging
from cthulhu.plugin import Plugin, cthulhu_hookimpl
logger = logging.getLogger(__name__)
class HelloWorld(Plugin):
"""Hello World plugin."""
def __init__(self, *args, **kwargs):
"""Initialize the plugin."""
super().__init__(*args, **kwargs)
print("Plugin hello world initialized.")
logger.info("HelloWorld plugin initialized")
@cthulhu_hookimpl
def activate(self, plugin=None):
"""Activate the plugin."""
# Skip if this activation call isn't for us
if plugin is not None and plugin is not self:
return
try:
logger.info("Activating Hello World plugin")
# Register our keyboard shortcut
self.registerGestureByString(
self.speakTest,
"hello world",
"kb:cthulhu+z",
learnModeEnabled=True
)
except Exception as e:
logger.error(f"Error activating Hello World plugin: {e}")
@cthulhu_hookimpl
def deactivate(self, plugin=None):
"""Deactivate the plugin."""
# Skip if this deactivation call isn't for us
if plugin is not None and plugin is not self:
return
try:
logger.info("Deactivating Hello World plugin")
except Exception as e:
logger.error(f"Error deactivating Hello World plugin: {e}")
def speakTest(self, script=None, inputEvent=None):
"""Speak a test message."""
try:
if self.app:
self.app.getDynamicApiManager().getAPI('CthulhuState').activeScript.presentMessage(
'hello world',
resetStyles=False
)
return True
except Exception as e:
logger.error(f"Error in speakTest: {e}")
return False

View File

@ -0,0 +1,7 @@
cthulhu_python_PYTHON = \
__init__.py \
plugin.info \
plugin.py
cthulhu_pythondir=$(pkgpythondir)/plugins/self_voice

View File

@ -0,0 +1,12 @@
[Plugin]
name=Self Voice
module_name=self_voice
version=1.0.0
description=Use Cthulhu speech and braille from external applications via Unix sockets
authors=Stormux
copyright=Copyright (c) 2024 Stormux
website=https://stormux.org
icon_name=audio-speakers
builtin=false
hidden=false
help_uri=https://stormux.org

View File

@ -0,0 +1,194 @@
#!/usr/bin/env python3
#
# Copyright (c) 2024 Stormux
# Copyright (c) 2010-2012 The Orca Team
# Copyright (c) 2012 Igalia, S.L.
# Copyright (c) 2005-2010 Sun Microsystems Inc.
#
# This library is free software; you can redistribute it and/or
# modify it under the terms of the GNU Lesser General Public
# License as published by the Free Software Foundation; either
# version 2.1 of the License, or (at your option) any later version.
#
# This library is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
# Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public
# License along with this library; if not, write to the
# Free Software Foundation, Inc., Franklin Street, Fifth Floor,
# Boston MA 02110-1301 USA.
#
# Fork of Orca Screen Reader (GNOME)
# Original source: https://gitlab.gnome.org/GNOME/orca
"""Self Voice plugin for Cthulhu screen reader."""
import os
import socket
import select
import logging
import threading
from threading import Thread, Lock
from cthulhu.plugin import Plugin, cthulhu_hookimpl
logger = logging.getLogger(__name__)
# Special codes for message handling
APPEND_CODE = '<#APPEND#>'
PERSISTENT_CODE = '<#PERSISTENT#>'
class SelfVoice(Plugin):
"""Plugin that provides a socket interface for external applications to send text to Cthulhu."""
def __init__(self):
"""Initialize the plugin."""
super().__init__()
self.lock = Lock()
self.active = False
self.voiceThread = Thread(target=self.voiceWorker)
self.voiceThread.daemon = True # Make thread exit when main thread exits
@cthulhu_hookimpl
def activate(self):
"""Activate the self-voice plugin."""
super().activate()
logger.info("Activating Self Voice Plugin")
self.activateWorker()
@cthulhu_hookimpl
def deactivate(self):
"""Deactivate the self-voice plugin."""
logger.info("Deactivating Self Voice Plugin")
self.deactivateWorker()
super().deactivate()
def activateWorker(self):
"""Start the voice worker thread."""
with self.lock:
self.active = True
# Only start if not already running
if not self.voiceThread.is_alive():
self.voiceThread = Thread(target=self.voiceWorker)
self.voiceThread.daemon = True
self.voiceThread.start()
def deactivateWorker(self):
"""Stop the voice worker thread."""
with self.lock:
self.active = False
# Try to join the thread if it's alive, with a timeout
if self.voiceThread.is_alive():
try:
self.voiceThread.join(timeout=2.0)
except Exception as e:
logger.warning(f"Error stopping voice worker thread: {e}")
def isActive(self):
"""Check if the worker is active."""
with self.lock:
return self.active
def outputMessage(self, message):
"""Output a message through Cthulhu's speech and braille systems.
Args:
message: The message to output. May include special codes.
"""
# Process special codes
append = message.startswith(APPEND_CODE)
if append:
message = message[len(APPEND_CODE):]
persistent = False
if message.endswith(PERSISTENT_CODE):
message = message[:len(message)-len(PERSISTENT_CODE)]
persistent = True
# Output through appropriate channel
if persistent:
# Use the APIHelper for persistent messages
self.app.getAPIHelper().outputMessage(message, not append)
else:
# Use the script manager for standard messages
script_manager = self.app.getDynamicApiManager().getAPI('ScriptManager')
scriptManager = script_manager.getManager()
scriptManager.getDefaultScript().presentMessage(message, resetStyles=False)
def voiceWorker(self):
"""Worker thread that listens on a socket for messages to speak."""
socketFile = '/tmp/cthulhu.sock'
# For testing purposes
# socketFile = '/tmp/cthulhu-plugin.sock'
# Clean up any existing socket file
if os.path.exists(socketFile):
try:
os.unlink(socketFile)
except Exception as e:
logger.error(f"Error removing existing socket file: {e}")
return
try:
# Create and set up the socket
cthulhu_sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
cthulhu_sock.bind(socketFile)
os.chmod(socketFile, 0o222) # Write-only for everyone
cthulhu_sock.listen(1)
logger.info(f"Self Voice plugin listening on {socketFile}")
# Main loop - listen for connections
while self.isActive():
# Check if data is available with a timeout
try:
r, _, _ = select.select([cthulhu_sock], [], [], 0.8)
except select.error as e:
logger.error(f"Select error: {e}")
break
if not r: # No data available
continue
# Accept connection
if cthulhu_sock in r:
try:
client_sock, _ = cthulhu_sock.accept()
client_sock.settimeout(0.5) # Set a timeout for receiving data
try:
# Receive and process data
raw_data = client_sock.recv(8192)
if raw_data:
data = raw_data.decode("utf-8").strip()
if data:
self.outputMessage(data)
except socket.timeout:
pass
except Exception as e:
logger.error(f"Error receiving data: {e}")
finally:
client_sock.close()
except Exception as e:
logger.error(f"Error accepting connection: {e}")
except Exception as e:
logger.error(f"Socket error: {e}")
finally:
# Clean up
if 'cthulhu_sock' in locals():
try:
cthulhu_sock.close()
except Exception:
pass
if os.path.exists(socketFile):
try:
os.unlink(socketFile)
except Exception as e:
logger.error(f"Error removing socket file: {e}")
logger.info("Self Voice plugin socket closed")

View File

@ -113,7 +113,7 @@ class ResourceContext():
self.settings[profile][application] = {} self.settings[profile][application] = {}
# add entry # add entry
self.settings[profile][application][sub_setting_name] = entry self.settings[profile][application][sub_setting_name] = entry
print('add', 'settings', self.getName(), profile, application, entry.getResourceText()) print('add', 'settings', self.getName(), profile, application, entry.getResourceText())
@ -214,7 +214,7 @@ class ResourceContext():
self.unregisterAllAPI() self.unregisterAllAPI()
except Exception as e: except Exception as e:
print(e) print(e)
def unregisterAllAPI(self): def unregisterAllAPI(self):
dynamicApiManager = self.app.getDynamicApiManager() dynamicApiManager = self.app.getDynamicApiManager()
for application, value in self.getAPIs().copy().items(): for application, value in self.getAPIs().copy().items():
@ -226,7 +226,7 @@ class ResourceContext():
print('unregister api ', self.getName(), entry.getEntryType(), entry.getResourceText()) print('unregister api ', self.getName(), entry.getEntryType(), entry.getResourceText())
def unregisterAllGestures(self): def unregisterAllGestures(self):
APIHelper = self.app.getAPIHelper() APIHelper = self.app.getAPIHelper()
for profile, profileValue in self.getGestures().copy().items(): for profile, profileValue in self.getGestures().copy().items():
for application, applicationValue in profileValue.copy().items(): for application, applicationValue in profileValue.copy().items():
for gesture, entry in applicationValue.copy().items(): for gesture, entry in applicationValue.copy().items():
@ -272,12 +272,12 @@ class ResourceManager():
def removeResourceContext(self, contextName): def removeResourceContext(self, contextName):
if not contextName: if not contextName:
return return
try: try:
self.resourceContextDict[contextName].unregisterAllResources() self.resourceContextDict[contextName].unregisterAllResources()
except: except:
pass pass
# temp # temp
try: try:
print('_________', 'summery', self.resourceContextDict[contextName].getName(), '_________') print('_________', 'summery', self.resourceContextDict[contextName].getName(), '_________')
@ -302,7 +302,7 @@ class ResourceManager():
return self.resourceContextDict[contextName] return self.resourceContextDict[contextName]
except KeyError: except KeyError:
return None return None
def addAPI(self, application, api, contextName = None): def addAPI(self, application, api, contextName = None):
if not contextName: if not contextName:
return return

View File

@ -3558,10 +3558,10 @@ class Utilities:
@staticmethod @staticmethod
def unicodeValueString(character): def unicodeValueString(character):
""" Returns a four hex digit representation of the given character """ Returns a four hex digit representation of the given character
Arguments: Arguments:
- The character to return representation - The character to return representation
Returns a string representaition of the given character unicode vlue Returns a string representaition of the given character unicode vlue
""" """

View File

@ -1,4 +1,4 @@
SUBDIRS = apps toolkits switcher terminal web SUBDIRS = apps toolkits sleepmode switcher terminal web
cthulhu_python_PYTHON = \ cthulhu_python_PYTHON = \
__init__.py \ __init__.py \

View File

@ -204,6 +204,11 @@ class Script(script.Script):
Script.processBrailleCutLine, Script.processBrailleCutLine,
cmdnames.PROCESS_BRAILLE_CUT_LINE) cmdnames.PROCESS_BRAILLE_CUT_LINE)
self.inputEventHandlers["toggleSleepModeHandler"] = \
input_event.InputEventHandler(
Script.toggleSleepMode,
cmdnames.TOGGLE_SLEEP_MODE)
self.inputEventHandlers["shutdownHandler"] = \ self.inputEventHandlers["shutdownHandler"] = \
input_event.InputEventHandler( input_event.InputEventHandler(
cthulhu.quitCthulhu, cthulhu.quitCthulhu,
@ -732,6 +737,13 @@ class Script(script.Script):
# # # #
######################################################################## ########################################################################
def toggleSleepMode(self, input_event=None):
"""Toggles between sleep mode and regular mode."""
script_manager = _scriptManager
sleepScript = script_manager.createScript("sleepmode", self.app)
script_manager.setActiveScript(sleepScript, "Sleep mode toggled")
return True
def bypassNextCommand(self, inputEvent=None): def bypassNextCommand(self, inputEvent=None):
"""Causes the next keyboard command to be ignored by Cthulhu """Causes the next keyboard command to be ignored by Cthulhu
and passed along to the current application. and passed along to the current application.

View File

@ -0,0 +1,6 @@
cthulhu_python_PYTHON = \
__init__.py \
script.py \
script_utilities.py
cthulhu_pythondir=$(pkgpythondir)/scripts/terminal

View File

@ -0,0 +1,216 @@
#!/usr/bin/env python3
#
# Copyright (c) 2024 Stormux
# Copyright (c) 2010-2012 The Orca Team
# Copyright (c) 2012 Igalia, S.L.
# Copyright (c) 2005-2010 Sun Microsystems Inc.
#
# This library is free software; you can redistribute it and/or
# modify it under the terms of the GNU Lesser General Public
# License as published by the Free Software Foundation; either
# version 2.1 of the License, or (at your option) any later version.
#
# This library is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
# Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public
# License along with this library; if not, write to the
# Free Software Foundation, Inc., Franklin Street, Fifth Floor,
# Boston MA 02110-1301 USA.
#
# Fork of Orca Screen Reader (GNOME)
# Original source: https://gitlab.gnome.org/GNOME/orca
"""Script for sleep mode where Cthulhu ignores events and commands."""
__id__ = "$Id$"
__version__ = "$Revision$"
__date__ = "$Date$"
__copyright__ = "Copyright (c) 2024 Stormux"
__license__ = "LGPL"
import cthulhu.debug as debug
import cthulhu.scripts.default as default
import cthulhu.input_event as input_event
import cthulhu.keybindings as keybindings
import cthulhu.messages as messages
from cthulhu.ax_object import AXObject
from cthulhu.ax_utilities import AXUtilities
class Script(default.Script):
"""The sleep-mode script."""
def __init__(self, app):
super().__init__(app)
self.presentIfInactive = True
def activate(self):
"""Called when this script is activated."""
tokens = ["SLEEP MODE: Activating script for", self.app]
debug.printTokens(debug.LEVEL_INFO, tokens, True)
# Only keep the sleep mode toggle binding active
self.removeKeyGrabs()
self.addKeyGrabs()
# Present sleep mode status
self.clearBraille()
self.presentMessage(messages.SLEEP_MODE_ENABLED_FOR % AXObject.get_name(self.app))
def deactivate(self):
"""Called when this script is deactivated."""
tokens = ["SLEEP MODE: De-activating script for", self.app]
debug.printTokens(debug.LEVEL_INFO, tokens, True)
self.removeKeyGrabs()
def getKeyBindings(self):
"""Only provide the key binding needed to exit sleep mode."""
keyBindings = keybindings.KeyBindings()
keyBindings.load([("q", keybindings.defaultModifierMask,
keybindings.CTHULHU_CTRL_ALT_MODIFIER_MASK | keybindings.SHIFT_ALT_MODIFIER_MASK,
"toggleSleepModeHandler")],
self.inputEventHandlers)
return keyBindings
def setupInputEventHandlers(self):
"""Sets up the input event handlers for sleep mode."""
super().setupInputEventHandlers()
self.inputEventHandlers["toggleSleepModeHandler"] = \
input_event.InputEventHandler(
Script.toggleSleepMode,
"Toggles sleep mode on/off")
def toggleSleepMode(self, input_event=None):
"""Toggles between sleep mode and regular mode."""
script_manager = _scriptManager
script_manager.setActiveScript(script_manager.getDefaultScript(), "Sleep mode toggled")
self.presentMessage(messages.SLEEP_MODE_DISABLED_FOR % AXObject.get_name(self.app))
return True
def locusOfFocusChanged(self, event, oldLocusOfFocus, newLocusOfFocus):
"""Handles changes of focus of interest to the script."""
tokens = ["SLEEP MODE: focus changed from", oldLocusOfFocus, "to", newLocusOfFocus]
debug.printTokens(debug.LEVEL_INFO, tokens, True)
if oldLocusOfFocus is None and AXUtilities.is_application(AXObject.get_parent(newLocusOfFocus)):
self.clearBraille()
self.presentMessage(messages.SLEEP_MODE_ENABLED_FOR % AXObject.get_name(self.app))
return
msg = "SLEEP MODE: Ignoring event."
debug.printMessage(debug.LEVEL_INFO, msg, True)
def presentKeyboardEvent(self, event):
"""Prevents keyboard echo in sleep mode."""
msg = "SLEEP MODE: Not presenting keyboard event."
debug.printMessage(debug.LEVEL_INFO, msg, True)
return True
def updateBraille(self, obj, **args):
"""Don't update braille in sleep mode."""
msg = "SLEEP MODE: Not updating braille."
debug.printMessage(debug.LEVEL_INFO, msg, True)
# Event handler overrides - all do nothing
def onActiveChanged(self, event):
msg = "SLEEP MODE: Ignoring event."
debug.printMessage(debug.LEVEL_INFO, msg, True)
def onActiveDescendantChanged(self, event):
msg = "SLEEP MODE: Ignoring event."
debug.printMessage(debug.LEVEL_INFO, msg, True)
def onBusyChanged(self, event):
msg = "SLEEP MODE: Ignoring event."
debug.printMessage(debug.LEVEL_INFO, msg, True)
def onCaretMoved(self, event):
msg = "SLEEP MODE: Ignoring event."
debug.printMessage(debug.LEVEL_INFO, msg, True)
def onCheckedChanged(self, event):
msg = "SLEEP MODE: Ignoring event."
debug.printMessage(debug.LEVEL_INFO, msg, True)
def onChildrenAdded(self, event):
msg = "SLEEP MODE: Ignoring event."
debug.printMessage(debug.LEVEL_INFO, msg, True)
def onChildrenRemoved(self, event):
msg = "SLEEP MODE: Ignoring event."
debug.printMessage(debug.LEVEL_INFO, msg, True)
def onColumnReordered(self, event):
msg = "SLEEP MODE: Ignoring event."
debug.printMessage(debug.LEVEL_INFO, msg, True)
def onDocumentLoadComplete(self, event):
msg = "SLEEP MODE: Ignoring event."
debug.printMessage(debug.LEVEL_INFO, msg, True)
def onDocumentLoadStopped(self, event):
msg = "SLEEP MODE: Ignoring event."
debug.printMessage(debug.LEVEL_INFO, msg, True)
def onDocumentReload(self, event):
msg = "SLEEP MODE: Ignoring event."
debug.printMessage(debug.LEVEL_INFO, msg, True)
def onExpandedChanged(self, event):
msg = "SLEEP MODE: Ignoring event."
debug.printMessage(debug.LEVEL_INFO, msg, True)
def onFocus(self, event):
msg = "SLEEP MODE: Ignoring event."
debug.printMessage(debug.LEVEL_INFO, msg, True)
def onFocusedChanged(self, event):
msg = "SLEEP MODE: Ignoring event."
debug.printMessage(debug.LEVEL_INFO, msg, True)
def onMouseButton(self, event):
msg = "SLEEP MODE: Ignoring event."
debug.printMessage(debug.LEVEL_INFO, msg, True)
def onNameChanged(self, event):
msg = "SLEEP MODE: Ignoring event."
debug.printMessage(debug.LEVEL_INFO, msg, True)
def onSelectedChanged(self, event):
msg = "SLEEP MODE: Ignoring event."
debug.printMessage(debug.LEVEL_INFO, msg, True)
def onSelectionChanged(self, event):
msg = "SLEEP MODE: Ignoring event."
debug.printMessage(debug.LEVEL_INFO, msg, True)
def onShowingChanged(self, event):
msg = "SLEEP MODE: Ignoring event."
debug.printMessage(debug.LEVEL_INFO, msg, True)
def onTextAttributesChanged(self, event):
msg = "SLEEP MODE: Ignoring event."
debug.printMessage(debug.LEVEL_INFO, msg, True)
def onTextDeleted(self, event):
msg = "SLEEP MODE: Ignoring event."
debug.printMessage(debug.LEVEL_INFO, msg, True)
def onTextInserted(self, event):
msg = "SLEEP MODE: Ignoring event."
debug.printMessage(debug.LEVEL_INFO, msg, True)
def onTextSelectionChanged(self, event):
msg = "SLEEP MODE: Ignoring event."
debug.printMessage(debug.LEVEL_INFO, msg, True)
def onWindowActivated(self, event):
"""Callback for window:activate accessibility events."""
self.clearBraille()
self.presentMessage(messages.SLEEP_MODE_ENABLED_FOR % AXObject.get_name(self.app))
def onWindowDeactivated(self, event):
msg = "SLEEP MODE: Ignoring event."
debug.printMessage(debug.LEVEL_INFO, msg, True)

View File

@ -0,0 +1,59 @@
#!/usr/bin/env python3
#
# Copyright (c) 2024 Stormux
# Copyright (c) 2010-2012 The Orca Team
# Copyright (c) 2012 Igalia, S.L.
# Copyright (c) 2005-2010 Sun Microsystems Inc.
#
# This library is free software; you can redistribute it and/or
# modify it under the terms of the GNU Lesser General Public
# License as published by the Free Software Foundation; either
# version 2.1 of the License, or (at your option) any later version.
#
# This library is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
# Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public
# License along with this library; if not, write to the
# Free Software Foundation, Inc., Franklin Street, Fifth Floor,
# Boston MA 02110-1301 USA.
#
# Fork of Orca Screen Reader (GNOME)
# Original source: https://gitlab.gnome.org/GNOME/orca
"""Utilities for Sleep Mode. Helps ensure we do nothing. When nothing is done, nothing is left undone."""
__id__ = "$Id$"
__version__ = "$Revision$"
__date__ = "$Date$"
__copyright__ = "Copyright (c) 2024 Stormux"
__license__ = "LGPL"
import cthulhu.debug as debug
import cthulhu.script_utilities as script_utilities
class Utilities(script_utilities.Utilities):
"""Utilities class for the sleep-mode script."""
def willEchoCharacter(self, event):
"""Returns True if we'll echo this character."""
msg = "SLEEP MODE: Will not echo character."
debug.printMessage(debug.LEVEL_INFO, msg, True)
return False
def displayRegionChanged(self, obj, start, end, mode):
"""Don't track changes in sleep mode."""
msg = "SLEEP MODE: Will not track display changes."
debug.printMessage(debug.LEVEL_INFO, msg, True)
return False
def shouldPresentContext(self):
"""Don't present context in sleep mode."""
msg = "SLEEP MODE: Will not present context."
debug.printMessage(debug.LEVEL_INFO, msg, True)
return False

View File

@ -413,4 +413,4 @@ presentChatRoomLast = False
presentLiveRegionFromInactiveTab = False presentLiveRegionFromInactiveTab = False
# Plugins # Plugins
activePlugins = ['Clipboard', 'MouseReview', 'Date', 'ByeCthulhu', 'Time', 'HelloCthulhu', 'HelloWorld', 'SelfVoice', 'PluginManager', 'SimplePluginSystem'] activePlugins = ['Clipboard', 'DisplayVersion', 'ByeCthulhu', 'HelloCthulhu', 'hello_world', 'self_voice', 'SimplePluginSystem']

View File

@ -69,7 +69,7 @@ class SpeechServer(speechserver.SpeechServer):
# See the parent class for documentation. # See the parent class for documentation.
_active_servers = {} _active_servers = {}
DEFAULT_SERVER_ID = 'default' DEFAULT_SERVER_ID = 'default'
_SERVER_NAMES = {DEFAULT_SERVER_ID: guilabels.DEFAULT_SYNTHESIZER} _SERVER_NAMES = {DEFAULT_SERVER_ID: guilabels.DEFAULT_SYNTHESIZER}
@ -93,7 +93,7 @@ class SpeechServer(speechserver.SpeechServer):
Attempt to create the server if it doesn't exist yet. Returns None Attempt to create the server if it doesn't exist yet. Returns None
when it is not possible to create the server. when it is not possible to create the server.
""" """
if serverId not in cls._active_servers: if serverId not in cls._active_servers:
cls(serverId) cls(serverId)
@ -781,16 +781,16 @@ class SpeechServer(speechserver.SpeechServer):
def reset(self, text=None, acss=None): def reset(self, text=None, acss=None):
self._client.close() self._client.close()
self._init() self._init()
def list_output_modules(self): def list_output_modules(self):
"""Return names of available output modules as a tuple of strings. """Return names of available output modules as a tuple of strings.
This method is not a part of Cthulhu speech API, but is used internally This method is not a part of Cthulhu speech API, but is used internally
by the Speech Dispatcher backend. by the Speech Dispatcher backend.
The returned tuple can be empty if the information can not be The returned tuple can be empty if the information can not be
obtained (e.g. with an older Speech Dispatcher version). obtained (e.g. with an older Speech Dispatcher version).
""" """
try: try:
return self._send_command(self._client.list_output_modules) return self._send_command(self._client.list_output_modules)