8 Commits

17 changed files with 428 additions and 133 deletions
+2 -1
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.26 pkgver=2026.02.17
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,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 -1
View File
@@ -1,5 +1,5 @@
project('cthulhu', project('cthulhu',
version: '2026.01.26-master', version: '2026.02.17-master',
meson_version: '>= 1.0.0', meson_version: '>= 1.0.0',
) )
+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)
+1 -1
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.26" version = "2026.02.17"
codeName = "master" codeName = "master"
+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"])
+34
View File
@@ -844,6 +844,11 @@ class KeyboardEvent(InputEvent):
"shouldConsume: No handler found", "shouldConsume: No handler found",
reason="no-handler", timestamp=True) reason="no-handler", timestamp=True)
if self._isSleepModeActive():
if self._isSleepModeToggleHandler():
return True, 'Sleep mode toggle command'
return False, 'Sleep mode active'
self._script.updateKeyboardEventState(self, self._handler) self._script.updateKeyboardEventState(self, self._handler)
scriptConsumes = self._script.shouldConsumeKeyboardEvent(self, self._handler) scriptConsumes = self._script.shouldConsumeKeyboardEvent(self, self._handler)
if globalHandlerUsed: if globalHandlerUsed:
@@ -881,6 +886,35 @@ class KeyboardEvent(InputEvent):
return None return None
return global_bindings.getInputHandler(self) return global_bindings.getInputHandler(self)
def _isSleepModeActive(self):
"""Returns True if the script for this event is in sleep mode."""
if not self._script:
return False
if "scripts.sleepmode" in self._script.__module__:
return True
app = getattr(self._script, "app", None)
if app is None:
return False
try:
from . import sleep_mode_manager
manager = sleep_mode_manager.getManager()
return bool(manager and manager.isActiveForApp(app))
except Exception:
return False
def _isSleepModeToggleHandler(self):
"""Returns True if the resolved handler toggles sleep mode."""
if not self._handler or not self._handler.function:
return False
functionName = getattr(self._handler.function, "__name__", "")
return "toggleSleepMode" in functionName
def didConsume(self): def didConsume(self):
"""Returns True if this event was consumed.""" """Returns True if this event was consumed."""
+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
+10
View File
@@ -282,6 +282,16 @@ class ScriptManager:
Returns an instance of a Script. Returns an instance of a Script.
""" """
if app:
try:
from . import sleep_mode_manager
sleepModeManager = sleep_mode_manager.getManager()
sleepModeManager.refreshAutoSleepConfig()
if sleepModeManager and sleepModeManager.isActiveForApp(app):
return self.get_or_create_sleep_mode_script(app)
except Exception as error:
_log_tokens(["Could not check sleep mode for", app, ":", error], "sleep-mode-check-failed")
customScript = None customScript = None
appScript = None appScript = None
toolkitScript = None toolkitScript = None
+10 -29
View File
@@ -75,9 +75,6 @@ class Script(default.Script):
"""Called when this script is deactivated.""" """Called when this script is deactivated."""
debug.printMessage(debug.LEVEL_INFO, "SLEEP MODE SCRIPT: Deactivating", True) debug.printMessage(debug.LEVEL_INFO, "SLEEP MODE SCRIPT: Deactivating", True)
# Restore key grabs
self.addKeyGrabs()
cthulhu_modifier_manager.getManager().refreshCthulhuModifiers("Exiting sleep mode.") cthulhu_modifier_manager.getManager().refreshCthulhuModifiers("Exiting sleep mode.")
super().deactivate() super().deactivate()
@@ -86,18 +83,23 @@ class Script(default.Script):
"""Remove key grabs except for sleep mode toggle.""" """Remove key grabs except for sleep mode toggle."""
try: try:
# First remove all grabs inherited from default activation,
# including modifier grabs.
super().removeKeyGrabs()
self.grab_ids = [] self.grab_ids = []
for keyBinding in self.keyBindings: for keyBinding in self.keyBindings.keyBindings:
if hasattr(keyBinding, 'handler') and hasattr(keyBinding.handler, 'function'): if hasattr(keyBinding, 'handler') and hasattr(keyBinding.handler, 'function'):
if hasattr(keyBinding.handler.function, '__name__'): if hasattr(keyBinding.handler.function, '__name__'):
if 'toggleSleepMode' in keyBinding.handler.function.__name__: if 'toggleSleepMode' in keyBinding.handler.function.__name__:
# Keep sleep mode toggle # Keep sleep mode toggle
try: try:
import cthulhu import cthulhu
grab_id = cthulhu.addKeyGrab(keyBinding) grabIds = cthulhu.addKeyGrab(keyBinding)
if grab_id: if grabIds:
self.grab_ids.append(grab_id) for grabId in grabIds:
debug.printMessage(debug.LEVEL_INFO, f"SLEEP MODE: Kept sleep toggle key grab: {grab_id}", True) self.grab_ids.append(grabId)
debug.printMessage(debug.LEVEL_INFO, f"SLEEP MODE: Kept sleep toggle key grab: {grabId}", True)
except Exception as e: except Exception as e:
debug.printMessage(debug.LEVEL_INFO, f"SLEEP MODE: Error keeping key grab: {e}", True) debug.printMessage(debug.LEVEL_INFO, f"SLEEP MODE: Error keeping key grab: {e}", True)
else: else:
@@ -106,27 +108,6 @@ class Script(default.Script):
except Exception as e: except Exception as e:
debug.printMessage(debug.LEVEL_INFO, f"SLEEP MODE: Error in removeKeyGrabs: {e}", True) debug.printMessage(debug.LEVEL_INFO, f"SLEEP MODE: Error in removeKeyGrabs: {e}", True)
def addKeyGrabs(self):
"""Add back all key grabs."""
try:
# Remove our limited grabs first
if hasattr(self, 'grab_ids'):
import cthulhu
for grab_id in self.grab_ids:
try:
cthulhu.removeKeyGrab(grab_id)
debug.printMessage(debug.LEVEL_INFO, f"SLEEP MODE: Removed key grab: {grab_id}", True)
except Exception as e:
debug.printMessage(debug.LEVEL_INFO, f"SLEEP MODE: Error removing key grab {grab_id}: {e}", True)
self.grab_ids = []
# Let the parent class restore all grabs
super().addKeyGrabs()
except Exception as e:
debug.printMessage(debug.LEVEL_INFO, f"SLEEP MODE: Error in addKeyGrabs: {e}", True)
# Block common event handlers as an additional layer of protection # Block common event handlers as an additional layer of protection
def onCaretMoved(self, event): def onCaretMoved(self, event):
"""Block caret movement events.""" """Block caret movement events."""
+26 -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
@@ -214,6 +214,25 @@ class SettingsManager(object):
if not os.path.exists(userCustomFile): if not os.path.exists(userCustomFile):
os.close(os.open(userCustomFile, os.O_CREAT, 0o700)) os.close(os.open(userCustomFile, os.O_CREAT, 0o700))
sleepConfigFile = os.path.join(cthulhuDir, "sleep.toml")
if not os.path.exists(sleepConfigFile):
sleepTemplate = (
"# Cthulhu auto-sleep apps\n"
"#\n"
"# List current app names with:\n"
"# cthulhu --list-apps\n"
"# Use the middle app-name column from that output.\n"
"#\n"
"# Add app names to auto-enable sleep mode:\n"
"# apps = [\"qemu\"]\n"
"#\n"
"# Or use a section:\n"
"# [sleep]\n"
"# apps = [\"qemu\"]\n"
)
with open(sleepConfigFile, "w", encoding="utf-8") as configFile:
configFile.write(sleepTemplate)
if self.isFirstStart() and self._backend: if self.isFirstStart() and self._backend:
self._backend.saveDefaultSettings(self.defaultGeneral, self._backend.saveDefaultSettings(self.defaultGeneral,
self.defaultPronunciations, self.defaultPronunciations,
@@ -259,13 +278,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 +591,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 +841,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
+112 -5
View File
@@ -31,7 +31,9 @@ __copyright__ = "Copyright (c) 2024 Stormux"
__license__ = "LGPL" __license__ = "LGPL"
import time import time
import os
from gi.repository import GLib from gi.repository import GLib
from tomlkit import parse
import cthulhu.braille as braille import cthulhu.braille as braille
import cthulhu.cmdnames as cmdnames import cthulhu.cmdnames as cmdnames
import cthulhu.debug as debug import cthulhu.debug as debug
@@ -47,7 +49,12 @@ class SleepModeManager:
def __init__(self): def __init__(self):
self._handlers = self.getHandlers(True) self._handlers = self.getHandlers(True)
self._bindings = keybindings.KeyBindings() self._bindings = keybindings.KeyBindings()
self._apps = [] self._apps = set()
self._disabledAutoSleepApps = set()
self._autoSleepAppNames = set()
self._autoSleepPath = self._getAutoSleepPath()
self._autoSleepConfigMTime = None
self._loadAutoSleepConfig()
self._lastToggleTime = 0 self._lastToggleTime = 0
self._toggleDebounceDelay = 0.1 # 100ms debounce (reduced for better responsiveness) self._toggleDebounceDelay = 0.1 # 100ms debounce (reduced for better responsiveness)
@@ -76,12 +83,106 @@ class SleepModeManager:
def isActiveForApp(self, app): def isActiveForApp(self, app):
"""Returns True if sleep mode is active for app.""" """Returns True if sleep mode is active for app."""
result = bool(app and hash(app) in self._apps) if not app:
return False
appHash = hash(app)
result = appHash in self._apps
if not result and self._isAutoSleepConfiguredForApp(app):
result = appHash not in self._disabledAutoSleepApps
if result: if result:
tokens = ["SLEEP MODE MANAGER: Is active for", app] tokens = ["SLEEP MODE MANAGER: Is active for", app]
debug.printTokens(debug.LEVEL_INFO, tokens, True) debug.printTokens(debug.LEVEL_INFO, tokens, True)
return result return result
def _getAutoSleepPath(self):
prefsDir = os.path.join(GLib.get_user_data_dir(), "cthulhu")
try:
from . import cthulhu
app = cthulhu.cthulhuApp
if app and app.settingsManager:
configuredDir = app.settingsManager.getPrefsDir()
if configuredDir:
prefsDir = configuredDir
except Exception:
pass
return os.path.join(prefsDir, "sleep.toml")
def _refreshAutoSleepPath(self):
latestPath = self._getAutoSleepPath()
if latestPath != self._autoSleepPath:
self._autoSleepPath = latestPath
self._autoSleepConfigMTime = None
self._loadAutoSleepConfig()
return
try:
latestMTime = os.path.getmtime(self._autoSleepPath)
except OSError:
latestMTime = None
if latestMTime != self._autoSleepConfigMTime:
self._loadAutoSleepConfig()
def refreshAutoSleepConfig(self):
"""Refresh auto-sleep config if prefs directory has changed."""
self._refreshAutoSleepPath()
def _loadAutoSleepConfig(self):
self._autoSleepAppNames = set()
self._autoSleepConfigMTime = None
if not os.path.isfile(self._autoSleepPath):
msg = f"SLEEP MODE MANAGER: No sleep config at {self._autoSleepPath}"
debug.printMessage(debug.LEVEL_INFO, msg, True)
return
try:
self._autoSleepConfigMTime = os.path.getmtime(self._autoSleepPath)
except OSError:
self._autoSleepConfigMTime = None
try:
with open(self._autoSleepPath, "r", encoding="utf-8") as configFile:
config = parse(configFile.read() or "")
except Exception as error:
tokens = ["SLEEP MODE MANAGER: Failed to parse", self._autoSleepPath, ":", error]
debug.printTokens(debug.LEVEL_WARNING, tokens, True)
return
appNames = []
topLevelApps = config.get("apps", [])
if isinstance(topLevelApps, list):
appNames.extend(topLevelApps)
sleepSection = config.get("sleep", {})
if isinstance(sleepSection, dict):
sectionApps = sleepSection.get("apps", [])
if isinstance(sectionApps, list):
appNames.extend(sectionApps)
for appName in appNames:
if not isinstance(appName, str):
continue
normalizedName = appName.strip().lower()
if normalizedName:
self._autoSleepAppNames.add(normalizedName)
msg = f"SLEEP MODE MANAGER: Loaded {len(self._autoSleepAppNames)} auto-sleep apps from {self._autoSleepPath}"
debug.printMessage(debug.LEVEL_INFO, msg, True)
def _isAutoSleepConfiguredForApp(self, app):
if not app:
return False
if not self._autoSleepAppNames:
return False
appName = (AXObject.get_name(app) or "").strip().lower()
return bool(appName and appName in self._autoSleepAppNames)
def _setupHandlers(self): def _setupHandlers(self):
"""Sets up and returns the sleep-mode-manager input event handlers.""" """Sets up and returns the sleep-mode-manager input event handlers."""
@@ -132,12 +233,16 @@ class SleepModeManager:
if not (script and script.app): if not (script and script.app):
return True return True
from . import cthulhu_state self.refreshAutoSleepConfig()
scriptManager = script_manager.get_manager() scriptManager = script_manager.get_manager()
if self.isActiveForApp(script.app): if self.isActiveForApp(script.app):
# Turning OFF sleep mode # Turning OFF sleep mode
self._apps.remove(hash(script.app)) appHash = hash(script.app)
self._apps.discard(appHash)
if self._isAutoSleepConfiguredForApp(script.app):
self._disabledAutoSleepApps.add(appHash)
newScript = scriptManager.get_script(script.app) newScript = scriptManager.get_script(script.app)
if notifyUser: if notifyUser:
newScript.presentMessage( newScript.presentMessage(
@@ -177,7 +282,9 @@ class SleepModeManager:
debug.printMessage(debug.LEVEL_INFO, f"SLEEP MODE: Active script set successfully", True) debug.printMessage(debug.LEVEL_INFO, f"SLEEP MODE: Active script set successfully", True)
debug.printMessage(debug.LEVEL_INFO, f"SLEEP MODE: Adding app to sleep list", True) debug.printMessage(debug.LEVEL_INFO, f"SLEEP MODE: Adding app to sleep list", True)
self._apps.append(hash(script.app)) appHash = hash(script.app)
self._disabledAutoSleepApps.discard(appHash)
self._apps.add(appHash)
debug.printMessage(debug.LEVEL_INFO, f"SLEEP MODE: Enabled for {AXObject.get_name(script.app)} (delayed)", True) debug.printMessage(debug.LEVEL_INFO, f"SLEEP MODE: Enabled for {AXObject.get_name(script.app)} (delayed)", True)
# Reset debounce timer after successful toggle # Reset debounce timer after successful toggle
self._lastToggleTime = 0 self._lastToggleTime = 0
+28 -8
View File
@@ -215,16 +215,37 @@ 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 ext in ['.wav', '.ogg', '.mp3', '.flac']: for themePath in themePaths:
soundPath = os.path.join(themePath, soundName + ext) for ext in ['.wav', '.ogg', '.mp3', '.flac']:
if os.path.isfile(soundPath): soundPath = os.path.join(themePath, soundName + ext)
return soundPath if os.path.isfile(soundPath):
return soundPath
return None return None
@@ -395,4 +416,3 @@ def getManager():
from . import cthulhu from . import cthulhu
_manager = SoundThemeManager(cthulhu.cthulhuApp) _manager = SoundThemeManager(cthulhu.cthulhuApp)
return _manager return _manager
+11 -6
View File
@@ -748,15 +748,20 @@ class SpeechGenerator(generator.Generator):
method for scripts to call. method for scripts to call.
""" """
generated = self._generateRoleName(obj, **args) generated = self._generateRoleName(obj, **args)
if generated: return self._getFirstString(generated)
return generated[0]
return ""
def getName(self, obj, **args): def getName(self, obj, **args):
generated = self._generateName(obj, **args) generated = self._generateName(obj, **args)
if generated: return self._getFirstString(generated)
return generated[0]
def _getFirstString(self, generated):
for item in generated or []:
if isinstance(item, str):
return item
if isinstance(item, list):
nestedString = self._getFirstString(item)
if nestedString:
return nestedString
return "" return ""