From 43fa90de6ed383bd9e676a39adc3e35a8f87512d Mon Sep 17 00:00:00 2001 From: Reef Turner Date: Thu, 6 Feb 2020 16:28:47 +0100 Subject: [PATCH 1/4] Cancel speech when focus has expired. This experimental feature can be controlled with a flag in the advanced settings panel. In eventHandler.py keep track of objects that have had focus (on the object, using attribute 'wasGainFocusObj'). This expands on the 'lastQueuedFocusObject' concept. A cancellable speech command is added to speech sequences that result from the focus change event. This command is able to check if the object once had focus ('wasGainFocusObj') and if it still has focus. Speech for objects that no longer have focus can be discarded, or cancelled if already with the synth. Then checking for cancellations is done both early (eventHandler) and late (speech.manager.speak) This requires some careful tracking and processing in speech manager. When nothing is with the synth, try pushing more. Otherwise, there are items in the queue but nothing being spoken. When cancelling synth clear tracking of "with synth" Use _removeCompletedFromQueue rather than _handleIndex _handleIndex may not actually call _removeCompleted. Cancelled speech does need it's callbacks called. For now CancellableSpeechCommand should be considered a private API --- source/NVDAObjects/IAccessible/__init__.py | 29 +++-- source/NVDAObjects/IAccessible/ia2Web.py | 12 +- source/NVDAObjects/__init__.py | 3 + source/config/configSpec.py | 4 + source/eventHandler.py | 66 ++++++++++- source/gui/settingsDialogs.py | 39 +++++++ source/speech/__init__.py | 26 +++-- source/speech/commands.py | 60 ++++++++-- source/speech/manager.py | 123 +++++++++++++++++++-- user_docs/en/userGuide.t2t | 4 + 10 files changed, 317 insertions(+), 49 deletions(-) diff --git a/source/NVDAObjects/IAccessible/__init__.py b/source/NVDAObjects/IAccessible/__init__.py index 5c0764dd101..46de3e93ebc 100644 --- a/source/NVDAObjects/IAccessible/__init__.py +++ b/source/NVDAObjects/IAccessible/__init__.py @@ -36,6 +36,9 @@ import eventHandler from NVDAObjects.behaviors import ProgressBar, Dialog, EditableTextWithAutoSelectDetection, FocusableUnfocusableContainer, ToolTip, Notification from locationHelper import RectLTWH +from typing import ( + Optional, +) def getNVDAObjectFromEvent(hwnd,objectID,childID): try: @@ -680,15 +683,9 @@ def isDuplicateIAccessibleEvent(self,obj): return False return obj.event_windowHandle==self.event_windowHandle and obj.event_objectID==self.event_objectID and obj.event_childID==self.event_childID - def _get_shouldAllowIAccessibleFocusEvent(self): - """Determine whether a focus event should be allowed for this object. - Normally, this checks for the focused state to help eliminate redundant or invalid focus events. - However, some implementations do not correctly set the focused state, so this must be overridden. - @return: C{True} if the focus event should be allowed. - @rtype: bool - """ - #this object or one of its ancestors must have state_focused. + def _getIndirectionsToParentWithFocus(self) -> Optional[int]: testObj = self + indirections = 0 while testObj: if controlTypes.STATE_FOCUSED in testObj.states: break @@ -696,9 +693,21 @@ def _get_shouldAllowIAccessibleFocusEvent(self): # Cache the parent. testObj.parent = parent testObj = parent + indirections += 1 else: - return False - return True + return None + return indirections + + def _get_shouldAllowIAccessibleFocusEvent(self): + """Determine whether a focus event should be allowed for this object. + Normally, this checks for the focused state to help eliminate redundant or invalid focus events. + However, some implementations do not correctly set the focused state, so this must be overridden. + @return: C{True} if the focus event should be allowed. + @rtype: bool + """ + # this object or one of its ancestors must have state_focused. + indirectionsToFocus = self._getIndirectionsToParentWithFocus() + return indirectionsToFocus is not None def _get_TextInfo(self): if hasattr(self,'IAccessibleTextObject'): diff --git a/source/NVDAObjects/IAccessible/ia2Web.py b/source/NVDAObjects/IAccessible/ia2Web.py index 73f7e666a9a..02206a125a8 100644 --- a/source/NVDAObjects/IAccessible/ia2Web.py +++ b/source/NVDAObjects/IAccessible/ia2Web.py @@ -1,8 +1,8 @@ -#NVDAObjects/IAccessible/ia2Web.py -#A part of NonVisual Desktop Access (NVDA) -#This file is covered by the GNU General Public License. -#See the file COPYING for more details. -#Copyright (C) 2006-2017 NV Access Limited +# NVDAObjects/IAccessible/ia2Web.py +# A part of NonVisual Desktop Access (NVDA) +# This file is covered by the GNU General Public License. +# See the file COPYING for more details. +# Copyright (C) 2006-2020 NV Access Limited """Base classes with common support for browsers exposing IAccessible2. """ @@ -14,7 +14,7 @@ import controlTypes from logHandler import log from documentBase import DocumentWithTableNavigation -from NVDAObjects.behaviors import Dialog, WebDialog +from NVDAObjects.behaviors import Dialog, WebDialog from . import IAccessible from .ia2TextMozilla import MozillaCompoundTextInfo import aria diff --git a/source/NVDAObjects/__init__.py b/source/NVDAObjects/__init__.py index 5b0ec5810ea..f0fff359c34 100644 --- a/source/NVDAObjects/__init__.py +++ b/source/NVDAObjects/__init__.py @@ -492,6 +492,9 @@ def _get_isInForeground(self): """ raise NotImplementedError + # Type info for auto property: + states: set + def _get_states(self): """Retrieves the current states of this object (example: selected, focused). @return: a set of STATE_* constants from L{controlTypes}. diff --git a/source/config/configSpec.py b/source/config/configSpec.py index e38651dc0be..6a754c9d99b 100644 --- a/source/config/configSpec.py +++ b/source/config/configSpec.py @@ -250,6 +250,10 @@ [development] enableScratchpadDir = boolean(default=false) + +[featureFlag] + # 0:default, 1:yes, 2:no + cancelExpiredFocusSpeech = integer(0, 2, default=0) """ #: The configuration specification diff --git a/source/eventHandler.py b/source/eventHandler.py index 6f724c293ef..7adb6543459 100755 --- a/source/eventHandler.py +++ b/source/eventHandler.py @@ -5,9 +5,12 @@ #Copyright (C) 2007-2017 NV Access Limited, Babbage B.V. import threading +from typing import Optional + import queueHandler import api import speech +from speech.commands import _CancellableSpeechCommand import appModuleHandler import treeInterceptorHandler import globalVars @@ -33,15 +36,14 @@ def queueEvent(eventName,obj,**kwargs): @param eventName: the name of the event type (e.g. 'gainFocus', 'nameChange') @type eventName: string """ - global lastQueuedFocusObject - if eventName=="gainFocus": - lastQueuedFocusObject=obj + _trackFocusObject(eventName, obj) with _pendingEventCountsLock: _pendingEventCountsByName[eventName]=_pendingEventCountsByName.get(eventName,0)+1 _pendingEventCountsByObj[obj]=_pendingEventCountsByObj.get(obj,0)+1 _pendingEventCountsByNameAndObj[(eventName,obj)]=_pendingEventCountsByNameAndObj.get((eventName,obj),0)+1 queueHandler.queueFunction(queueHandler.eventQueue,_queueEventCallback,eventName,obj,kwargs) + def _queueEventCallback(eventName,obj,kwargs): with _pendingEventCountsLock: curCount=_pendingEventCountsByName.get(eventName,0) @@ -134,7 +136,50 @@ def gen(self, eventName, obj): if func: yield func, () -def executeEvent(eventName,obj,**kwargs): + +WAS_GAIN_FOCUS_OBJ_ATTR_NAME = "wasGainFocusObj" + + +def _trackFocusObject(eventName, obj) -> None: + """ Keeps track of lastQueuedFocusObject and sets wasGainFocusObj attr on objects. + :param eventName: the event type, eg "gainFocus" + :param obj: the object to track if focused + """ + global lastQueuedFocusObject + if eventName == "gainFocus": + lastQueuedFocusObject = obj + setattr(obj, WAS_GAIN_FOCUS_OBJ_ATTR_NAME, obj is lastQueuedFocusObject) + + +def _getFocusLossCancellableSpeechCommand( + obj, + reason: controlTypes.OutputReason +) -> Optional[_CancellableSpeechCommand]: + if reason != controlTypes.REASON_FOCUS or not speech.manager._shouldCancelExpiredFocusEvents(): + return None + from NVDAObjects import NVDAObject + if not isinstance(obj, NVDAObject): + log.warning("Unhandled object type. Expected all objects to be descendant from NVDAObject") + return None + previouslyHadFocus: bool = getattr( + obj, + WAS_GAIN_FOCUS_OBJ_ATTR_NAME, + False + ) + + def isSpeechStillValid(): + from eventHandler import lastQueuedFocusObject + isLastFocusObj: bool = obj is lastQueuedFocusObject + stillValid = isLastFocusObj or not previouslyHadFocus + + log.debug( + f"checked if valid (isLast: {isLastFocusObj}, previouslyHad: {previouslyHadFocus}): {obj.name}") + return stillValid + + return _CancellableSpeechCommand(isSpeechStillValid) + + +def executeEvent(eventName, obj, **kwargs): """Executes an NVDA event. @param eventName: the name of the event type (e.g. 'gainFocus', 'nameChange') @type eventName: string @@ -143,11 +188,20 @@ def executeEvent(eventName,obj,**kwargs): @param kwargs: Additional event parameters as keyword arguments. """ try: + isGainFocus = eventName == "gainFocus" # Allow NVDAObjects to redirect focus events to another object of their choosing. - if eventName=="gainFocus" and obj.focusRedirect: + if isGainFocus and obj.focusRedirect: obj=obj.focusRedirect sleepMode=obj.sleepMode - if eventName=="gainFocus" and not doPreGainFocus(obj,sleepMode=sleepMode): + if isGainFocus and speech.manager._shouldCancelExpiredFocusEvents(): + log.debug("executeEvent: Removing cancelled speech commands.") + # ask speechManager to check if any of it's queued utterances should be cancelled + speech._manager.removeCancelledSpeechCommands() + # Don't skip objects without focus here. Even if an object no longer has focus, it needs to be processed + # to capture changes in document depth. For instance jumping into a list? + # This needs further investigation; when the next object gets focus, it should + # allow us to capture this information? + if isGainFocus and not doPreGainFocus(obj, sleepMode=sleepMode): return elif not sleepMode and eventName=="documentLoadComplete" and not doPreDocumentLoadComplete(obj): return diff --git a/source/gui/settingsDialogs.py b/source/gui/settingsDialogs.py index 614424194a6..7e214258e59 100644 --- a/source/gui/settingsDialogs.py +++ b/source/gui/settingsDialogs.py @@ -2237,6 +2237,7 @@ def onSave(self): lang = self.languageCodes[self.languageChoice.Selection] config.conf["uwpOcr"]["language"] = lang + class AdvancedPanelControls(wx.Panel): """Holds the actual controls for the Advanced Settings panel, this allows the state of the controls to be more easily managed. @@ -2324,6 +2325,41 @@ def __init__(self, parent): self.keyboardSupportInLegacyCheckBox.defaultValue = self._getDefaultValue(["terminals", "keyboardSupportInLegacy"]) self.keyboardSupportInLegacyCheckBox.Enable(winVersion.isWin10(1607)) + # Translators: This is the label for a group of advanced options in the + # Advanced settings panel + label = _("Speech") + speechGroup = guiHelper.BoxSizerHelper( + parent=self, + sizer=wx.StaticBoxSizer(parent=self, label=label, orient=wx.VERTICAL) + ) + sHelper.addItem(speechGroup) + + expiredFocusSpeechChoices = [ + # Translators: Label for the 'Cancel speech for expired &focus events' combobox + # in the Advanced settings panel. + _("Default (No)"), + # Translators: Label for the 'Cancel speech for expired &focus events' combobox + # in the Advanced settings panel. + _("Yes"), + # Translators: Label for the 'Cancel speech for expired &focus events' combobox + # in the Advanced settings panel. + _("No"), + ] + + # Translators: This is the label for combobox in the Advanced settings panel. + cancelExpiredFocusSpeechText = _("Attempt to cancel speech for expired focus events:") + self.cancelExpiredFocusSpeechCombo: wx.Choice = speechGroup.addLabeledControl( + cancelExpiredFocusSpeechText, + wx.Choice, + choices=expiredFocusSpeechChoices + ) + self.cancelExpiredFocusSpeechCombo.SetSelection( + config.conf["featureFlag"]["cancelExpiredFocusSpeech"] + ) + self.cancelExpiredFocusSpeechCombo.defaultValue = self._getDefaultValue( + ["featureFlag", "cancelExpiredFocusSpeech"] + ) + # Translators: This is the label for a group of advanced options in the # Advanced settings panel label = _("Browse mode") @@ -2413,6 +2449,7 @@ def haveConfigDefaultsBeenRestored(self): and self.UIAInMSWordCheckBox.IsChecked() == self.UIAInMSWordCheckBox.defaultValue and self.ConsoleUIACheckBox.IsChecked() == (self.ConsoleUIACheckBox.defaultValue == 'UIA') and self.winConsoleSpeakPasswordsCheckBox.IsChecked() == self.winConsoleSpeakPasswordsCheckBox.defaultValue + and self.cancelExpiredFocusSpeechCombo.GetSelection() == self.cancelExpiredFocusSpeechCombo.defaultValue and self.keyboardSupportInLegacyCheckBox.IsChecked() == self.keyboardSupportInLegacyCheckBox.defaultValue and ( self.autoFocusFocusableElementsCheckBox.IsChecked() @@ -2428,6 +2465,7 @@ def restoreToDefaults(self): self.UIAInMSWordCheckBox.SetValue(self.UIAInMSWordCheckBox.defaultValue) self.ConsoleUIACheckBox.SetValue(self.ConsoleUIACheckBox.defaultValue == 'UIA') self.winConsoleSpeakPasswordsCheckBox.SetValue(self.winConsoleSpeakPasswordsCheckBox.defaultValue) + self.cancelExpiredFocusSpeechCombo.SetValue(self.cancelExpiredFocusSpeechCombo.defaultValue) self.keyboardSupportInLegacyCheckBox.SetValue(self.keyboardSupportInLegacyCheckBox.defaultValue) self.autoFocusFocusableElementsCheckBox.SetValue(self.autoFocusFocusableElementsCheckBox.defaultValue) self.caretMoveTimeoutSpinControl.SetValue(self.caretMoveTimeoutSpinControl.defaultValue) @@ -2443,6 +2481,7 @@ def onSave(self): else: config.conf['UIA']['winConsoleImplementation'] = "auto" config.conf["terminals"]["speakPasswords"] = self.winConsoleSpeakPasswordsCheckBox.IsChecked() + config.conf["featureFlag"]["cancelExpiredFocusSpeech"] = self.cancelExpiredFocusSpeechCombo.GetSelection() config.conf["terminals"]["keyboardSupportInLegacy"]=self.keyboardSupportInLegacyCheckBox.IsChecked() config.conf["virtualBuffers"]["autoFocusFocusableElements"] = self.autoFocusFocusableElementsCheckBox.IsChecked() config.conf["editableText"]["caretMoveTimeoutMs"]=self.caretMoveTimeoutSpinControl.GetValue() diff --git a/source/speech/__init__.py b/source/speech/__init__.py index 56abe3a17c4..935906036d0 100755 --- a/source/speech/__init__.py +++ b/source/speech/__init__.py @@ -1,8 +1,8 @@ -# -*- coding: UTF-8 -*- -#A part of NonVisual Desktop Access (NVDA) -#This file is covered by the GNU General Public License. -#See the file COPYING for more details. -#Copyright (C) 2006-2019 NV Access Limited, Peter Vágner, Aleksey Sadovoy, Babbage B.V., Bill Dengler +# -*- coding: UTF-8 -*- +# A part of NonVisual Desktop Access (NVDA) +# This file is covered by the GNU General Public License. +# See the file COPYING for more details. +# Copyright (C) 2006-2020 NV Access Limited, Peter Vágner, Aleksey Sadovoy, Babbage B.V., Bill Dengler """High-level functions to speak information. """ @@ -23,6 +23,7 @@ import speechDictHandler import characterProcessing import languageHandler +from . import manager from .commands import ( # Commands that are used in this file. SpeechCommand, @@ -37,6 +38,7 @@ # The following are imported here because other files that speech.py # previously relied on "import * from .commands" # New commands added to commands.py should be directly imported only where needed. + # Usage of these imports is deprecated and will be removed in 2021.1 SynthCommand, IndexCommand, SynthParamCommand, @@ -51,6 +53,7 @@ ConfigProfileTriggerCommand, ) +from . import types from .types import ( SpeechSequence, SequenceItemT, @@ -66,6 +69,7 @@ Generator, Union, Callable, + Iterator, Tuple, ) from logHandler import log @@ -435,9 +439,14 @@ def getObjectPropertiesSpeech( # noqa: C901 newPropertyValues['states']=states #Get the speech text for the properties we want to speak, and then speak it speechSequence = getPropertiesSpeech(reason=reason, **newPropertyValues) + if speechSequence: if _prefixSpeechCommand is not None: speechSequence.insert(0, _prefixSpeechCommand) + from eventHandler import _getFocusLossCancellableSpeechCommand + cancelCommand = _getFocusLossCancellableSpeechCommand(obj, reason) + if cancelCommand is not None: + speechSequence.append(cancelCommand) return speechSequence @@ -472,6 +481,8 @@ def speakObject( speak(sequence, priority=priority) + + # C901 'getObjectSpeech' is too complex # Note: when working on getObjectSpeech, look for opportunities to simplify # and move logic out into smaller helper functions. @@ -2544,10 +2555,9 @@ def _getSpeech( #: Kept for backwards compatibility. re_last_pause = _speakWithoutPauses.re_last_pause -from .manager import SpeechManager #: The singleton _SpeechManager instance used for speech functions. -#: @type: L{_SpeechManager} -_manager = SpeechManager() +#: @type: L{manager.SpeechManager} +_manager = manager.SpeechManager() def clearTypedWordBuffer() -> None: diff --git a/source/speech/commands.py b/source/speech/commands.py index 5113b677d50..c4fafbba751 100644 --- a/source/speech/commands.py +++ b/source/speech/commands.py @@ -1,23 +1,63 @@ -# -*- coding: UTF-8 -*- -#A part of NonVisual Desktop Access (NVDA) -#This file is covered by the GNU General Public License. -#See the file COPYING for more details. -#Copyright (C) 2006-2019 NV Access Limited - -"""Commands that can be embedded in a speech sequence for changing synth parameters, playing sounds or running other callbacks.""" +# -*- coding: UTF-8 -*- +# A part of NonVisual Desktop Access (NVDA) +# This file is covered by the GNU General Public License. +# See the file COPYING for more details. +# Copyright (C) 2006-2020 NV Access Limited + +""" +Commands that can be embedded in a speech sequence for changing synth parameters, playing sounds or running + other callbacks. +""" from abc import ABCMeta, abstractmethod -from typing import Optional +from typing import Optional, Callable import config from synthDriverHandler import getSynth +from logHandler import log class SpeechCommand(object): - """The base class for objects that can be inserted between strings of text to perform actions, change voice parameters, etc. - Note that some of these commands are processed by NVDA and are not directly passed to synth drivers. + """The base class for objects that can be inserted between strings of text to perform actions, + change voice parameters, etc. + + Note: Some of these commands are processed by NVDA and are not directly passed to synth drivers. synth drivers will only receive commands derived from L{SynthCommand}. """ + +class _CancellableSpeechCommand(SpeechCommand): + """ + A command that allows cancelling the utterance that contains it. + Support currently experimental and may be subject to change. + """ + + def __init__(self, checkIfValid: Optional[Callable[[], bool]] = None): + self._isCancelled = False + if checkIfValid: + self._checkIfValid = checkIfValid + self._utteranceIndex = None + + @property + def isCancelled(self): + log.debug(f"Check if valid {self}, isCanceled: {self._isCancelled}, isValid: {self._checkIfValid()}") + if self._isCancelled: + return True + elif not self._checkIfValid(): + self._isCancelled = True + return self._isCancelled + + def cancelUtterance(self): + self._isCancelled = True + + @staticmethod + def _checkIfValid() -> bool: + """Overridable behavior.""" + return True + + def __repr__(self): + return f"CancellableSpeech ({ 'cancelled' if self._isCancelled else 'still valid' })" + + class SynthCommand(SpeechCommand): """Commands that can be passed to synth drivers. """ diff --git a/source/speech/manager.py b/source/speech/manager.py index 0c82b98342f..26c605f41f7 100644 --- a/source/speech/manager.py +++ b/source/speech/manager.py @@ -15,6 +15,7 @@ BaseCallbackCommand, ConfigProfileTriggerCommand, IndexCommand, + _CancellableSpeechCommand, ) from .commands import ( # noqa: F401 # F401 imported but unused: @@ -43,10 +44,16 @@ Any, List, Tuple, + Callable, Optional, + cast, ) +def _shouldCancelExpiredFocusEvents(): + # 0: default (no), 1: yes, 2: no + return config.conf["featureFlag"]["cancelExpiredFocusSpeech"] == 1 + class ParamChangeTracker(object): """Keeps track of commands which change parameters from their defaults. This is useful when an utterance needs to be split. @@ -149,6 +156,7 @@ class SpeechManager(object): Note: All of this activity is (and must be) synchronized and serialized on the main thread. """ + _cancelableSpeechCallbacks: Dict[_CancellableSpeechCommand, Callable[[_CancellableSpeechCommand, ], None]] _priQueues: Dict[Any, _ManagerPriorityQueue] _curPriQueue: Optional[_ManagerPriorityQueue] @@ -190,26 +198,40 @@ def _reset(self): self._indexesSpeaking = [] #: Whether to push more speech when the synth reports it is done speaking. self._shouldPushWhenDoneSpeaking = False + self._cancelCommandsForUtteranceBeingSpokenBySynth = {} def speak(self, speechSequence: SpeechSequence, priority: Spri): - # If speech isn't already in progress, we need to push the first speech. - push = self._curPriQueue is None + log.debug(f"manager.speak: {speechSequence}") interrupt = self._queueSpeechSequence(speechSequence, priority) + self.removeCancelledSpeechCommands() + # If speech isn't already in progress, we need to push the first speech. + push = self._curPriQueue is None or 1 > len(self._indexesSpeaking) if interrupt: + log.debug("interrupting speech") getSynth().cancel() + self._indexesSpeaking.clear() + self._cancelCommandsForUtteranceBeingSpokenBySynth.clear() push = True if push: + log.debug("pushing next speech") self._pushNextSpeech(True) + log.debug("not pushing speech") - def _queueSpeechSequence(self, inSeq: SpeechSequence, priority: Spri): + def _queueSpeechSequence(self, inSeq: SpeechSequence, priority: Spri) -> bool: """ @return: Whether to interrupt speech. - @rtype: bool """ outSeq = self._processSpeechSequence(inSeq) + log.debug(f"Out Seq: {outSeq}") queue = self._priQueues.get(priority) + log.debug( + f"Current priority: {priority}, queLen: " + f"{0 if queue is None else len(queue.pendingSequences)}" + ) if not queue: queue = self._priQueues[priority] = _ManagerPriorityQueue(priority) + else: + log.debug(f"current queue: {queue.pendingSequences}") first = len(queue.pendingSequences) == 0 queue.pendingSequences.extend(outSeq) if priority is Spri.NOW and first: @@ -239,8 +261,8 @@ def ensureEndUtterance(seq: SpeechSequence): return seq if not isinstance(lastCommand, IndexCommand): # Add an index so we know when we've reached the end of this utterance. - speechIndex = next(self._indexCounter) - lastOutSeq.append(IndexCommand(speechIndex)) + reachedIndex = next(self._indexCounter) + lastOutSeq.append(IndexCommand(reachedIndex)) outSeqs.append([EndUtteranceCommand()]) return seq @@ -290,6 +312,7 @@ def _pushNextSpeech(self, doneSpeaking: bool): queue = self._getNextPriority() if not queue: # No more speech. + log.debug("no more speech") self._curPriQueue = None return if not self._curPriQueue: @@ -328,7 +351,7 @@ def _pushNextSpeech(self, doneSpeaking: bool): return self._pushNextSpeech(True) seq = self._buildNextUtterance() if seq: - # Record all indexes that will be sent to the synthesizer + log.io(f"Sending to synth: {seq}") # So that we can handle any accidentally skipped indexes. for item in seq: if isinstance(item, IndexCommand): @@ -360,15 +383,81 @@ def _buildNextUtterance(self): # The utterance ends here. break utterance.extend(seq) + # if any items are cancelled, cancel the whole utterance. + if utterance and not self._checkForCancellations(utterance): + return self._buildNextUtterance() return utterance + def _checkForCancellations(self, utterance: SpeechSequence) -> bool: + """ + Checks utterance to ensure it is not cancelled (via a _CancellableSpeechCommand). + Because synthesizers do not expect CancellableSpeechCommands, they are removed from the utterance. + :arg utterance: The utterance to check for cancellations. Modified in place, CancellableSpeechCommands are + removed. + :return True if sequence is still valid, else False + """ + if not _shouldCancelExpiredFocusEvents(): + return True + utteranceIndex = self._getUtteranceIndex(utterance) + if utteranceIndex is None: + log.error("no utterance index, cant save cancellable commands") + return False + cancellableItems = list( + item for item in reversed(utterance) if isinstance(item, _CancellableSpeechCommand) + ) + for item in cancellableItems: + utterance.remove(item) # CancellableSpeechCommands should not be sent to the synthesizer. + if item.isCancelled: + log.debug(f"item already cancelled, canceling up to: {utteranceIndex}") + self._removeCompletedFromQueue(utteranceIndex) + return False + else: + item._utteranceIndex = utteranceIndex + log.debug(f"Speaking utterance with cancellable item, index: {utteranceIndex}") + self._cancelCommandsForUtteranceBeingSpokenBySynth[item] = utteranceIndex + return True + + def removeCancelledSpeechCommands(self): + if not _shouldCancelExpiredFocusEvents(): + return + latestCanceledUtteranceIndex = None + log.debug( + f"Length of _cancelCommandsForUtteranceBeingSpokenBySynth: " + f"{len(self._cancelCommandsForUtteranceBeingSpokenBySynth.keys())} " + f"Length of _indexesSpeaking: " + f"{len(self._indexesSpeaking)} " + ) + for command, index in self._cancelCommandsForUtteranceBeingSpokenBySynth.items(): + if command.isCancelled: + # we must not risk deleting commands while iterating over _cancelCommandsForUtteranceBeingSpokenBySynth + if not latestCanceledUtteranceIndex or latestCanceledUtteranceIndex < index: + latestCanceledUtteranceIndex = index + log.debug(f"Last index: {latestCanceledUtteranceIndex}") + if latestCanceledUtteranceIndex is not None: + log.debug(f"Cancel and push speech") + self._removeCompletedFromQueue(latestCanceledUtteranceIndex) + getSynth().cancel() + self._cancelCommandsForUtteranceBeingSpokenBySynth.clear() + self._indexesSpeaking.clear() + self._pushNextSpeech(True) + + def _getUtteranceIndex(self, utterance: SpeechSequence): + # find the index command, should be the last in sequence + indexItem: IndexCommand = cast(IndexCommand, utterance[-1]) + if not isinstance(indexItem, IndexCommand): + log.error("Expected last item to be an indexCommand.") + return None + return indexItem.index + def _onSynthIndexReached(self, synth=None, index=None): if synth != getSynth(): return # This needs to be handled in the main thread. queueHandler.queueFunction(queueHandler.eventQueue, self._handleIndex, index) - def _removeCompletedFromQueue(self, index: int) -> Tuple[bool, bool]: + # C901 'SpeechManager._removeCompletedFromQueue' is too complex + # SpeechManager needs unit tests and a breakdown of responsibilities. + def _removeCompletedFromQueue(self, index: int) -> Tuple[bool, bool]: # noqa: C901 """Removes completed speech sequences from the queue. @param index: The index just reached indicating a completed sequence. @return: Tuple of (valid, endOfUtterance), @@ -393,6 +482,7 @@ def _removeCompletedFromQueue(self, index: int) -> Tuple[bool, bool]: break # Found it! else: # Unknown index. Probably from a previous utterance which was cancelled. + log.debug("unknown index. Probably from a previous utterance which was cancelled") return False, False if endOfUtterance: # These params may not apply to the next utterance if it was queued separately, @@ -408,10 +498,24 @@ def _removeCompletedFromQueue(self, index: int) -> Tuple[bool, bool]: if isinstance(command, SynthParamCommand): self._curPriQueue.paramTracker.update(command) # This sequence is done, so we don't need to track it any more. - del self._curPriQueue.pendingSequences[:seqIndex + 1] + toRemove = self._curPriQueue.pendingSequences[:seqIndex + 1] + for seq in toRemove: + log.debug(f"Removing: {seq}") + if shouldCancelExpiredFocusEvents(): + # Debug logging for cancelling expired focus events. + for item in seq: + if isinstance(item, _CancellableSpeechCommand): + log.debug( + f"Item is in _cancelCommandsForUtteranceBeingSpokenBySynth: " + f"{item in self._cancelCommandsForUtteranceBeingSpokenBySynth.keys()}" + ) + self._cancelCommandsForUtteranceBeingSpokenBySynth.pop(item, None) + self._curPriQueue.pendingSequences.remove(seq) + return True, endOfUtterance def _handleIndex(self, index: int): + log.debug(f"Handle index: {index}") # A synth (such as OneCore) may skip indexes # If before another index, with no text content in between. # Therefore, detect this and ensure we handle all skipped indexes. @@ -453,6 +557,7 @@ def _onSynthDoneSpeaking(self, synth: Optional[synthDriverHandler.SynthDriver] = queueHandler.queueFunction(queueHandler.eventQueue, self._handleDoneSpeaking) def _handleDoneSpeaking(self): + log.debug(f"Synth done speaking, should push: {self._shouldPushWhenDoneSpeaking}") if self._shouldPushWhenDoneSpeaking: self._shouldPushWhenDoneSpeaking = False self._pushNextSpeech(True) diff --git a/user_docs/en/userGuide.t2t b/user_docs/en/userGuide.t2t index 97c71bbd025..0fc59479a02 100644 --- a/user_docs/en/userGuide.t2t +++ b/user_docs/en/userGuide.t2t @@ -1787,6 +1787,10 @@ This feature is available and enabled by default on Windows 10 versions 1607and Warning: with this option enabled, typed characters that do not appear onscreen, such as passwords, will not be suppressed. In untrusted environments, you may temporarily disable [speak typed characters #KeyboardSettingsSpeakTypedCharacters] and [speak typed words #KeyboardSettingsSpeakTypedWords] when entering passwords. +==== Attempt to cancel speech for expired focus events ====[CancelExpiredFocusSpeech] +This option enables behaviour which attempts to cancel speech for expired focus events. +In particular moving quickly through messages in Gmail with Chrome can cause NVDA to speak outdated information. + ==== Automatically set system focus to focusable elements in Browse Mode ====[BrowseModeSettingsAutoFocusFocusableElements] Key: NVDA+8 From 1d146f6ead525a96361be9837bffe35551c9a15c Mon Sep 17 00:00:00 2001 From: Reef Turner Date: Thu, 23 Apr 2020 11:55:16 +0200 Subject: [PATCH 2/4] Enable debug logging via config --- source/config/configSpec.py | 1 + source/eventHandler.py | 10 +++-- source/gui/settingsDialogs.py | 1 + source/speech/manager.py | 81 ++++++++++++++++++++++++----------- 4 files changed, 64 insertions(+), 29 deletions(-) diff --git a/source/config/configSpec.py b/source/config/configSpec.py index 6a754c9d99b..408d26f4c0a 100644 --- a/source/config/configSpec.py +++ b/source/config/configSpec.py @@ -238,6 +238,7 @@ timeSinceInput = boolean(default=false) vision = boolean(default=false) speech = boolean(default=false) + speechManager = boolean(default=false) [uwpOcr] language = string(default="") diff --git a/source/eventHandler.py b/source/eventHandler.py index 7adb6543459..1fade4b4d6a 100755 --- a/source/eventHandler.py +++ b/source/eventHandler.py @@ -172,8 +172,12 @@ def isSpeechStillValid(): isLastFocusObj: bool = obj is lastQueuedFocusObject stillValid = isLastFocusObj or not previouslyHadFocus - log.debug( - f"checked if valid (isLast: {isLastFocusObj}, previouslyHad: {previouslyHadFocus}): {obj.name}") + log._speechManagerDebug( + "checked if valid (isLast: %s, previouslyHad: %s): %s", + isLastFocusObj, + previouslyHadFocus, + obj.name + ) return stillValid return _CancellableSpeechCommand(isSpeechStillValid) @@ -194,7 +198,7 @@ def executeEvent(eventName, obj, **kwargs): obj=obj.focusRedirect sleepMode=obj.sleepMode if isGainFocus and speech.manager._shouldCancelExpiredFocusEvents(): - log.debug("executeEvent: Removing cancelled speech commands.") + log._speechManagerDebug("executeEvent: Removing cancelled speech commands.") # ask speechManager to check if any of it's queued utterances should be cancelled speech._manager.removeCancelledSpeechCommands() # Don't skip objects without focus here. Even if an object no longer has focus, it needs to be processed diff --git a/source/gui/settingsDialogs.py b/source/gui/settingsDialogs.py index 7e214258e59..3b5b8fb7572 100644 --- a/source/gui/settingsDialogs.py +++ b/source/gui/settingsDialogs.py @@ -2415,6 +2415,7 @@ def __init__(self, parent): "timeSinceInput", "vision", "speech", + "speechManager", ] # Translators: This is the label for a list in the # Advanced settings panel diff --git a/source/speech/manager.py b/source/speech/manager.py index 26c605f41f7..5aab49a0c0c 100644 --- a/source/speech/manager.py +++ b/source/speech/manager.py @@ -54,6 +54,25 @@ def _shouldCancelExpiredFocusEvents(): # 0: default (no), 1: yes, 2: no return config.conf["featureFlag"]["cancelExpiredFocusSpeech"] == 1 + +def _shouldDoSpeechManagerLogging(): + return config.conf["debugLog"]["speechManager"] + + +def _speechManagerDebug(msg, *args, **kwargs) -> None: + """Log 'msg % args' with severity 'DEBUG' if speech manager logging is enabled. + 'SpeechManager-' is prefixed to all messages to make searching the log easier. + """ + if not log.isEnabledFor(log.DEBUG) or not _shouldDoSpeechManagerLogging(): + return + log._log(log.DEBUG, f"SpeechManager- " + msg, args, **kwargs) + + +# Install the custom log handler. + +log._speechManagerDebug = _speechManagerDebug + + class ParamChangeTracker(object): """Keeps track of commands which change parameters from their defaults. This is useful when an utterance needs to be split. @@ -201,37 +220,41 @@ def _reset(self): self._cancelCommandsForUtteranceBeingSpokenBySynth = {} def speak(self, speechSequence: SpeechSequence, priority: Spri): - log.debug(f"manager.speak: {speechSequence}") + log._speechManagerDebug("Speak called: %r", speechSequence) # expensive string to build - defer interrupt = self._queueSpeechSequence(speechSequence, priority) self.removeCancelledSpeechCommands() # If speech isn't already in progress, we need to push the first speech. push = self._curPriQueue is None or 1 > len(self._indexesSpeaking) if interrupt: - log.debug("interrupting speech") + log._speechManagerDebug("Interrupting speech") getSynth().cancel() self._indexesSpeaking.clear() self._cancelCommandsForUtteranceBeingSpokenBySynth.clear() push = True if push: - log.debug("pushing next speech") + log._speechManagerDebug("Pushing next speech") self._pushNextSpeech(True) - log.debug("not pushing speech") + else: + log._speechManagerDebug("Not pushing speech") def _queueSpeechSequence(self, inSeq: SpeechSequence, priority: Spri) -> bool: """ @return: Whether to interrupt speech. """ outSeq = self._processSpeechSequence(inSeq) - log.debug(f"Out Seq: {outSeq}") + log._speechManagerDebug("Out Seq: %r", outSeq) # expensive string to build - defer queue = self._priQueues.get(priority) - log.debug( - f"Current priority: {priority}, queLen: " - f"{0 if queue is None else len(queue.pendingSequences)}" + log._speechManagerDebug( + f"Current priority: {priority}," + f" queLen: {0 if queue is None else len(queue.pendingSequences)}" ) if not queue: queue = self._priQueues[priority] = _ManagerPriorityQueue(priority) else: - log.debug(f"current queue: {queue.pendingSequences}") + log._speechManagerDebug( + "current queue: %r", # expensive string to build - defer + queue.pendingSequences + ) first = len(queue.pendingSequences) == 0 queue.pendingSequences.extend(outSeq) if priority is Spri.NOW and first: @@ -312,7 +335,7 @@ def _pushNextSpeech(self, doneSpeaking: bool): queue = self._getNextPriority() if not queue: # No more speech. - log.debug("no more speech") + log._speechManagerDebug("No more speech") self._curPriQueue = None return if not self._curPriQueue: @@ -408,12 +431,14 @@ def _checkForCancellations(self, utterance: SpeechSequence) -> bool: for item in cancellableItems: utterance.remove(item) # CancellableSpeechCommands should not be sent to the synthesizer. if item.isCancelled: - log.debug(f"item already cancelled, canceling up to: {utteranceIndex}") + log._speechManagerDebug(f"item already cancelled, canceling up to: {utteranceIndex}") self._removeCompletedFromQueue(utteranceIndex) return False else: item._utteranceIndex = utteranceIndex - log.debug(f"Speaking utterance with cancellable item, index: {utteranceIndex}") + log._speechManagerDebug( + f"Speaking utterance with cancellable item, index: {utteranceIndex}" + ) self._cancelCommandsForUtteranceBeingSpokenBySynth[item] = utteranceIndex return True @@ -421,9 +446,9 @@ def removeCancelledSpeechCommands(self): if not _shouldCancelExpiredFocusEvents(): return latestCanceledUtteranceIndex = None - log.debug( + log._speechManagerDebug( f"Length of _cancelCommandsForUtteranceBeingSpokenBySynth: " - f"{len(self._cancelCommandsForUtteranceBeingSpokenBySynth.keys())} " + f"{len(self._cancelCommandsForUtteranceBeingSpokenBySynth)} " f"Length of _indexesSpeaking: " f"{len(self._indexesSpeaking)} " ) @@ -432,9 +457,9 @@ def removeCancelledSpeechCommands(self): # we must not risk deleting commands while iterating over _cancelCommandsForUtteranceBeingSpokenBySynth if not latestCanceledUtteranceIndex or latestCanceledUtteranceIndex < index: latestCanceledUtteranceIndex = index - log.debug(f"Last index: {latestCanceledUtteranceIndex}") + log._speechManagerDebug(f"Last index: {latestCanceledUtteranceIndex}") if latestCanceledUtteranceIndex is not None: - log.debug(f"Cancel and push speech") + log._speechManagerDebug(f"Cancel and push speech") self._removeCompletedFromQueue(latestCanceledUtteranceIndex) getSynth().cancel() self._cancelCommandsForUtteranceBeingSpokenBySynth.clear() @@ -481,8 +506,9 @@ def _removeCompletedFromQueue(self, index: int) -> Tuple[bool, bool]: # noqa: C seqIndex += 1 break # Found it! else: - # Unknown index. Probably from a previous utterance which was cancelled. - log.debug("unknown index. Probably from a previous utterance which was cancelled") + log._speechManagerDebug( + "Unknown index. Probably from a previous utterance which was cancelled." + ) return False, False if endOfUtterance: # These params may not apply to the next utterance if it was queued separately, @@ -500,22 +526,23 @@ def _removeCompletedFromQueue(self, index: int) -> Tuple[bool, bool]: # noqa: C # This sequence is done, so we don't need to track it any more. toRemove = self._curPriQueue.pendingSequences[:seqIndex + 1] for seq in toRemove: - log.debug(f"Removing: {seq}") - if shouldCancelExpiredFocusEvents(): + log._speechManagerDebug("Removing: %r", seq) + if _shouldCancelExpiredFocusEvents(): # Debug logging for cancelling expired focus events. for item in seq: if isinstance(item, _CancellableSpeechCommand): - log.debug( - f"Item is in _cancelCommandsForUtteranceBeingSpokenBySynth: " - f"{item in self._cancelCommandsForUtteranceBeingSpokenBySynth.keys()}" - ) + if log.isEnabledFor(log.DEBUG) and _shouldDoSpeechManagerLogging(): + log._speechManagerDebug( + f"Item is in _cancelCommandsForUtteranceBeingSpokenBySynth: " + f"{item in self._cancelCommandsForUtteranceBeingSpokenBySynth.keys()}" + ) self._cancelCommandsForUtteranceBeingSpokenBySynth.pop(item, None) self._curPriQueue.pendingSequences.remove(seq) return True, endOfUtterance def _handleIndex(self, index: int): - log.debug(f"Handle index: {index}") + log._speechManagerDebug(f"Handle index: {index}") # A synth (such as OneCore) may skip indexes # If before another index, with no text content in between. # Therefore, detect this and ensure we handle all skipped indexes. @@ -557,7 +584,9 @@ def _onSynthDoneSpeaking(self, synth: Optional[synthDriverHandler.SynthDriver] = queueHandler.queueFunction(queueHandler.eventQueue, self._handleDoneSpeaking) def _handleDoneSpeaking(self): - log.debug(f"Synth done speaking, should push: {self._shouldPushWhenDoneSpeaking}") + log._speechManagerDebug( + f"Synth done speaking, should push: {self._shouldPushWhenDoneSpeaking}" + ) if self._shouldPushWhenDoneSpeaking: self._shouldPushWhenDoneSpeaking = False self._pushNextSpeech(True) From 5685b052312f7ab50d99310e4291e9b88337eb67 Mon Sep 17 00:00:00 2001 From: Reef Turner Date: Fri, 8 May 2020 10:29:58 +0200 Subject: [PATCH 3/4] revert unnecessary changes --- source/NVDAObjects/IAccessible/__init__.py | 29 ++++++++-------------- source/NVDAObjects/IAccessible/ia2Web.py | 12 ++++----- 2 files changed, 16 insertions(+), 25 deletions(-) diff --git a/source/NVDAObjects/IAccessible/__init__.py b/source/NVDAObjects/IAccessible/__init__.py index 46de3e93ebc..5c0764dd101 100644 --- a/source/NVDAObjects/IAccessible/__init__.py +++ b/source/NVDAObjects/IAccessible/__init__.py @@ -36,9 +36,6 @@ import eventHandler from NVDAObjects.behaviors import ProgressBar, Dialog, EditableTextWithAutoSelectDetection, FocusableUnfocusableContainer, ToolTip, Notification from locationHelper import RectLTWH -from typing import ( - Optional, -) def getNVDAObjectFromEvent(hwnd,objectID,childID): try: @@ -683,9 +680,15 @@ def isDuplicateIAccessibleEvent(self,obj): return False return obj.event_windowHandle==self.event_windowHandle and obj.event_objectID==self.event_objectID and obj.event_childID==self.event_childID - def _getIndirectionsToParentWithFocus(self) -> Optional[int]: + def _get_shouldAllowIAccessibleFocusEvent(self): + """Determine whether a focus event should be allowed for this object. + Normally, this checks for the focused state to help eliminate redundant or invalid focus events. + However, some implementations do not correctly set the focused state, so this must be overridden. + @return: C{True} if the focus event should be allowed. + @rtype: bool + """ + #this object or one of its ancestors must have state_focused. testObj = self - indirections = 0 while testObj: if controlTypes.STATE_FOCUSED in testObj.states: break @@ -693,21 +696,9 @@ def _getIndirectionsToParentWithFocus(self) -> Optional[int]: # Cache the parent. testObj.parent = parent testObj = parent - indirections += 1 else: - return None - return indirections - - def _get_shouldAllowIAccessibleFocusEvent(self): - """Determine whether a focus event should be allowed for this object. - Normally, this checks for the focused state to help eliminate redundant or invalid focus events. - However, some implementations do not correctly set the focused state, so this must be overridden. - @return: C{True} if the focus event should be allowed. - @rtype: bool - """ - # this object or one of its ancestors must have state_focused. - indirectionsToFocus = self._getIndirectionsToParentWithFocus() - return indirectionsToFocus is not None + return False + return True def _get_TextInfo(self): if hasattr(self,'IAccessibleTextObject'): diff --git a/source/NVDAObjects/IAccessible/ia2Web.py b/source/NVDAObjects/IAccessible/ia2Web.py index 02206a125a8..73f7e666a9a 100644 --- a/source/NVDAObjects/IAccessible/ia2Web.py +++ b/source/NVDAObjects/IAccessible/ia2Web.py @@ -1,8 +1,8 @@ -# NVDAObjects/IAccessible/ia2Web.py -# A part of NonVisual Desktop Access (NVDA) -# This file is covered by the GNU General Public License. -# See the file COPYING for more details. -# Copyright (C) 2006-2020 NV Access Limited +#NVDAObjects/IAccessible/ia2Web.py +#A part of NonVisual Desktop Access (NVDA) +#This file is covered by the GNU General Public License. +#See the file COPYING for more details. +#Copyright (C) 2006-2017 NV Access Limited """Base classes with common support for browsers exposing IAccessible2. """ @@ -14,7 +14,7 @@ import controlTypes from logHandler import log from documentBase import DocumentWithTableNavigation -from NVDAObjects.behaviors import Dialog, WebDialog +from NVDAObjects.behaviors import Dialog, WebDialog from . import IAccessible from .ia2TextMozilla import MozillaCompoundTextInfo import aria From 7974c97a99d5edea6c0eb150a31b79d300a5d54b Mon Sep 17 00:00:00 2001 From: Reef Turner Date: Fri, 8 May 2020 14:10:54 +0200 Subject: [PATCH 4/4] Update Changes file for PR #10885 --- user_docs/en/changes.t2t | 2 ++ 1 file changed, 2 insertions(+) diff --git a/user_docs/en/changes.t2t b/user_docs/en/changes.t2t index 11a87c9d605..3395a465a60 100644 --- a/user_docs/en/changes.t2t +++ b/user_docs/en/changes.t2t @@ -13,6 +13,8 @@ What's New in NVDA - Added support for Windows Terminal. (#10305) - Added a command to report the active configuration profile. (#9325) - Added a command to toggle reporting of subscripts and superscripts. (#10985) +- Web applications (EG Gmail) no longer speak outdated information when moving focus rapidly. (#10885) + - Must be manually enabled via the 'Attempt to cancel speech for expired focus events' option in the advanced settings panel. == Changes ==