diff --git a/distro-packages/Arch-Linux/PKGBUILD b/distro-packages/Arch-Linux/PKGBUILD index f879a83..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.16 +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 49a1d49..c3268b7 100644 --- a/meson.build +++ b/meson.build @@ -1,5 +1,5 @@ project('cthulhu', - version: '2026.02.16-testing', + version: '2026.02.17-master', meson_version: '>= 1.0.0', ) diff --git a/src/cthulhu/cthulhuVersion.py b/src/cthulhu/cthulhuVersion.py index 23dc864..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.16" -codeName = "testing" +version = "2026.02.17" +codeName = "master" diff --git a/src/cthulhu/liveregions.py b/src/cthulhu/liveregions.py index 00cbba1..5f9e773 100644 --- a/src/cthulhu/liveregions.py +++ b/src/cthulhu/liveregions.py @@ -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) diff --git a/src/cthulhu/scripts/default.py b/src/cthulhu/scripts/default.py index 33dd2e4..16b6444 100644 --- a/src/cthulhu/scripts/default.py +++ b/src/cthulhu/scripts/default.py @@ -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.""" diff --git a/src/cthulhu/scripts/web/script.py b/src/cthulhu/scripts/web/script.py index b944c33..48fd087 100644 --- a/src/cthulhu/scripts/web/script.py +++ b/src/cthulhu/scripts/web/script.py @@ -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) diff --git a/src/cthulhu/scripts/web/script_utilities.py b/src/cthulhu/scripts/web/script_utilities.py index efd647f..dc24331 100644 --- a/src/cthulhu/scripts/web/script_utilities.py +++ b/src/cthulhu/scripts/web/script_utilities.py @@ -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] diff --git a/src/cthulhu/scripts/web/speech_generator.py b/src/cthulhu/scripts/web/speech_generator.py index 9ecc402..11b57f2 100644 --- a/src/cthulhu/scripts/web/speech_generator.py +++ b/src/cthulhu/scripts/web/speech_generator.py @@ -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')