7 Commits

Author SHA1 Message Date
destructatron 8312a842c1 Fix duplicate role name when aria-roledescription is present
When an element has aria-roledescription (e.g. Discord messages use
'Message'), _generateRoleName was adding both the roledescription AND
the standard localized role name (e.g. 'article'). This resulted in
the role being announced twice.

The aria-roledescription attribute is intended to replace the standard
role name, not supplement it. Return early after adding the
roledescription to match Orca's behavior.
2026-02-17 19:21:36 +00:00
destructatron 07138197cb Fix web content editable label, line boundary, and punctuation bugs
Bug 1: Content editable entry label not announced on caret-nav focus
- When navigating into a content editable via caret navigation (e.g. down
  arrow from message list to message entry in Discord), the label and role
  were suppressed because the code treated entering the field the same as
  navigating within it.
- Fixed locus_of_focus_changed in web/script.py to detect when focus enters
  a content editable from outside and generate full object speech.
- Fixed _generateLabelOrName in web/speech_generator.py to preserve the
  label when the prior object was outside the content editable.

Bug 2: Adjacent button included in line contents on first line
- When reading line contents inside a content editable, the layout-mode
  expansion could walk outside the content editable boundary and include
  adjacent UI elements (e.g. a 'More options' button) that happened to
  share the same visual line.
- Fixed _getLineContentsAtOffset in web/script_utilities.py to stop
  expanding at content editable boundaries in both directions.

Bug 3: Punctuation stripped from live regions and AT-SPI announcements
- presentMessage uses resetStyles=True by default, which sets punctuation
  to NONE for system voice messages. This is correct for generated text
  but wrong for user content in live regions and AT-SPI announcements.
- Fixed liveregions.py pumpMessages and default.py onAnnouncement to pass
  resetStyles=False, preserving the user's punctuation settings.
2026-02-17 19:07:41 +00:00
Storm Dragon 4add36f5ca latest changed merged, seems to be reasonably stable. 2026-02-17 08:01:47 -05:00
Storm Dragon 40e63150a6 Read a list of applications that should always be started in sleep mode from sleep.toml. 2026-02-17 07:57:14 -05:00
Storm Dragon 4dba0ec0cd Fixed a bug with sleep mode, was not suspending review keys for sleeping applications. 2026-02-16 14:55:56 -05:00
Storm Dragon e6f780c38b New code tested and seems pretty stable so merged and bumped version. 2026-02-15 12:10:36 -05:00
Storm Dragon 0f7f73a6a0 Fixed flat review bug affecting some sites in chrome. 2026-02-13 13:23:14 -05:00
14 changed files with 248 additions and 49 deletions
+1 -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"
+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 -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"
+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."""
+3 -1
View File
@@ -276,7 +276,9 @@ class LiveRegionManager:
utts = message['labels'] + message['content']
if self.monitoring:
self._script.presentMessage(utts)
# Live region content is user-generated text, not system messages.
# Use resetStyles=False to preserve the user's punctuation settings.
self._script.presentMessage(utts, resetStyles=False)
else:
msg = "INFO: Not presenting message because monitoring is off"
debug.printMessage(debug.LEVEL_INFO, msg, True)
+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
+3 -1
View File
@@ -1473,7 +1473,9 @@ class Script(script.Script):
"""Callback for object:announcement events."""
if isinstance(event.any_data, str):
self.presentMessage(event.any_data)
# AT-SPI announcements contain application content, not system messages.
# Use resetStyles=False to preserve the user's punctuation settings.
self.presentMessage(event.any_data, resetStyles=False)
def onNameChanged(self, event):
"""Callback for object:property-change:accessible-name events."""
+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."""
+15 -3
View File
@@ -1648,9 +1648,21 @@ class Script(default.Script):
elif self.utilities.isContentEditableWithEmbeddedObjects(newFocus) \
and (self._lastCommandWasCaretNav or self._lastCommandWasStructNav) \
and not (AXUtilities.is_table_cell(newFocus) and AXObject.get_name(newFocus)):
tokens = ["WEB: New focus", newFocus, "content editable. Generating line."]
debug.printTokens(debug.LEVEL_INFO, tokens, True)
contents = self.utilities.getLineContentsAtOffset(newFocus, caretOffset)
# Check if we're entering the content editable from outside (e.g. down arrow
# from a message list into a message entry). In that case, generate full object
# speech (with label and role) rather than just line contents.
enteredFromOutside = oldFocus is not None \
and oldFocus != newFocus \
and not AXObject.find_ancestor(oldFocus, lambda x: x == newFocus)
if enteredFromOutside:
tokens = ["WEB: New focus", newFocus,
"content editable entered from outside. Generating speech."]
debug.printTokens(debug.LEVEL_INFO, tokens, True)
args['priorObj'] = oldFocus
else:
tokens = ["WEB: New focus", newFocus, "content editable. Generating line."]
debug.printTokens(debug.LEVEL_INFO, tokens, True)
contents = self.utilities.getLineContentsAtOffset(newFocus, caretOffset)
elif self.utilities.isAnchor(newFocus):
tokens = ["WEB: New focus", newFocus, "is anchor. Generating line."]
debug.printTokens(debug.LEVEL_INFO, tokens, True)
@@ -1834,6 +1834,16 @@ class Utilities(script_utilities.Utilities):
prevObj, pOffset = self.findPreviousCaretInOrder(firstObj, firstStart)
nextObj, nOffset = self.findNextCaretInOrder(lastObj, lastEnd - 1)
# If we're inside a content editable, don't expand line contents beyond
# its boundaries (e.g. don't include a "More options" button adjacent to
# a message entry just because it's on the same visual line).
contentEditableBoundary = None
if self.isContentEditableWithEmbeddedObjects(obj):
contentEditableBoundary = obj
else:
contentEditableBoundary = AXObject.find_ancestor(
obj, self.isContentEditableWithEmbeddedObjects)
# Check for things on the same line to the left of this object.
prevStartTime = time.time()
while prevObj and self.getDocumentForObject(prevObj) == document:
@@ -1848,6 +1858,10 @@ class Utilities(script_utilities.Utilities):
if objRow != AXObject.find_ancestor(prevObj, AXUtilities.is_table_row):
break
if contentEditableBoundary and prevObj != contentEditableBoundary \
and not AXObject.find_ancestor(prevObj, lambda x: x == contentEditableBoundary):
break
onLeft = self._getContentsForObj(prevObj, pOffset, boundary)
onLeft = list(filter(_include, onLeft))
if not onLeft:
@@ -1878,6 +1892,10 @@ class Utilities(script_utilities.Utilities):
if objRow != AXObject.find_ancestor(nextObj, AXUtilities.is_table_row):
break
if contentEditableBoundary and nextObj != contentEditableBoundary \
and not AXObject.find_ancestor(nextObj, lambda x: x == contentEditableBoundary):
break
onRight = self._getContentsForObj(nextObj, nOffset, boundary)
if onRight and self._contentIsSubsetOf(objects[0], onRight[-1]):
onRight = onRight[0:-1]
+10 -1
View File
@@ -361,7 +361,13 @@ class SpeechGenerator(speech_generator.SpeechGenerator):
if self._script.utilities.isContentEditableWithEmbeddedObjects(obj) \
or self._script.utilities.isDocument(obj):
if input_event_manager.get_manager().last_event_was_caret_navigation():
return []
# Still generate the label if we just entered this object from outside
# (e.g. down arrow from message list into message entry in Discord).
enteredFromOutside = priorObj is not None \
and priorObj != obj \
and not AXObject.find_ancestor(priorObj, lambda x: x == obj)
if not enteredFromOutside:
return []
if AXUtilities.is_page_tab(priorObj) and AXObject.get_name(priorObj) == objName:
return []
@@ -554,6 +560,9 @@ class SpeechGenerator(speech_generator.SpeechGenerator):
if roledescription:
result = [roledescription]
result.extend(self.voice(speech_generator.SYSTEM, obj=obj, **args))
# aria-roledescription replaces the standard role name, so return
# early to avoid announcing both (e.g. "Message" + "article").
return result
role = args.get('role', AXObject.get_role(obj))
roleSoundPresentation = cthulhu.cthulhuApp.settingsManager.getSetting('roleSoundPresentation')
+19
View File
@@ -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,
+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
+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 ""