4 Commits

Author SHA1 Message Date
destructatron 51ef3de672 Port keyboard monitoring API from Orca, and fix flat review bug in GTK apps 2026-02-13 10:14:04 +00:00
Storm Dragon c6049ef5f3 Preparing for release. 2026-01-26 13:07:33 -05:00
Storm Dragon 78ef51d01f Latest code, hopefully stable. 2026-01-19 05:24:48 -05:00
Storm Dragon 30a40f6974 Merged dbus implementation for plugins. 2026-01-12 11:54:42 -05:00
16 changed files with 282 additions and 427 deletions
+1 -2
View File
@@ -1,7 +1,7 @@
# Maintainer: Storm Dragon <storm_dragon@stormux.org> # Maintainer: Storm Dragon <storm_dragon@stormux.org>
pkgname=cthulhu pkgname=cthulhu
pkgver=2026.01.19 pkgver=2026.01.26
pkgrel=1 pkgrel=1
pkgdesc="Desktop-agnostic screen reader with plugin system, forked from Orca" pkgdesc="Desktop-agnostic screen reader with plugin system, forked from Orca"
url="https://git.stormux.org/storm/cthulhu" url="https://git.stormux.org/storm/cthulhu"
@@ -27,7 +27,6 @@ depends=(
# Plugin system and D-Bus remote control # Plugin system and D-Bus remote control
python-pluggy python-pluggy
python-tomlkit
python-dasbus python-dasbus
# AI Assistant dependencies (for screenshots, HTTP requests, and actions) # AI Assistant dependencies (for screenshots, HTTP requests, and actions)
-46
View File
@@ -1,46 +0,0 @@
# Cthulhu Logging Guidelines
This document defines the preferred format for debug logging in Cthulhu.
The goal is to keep logs consistent, searchable, and easy to scan.
## Helpers
Use the helpers in `cthulhu.debug` for new logs:
- `debug.print_log(level, prefix, message, reason=None, timestamp=False, stack=False)`
- `debug.print_log_tokens(level, prefix, tokens, reason=None, timestamp=False, stack=False)`
These helpers ensure the prefix and optional reason tag are formatted consistently.
Timestamps are appended at the end of the message when enabled.
## Prefixes
Use short, uppercase prefixes that identify the subsystem:
- `EVENT MANAGER`
- `FOCUS MANAGER`
- `INPUT EVENT`
- `SCRIPT MANAGER`
- `WEB`
## Messages
- Keep messages short and action-focused.
- Do not include the prefix in the message.
- Prefer consistent verbs (e.g., "Not using …", "Setting …", "Ignoring …").
## Reason Tags
Use reason tags to explain decisions or early exits.
- Lowercase with hyphens (e.g., `focus-mode`, `no-active-script`).
- Use a short phrase rather than a full sentence.
- If a human-readable reason is already available, it can be used directly.
## Examples
```text
WEB: Not using caret navigation (reason=disabled)
FOCUS MANAGER: Setting locus of focus to existing locus of focus (reason=no-change)
SCRIPT MANAGER: Setting active script to [script] (reason=focus: active-window)
```
+1 -1
View File
@@ -290,7 +290,7 @@ toggle the reading of tables, either by single cell or whole row.
.B Cthulhu .B Cthulhu
user preferences directory user preferences directory
.TP .TP
.BI ~/.local/share/cthulhu/user-settings.toml .BI ~/.local/share/cthulhu/user-settings.conf
.B Cthulhu .B Cthulhu
user preferences configuration file. user preferences configuration file.
.TP .TP
+1 -1
View File
@@ -1,5 +1,5 @@
project('cthulhu', project('cthulhu',
version: '2026.01.19-testing', version: '2026.01.26-master',
meson_version: '>= 1.0.0', meson_version: '>= 1.0.0',
) )
-1
View File
@@ -12,7 +12,6 @@ license = { text = "LGPL-2.1-or-later" }
dependencies = [ dependencies = [
"pygobject>=3.18", "pygobject>=3.18",
"pluggy", "pluggy",
"tomlkit",
"brlapi; extra == 'braille'", "brlapi; extra == 'braille'",
"python-speechd; extra == 'speech'", "python-speechd; extra == 'speech'",
"piper-tts; extra == 'piper'", "piper-tts; extra == 'piper'",
@@ -23,7 +23,7 @@
# Forked from Orca screen reader. # Forked from Orca screen reader.
# Cthulhu project: https://git.stormux.org/storm/cthulhu # Cthulhu project: https://git.stormux.org/storm/cthulhu
"""TOML backend for Cthulhu settings""" """JSON backend for Cthulhu settings"""
__id__ = "$Id$" __id__ = "$Id$"
__version__ = "$Revision$" __version__ = "$Revision$"
@@ -31,22 +31,20 @@ __date__ = "$Date$"
__copyright__ = "Copyright (c) 2010-2011 Consorcio Fernando de los Rios." __copyright__ = "Copyright (c) 2010-2011 Consorcio Fernando de los Rios."
__license__ = "LGPL" __license__ = "LGPL"
from json import load, dump
import os import os
from tomlkit import parse, dumps, document
from cthulhu import settings, acss from cthulhu import settings, acss
class Backend: class Backend:
def __init__(self, prefsDir): def __init__(self, prefsDir):
""" Initialize the TOML Backend. """ Initialize the JSON Backend.
""" """
self.general = {} self.general = {}
self.pronunciations = {} self.pronunciations = {}
self.keybindings = {} self.keybindings = {}
self.profiles = {} self.profiles = {}
self.settingsFile = os.path.join(prefsDir, "user-settings.toml") self.settingsFile = os.path.join(prefsDir, "user-settings.conf")
self.appPrefsDir = os.path.join(prefsDir, "app-settings") self.appPrefsDir = os.path.join(prefsDir, "app-settings")
self._defaultProfiles = {'default': { 'profile': settings.profile, self._defaultProfiles = {'default': { 'profile': settings.profile,
@@ -55,101 +53,46 @@ class Backend:
} }
} }
def _stripNone(self, value):
if isinstance(value, dict):
cleaned = {}
for key, item in value.items():
if item is None:
continue
cleanedItem = self._stripNone(item)
if cleanedItem is None:
continue
cleaned[key] = cleanedItem
return cleaned
if isinstance(value, list):
cleanedList = []
for item in value:
if item is None:
continue
cleanedList.append(self._stripNone(item))
return cleanedList
return value
def _readDocument(self, fileName):
if os.path.exists(fileName):
with open(fileName, 'r', encoding='utf-8') as settingsFile:
return parse(settingsFile.read())
return document()
def _writeDocument(self, fileName, prefsDoc):
with open(fileName, 'w', encoding='utf-8') as settingsFile:
settingsFile.write(dumps(prefsDoc))
def _updateTable(self, targetTable, newValues):
if not isinstance(newValues, dict):
return
for key in list(targetTable.keys()):
if key not in newValues:
del targetTable[key]
continue
newValue = newValues[key]
existingValue = targetTable.get(key)
if isinstance(newValue, dict) and isinstance(existingValue, dict):
self._updateTable(existingValue, newValue)
continue
if existingValue != newValue:
targetTable[key] = newValue
for key, newValue in newValues.items():
if key not in targetTable:
targetTable[key] = newValue
def saveDefaultSettings(self, general, pronunciations, keybindings): def saveDefaultSettings(self, general, pronunciations, keybindings):
""" Save default settings for all the properties from """ Save default settings for all the properties from
cthulhu.settings. """ cthulhu.settings. """
prefs = {'general': self._stripNone(general), prefs = {'general': general,
'profiles': self._stripNone(self._defaultProfiles), 'profiles': self._defaultProfiles,
'pronunciations': self._stripNone(pronunciations), 'pronunciations': pronunciations,
'keybindings': self._stripNone(keybindings)} 'keybindings': keybindings}
self.general = general self.general = general
self.profiles = self._defaultProfiles self.profiles = self._defaultProfiles
self.pronunciations = pronunciations self.pronunciations = pronunciations
self.keybindings = keybindings self.keybindings = keybindings
prefsDoc = document() settingsFile = open(self.settingsFile, 'w')
prefsDoc['general'] = prefs['general'] dump(prefs, settingsFile, indent=4)
prefsDoc['profiles'] = prefs['profiles'] settingsFile.close()
prefsDoc['pronunciations'] = prefs['pronunciations']
prefsDoc['keybindings'] = prefs['keybindings']
self._writeDocument(self.settingsFile, prefsDoc)
def getAppSettings(self, appName): def getAppSettings(self, appName):
fileName = os.path.join(self.appPrefsDir, f"{appName}.toml") fileName = os.path.join(self.appPrefsDir, f"{appName}.conf")
return self._readDocument(fileName) if os.path.exists(fileName):
settingsFile = open(fileName, 'r')
prefs = load(settingsFile)
settingsFile.close()
else:
prefs = {}
return prefs
def saveAppSettings(self, appName, profile, general, pronunciations, keybindings): def saveAppSettings(self, appName, profile, general, pronunciations, keybindings):
prefsDoc = self.getAppSettings(appName) prefs = self.getAppSettings(appName)
profiles = prefsDoc.get('profiles') profiles = prefs.get('profiles', {})
if profiles is None or not isinstance(profiles, dict): profiles[profile] = {'general': general,
prefsDoc['profiles'] = {} 'pronunciations': pronunciations,
profiles = prefsDoc['profiles'] 'keybindings': keybindings}
prefs['profiles'] = profiles
profileTable = profiles.get(profile) fileName = os.path.join(self.appPrefsDir, f"{appName}.conf")
if profileTable is None or not isinstance(profileTable, dict): settingsFile = open(fileName, 'w')
profiles[profile] = {} dump(prefs, settingsFile, indent=4)
profileTable = profiles[profile] settingsFile.close()
self._updateTable(profileTable, {
'general': self._stripNone(general),
'pronunciations': self._stripNone(pronunciations),
'keybindings': self._stripNone(keybindings),
})
fileName = os.path.join(self.appPrefsDir, f"{appName}.toml")
self._writeDocument(fileName, prefsDoc)
def saveProfileSettings(self, profile, general, def saveProfileSettings(self, profile, general,
pronunciations, keybindings): pronunciations, keybindings):
@@ -160,32 +103,25 @@ class Backend:
general['pronunciations'] = pronunciations general['pronunciations'] = pronunciations
general['keybindings'] = keybindings general['keybindings'] = keybindings
general = self._stripNone(general)
prefsDoc = self._readDocument(self.settingsFile) with open(self.settingsFile, 'r+') as settingsFile:
profiles = prefsDoc.get('profiles') prefs = load(settingsFile)
if profiles is None or not isinstance(profiles, dict): prefs['profiles'][profile] = general
prefsDoc['profiles'] = {} settingsFile.seek(0)
profiles = prefsDoc['profiles'] settingsFile.truncate()
dump(prefs, settingsFile, indent=4)
profileTable = profiles.get(profile)
if profileTable is None or not isinstance(profileTable, dict):
profiles[profile] = {}
profileTable = profiles[profile]
self._updateTable(profileTable, general)
self._writeDocument(self.settingsFile, prefsDoc)
def _getSettings(self): def _getSettings(self):
""" Load from config file all settings """ """ Load from config file all settings """
prefsDoc = self._readDocument(self.settingsFile) settingsFile = open(self.settingsFile)
try: try:
self.general = dict(prefsDoc.get('general', {})) prefs = load(settingsFile)
self.pronunciations = dict(prefsDoc.get('pronunciations', {})) except ValueError:
self.keybindings = dict(prefsDoc.get('keybindings', {}))
self.profiles = dict(prefsDoc.get('profiles', {}))
except Exception:
return return
self.general = prefs['general'].copy()
self.pronunciations = prefs['pronunciations']
self.keybindings = prefs['keybindings']
self.profiles = prefs['profiles'].copy()
def _migrateSettings(self, settingsDict): def _migrateSettings(self, settingsDict):
"""Migrate old setting names to new ones.""" """Migrate old setting names to new ones."""
@@ -265,18 +201,12 @@ class Backend:
def _setProfileKey(self, key, value): def _setProfileKey(self, key, value):
self.general[key] = value self.general[key] = value
prefsDoc = self._readDocument(self.settingsFile) with open(self.settingsFile, 'r+') as settingsFile:
general = prefsDoc.get('general') prefs = load(settingsFile)
if general is None or not isinstance(general, dict): prefs['general'][key] = value
prefsDoc['general'] = {} settingsFile.seek(0)
general = prefsDoc['general'] settingsFile.truncate()
dump(prefs, settingsFile, indent=4)
if value is None:
if key in general:
del general[key]
else:
general[key] = value
self._writeDocument(self.settingsFile, prefsDoc)
def setFirstStart(self, value=False): def setFirstStart(self, value=False):
"""Set firstStart. This user-configurable setting is primarily """Set firstStart. This user-configurable setting is primarily
@@ -308,8 +238,10 @@ class Backend:
if profile in self.profiles: if profile in self.profiles:
removeProfileFrom(self.profiles) removeProfileFrom(self.profiles)
prefsDoc = self._readDocument(self.settingsFile) with open(self.settingsFile, 'r+') as settingsFile:
profiles = prefsDoc.get('profiles') prefs = load(settingsFile)
if isinstance(profiles, dict) and profile in profiles: if profile in prefs['profiles']:
removeProfileFrom(profiles) removeProfileFrom(prefs['profiles'])
self._writeDocument(self.settingsFile, prefsDoc) settingsFile.seek(0)
settingsFile.truncate()
dump(prefs, settingsFile, indent=4)
+1 -1
View File
@@ -1,6 +1,6 @@
backends_python_sources = files([ backends_python_sources = files([
'__init__.py', '__init__.py',
'toml_backend.py', 'json_backend.py',
]) ])
python3.install_sources( python3.install_sources(
+2 -2
View File
@@ -23,5 +23,5 @@
# Forked from Orca screen reader. # Forked from Orca screen reader.
# Cthulhu project: https://git.stormux.org/storm/cthulhu # Cthulhu project: https://git.stormux.org/storm/cthulhu
version = "2026.01.19" version = "2026.01.26"
codeName = "testing" codeName = "master"
-5
View File
@@ -180,8 +180,6 @@ class CthulhuSetupGUI(cthulhu_gtkbuilder.GtkBuilderWrapper):
self.savedGain = None self.savedGain = None
self.savedPitch = None self.savedPitch = None
self.savedRate = None self.savedRate = None
self.soundThemeCombo = None
self.roleSoundPresentationCombo = None
self._isInitialSetup = False self._isInitialSetup = False
self.selectedFamilyChoices = {} self.selectedFamilyChoices = {}
self.selectedLanguageChoices = {} self.selectedLanguageChoices = {}
@@ -2673,8 +2671,6 @@ class CthulhuSetupGUI(cthulhu_gtkbuilder.GtkBuilderWrapper):
# Get widget references # Get widget references
self.soundThemeCombo = self.get_widget("soundThemeCombo") self.soundThemeCombo = self.get_widget("soundThemeCombo")
self.roleSoundPresentationCombo = self.get_widget("roleSoundPresentationCombo") self.roleSoundPresentationCombo = self.get_widget("roleSoundPresentationCombo")
self.soundThemeCombo.set_can_focus(False)
self.roleSoundPresentationCombo.set_can_focus(False)
# Populate sound theme combo box # Populate sound theme combo box
themeManager = sound_theme_manager.getManager() themeManager = sound_theme_manager.getManager()
@@ -2732,7 +2728,6 @@ class CthulhuSetupGUI(cthulhu_gtkbuilder.GtkBuilderWrapper):
value = self._roleSoundPresentationChoices[activeIndex][0] value = self._roleSoundPresentationChoices[activeIndex][0]
self.prefsDict["roleSoundPresentation"] = value self.prefsDict["roleSoundPresentation"] = value
def _updateCthulhuModifier(self): def _updateCthulhuModifier(self):
combobox = self.get_widget("cthulhuModifierComboBox") combobox = self.get_widget("cthulhuModifierComboBox")
keystring = ", ".join(self.prefsDict["cthulhuModifierKeys"]) keystring = ", ".join(self.prefsDict["cthulhuModifierKeys"])
+10 -1
View File
@@ -1139,7 +1139,7 @@ class RemoteControllerEvent(InputEvent):
class InputEventHandler: class InputEventHandler:
def __init__(self, function, description, learnModeEnabled=True): def __init__(self, function, description, learnModeEnabled=True, enabled=True):
"""Creates a new InputEventHandler instance. All bindings """Creates a new InputEventHandler instance. All bindings
(e.g., key bindings and braille bindings) will be handled (e.g., key bindings and braille bindings) will be handled
by an instance of an InputEventHandler. by an instance of an InputEventHandler.
@@ -1159,6 +1159,15 @@ class InputEventHandler:
self.function = function self.function = function
self.description = description self.description = description
self.learnModeEnabled = learnModeEnabled self.learnModeEnabled = learnModeEnabled
self._enabled = enabled
def is_enabled(self):
"""Returns True if this handler is enabled."""
return self._enabled
def set_enabled(self, enabled):
"""Sets enabled state of this handler."""
self._enabled = enabled
def __eq__(self, other): def __eq__(self, other):
"""Compares one input handler to another.""" """Compares one input handler to another."""
+13 -37
View File
@@ -74,7 +74,10 @@ class InputEventManager:
msg = "INPUT EVENT MANAGER: Starting key watcher." msg = "INPUT EVENT MANAGER: Starting key watcher."
debug.print_message(debug.LEVEL_INFO, msg, True) debug.print_message(debug.LEVEL_INFO, msg, True)
if Atspi.get_version() >= (2, 55, 90):
self._device = Atspi.Device.new_full("org.stormux.Cthulhu") self._device = Atspi.Device.new_full("org.stormux.Cthulhu")
else:
self._device = Atspi.Device.new()
self._device.add_key_watcher(self.process_keyboard_event) self._device.add_key_watcher(self.process_keyboard_event)
def stop_key_watcher(self) -> None: def stop_key_watcher(self) -> None:
@@ -100,43 +103,30 @@ class InputEventManager:
msg = f"INPUT EVENT MANAGER: {grab_id} for: {binding}" msg = f"INPUT EVENT MANAGER: {grab_id} for: {binding}"
debug.print_message(debug.LEVEL_INFO, msg, True) debug.print_message(debug.LEVEL_INFO, msg, True)
def _get_key_definitions(self, binding: keybindings.KeyBinding) -> List[Atspi.KeyDefinition]:
if hasattr(binding, "key_definitions"):
return list(binding.key_definitions())
if hasattr(binding, "keyDefs"):
return list(binding.keyDefs())
return []
def add_grabs_for_keybinding(self, binding: keybindings.KeyBinding) -> List[int]: def add_grabs_for_keybinding(self, binding: keybindings.KeyBinding) -> List[int]:
"""Adds grabs for binding if it is enabled, returns grab IDs.""" """Adds grabs for binding if it is enabled, returns grab IDs."""
if not (binding.is_enabled() and binding.is_bound()):
return []
if binding.has_grabs():
tokens = ["INPUT EVENT MANAGER:", binding, "already has grabs."]
debug.print_tokens(debug.LEVEL_INFO, tokens, True)
return []
if self._device is None: if self._device is None:
tokens = ["INPUT EVENT MANAGER: No device to add grab for", binding] tokens = ["INPUT EVENT MANAGER: No device to add grab for", binding]
debug.print_tokens(debug.LEVEL_INFO, tokens, True) debug.print_tokens(debug.LEVEL_INFO, tokens, True)
return [] return []
grab_ids = [] grab_ids = []
key_definitions = self._get_key_definitions(binding) for kd in binding.key_definitions():
if not key_definitions:
tokens = ["INPUT EVENT MANAGER: No key definitions for", binding]
debug.print_tokens(debug.LEVEL_INFO, tokens, True)
return []
for kd in key_definitions:
grab_id = self._device.add_key_grab(kd, None) grab_id = self._device.add_key_grab(kd, None)
# When we have double/triple-click bindings, the single-click binding will be
# registered first, and subsequent attempts to register what is externally the
# same grab will fail. If we only have a double/triple-click, it succeeds.
# A grab id of 0 indicates failure.
if grab_id == 0: if grab_id == 0:
continue continue
grab_ids.append(grab_id) grab_ids.append(grab_id)
self._grabbed_bindings[grab_id] = binding self._grabbed_bindings[grab_id] = binding
if grab_ids:
tokens = ["INPUT EVENT MANAGER: Added grabs", grab_ids, "for", binding]
debug.print_tokens(debug.LEVEL_INFO, tokens, True)
return grab_ids return grab_ids
def remove_grabs_for_keybinding(self, binding: keybindings.KeyBinding) -> None: def remove_grabs_for_keybinding(self, binding: keybindings.KeyBinding) -> None:
@@ -147,30 +137,18 @@ class InputEventManager:
debug.print_tokens(debug.LEVEL_INFO, tokens, True) debug.print_tokens(debug.LEVEL_INFO, tokens, True)
return return
grab_ids = None
if hasattr(binding, "get_grab_ids"):
grab_ids = binding.get_grab_ids() grab_ids = binding.get_grab_ids()
elif hasattr(binding, "_grab_ids"):
grab_ids = list(binding._grab_ids)
if not grab_ids: if not grab_ids:
tokens = ["INPUT EVENT MANAGER:", binding, "doesn't have grabs to remove."] tokens = ["INPUT EVENT MANAGER:", binding, "doesn't have grabs to remove."]
debug.print_tokens(debug.LEVEL_INFO, tokens, True) debug.print_tokens(debug.LEVEL_INFO, tokens, True)
return return
tokens = ["INPUT EVENT MANAGER: Removing grabs", grab_ids, "for", binding]
debug.print_tokens(debug.LEVEL_INFO, tokens, True)
for grab_id in grab_ids: for grab_id in grab_ids:
self._device.remove_key_grab(grab_id) self._device.remove_key_grab(grab_id)
removed = self._grabbed_bindings.pop(grab_id, None) removed = self._grabbed_bindings.pop(grab_id, None)
if removed is None: if removed is None:
msg = f"INPUT EVENT MANAGER: No key binding for grab id {grab_id}" msg = f"INPUT EVENT MANAGER: No key binding for grab id {grab_id}"
debug.print_message(debug.LEVEL_INFO, msg, True) debug.print_message(debug.LEVEL_INFO, msg, True)
if hasattr(binding, "_grab_ids") and grab_id in binding._grab_ids:
binding._grab_ids.remove(grab_id)
if hasattr(binding, "_grab_ids") and not binding._grab_ids:
delattr(binding, "_grab_ids")
def remove_grab_by_id(self, grab_id: int) -> None: def remove_grab_by_id(self, grab_id: int) -> None:
"""Removes a grab by id.""" """Removes a grab by id."""
@@ -182,10 +160,8 @@ class InputEventManager:
self._device.remove_key_grab(grab_id) self._device.remove_key_grab(grab_id)
binding = self._grabbed_bindings.pop(grab_id, None) binding = self._grabbed_bindings.pop(grab_id, None)
if binding and hasattr(binding, "_grab_ids") and grab_id in binding._grab_ids: if binding and grab_id in binding._grab_ids:
binding._grab_ids.remove(grab_id) binding._grab_ids.remove(grab_id)
if not binding._grab_ids:
delattr(binding, "_grab_ids")
if binding: if binding:
tokens = ["INPUT EVENT MANAGER: Removed grab", grab_id, "for", binding] tokens = ["INPUT EVENT MANAGER: Removed grab", grab_id, "for", binding]
debug.print_tokens(debug.LEVEL_INFO, tokens, True) debug.print_tokens(debug.LEVEL_INFO, tokens, True)
+161 -152
View File
@@ -26,6 +26,8 @@
"""Provides support for defining keybindings and matching them to input """Provides support for defining keybindings and matching them to input
events.""" events."""
from __future__ import annotations
__id__ = "$Id$" __id__ = "$Id$"
__version__ = "$Revision$" __version__ = "$Revision$"
__date__ = "$Date$" __date__ = "$Date$"
@@ -39,6 +41,7 @@ from gi.repository import Gdk
from gi.repository import Atspi from gi.repository import Atspi
import functools import functools
from typing import TYPE_CHECKING
from . import debug from . import debug
from . import settings from . import settings
@@ -46,6 +49,9 @@ from . import cthulhu_state
from .cthulhu_i18n import _ from .cthulhu_i18n import _
if TYPE_CHECKING:
from .input_event import KeyboardEvent, InputEventHandler
_keysymsCache = {} _keysymsCache = {}
_keycodeCache = {} _keycodeCache = {}
@@ -80,63 +86,35 @@ NON_LOCKING_MODIFIER_MASK = (1 << Atspi.ModifierType.SHIFT |
1 << MODIFIER_CTHULHU) 1 << MODIFIER_CTHULHU)
defaultModifierMask = NON_LOCKING_MODIFIER_MASK defaultModifierMask = NON_LOCKING_MODIFIER_MASK
def getKeycode(keysym): CAN_USE_KEYSYMS = Atspi.get_version() >= (2, 55, 0)
"""Converts an XKeysym string (e.g., 'KP_Enter') to a keycode that
should match the event.hw_code for key events.
This whole situation is caused by the fact that Solaris chooses
to give us different keycodes for the same key, and the keypad
is the primary place where this happens: if NumLock is not on,
there is no telling the difference between keypad keys and the
other navigation keys (e.g., arrows, page up/down, etc.). One,
for example, would expect to get KP_End for the '1' key on the
keypad if NumLock were not on. Instead, we get 'End' and the
keycode for it matches the keycode for the other 'End' key. Odd.
If NumLock is on, we at least get KP_* keys.
So...when setting up keybindings, we say we're interested in
KeySyms, but those keysyms are carefully chosen so as to result
in a keycode that matches the actual key on the keyboard. This
is why we use KP_1 instead of KP_End and so on in our keybindings.
Arguments:
- keysym: a string that is a valid representation of an XKeysym.
Returns an integer representing a key code that should match the
event.hw_code for key events.
"""
def get_keycodes(keysym):
"""Converts an XKeysym string to a (keyval, keycode) tuple."""
if not keysym: if not keysym:
return 0 return (0, 0)
if keysym not in _keycodeCache: if keysym not in _keycodeCache:
keymap = Gdk.Keymap.get_default() keymap = Gdk.Keymap.get_default()
# Find the numerical value of the keysym
#
keyval = Gdk.keyval_from_name(keysym) keyval = Gdk.keyval_from_name(keysym)
if keyval == 0: if keyval == 0:
return 0 return (0, 0)
# Now find the keycodes for the keysym. Since a keysym can _keycodeCache[keysym] = (keyval, 0)
# be associated with more than one key, we'll shoot for the _success, entries = keymap.get_entries_for_keyval(keyval)
# keysym that's in group 0, regardless of shift level (each
# entry is of the form [keycode, group, level]).
#
_keycodeCache[keysym] = 0
success, entries = keymap.get_entries_for_keyval(keyval)
for entry in entries: for entry in entries:
if entry.group == 0: if entry.group == 0:
_keycodeCache[keysym] = entry.keycode _keycodeCache[keysym] = (keyval, entry.keycode)
break break
if _keycodeCache[keysym] == 0: if _keycodeCache[keysym] == (keyval, 0):
_keycodeCache[keysym] = entries[0].keycode _keycodeCache[keysym] = (keyval, entries[0].keycode)
#print keysym, keyval, entries, _keycodeCache[keysym]
return _keycodeCache[keysym] return _keycodeCache[keysym]
def getKeycode(keysym):
"""Converts an XKeysym string to a keycode. Legacy wrapper."""
return get_keycodes(keysym)[1]
def getModifierNames(mods): def getModifierNames(mods):
"""Gets the modifier names of a numeric modifier mask as a human """Gets the modifier names of a numeric modifier mask as a human
consumable string. consumable string.
@@ -211,13 +189,43 @@ def get_click_countString(count):
return _("triple click") return _("triple click")
return "" return ""
def create_key_definitions(keycode, keyval, modifiers):
"""Returns a list of Atspi key definitions for the given keycode, keyval, and modifiers."""
ret = []
if modifiers & CTHULHU_MODIFIER_MASK:
modifier_list = []
other_modifiers = modifiers & ~CTHULHU_MODIFIER_MASK
from . import input_event_manager
manager = input_event_manager.get_manager()
for key in settings.cthulhuModifierKeys:
mod_keyval, mod_keycode = get_keycodes(key)
if mod_keycode == 0 and key == "Shift_Lock":
mod_keyval, mod_keycode = get_keycodes("Caps_Lock")
if CAN_USE_KEYSYMS:
mod = manager.map_keysym_to_modifier(mod_keyval)
else:
mod = manager.map_keycode_to_modifier(mod_keycode)
if mod:
modifier_list.append(mod | other_modifiers)
else:
modifier_list = [modifiers]
for mod in modifier_list:
kd = Atspi.KeyDefinition()
if CAN_USE_KEYSYMS:
kd.keysym = keyval
else:
kd.keycode = keycode
kd.modifiers = mod
ret.append(kd)
return ret
class KeyBinding: class KeyBinding:
"""A single key binding, consisting of a keycode, a modifier mask, """A single key binding, consisting of a keycode, a modifier mask,
and the InputEventHandler. and the InputEventHandler.
""" """
def __init__(self, keysymstring, modifier_mask, modifiers, handler, def __init__(self, keysymstring, modifier_mask, modifiers, handler,
click_count = 1): click_count = 1, enabled=True):
"""Creates a new key binding. """Creates a new key binding.
Arguments: Arguments:
@@ -237,41 +245,21 @@ class KeyBinding:
self.modifiers = modifiers self.modifiers = modifiers
self.handler = handler self.handler = handler
self.click_count = click_count self.click_count = click_count
self.keycode = None self.keycode = 0
self.keyval = 0
self._enabled = enabled
self._grab_ids = []
def matches(self, keycode, modifiers): def matches(self, keyval, keycode, modifiers):
"""Returns true if this key binding matches the given keycode and """Returns true if this key binding matches the given keyval/keycode and modifier state."""
modifier state.
"""
# We lazily bind the keycode. The primary reason for doing this
# is so that atspi does not have to be initialized before setting
# keybindings in the user's preferences file.
#
if not self.keycode: if not self.keycode:
self.keycode = getKeycode(self.keysymstring) self.keyval, self.keycode = get_keycodes(self.keysymstring)
# Debug logging for DisplayVersion plugin specifically if self.keycode == keycode or self.keyval == keyval:
if self.keysymstring == 'v' and self.modifiers == 257:
with open('/tmp/displayversion_matches.log', 'a') as f:
f.write(f"=== DisplayVersion matches() debug ===\n")
f.write(f"Self keycode: {self.keycode}\n")
f.write(f"Self keysymstring: {self.keysymstring}\n")
f.write(f"Self modifiers: {self.modifiers}\n")
f.write(f"Self modifier_mask: {self.modifier_mask}\n")
f.write(f"Input keycode: {keycode}\n")
f.write(f"Input modifiers: {modifiers}\n")
f.write(f"Keycode match: {self.keycode == keycode}\n")
if self.keycode == keycode:
result = modifiers & self.modifier_mask
f.write(f"Modifier calculation: {modifiers} & {self.modifier_mask} = {result}\n")
f.write(f"Modifier match: {result == self.modifiers}\n")
f.write(f"Overall match: {self.keycode == keycode and (modifiers & self.modifier_mask) == self.modifiers}\n")
if self.keycode == keycode:
result = modifiers & self.modifier_mask result = modifiers & self.modifier_mask
return result == self.modifiers return result == self.modifiers
else:
return False return False
def description(self): def description(self):
@@ -292,41 +280,52 @@ class KeyBinding:
return string.strip() return string.strip()
def keyDefs(self): def is_bound(self):
""" return a list of Atspi key definitions for the given binding. """Returns True if this KeyBinding is bound to a key."""
This may return more than one binding if the Cthulhu modifier is bound return bool(self.keysymstring)
to more than one key.
If AT-SPI is older than 2.40, then this function will not work and def is_enabled(self):
will return an empty set. """Returns True if this KeyBinding is enabled."""
""" return self._enabled
def set_enabled(self, enabled):
"""Set this KeyBinding's enabled state."""
self._enabled = enabled
def get_grab_ids(self):
"""Returns the grab IDs for this KeyBinding."""
return self._grab_ids
def has_grabs(self):
"""Returns True if there are existing grabs associated with this KeyBinding."""
return bool(self._grab_ids)
def add_grabs(self):
"""Adds key grabs for this KeyBinding."""
from . import input_event_manager
self._grab_ids = input_event_manager.get_manager().add_grabs_for_keybinding(self)
def remove_grabs(self):
"""Removes key grabs for this KeyBinding."""
from . import input_event_manager
input_event_manager.get_manager().remove_grabs_for_keybinding(self)
self._grab_ids = []
def key_definitions(self):
"""Return a list of Atspi key definitions for the given binding."""
ret = [] ret = []
if not self.keycode: if not self.keycode:
self.keycode = getKeycode(self.keysymstring) self.keyval, self.keycode = get_keycodes(self.keysymstring)
ret.extend(create_key_definitions(self.keycode, self.keyval, self.modifiers))
if CAN_USE_KEYSYMS and self.modifiers & SHIFT_MODIFIER_MASK:
upper_keyval = Gdk.keyval_to_upper(self.keyval)
if upper_keyval != self.keyval:
ret.extend(create_key_definitions(self.keycode, upper_keyval, self.modifiers))
return ret
if self.modifiers & CTHULHU_MODIFIER_MASK: def keyDefs(self):
device = cthulhu_state.device """Legacy wrapper. Use key_definitions() instead."""
if device is None: return self.key_definitions()
return ret
modList = []
otherMods = self.modifiers & ~CTHULHU_MODIFIER_MASK
numLockMod = device.get_modifier(getKeycode("Num_Lock"))
lockedMods = device.get_locked_modifiers()
numLockOn = lockedMods & numLockMod
for key in settings.cthulhuModifierKeys:
keycode = getKeycode(key)
if keycode == 0 and key == "Shift_Lock":
keycode = getKeycode("Caps_Lock")
mod = device.map_modifier(keycode)
if key != "KP_Insert" or not numLockOn:
modList.append(mod | otherMods)
else:
modList = [self.modifiers]
for mod in modList:
kd = Atspi.KeyDefinition()
kd.keycode = self.keycode
kd.modifiers = mod
ret.append(kd)
return ret
class KeyBindings: class KeyBindings:
"""Structure that maintains a set of KeyBinding instances. """Structure that maintains a set of KeyBinding instances.
@@ -347,7 +346,7 @@ class KeyBindings:
result += "]" result += "]"
return result return result
def add(self, keyBinding): def add(self, keyBinding, include_grabs=False):
"""Adds the given KeyBinding instance to this set of keybindings. """Adds the given KeyBinding instance to this set of keybindings.
""" """
@@ -359,17 +358,29 @@ class KeyBindings:
debug.printMessage(debug.LEVEL_INFO, msg, True) debug.printMessage(debug.LEVEL_INFO, msg, True)
self.keyBindings.append(keyBinding) self.keyBindings.append(keyBinding)
if include_grabs:
keyBinding.add_grabs()
def remove(self, keyBinding): def remove(self, keyBinding, include_grabs=False):
"""Removes the given KeyBinding instance from this set of keybindings. """Removes the given KeyBinding instance from this set of keybindings.
""" """
if keyBinding not in self.keyBindings:
candidates = self.getBindingsForHandler(keyBinding.handler)
if not candidates:
return
for candidate in self.getBindingsForHandler(keyBinding.handler):
self.remove(candidate, include_grabs)
return
if keyBinding.has_grabs():
if include_grabs:
keyBinding.remove_grabs()
try: try:
i = self.keyBindings.index(keyBinding) self.keyBindings.remove(keyBinding)
except Exception: except ValueError:
pass pass
else:
del self.keyBindings[i]
def removeByHandler(self, handler): def removeByHandler(self, handler):
"""Removes the given KeyBinding instance from this set of keybindings. """Removes the given KeyBinding instance from this set of keybindings.
@@ -380,6 +391,38 @@ class KeyBindings:
del self.keyBindings[i - 1] del self.keyBindings[i - 1]
i = i - 1 i = i - 1
def add_key_grabs(self, reason=""):
"""Adds grabs for all enabled bindings in this set of keybindings."""
msg = "KEYBINDINGS: Adding key grabs"
if reason:
msg += f": {reason}"
debug.printMessage(debug.LEVEL_INFO, msg, True)
count = 0
for binding in self.keyBindings:
if binding.is_enabled() and not binding.has_grabs():
count += 1
binding.add_grabs()
msg = f"KEYBINDINGS: {count} key grabs added (total bindings: {len(self.keyBindings)})."
debug.printMessage(debug.LEVEL_INFO, msg, True)
def remove_key_grabs(self, reason=""):
"""Removes all grabs for this set of keybindings."""
msg = "KEYBINDINGS: Removing key grabs"
if reason:
msg += f": {reason}"
debug.printMessage(debug.LEVEL_INFO, msg, True)
count = 0
for binding in self.keyBindings:
if binding.has_grabs():
count += 1
binding.remove_grabs()
msg = f"KEYBINDINGS: {count} key grabs removed (total bindings: {len(self.keyBindings)})."
debug.printMessage(debug.LEVEL_INFO, msg, True)
def hasKeyBinding (self, newKeyBinding, typeOfSearch="strict"): def hasKeyBinding (self, newKeyBinding, typeOfSearch="strict"):
"""Return True if keyBinding is already in self.keyBindings. """Return True if keyBinding is already in self.keyBindings.
@@ -461,6 +504,13 @@ class KeyBindings:
return [kb for kb in self.keyBindings if kb.handler == handler] return [kb for kb in self.keyBindings if kb.handler == handler]
def has_enabled_handler(self, handler):
"""Returns True if the handler is found in this set of keybindings and is enabled."""
for binding in self.keyBindings:
if binding.handler == handler and binding.is_enabled():
return True
return False
def _checkMatchingBindings(self, keyboardEvent, result): def _checkMatchingBindings(self, keyboardEvent, result):
if debug.debugLevel > debug.LEVEL_INFO: if debug.debugLevel > debug.LEVEL_INFO:
return return
@@ -487,39 +537,11 @@ class KeyBindings:
given keycode and modifiers, or None if no match exists. given keycode and modifiers, or None if no match exists.
""" """
import logging
logger = logging.getLogger(__name__)
# Check if this might be the DisplayVersion key combination
event_str = keyboardEvent.event_string if hasattr(keyboardEvent, 'event_string') else 'unknown'
if event_str.lower() == 'v':
logger.info(f"=== KeyBindings.getInputHandler: Looking for handler ===")
logger.info(f"Event string: {event_str}")
logger.info(f"Hardware code: {keyboardEvent.hw_code}")
logger.info(f"Modifiers: {keyboardEvent.modifiers}")
logger.info(f"Total keybindings to check: {len(self.keyBindings)}")
with open('/tmp/keybinding_lookup.log', 'a') as f:
f.write(f"=== Looking for 'v' key handler ===\n")
f.write(f"Event string: {event_str}\n")
f.write(f"Hardware code: {keyboardEvent.hw_code}\n")
f.write(f"Modifiers: {keyboardEvent.modifiers}\n")
f.write(f"Total keybindings: {len(self.keyBindings)}\n")
# Log all keybindings for comparison
for i, kb in enumerate(self.keyBindings):
if 'v' in kb.keysymstring.lower() or 'version' in kb.handler.description.lower():
logger.info(f"Binding {i}: keysym={kb.keysymstring}, modifiers={kb.modifiers}, mask={kb.modifier_mask}, desc={kb.handler.description}")
with open('/tmp/keybinding_lookup.log', 'a') as f:
f.write(f"Found V-related binding {i}: keysym={kb.keysymstring}, modifiers={kb.modifiers}, mask={kb.modifier_mask}, desc={kb.handler.description}\n")
matches = [] matches = []
candidates = [] candidates = []
clickCount = keyboardEvent.get_click_count() clickCount = keyboardEvent.get_click_count()
for keyBinding in self.keyBindings: for keyBinding in self.keyBindings:
if keyBinding.matches(keyboardEvent.hw_code, keyboardEvent.modifiers): if keyBinding.matches(keyboardEvent.id, keyboardEvent.hw_code, keyboardEvent.modifiers):
if event_str.lower() == 'v':
logger.info(f"MATCH found! keysym={keyBinding.keysymstring}, desc={keyBinding.handler.description}")
if (keyboardEvent.modifiers & keyBinding.modifier_mask) == keyBinding.modifiers and \ if (keyboardEvent.modifiers & keyBinding.modifier_mask) == keyBinding.modifiers and \
keyBinding.click_count == clickCount: keyBinding.click_count == clickCount:
matches.append(keyBinding) matches.append(keyBinding)
@@ -529,17 +551,8 @@ class KeyBindings:
if keyBinding.keysymstring: if keyBinding.keysymstring:
candidates.append(keyBinding) candidates.append(keyBinding)
if event_str.lower() == 'v':
logger.info(f"Exact matches: {len(matches)}")
logger.info(f"Candidates: {len(candidates)}")
with open('/tmp/keybinding_lookup.log', 'a') as f:
f.write(f"Exact matches: {len(matches)}\n")
f.write(f"Candidates: {len(candidates)}\n")
self._checkMatchingBindings(keyboardEvent, matches) self._checkMatchingBindings(keyboardEvent, matches)
if matches: if matches:
if event_str.lower() == 'v':
logger.info(f"Returning exact match handler: {matches[0].handler.description}")
return matches[0].handler return matches[0].handler
if keyboardEvent.isKeyPadKeyWithNumlockOn(): if keyboardEvent.isKeyPadKeyWithNumlockOn():
@@ -553,12 +566,8 @@ class KeyBindings:
self._checkMatchingBindings(keyboardEvent, candidates) self._checkMatchingBindings(keyboardEvent, candidates)
for candidate in candidates: for candidate in candidates:
if candidate.click_count <= clickCount: if candidate.click_count <= clickCount:
if event_str.lower() == 'v':
logger.info(f"Returning candidate handler: {candidate.handler.description}")
return candidate.handler return candidate.handler
if event_str.lower() == 'v':
logger.info("No handler found!")
return None return None
def load(self, keymap, handlers): def load(self, keymap, handlers):
+13 -11
View File
@@ -140,19 +140,21 @@ Access comprehensive OCR settings through Cthulhu Preferences:
### Configuration File ### Configuration File
Settings are automatically stored in Cthulhu's configuration system: Settings are automatically stored in Cthulhu's configuration system:
- **Global Settings**: `~/.local/share/cthulhu/user-settings.toml` - **Global Settings**: `~/.local/share/cthulhu/user-settings.conf`
- **Application Settings**: `~/.local/share/cthulhu/app-settings/<app>.toml` - **Profile Settings**: `~/.local/share/cthulhu/app-settings/[profile]/`
### Example Configuration Values ### Example Configuration Values
```toml ```json
ocrLanguageCode = "eng" {
ocrScaleFactor = 3 "ocrLanguageCode": "eng",
ocrGrayscaleImg = false "ocrScaleFactor": 3,
ocrInvertImg = false "ocrGrayscaleImg": false,
ocrBlackWhiteImg = false "ocrInvertImg": false,
ocrBlackWhiteImgValue = 200 "ocrBlackWhiteImg": false,
ocrColorCalculation = false "ocrBlackWhiteImgValue": 200,
ocrCopyToClipboard = true "ocrColorCalculation": false,
"ocrCopyToClipboard": true
}
``` ```
## Troubleshooting ## Troubleshooting
+1 -1
View File
@@ -4455,7 +4455,7 @@ class Utilities:
tokens = ["SCRIPT UTILITIES: ", obj, f"has {nRows} rows"] tokens = ["SCRIPT UTILITIES: ", obj, f"has {nRows} rows"]
debug.printTokens(debug.LEVEL_INFO, tokens, True) debug.printTokens(debug.LEVEL_INFO, tokens, True)
x, y, width, height = boundingbox x, y, width, height = boundingbox.x, boundingbox.y, boundingbox.width, boundingbox.height
cell = self.descendantAtPoint(obj, x, y + 1) cell = self.descendantAtPoint(obj, x, y + 1)
row, col = self.coordinatesForCell(cell) row, col = self.coordinatesForCell(cell)
startIndex = max(0, row) startIndex = max(0, row)
+7 -7
View File
@@ -36,11 +36,11 @@ __license__ = "LGPL"
import copy import copy
import importlib import importlib
import json
import os import os
from typing import TYPE_CHECKING, Any, Dict, List, Optional, Union from typing import TYPE_CHECKING, Any, Dict, List, Optional, Union
from gi.repository import Gio, GLib from gi.repository import Gio, GLib
from tomlkit import parse
from . import debug from . import debug
from . import cthulhu_i18n from . import cthulhu_i18n
@@ -62,7 +62,7 @@ class SettingsManager(object):
"""Settings backend manager. This class manages cthulhu user's settings """Settings backend manager. This class manages cthulhu user's settings
using different backends""" using different backends"""
def __init__(self, app: Cthulhu, backend: str = 'toml') -> None: # Modified signature def __init__(self, app: Cthulhu, backend: str = 'json') -> None: # Modified signature
debug.printMessage(debug.LEVEL_INFO, 'SETTINGS MANAGER: Initializing', True) debug.printMessage(debug.LEVEL_INFO, 'SETTINGS MANAGER: Initializing', True)
self.app: Cthulhu = app # Store app instance self.app: Cthulhu = app # Store app instance
@@ -259,13 +259,13 @@ class SettingsManager(object):
if not self._prefsDir: if not self._prefsDir:
return {} return {}
settings_path = os.path.join(self._prefsDir, "user-settings.toml") settings_path = os.path.join(self._prefsDir, "user-settings.conf")
if not os.path.exists(settings_path): if not os.path.exists(settings_path):
return {} return {}
try: try:
with open(settings_path, "r", encoding="utf-8") as settings_file: with open(settings_path, "r", encoding="utf-8") as settings_file:
prefs = parse(settings_file.read()) prefs = json.load(settings_file)
except Exception as error: except Exception as error:
msg = f"SETTINGS MANAGER: Unable to read default settings from {settings_path}: {error}" msg = f"SETTINGS MANAGER: Unable to read default settings from {settings_path}: {error}"
debug.printMessage(debug.LEVEL_WARNING, msg, True) debug.printMessage(debug.LEVEL_WARNING, msg, True)
@@ -572,10 +572,9 @@ class SettingsManager(object):
"""Return the current general settings. """Return the current general settings.
Those settings comes from updating the default settings Those settings comes from updating the default settings
with the profiles' ones""" with the profiles' ones"""
general = self.defaultGeneral.copy()
if self._backend: if self._backend:
general.update(self._backend.getGeneral(profile)) return self._backend.getGeneral(profile)
return general return {}
def getPronunciations(self, profile: str = 'default') -> Dict[str, Any]: def getPronunciations(self, profile: str = 'default') -> Dict[str, Any]:
"""Return the current pronunciations settings. """Return the current pronunciations settings.
@@ -822,3 +821,4 @@ def getManager() -> Optional[SettingsManager]:
# During import phase, cthulhuApp may not exist yet # During import phase, cthulhuApp may not exist yet
pass pass
return _managerInstance return _managerInstance
+4 -24
View File
@@ -215,33 +215,12 @@ class SoundThemeManager:
def getSoundPath(self, themeName, soundName): def getSoundPath(self, themeName, soundName):
"""Get path to a specific sound file. """Get path to a specific sound file.
Checks for common audio file extensions: .wav, .ogg, .mp3, .flac. Checks for common audio file extensions: .wav, .ogg, .mp3, .flac
User themes are checked first, then system themes as fallback so
partial user themes can inherit missing sounds.
""" """
themePaths = [] themePath = self.getThemePath(themeName)
if not themePath:
userPath = os.path.join(self.getUserSoundsDir(), themeName)
if os.path.isdir(userPath):
themePaths.append(userPath)
systemPath = os.path.join(self.getSystemSoundsDir(), themeName)
if os.path.isdir(systemPath):
themePaths.append(systemPath)
fallbackDirs = [
'/usr/share/cthulhu/sounds',
'/usr/local/share/cthulhu/sounds',
]
for fallbackDir in fallbackDirs:
fallbackPath = os.path.join(fallbackDir, themeName)
if os.path.isdir(fallbackPath) and fallbackPath not in themePaths:
themePaths.append(fallbackPath)
if not themePaths:
return None return None
for themePath in themePaths:
for ext in ['.wav', '.ogg', '.mp3', '.flac']: for ext in ['.wav', '.ogg', '.mp3', '.flac']:
soundPath = os.path.join(themePath, soundName + ext) soundPath = os.path.join(themePath, soundName + ext)
if os.path.isfile(soundPath): if os.path.isfile(soundPath):
@@ -416,3 +395,4 @@ def getManager():
from . import cthulhu from . import cthulhu
_manager = SoundThemeManager(cthulhu.cthulhuApp) _manager = SoundThemeManager(cthulhu.cthulhuApp)
return _manager return _manager