From 9967903a515e8087de889b8a8c67bdc31fbf3a4f Mon Sep 17 00:00:00 2001 From: Reef Turner Date: Fri, 8 May 2020 14:14:19 +0200 Subject: [PATCH] Attempt to cancel speech for expired focus events (PR #10885) This experimental feature (disabled by default) 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 in the speech pipeline (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 Updates Changes file for PR #10885 --- source/NVDAObjects/__init__.py | 3 + source/config/configSpec.py | 5 ++ source/eventHandler.py | 70 +++++++++++++-- source/gui/settingsDialogs.py | 40 +++++++++ source/speech/__init__.py | 26 ++++-- source/speech/commands.py | 60 ++++++++++--- source/speech/manager.py | 154 ++++++++++++++++++++++++++++++--- user_docs/en/changes.t2t | 2 + user_docs/en/userGuide.t2t | 4 + 9 files changed, 330 insertions(+), 34 deletions(-) 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..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="") @@ -250,6 +251,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 cda98907ebb..01e32aaa288 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,54 @@ 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._speechManagerDebug( + "checked if valid (isLast: %s, previouslyHad: %s): %s", + isLastFocusObj, + 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 +192,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._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 + # 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 fd524085054..7cdf8ba7500 100644 --- a/source/gui/settingsDialogs.py +++ b/source/gui/settingsDialogs.py @@ -2251,6 +2251,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. @@ -2338,6 +2339,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") @@ -2393,6 +2429,7 @@ def __init__(self, parent): "timeSinceInput", "vision", "speech", + "speechManager", ] # Translators: This is the label for a list in the # Advanced settings panel @@ -2427,6 +2464,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() @@ -2442,6 +2480,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) @@ -2457,6 +2496,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..5aab49a0c0c 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,35 @@ Any, List, Tuple, + Callable, Optional, + cast, ) +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. @@ -149,6 +175,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 +217,44 @@ 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._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._speechManagerDebug("Interrupting speech") getSynth().cancel() + self._indexesSpeaking.clear() + self._cancelCommandsForUtteranceBeingSpokenBySynth.clear() push = True if push: + log._speechManagerDebug("Pushing next speech") self._pushNextSpeech(True) + else: + log._speechManagerDebug("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._speechManagerDebug("Out Seq: %r", outSeq) # expensive string to build - defer queue = self._priQueues.get(priority) + 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._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: @@ -239,8 +284,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 +335,7 @@ def _pushNextSpeech(self, doneSpeaking: bool): queue = self._getNextPriority() if not queue: # No more speech. + log._speechManagerDebug("No more speech") self._curPriQueue = None return if not self._curPriQueue: @@ -328,7 +374,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 +406,83 @@ 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._speechManagerDebug(f"item already cancelled, canceling up to: {utteranceIndex}") + self._removeCompletedFromQueue(utteranceIndex) + return False + else: + item._utteranceIndex = utteranceIndex + log._speechManagerDebug( + f"Speaking utterance with cancellable item, index: {utteranceIndex}" + ) + self._cancelCommandsForUtteranceBeingSpokenBySynth[item] = utteranceIndex + return True + + def removeCancelledSpeechCommands(self): + if not _shouldCancelExpiredFocusEvents(): + return + latestCanceledUtteranceIndex = None + log._speechManagerDebug( + f"Length of _cancelCommandsForUtteranceBeingSpokenBySynth: " + f"{len(self._cancelCommandsForUtteranceBeingSpokenBySynth)} " + 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._speechManagerDebug(f"Last index: {latestCanceledUtteranceIndex}") + if latestCanceledUtteranceIndex is not None: + log._speechManagerDebug(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), @@ -392,7 +506,9 @@ def _removeCompletedFromQueue(self, index: int) -> Tuple[bool, bool]: seqIndex += 1 break # Found it! else: - # 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, @@ -408,10 +524,25 @@ 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._speechManagerDebug("Removing: %r", seq) + if _shouldCancelExpiredFocusEvents(): + # Debug logging for cancelling expired focus events. + for item in seq: + if isinstance(item, _CancellableSpeechCommand): + 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._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. @@ -453,6 +584,9 @@ def _onSynthDoneSpeaking(self, synth: Optional[synthDriverHandler.SynthDriver] = queueHandler.queueFunction(queueHandler.eventQueue, self._handleDoneSpeaking) def _handleDoneSpeaking(self): + log._speechManagerDebug( + f"Synth done speaking, should push: {self._shouldPushWhenDoneSpeaking}" + ) if self._shouldPushWhenDoneSpeaking: self._shouldPushWhenDoneSpeaking = False self._pushNextSpeech(True) diff --git a/user_docs/en/changes.t2t b/user_docs/en/changes.t2t index 8a9e7c91dd7..17813108d81 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 == diff --git a/user_docs/en/userGuide.t2t b/user_docs/en/userGuide.t2t index 5d3f71c4d18..db6aae59bcf 100644 --- a/user_docs/en/userGuide.t2t +++ b/user_docs/en/userGuide.t2t @@ -1801,6 +1801,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