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>
pkgname=cthulhu
pkgver=2026.01.26
pkgver=2026.02.17
pkgrel=1
pkgdesc="Desktop-agnostic screen reader with plugin system, forked from Orca"
url="https://git.stormux.org/storm/cthulhu"
@@ -27,6 +27,7 @@ depends=(
# Plugin system and D-Bus remote control
python-pluggy
python-tomlkit
python-dasbus
# 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
user preferences directory
.TP
.BI ~/.local/share/cthulhu/user-settings.conf
.BI ~/.local/share/cthulhu/user-settings.toml
.B Cthulhu
user preferences configuration file.
.TP
+1 -1
View File
@@ -1,5 +1,5 @@
project('cthulhu',
version: '2026.01.26-master',
version: '2026.02.17-master',
meson_version: '>= 1.0.0',
)
+1
View File
@@ -12,6 +12,7 @@ license = { text = "LGPL-2.1-or-later" }
dependencies = [
"pygobject>=3.18",
"pluggy",
"tomlkit",
"brlapi; extra == 'braille'",
"python-speechd; extra == 'speech'",
"piper-tts; extra == 'piper'",
+2 -2
View File
@@ -1,9 +1,9 @@
backends_python_sources = files([
'__init__.py',
'json_backend.py',
'toml_backend.py',
])
python3.install_sources(
backends_python_sources,
subdir: 'cthulhu/backends'
)
)
@@ -23,7 +23,7 @@
# Forked from Orca screen reader.
# Cthulhu project: https://git.stormux.org/storm/cthulhu
"""JSON backend for Cthulhu settings"""
"""TOML backend for Cthulhu settings"""
__id__ = "$Id$"
__version__ = "$Revision$"
@@ -31,20 +31,22 @@ __date__ = "$Date$"
__copyright__ = "Copyright (c) 2010-2011 Consorcio Fernando de los Rios."
__license__ = "LGPL"
from json import load, dump
import os
from tomlkit import parse, dumps, document
from cthulhu import settings, acss
class Backend:
def __init__(self, prefsDir):
""" Initialize the JSON Backend.
"""
""" Initialize the TOML Backend.
"""
self.general = {}
self.pronunciations = {}
self.keybindings = {}
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._defaultProfiles = {'default': { 'profile': settings.profile,
@@ -53,75 +55,137 @@ 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):
""" Save default settings for all the properties from
cthulhu.settings. """
prefs = {'general': general,
'profiles': self._defaultProfiles,
'pronunciations': pronunciations,
'keybindings': keybindings}
prefs = {'general': self._stripNone(general),
'profiles': self._stripNone(self._defaultProfiles),
'pronunciations': self._stripNone(pronunciations),
'keybindings': self._stripNone(keybindings)}
self.general = general
self.profiles = self._defaultProfiles
self.pronunciations = pronunciations
self.keybindings = keybindings
settingsFile = open(self.settingsFile, 'w')
dump(prefs, settingsFile, indent=4)
settingsFile.close()
prefsDoc = document()
prefsDoc['general'] = prefs['general']
prefsDoc['profiles'] = prefs['profiles']
prefsDoc['pronunciations'] = prefs['pronunciations']
prefsDoc['keybindings'] = prefs['keybindings']
self._writeDocument(self.settingsFile, prefsDoc)
def getAppSettings(self, appName):
fileName = os.path.join(self.appPrefsDir, f"{appName}.conf")
if os.path.exists(fileName):
settingsFile = open(fileName, 'r')
prefs = load(settingsFile)
settingsFile.close()
else:
prefs = {}
return prefs
fileName = os.path.join(self.appPrefsDir, f"{appName}.toml")
return self._readDocument(fileName)
def saveAppSettings(self, appName, profile, general, pronunciations, keybindings):
prefs = self.getAppSettings(appName)
profiles = prefs.get('profiles', {})
profiles[profile] = {'general': general,
'pronunciations': pronunciations,
'keybindings': keybindings}
prefs['profiles'] = profiles
prefsDoc = self.getAppSettings(appName)
profiles = prefsDoc.get('profiles')
if profiles is None or not isinstance(profiles, dict):
prefsDoc['profiles'] = {}
profiles = prefsDoc['profiles']
fileName = os.path.join(self.appPrefsDir, f"{appName}.conf")
settingsFile = open(fileName, 'w')
dump(prefs, settingsFile, indent=4)
settingsFile.close()
profileTable = profiles.get(profile)
if profileTable is None or not isinstance(profileTable, dict):
profiles[profile] = {}
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,
pronunciations, keybindings):
""" Save minimal subset defined in the profile against current
""" Save minimal subset defined in the profile against current
defaults. """
if profile is None:
profile = 'default'
general['pronunciations'] = pronunciations
general['keybindings'] = keybindings
general = self._stripNone(general)
with open(self.settingsFile, 'r+') as settingsFile:
prefs = load(settingsFile)
prefs['profiles'][profile] = general
settingsFile.seek(0)
settingsFile.truncate()
dump(prefs, settingsFile, indent=4)
prefsDoc = self._readDocument(self.settingsFile)
profiles = prefsDoc.get('profiles')
if profiles is None or not isinstance(profiles, dict):
prefsDoc['profiles'] = {}
profiles = prefsDoc['profiles']
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):
""" Load from config file all settings """
settingsFile = open(self.settingsFile)
prefsDoc = self._readDocument(self.settingsFile)
try:
prefs = load(settingsFile)
except ValueError:
self.general = dict(prefsDoc.get('general', {}))
self.pronunciations = dict(prefsDoc.get('pronunciations', {}))
self.keybindings = dict(prefsDoc.get('keybindings', {}))
self.profiles = dict(prefsDoc.get('profiles', {}))
except Exception:
return
self.general = prefs['general'].copy()
self.pronunciations = prefs['pronunciations']
self.keybindings = prefs['keybindings']
self.profiles = prefs['profiles'].copy()
def _migrateSettings(self, settingsDict):
"""Migrate old setting names to new ones."""
@@ -195,18 +259,24 @@ class Backend:
def isFirstStart(self):
""" Check if we're in first start. """
return not os.path.exists(self.settingsFile)
def _setProfileKey(self, key, value):
self.general[key] = value
with open(self.settingsFile, 'r+') as settingsFile:
prefs = load(settingsFile)
prefs['general'][key] = value
settingsFile.seek(0)
settingsFile.truncate()
dump(prefs, settingsFile, indent=4)
prefsDoc = self._readDocument(self.settingsFile)
general = prefsDoc.get('general')
if general is None or not isinstance(general, dict):
prefsDoc['general'] = {}
general = prefsDoc['general']
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):
"""Set firstStart. This user-configurable setting is primarily
@@ -238,10 +308,8 @@ class Backend:
if profile in self.profiles:
removeProfileFrom(self.profiles)
with open(self.settingsFile, 'r+') as settingsFile:
prefs = load(settingsFile)
if profile in prefs['profiles']:
removeProfileFrom(prefs['profiles'])
settingsFile.seek(0)
settingsFile.truncate()
dump(prefs, settingsFile, indent=4)
prefsDoc = self._readDocument(self.settingsFile)
profiles = prefsDoc.get('profiles')
if isinstance(profiles, dict) and profile in profiles:
removeProfileFrom(profiles)
self._writeDocument(self.settingsFile, prefsDoc)
+1 -1
View File
@@ -23,5 +23,5 @@
# Forked from Orca screen reader.
# Cthulhu project: https://git.stormux.org/storm/cthulhu
version = "2026.01.26"
version = "2026.02.17"
codeName = "master"
+5
View File
@@ -180,6 +180,8 @@ class CthulhuSetupGUI(cthulhu_gtkbuilder.GtkBuilderWrapper):
self.savedGain = None
self.savedPitch = None
self.savedRate = None
self.soundThemeCombo = None
self.roleSoundPresentationCombo = None
self._isInitialSetup = False
self.selectedFamilyChoices = {}
self.selectedLanguageChoices = {}
@@ -2671,6 +2673,8 @@ class CthulhuSetupGUI(cthulhu_gtkbuilder.GtkBuilderWrapper):
# Get widget references
self.soundThemeCombo = self.get_widget("soundThemeCombo")
self.roleSoundPresentationCombo = self.get_widget("roleSoundPresentationCombo")
self.soundThemeCombo.set_can_focus(False)
self.roleSoundPresentationCombo.set_can_focus(False)
# Populate sound theme combo box
themeManager = sound_theme_manager.getManager()
@@ -2728,6 +2732,7 @@ class CthulhuSetupGUI(cthulhu_gtkbuilder.GtkBuilderWrapper):
value = self._roleSoundPresentationChoices[activeIndex][0]
self.prefsDict["roleSoundPresentation"] = value
def _updateCthulhuModifier(self):
combobox = self.get_widget("cthulhuModifierComboBox")
keystring = ", ".join(self.prefsDict["cthulhuModifierKeys"])
+34
View File
@@ -844,6 +844,11 @@ class KeyboardEvent(InputEvent):
"shouldConsume: No handler found",
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)
scriptConsumes = self._script.shouldConsumeKeyboardEvent(self, self._handler)
if globalHandlerUsed:
@@ -881,6 +886,35 @@ class KeyboardEvent(InputEvent):
return None
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):
"""Returns True if this event was consumed."""
+11 -13
View File
@@ -140,21 +140,19 @@ Access comprehensive OCR settings through Cthulhu Preferences:
### Configuration File
Settings are automatically stored in Cthulhu's configuration system:
- **Global Settings**: `~/.local/share/cthulhu/user-settings.conf`
- **Profile Settings**: `~/.local/share/cthulhu/app-settings/[profile]/`
- **Global Settings**: `~/.local/share/cthulhu/user-settings.toml`
- **Application Settings**: `~/.local/share/cthulhu/app-settings/<app>.toml`
### Example Configuration Values
```json
{
"ocrLanguageCode": "eng",
"ocrScaleFactor": 3,
"ocrGrayscaleImg": false,
"ocrInvertImg": false,
"ocrBlackWhiteImg": false,
"ocrBlackWhiteImgValue": 200,
"ocrColorCalculation": false,
"ocrCopyToClipboard": true
}
```toml
ocrLanguageCode = "eng"
ocrScaleFactor = 3
ocrGrayscaleImg = false
ocrInvertImg = false
ocrBlackWhiteImg = false
ocrBlackWhiteImgValue = 200
ocrColorCalculation = false
ocrCopyToClipboard = true
```
## Troubleshooting
+10
View File
@@ -282,6 +282,16 @@ class ScriptManager:
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
appScript = None
toolkitScript = None
+10 -29
View File
@@ -75,9 +75,6 @@ class Script(default.Script):
"""Called when this script is deactivated."""
debug.printMessage(debug.LEVEL_INFO, "SLEEP MODE SCRIPT: Deactivating", True)
# Restore key grabs
self.addKeyGrabs()
cthulhu_modifier_manager.getManager().refreshCthulhuModifiers("Exiting sleep mode.")
super().deactivate()
@@ -86,18 +83,23 @@ class Script(default.Script):
"""Remove key grabs except for sleep mode toggle."""
try:
# First remove all grabs inherited from default activation,
# including modifier grabs.
super().removeKeyGrabs()
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.function, '__name__'):
if 'toggleSleepMode' in keyBinding.handler.function.__name__:
# Keep sleep mode toggle
try:
import cthulhu
grab_id = cthulhu.addKeyGrab(keyBinding)
if grab_id:
self.grab_ids.append(grab_id)
debug.printMessage(debug.LEVEL_INFO, f"SLEEP MODE: Kept sleep toggle key grab: {grab_id}", True)
grabIds = cthulhu.addKeyGrab(keyBinding)
if grabIds:
for grabId in grabIds:
self.grab_ids.append(grabId)
debug.printMessage(debug.LEVEL_INFO, f"SLEEP MODE: Kept sleep toggle key grab: {grabId}", True)
except Exception as e:
debug.printMessage(debug.LEVEL_INFO, f"SLEEP MODE: Error keeping key grab: {e}", True)
else:
@@ -106,27 +108,6 @@ class Script(default.Script):
except Exception as e:
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
def onCaretMoved(self, event):
"""Block caret movement events."""
+26 -7
View File
@@ -36,11 +36,11 @@ __license__ = "LGPL"
import copy
import importlib
import json
import os
from typing import TYPE_CHECKING, Any, Dict, List, Optional, Union
from gi.repository import Gio, GLib
from tomlkit import parse
from . import debug
from . import cthulhu_i18n
@@ -62,7 +62,7 @@ class SettingsManager(object):
"""Settings backend manager. This class manages cthulhu user's settings
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)
self.app: Cthulhu = app # Store app instance
@@ -214,6 +214,25 @@ class SettingsManager(object):
if not os.path.exists(userCustomFile):
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:
self._backend.saveDefaultSettings(self.defaultGeneral,
self.defaultPronunciations,
@@ -259,13 +278,13 @@ class SettingsManager(object):
if not self._prefsDir:
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):
return {}
try:
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:
msg = f"SETTINGS MANAGER: Unable to read default settings from {settings_path}: {error}"
debug.printMessage(debug.LEVEL_WARNING, msg, True)
@@ -572,9 +591,10 @@ class SettingsManager(object):
"""Return the current general settings.
Those settings comes from updating the default settings
with the profiles' ones"""
general = self.defaultGeneral.copy()
if self._backend:
return self._backend.getGeneral(profile)
return {}
general.update(self._backend.getGeneral(profile))
return general
def getPronunciations(self, profile: str = 'default') -> Dict[str, Any]:
"""Return the current pronunciations settings.
@@ -821,4 +841,3 @@ def getManager() -> Optional[SettingsManager]:
# During import phase, cthulhuApp may not exist yet
pass
return _managerInstance
+112 -5
View File
@@ -31,7 +31,9 @@ __copyright__ = "Copyright (c) 2024 Stormux"
__license__ = "LGPL"
import time
import os
from gi.repository import GLib
from tomlkit import parse
import cthulhu.braille as braille
import cthulhu.cmdnames as cmdnames
import cthulhu.debug as debug
@@ -47,7 +49,12 @@ class SleepModeManager:
def __init__(self):
self._handlers = self.getHandlers(True)
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._toggleDebounceDelay = 0.1 # 100ms debounce (reduced for better responsiveness)
@@ -76,12 +83,106 @@ class SleepModeManager:
def isActiveForApp(self, 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:
tokens = ["SLEEP MODE MANAGER: Is active for", app]
debug.printTokens(debug.LEVEL_INFO, tokens, True)
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):
"""Sets up and returns the sleep-mode-manager input event handlers."""
@@ -132,12 +233,16 @@ class SleepModeManager:
if not (script and script.app):
return True
from . import cthulhu_state
self.refreshAutoSleepConfig()
scriptManager = script_manager.get_manager()
if self.isActiveForApp(script.app):
# 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)
if notifyUser:
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: 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)
# Reset debounce timer after successful toggle
self._lastToggleTime = 0
+28 -8
View File
@@ -215,16 +215,37 @@ class SoundThemeManager:
def getSoundPath(self, themeName, soundName):
"""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)
if not themePath:
themePaths = []
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
for ext in ['.wav', '.ogg', '.mp3', '.flac']:
soundPath = os.path.join(themePath, soundName + ext)
if os.path.isfile(soundPath):
return soundPath
for themePath in themePaths:
for ext in ['.wav', '.ogg', '.mp3', '.flac']:
soundPath = os.path.join(themePath, soundName + ext)
if os.path.isfile(soundPath):
return soundPath
return None
@@ -395,4 +416,3 @@ def getManager():
from . import cthulhu
_manager = SoundThemeManager(cthulhu.cthulhuApp)
return _manager
+11 -6
View File
@@ -748,15 +748,20 @@ class SpeechGenerator(generator.Generator):
method for scripts to call.
"""
generated = self._generateRoleName(obj, **args)
if generated:
return generated[0]
return ""
return self._getFirstString(generated)
def getName(self, obj, **args):
generated = self._generateName(obj, **args)
if generated:
return generated[0]
return self._getFirstString(generated)
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 ""