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 6f724c293ef..1fade4b4d6a 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 614424194a6..3b5b8fb7572 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") @@ -2379,6 +2415,7 @@ def __init__(self, parent): "timeSinceInput", "vision", "speech", + "speechManager", ] # Translators: This is the label for a list in the # Advanced settings panel @@ -2413,6 +2450,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 +2466,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 +2482,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 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 == 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