3 Commits

10 changed files with 229 additions and 90 deletions
+1
View File
@@ -27,6 +27,7 @@ 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
@@ -0,0 +1,46 @@
# 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.conf .BI ~/.local/share/cthulhu/user-settings.toml
.B Cthulhu .B Cthulhu
user preferences configuration file. user preferences configuration file.
.TP .TP
+1
View File
@@ -12,6 +12,7 @@ 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'",
+1 -1
View File
@@ -1,6 +1,6 @@
backends_python_sources = files([ backends_python_sources = files([
'__init__.py', '__init__.py',
'json_backend.py', 'toml_backend.py',
]) ])
python3.install_sources( python3.install_sources(
@@ -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
"""JSON backend for Cthulhu settings""" """TOML backend for Cthulhu settings"""
__id__ = "$Id$" __id__ = "$Id$"
__version__ = "$Revision$" __version__ = "$Revision$"
@@ -31,20 +31,22 @@ __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 JSON Backend. """ Initialize the TOML 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.conf") self.settingsFile = os.path.join(prefsDir, "user-settings.toml")
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,
@@ -53,46 +55,101 @@ 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': general, prefs = {'general': self._stripNone(general),
'profiles': self._defaultProfiles, 'profiles': self._stripNone(self._defaultProfiles),
'pronunciations': pronunciations, 'pronunciations': self._stripNone(pronunciations),
'keybindings': keybindings} 'keybindings': self._stripNone(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
settingsFile = open(self.settingsFile, 'w') prefsDoc = document()
dump(prefs, settingsFile, indent=4) prefsDoc['general'] = prefs['general']
settingsFile.close() prefsDoc['profiles'] = prefs['profiles']
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}.conf") fileName = os.path.join(self.appPrefsDir, f"{appName}.toml")
if os.path.exists(fileName): return self._readDocument(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):
prefs = self.getAppSettings(appName) prefsDoc = self.getAppSettings(appName)
profiles = prefs.get('profiles', {}) profiles = prefsDoc.get('profiles')
profiles[profile] = {'general': general, if profiles is None or not isinstance(profiles, dict):
'pronunciations': pronunciations, prefsDoc['profiles'] = {}
'keybindings': keybindings} profiles = prefsDoc['profiles']
prefs['profiles'] = profiles
fileName = os.path.join(self.appPrefsDir, f"{appName}.conf") profileTable = profiles.get(profile)
settingsFile = open(fileName, 'w') if profileTable is None or not isinstance(profileTable, dict):
dump(prefs, settingsFile, indent=4) profiles[profile] = {}
settingsFile.close() profileTable = profiles[profile]
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):
@@ -103,25 +160,32 @@ class Backend:
general['pronunciations'] = pronunciations general['pronunciations'] = pronunciations
general['keybindings'] = keybindings general['keybindings'] = keybindings
general = self._stripNone(general)
with open(self.settingsFile, 'r+') as settingsFile: prefsDoc = self._readDocument(self.settingsFile)
prefs = load(settingsFile) profiles = prefsDoc.get('profiles')
prefs['profiles'][profile] = general if profiles is None or not isinstance(profiles, dict):
settingsFile.seek(0) prefsDoc['profiles'] = {}
settingsFile.truncate() profiles = prefsDoc['profiles']
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 """
settingsFile = open(self.settingsFile) prefsDoc = self._readDocument(self.settingsFile)
try: try:
prefs = load(settingsFile) self.general = dict(prefsDoc.get('general', {}))
except ValueError: self.pronunciations = dict(prefsDoc.get('pronunciations', {}))
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."""
@@ -201,12 +265,18 @@ class Backend:
def _setProfileKey(self, key, value): def _setProfileKey(self, key, value):
self.general[key] = value self.general[key] = value
with open(self.settingsFile, 'r+') as settingsFile: prefsDoc = self._readDocument(self.settingsFile)
prefs = load(settingsFile) general = prefsDoc.get('general')
prefs['general'][key] = value if general is None or not isinstance(general, dict):
settingsFile.seek(0) prefsDoc['general'] = {}
settingsFile.truncate() general = prefsDoc['general']
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
@@ -238,10 +308,8 @@ class Backend:
if profile in self.profiles: if profile in self.profiles:
removeProfileFrom(self.profiles) removeProfileFrom(self.profiles)
with open(self.settingsFile, 'r+') as settingsFile: prefsDoc = self._readDocument(self.settingsFile)
prefs = load(settingsFile) profiles = prefsDoc.get('profiles')
if profile in prefs['profiles']: if isinstance(profiles, dict) and profile in profiles:
removeProfileFrom(prefs['profiles']) removeProfileFrom(profiles)
settingsFile.seek(0) self._writeDocument(self.settingsFile, prefsDoc)
settingsFile.truncate()
dump(prefs, settingsFile, indent=4)
+5
View File
@@ -180,6 +180,8 @@ 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 = {}
@@ -2671,6 +2673,8 @@ 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()
@@ -2728,6 +2732,7 @@ 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"])
+11 -13
View File
@@ -140,21 +140,19 @@ 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.conf` - **Global Settings**: `~/.local/share/cthulhu/user-settings.toml`
- **Profile Settings**: `~/.local/share/cthulhu/app-settings/[profile]/` - **Application Settings**: `~/.local/share/cthulhu/app-settings/<app>.toml`
### Example Configuration Values ### Example Configuration Values
```json ```toml
{ ocrLanguageCode = "eng"
"ocrLanguageCode": "eng", ocrScaleFactor = 3
"ocrScaleFactor": 3, ocrGrayscaleImg = false
"ocrGrayscaleImg": false, ocrInvertImg = false
"ocrInvertImg": false, ocrBlackWhiteImg = false
"ocrBlackWhiteImg": false, ocrBlackWhiteImgValue = 200
"ocrBlackWhiteImgValue": 200, ocrColorCalculation = false
"ocrColorCalculation": false, ocrCopyToClipboard = true
"ocrCopyToClipboard": true
}
``` ```
## Troubleshooting ## Troubleshooting
+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 = 'json') -> None: # Modified signature def __init__(self, app: Cthulhu, backend: str = 'toml') -> 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.conf") settings_path = os.path.join(self._prefsDir, "user-settings.toml")
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 = json.load(settings_file) prefs = parse(settings_file.read())
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,9 +572,10 @@ 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:
return self._backend.getGeneral(profile) general.update(self._backend.getGeneral(profile))
return {} return general
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.
@@ -821,4 +822,3 @@ 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
+24 -4
View File
@@ -215,12 +215,33 @@ 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.
""" """
themePath = self.getThemePath(themeName) themePaths = []
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):
@@ -395,4 +416,3 @@ def getManager():
from . import cthulhu from . import cthulhu
_manager = SoundThemeManager(cthulhu.cthulhuApp) _manager = SoundThemeManager(cthulhu.cthulhuApp)
return _manager return _manager