diff --git a/distro-packages/Arch-Linux/PKGBUILD b/distro-packages/Arch-Linux/PKGBUILD index ee22072..36ae9ce 100644 --- a/distro-packages/Arch-Linux/PKGBUILD +++ b/distro-packages/Arch-Linux/PKGBUILD @@ -1,7 +1,7 @@ # Maintainer: Storm Dragon pkgname=cthulhu -pkgver=2026.02.15 +pkgver=2026.02.17 pkgrel=1 pkgdesc="Desktop-agnostic screen reader with plugin system, forked from Orca" url="https://git.stormux.org/storm/cthulhu" diff --git a/meson.build b/meson.build index f1bf4c8..c3268b7 100644 --- a/meson.build +++ b/meson.build @@ -1,5 +1,5 @@ project('cthulhu', - version: '2026.02.15-master', + version: '2026.02.17-master', meson_version: '>= 1.0.0', ) diff --git a/src/cthulhu/cthulhuVersion.py b/src/cthulhu/cthulhuVersion.py index a7863b5..5ec38f0 100644 --- a/src/cthulhu/cthulhuVersion.py +++ b/src/cthulhu/cthulhuVersion.py @@ -23,5 +23,5 @@ # Forked from Orca screen reader. # Cthulhu project: https://git.stormux.org/storm/cthulhu -version = "2026.02.15" +version = "2026.02.17" codeName = "master" diff --git a/src/cthulhu/input_event.py b/src/cthulhu/input_event.py index 20cd536..a49aab8 100644 --- a/src/cthulhu/input_event.py +++ b/src/cthulhu/input_event.py @@ -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.""" diff --git a/src/cthulhu/script_manager.py b/src/cthulhu/script_manager.py index 9ef82eb..8855d24 100644 --- a/src/cthulhu/script_manager.py +++ b/src/cthulhu/script_manager.py @@ -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 diff --git a/src/cthulhu/scripts/sleepmode/script.py b/src/cthulhu/scripts/sleepmode/script.py index 11104f6..3a684e5 100644 --- a/src/cthulhu/scripts/sleepmode/script.py +++ b/src/cthulhu/scripts/sleepmode/script.py @@ -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.""" diff --git a/src/cthulhu/settings_manager.py b/src/cthulhu/settings_manager.py index 1d9d3a9..2b18db7 100644 --- a/src/cthulhu/settings_manager.py +++ b/src/cthulhu/settings_manager.py @@ -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, diff --git a/src/cthulhu/sleep_mode_manager.py b/src/cthulhu/sleep_mode_manager.py index 14f44f9..75c3f45 100644 --- a/src/cthulhu/sleep_mode_manager.py +++ b/src/cthulhu/sleep_mode_manager.py @@ -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