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.
This commit is contained in:
2026-02-17 19:07:41 +00:00
parent 4add36f5ca
commit 07138197cb
5 changed files with 46 additions and 6 deletions
+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)
+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."""
+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]
+7 -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 []