Skip to content

Commit

Permalink
Attempt to cancel speech for expired focus events (PR #10885)
Browse files Browse the repository at this point in the history
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
  • Loading branch information
feerrenrut authored May 8, 2020
1 parent 1cf36fb commit 9967903
Show file tree
Hide file tree
Showing 9 changed files with 330 additions and 34 deletions.
3 changes: 3 additions & 0 deletions source/NVDAObjects/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}.
Expand Down
5 changes: 5 additions & 0 deletions source/config/configSpec.py
Original file line number Diff line number Diff line change
Expand Up @@ -238,6 +238,7 @@
timeSinceInput = boolean(default=false)
vision = boolean(default=false)
speech = boolean(default=false)
speechManager = boolean(default=false)
[uwpOcr]
language = string(default="")
Expand All @@ -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
Expand Down
70 changes: 64 additions & 6 deletions source/eventHandler.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
40 changes: 40 additions & 0 deletions source/gui/settingsDialogs.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand All @@ -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)
Expand All @@ -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()
Expand Down
26 changes: 18 additions & 8 deletions source/speech/__init__.py
Original file line number Diff line number Diff line change
@@ -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.
"""
Expand All @@ -23,6 +23,7 @@
import speechDictHandler
import characterProcessing
import languageHandler
from . import manager
from .commands import (
# Commands that are used in this file.
SpeechCommand,
Expand All @@ -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,
Expand All @@ -51,6 +53,7 @@
ConfigProfileTriggerCommand,
)

from . import types
from .types import (
SpeechSequence,
SequenceItemT,
Expand All @@ -66,6 +69,7 @@
Generator,
Union,
Callable,
Iterator,
Tuple,
)
from logHandler import log
Expand Down Expand Up @@ -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


Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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:
Expand Down
60 changes: 50 additions & 10 deletions source/speech/commands.py
Original file line number Diff line number Diff line change
@@ -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.
"""
Expand Down
Loading

0 comments on commit 9967903

Please sign in to comment.