diff --git a/source/autoSettingsUtils/__init__.py b/source/autoSettingsUtils/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/source/autoSettingsUtils/autoSettings.py b/source/autoSettingsUtils/autoSettings.py new file mode 100644 index 00000000000..f9386a361db --- /dev/null +++ b/source/autoSettingsUtils/autoSettings.py @@ -0,0 +1,248 @@ +# -*- 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) 2019 NV Access Limited + +"""autoSettings for add-ons""" +from abc import abstractmethod +from copy import deepcopy +from typing import Dict, Type, Any, Iterable + +import config +from autoSettingsUtils.utils import paramToPercent, percentToParam, UnsupportedConfigParameterError +from baseObject import AutoPropertyObject +from logHandler import log +from .driverSetting import DriverSetting + +SupportedSettingType: Type = Iterable[DriverSetting] + + +class AutoSettings(AutoPropertyObject): + """ An AutoSettings instance is used to simplify the load/save of user config for NVDA extensions + (Synth drivers, braille drivers, vision providers) and make it possible to automatically provide a + standard GUI for these settings. + Derived classes must implement: + - getId + - getDisplayName + - _get_supportedSettings + """ + + def __init__(self): + """Perform any initialisation + @note: registers with the config save action extension point + """ + super().__init__() + self._registerConfigSaveAction() + + def __del__(self): + self._unregisterConfigSaveAction() + + def _registerConfigSaveAction(self): + """ Overrideable pre_configSave registration + """ + log.debug(f"registering pre_configSave action: {self.__class__!r}") + config.pre_configSave.register(self.saveSettings) + + def _unregisterConfigSaveAction(self): + """ Overrideable pre_configSave de-registration + """ + config.pre_configSave.unregister(self.saveSettings) + + @classmethod + @abstractmethod + def getId(cls) -> str: + """ + @return: Application friendly name, should be globally unique, however since this is used in the config file + human readable is also beneficial. + """ + ... + + @classmethod + @abstractmethod + def getDisplayName(cls) -> str: + """ + @return: The translated name for this collection of settings. This is for use in the GUI to represent the + group of these settings. + """ + ... + + @classmethod + @abstractmethod + def _getConfigSection(cls) -> str: + """ + @return: The section of the config that these settings belong in. + """ + ... + + @classmethod + def _initSpecificSettings( + cls, + clsOrInst: Any, + settings: SupportedSettingType + ) -> None: + section = cls._getConfigSection() + settingsId = cls.getId() + firstLoad = not config.conf[section].isSet(settingsId) + if firstLoad: + # Create the new section. + config.conf[section][settingsId] = {} + # Make sure the config spec is up to date, so the config validator does its work. + config.conf[section][settingsId].spec.update( + cls._getConfigSpecForSettings(settings) + ) + # Make sure the clsOrInst has attributes for every setting + for setting in settings: + if not hasattr(clsOrInst, setting.id): + setattr(clsOrInst, setting.id, setting.defaultVal) + if firstLoad: + cls._saveSpecificSettings(clsOrInst, settings) # save defaults + else: + cls._loadSpecificSettings(clsOrInst, settings) + + def initSettings(self): + """Initializes the configuration for this AutoSettings instance. + This method is called when initializing the AutoSettings instance. + """ + self._initSpecificSettings(self, self.supportedSettings) + + #: Typing for auto property L{_get_supportedSettings} + supportedSettings: SupportedSettingType + + # make supportedSettings an abstract property + _abstract_supportedSettings = True + + def _get_supportedSettings(self) -> SupportedSettingType: + """The settings supported by the AutoSettings instance. Abstract. + """ + return [] + + def isSupported(self, settingID) -> bool: + """Checks whether given setting is supported by the AutoSettings instance. + """ + for s in self.supportedSettings: + if s.id == settingID: + return True + return False + + @classmethod + def _getConfigSpecForSettings( + cls, + settings: SupportedSettingType + ) -> Dict: + section = cls._getConfigSection() + spec = deepcopy(config.confspec[section]["__many__"]) + for setting in settings: + if not setting.useConfig: + continue + spec[setting.id] = setting.configSpec + return spec + + def getConfigSpec(self): + return self._getConfigSpecForSettings(self.supportedSettings) + + @classmethod + def _saveSpecificSettings( + cls, + clsOrInst: Any, + settings: SupportedSettingType + ) -> None: + """ + Save values for settings to config. + The values from the attributes of `clsOrInst` that match the `id` of each setting are saved to config. + @param clsOrInst: Destination for the values. + @param settings: The settings to load. + """ + section = cls._getConfigSection() + setingsId = cls.getId() + conf = config.conf[section][setingsId] + for setting in settings: + if not setting.useConfig: + continue + try: + conf[setting.id] = getattr(clsOrInst, setting.id) + except UnsupportedConfigParameterError: + log.debugWarning( + f"Unsupported setting {setting.id!r}; ignoring", + exc_info=True + ) + continue + if settings: + log.debug(f"Saved settings for {cls.__qualname__}") + + def saveSettings(self): + """ + Saves the current settings for the AutoSettings instance to the configuration. + This method is also executed when the AutoSettings instance is loaded for the first time, + in order to populate the configuration with the initial settings.. + """ + self._saveSpecificSettings(self, self.supportedSettings) + + @classmethod + def _loadSpecificSettings( + cls, + clsOrInst: Any, + settings: SupportedSettingType, + onlyChanged: bool = False + ) -> None: + """ + Load settings from config, set them on `clsOrInst`. + @param clsOrInst: Destination for the values. + @param settings: The settings to load. + @param onlyChanged: When True, only settings that no longer match the config are set. + @note: attributes are set on clsOrInst using setattr. + The id of each setting in `settings` is used as the attribute name. + """ + section = cls._getConfigSection() + settingsID = cls.getId() + log.debug(f"loading {section} {settingsID}") + conf = config.conf[section][settingsID] + for setting in settings: + if not setting.useConfig or conf.get(setting.id) is None: + continue + val = conf[setting.id] + if onlyChanged and getattr(clsOrInst, setting.id) == val: + continue + try: + setattr(clsOrInst, setting.id, val) + except UnsupportedConfigParameterError: + log.debugWarning( + f"Unsupported setting {setting.id!r}; ignoring", + exc_info=True + ) + continue + if settings: + log.debug( + f"Loaded changed settings for {cls.__qualname__}" + if onlyChanged else + f"Loaded settings for {cls.__qualname__}" + ) + + def loadSettings(self, onlyChanged: bool = False): + """ + Loads settings for this AutoSettings instance from the configuration. + This method assumes that the instance has attributes o/properties + corresponding with the name of every setting in L{supportedSettings}. + @param onlyChanged: When loading settings, only apply those for which + the value in the configuration differs from the current value. + """ + self._loadSpecificSettings(self, self.supportedSettings, onlyChanged) + + @classmethod + def _paramToPercent(cls, current: int, min: int, max: int) -> int: + """Convert a raw parameter value to a percentage given the current, minimum and maximum raw values. + @param current: The current value. + @param min: The minimum value. + @param max: The maximum value. + """ + return paramToPercent(current, min, max) + + @classmethod + def _percentToParam(cls, percent: int, min: int, max: int) -> int: + """Convert a percentage to a raw parameter value given the current percentage and the minimum and maximum + raw parameter values. + @param percent: The current percentage. + @param min: The minimum raw parameter value. + @param max: The maximum raw parameter value. + """ + return percentToParam(percent, min, max) diff --git a/source/autoSettingsUtils/driverSetting.py b/source/autoSettingsUtils/driverSetting.py new file mode 100644 index 00000000000..05b7819ec58 --- /dev/null +++ b/source/autoSettingsUtils/driverSetting.py @@ -0,0 +1,150 @@ +# -*- 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) 2019 NV Access Limited + +"""Classes used to represent settings for Drivers and other AutoSettings instances + + Naming of these classes is historical, kept for backwards compatibility purposes. +""" + +from typing import Optional +from baseObject import AutoPropertyObject + + +class DriverSetting(AutoPropertyObject): + """As a base class, represents a setting to be shown in GUI and saved to config. + + GUI representation is a string selection GUI control, a wx.Choice control. + + Used for synthesizer or braille display setting such as voice, variant or dot firmness as + well as for settings in Vision Providers + """ + id: str + displayName: str + displayNameWithAccelerator: str + availableInSettingsRing: bool + defaultVal: object + useConfig: bool + + #: Type information for _get_configSpec + configSpec: str + + def _get_configSpec(self): + """Returns the configuration specification of this particular setting for config file validator. + @rtype: str + """ + return "string(default={defaultVal})".format(defaultVal=self.defaultVal) + + def __init__( + self, + id: str, + displayNameWithAccelerator: str, + availableInSettingsRing: bool = False, + defaultVal: object = None, + displayName: Optional[str] = None, + useConfig: bool = True + ): + """ + @param id: internal identifier of the setting + @param displayNameWithAccelerator: the localized string shown in voice or braille settings dialog + @param availableInSettingsRing: Will this option be available in a settings ring? + @param defaultVal: Specifies the default value for a driver setting. + @param displayName: the localized string used in synth settings ring or + None to use displayNameWithAccelerator + @param useConfig: Whether the value of this option is loaded from and saved to NVDA's configuration. + Set this to C{False} if the driver deals with loading and saving. + """ + self.id = id + self.displayNameWithAccelerator = displayNameWithAccelerator + if not displayName: + # Strip accelerator from displayNameWithAccelerator. + displayName = displayNameWithAccelerator.replace("&", "") + self.displayName = displayName + self.availableInSettingsRing = availableInSettingsRing + self.defaultVal = defaultVal + self.useConfig = useConfig + + +class NumericDriverSetting(DriverSetting): + """Represents a numeric driver setting such as rate, volume, pitch or dot firmness. + GUI representation is a slider control. + """ + + defaultVal: int + + def _get_configSpec(self): + return "integer(default={defaultVal},min={minVal},max={maxVal})".format( + defaultVal=self.defaultVal, minVal=self.minVal, maxVal=self.maxVal) + + def __init__( + self, + id, + displayNameWithAccelerator, + availableInSettingsRing=False, + defaultVal: int = 50, + minVal: int = 0, + maxVal: int = 100, + minStep: int = 1, + normalStep: int = 5, + largeStep: int = 10, + displayName: Optional[str] = None, + useConfig: bool = True): + """ + @param defaultVal: Specifies the default value for a numeric driver setting. + @param minVal: Specifies the minimum valid value for a numeric driver setting. + @param maxVal: Specifies the maximum valid value for a numeric driver setting. + @param minStep: Specifies the minimum step between valid values for each numeric setting. + For example, if L{minStep} is set to 10, setting values can only be multiples of 10; 10, 20, 30, etc. + @param normalStep: Specifies the step between values that a user will normally prefer. + This is used in the settings ring. + @param largeStep: Specifies the step between values if a large adjustment is desired. + This is used for pageUp/pageDown on sliders in the Voice Settings dialog. + @note: If necessary, the step values will be normalised so that L{minStep} <= L{normalStep} <= L{largeStep}. + """ + super(NumericDriverSetting, self).__init__( + id, + displayNameWithAccelerator, + availableInSettingsRing=availableInSettingsRing, + defaultVal=defaultVal, + displayName=displayName, + useConfig=useConfig + ) + self.minVal = minVal + self.maxVal = max(maxVal, self.defaultVal) + self.minStep = minStep + self.normalStep = max(normalStep, minStep) + self.largeStep = max(largeStep, self.normalStep) + + +class BooleanDriverSetting(DriverSetting): + """Represents a boolean driver setting such as rate boost or automatic time sync. + GUI representation is a wx.Checkbox + """ + defaultVal: bool + + def __init__( + self, + id: str, + displayNameWithAccelerator: str, + availableInSettingsRing: bool = False, + displayName: Optional[str] = None, + defaultVal: bool = False, + useConfig: bool = True + ): + """ + @param defaultVal: Specifies the default value for a boolean driver setting. + """ + super(BooleanDriverSetting, self).__init__( + id, + displayNameWithAccelerator, + availableInSettingsRing=availableInSettingsRing, + defaultVal=defaultVal, + displayName=displayName, + useConfig=useConfig + ) + + def _get_configSpec(self): + defaultVal = repr(self.defaultVal) if self.defaultVal is not None else self.defaultVal + return "boolean(default={defaultVal})".format(defaultVal=defaultVal) diff --git a/source/autoSettingsUtils/utils.py b/source/autoSettingsUtils/utils.py new file mode 100644 index 00000000000..8ffaa689149 --- /dev/null +++ b/source/autoSettingsUtils/utils.py @@ -0,0 +1,55 @@ +# -*- 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) 2019 NV Access Limited + +"""Utility methods for Driver and AutoSettings instances +""" + + +def paramToPercent(current: int, min: int, max: int) -> int: + """Convert a raw parameter value to a percentage given the current, minimum and maximum raw values. + @param current: The current value. + @type current: int + @param min: The minimum value. + @type current: int + @param max: The maximum value. + @type max: int + """ + return round(float(current - min) / (max - min) * 100) + + +def percentToParam(percent: int, min: int, max: int) -> int: + """Convert a percentage to a raw parameter value given the current percentage and the minimum and maximum + raw parameter values. + @param percent: The current percentage. + @type percent: int + @param min: The minimum raw parameter value. + @type min: int + @param max: The maximum raw parameter value. + @type max: int + """ + return round(float(percent) / 100 * (max - min) + min) + + +class UnsupportedConfigParameterError(NotImplementedError): + """ + Raised when changing or retrieving a driver setting that is unsupported for the connected device. + """ + + +class StringParameterInfo(object): + """ + Used to represent a value of a DriverSetting instance. + """ + id: str + displayName: str + + def __init__(self, id: str, displayName: str): + """ + @param id: The unique identifier of the value. + @param displayName: The name of the value, visible to the user. + """ + self.id = id + self.displayName = displayName diff --git a/source/driverHandler.py b/source/driverHandler.py index b12580507fc..cee0ea31f53 100644 --- a/source/driverHandler.py +++ b/source/driverHandler.py @@ -1,18 +1,26 @@ # -*- coding: UTF-8 -*- -#driverHandler.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-2018 NV Access Limited, Leonard de Ruijter +# 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, Leonard de Ruijter """Handler for driver functionality that is global to synthesizers and braille displays.""" - -from baseObject import AutoPropertyObject -import config -from copy import deepcopy -from logHandler import log - -class Driver(AutoPropertyObject): +from autoSettingsUtils.autoSettings import AutoSettings + +# F401: the following imports, while unused in this file, are provided for backwards compatibility. +from autoSettingsUtils.driverSetting import ( # noqa: F401 + DriverSetting, + BooleanDriverSetting, + NumericDriverSetting, + AutoPropertyObject, +) +from autoSettingsUtils.utils import ( # noqa: F401 + UnsupportedConfigParameterError, + StringParameterInfo, +) + + +class Driver(AutoSettings): """ Abstract base class for drivers, such as speech synthesizer and braille display drivers. Abstract subclasses such as L{braille.BrailleDisplayDriver} should set L{_configSection}. @@ -42,43 +50,15 @@ def __init__(self): @postcondition: This driver can be used. """ super(Driver, self).__init__() - config.pre_configSave.register(self.saveSettings) - - def initSettings(self): - """ - Initializes the configuration for this driver. - This method is called when initializing the driver. - """ - firstLoad = not config.conf[self._configSection].isSet(self.name) - if firstLoad: - # Create the new section. - config.conf[self._configSection][self.name] = {} - # Make sure the config spec is up to date, so the config validator does its work. - config.conf[self._configSection][self.name].spec.update(self.getConfigSpec()) - # Make sure the instance has attributes for every setting - for setting in self.supportedSettings: - if not hasattr(self, setting.id): - setattr(self, setting.id, setting.defaultVal) - if firstLoad: - self.saveSettings() #save defaults - else: - self.loadSettings() def terminate(self): - """Terminate this driver. + """Save settings and terminate this driver. This should be used for any required clean up. @precondition: L{initialize} has been called. @postcondition: This driver can no longer be used. """ self.saveSettings() - config.pre_configSave.unregister(self.saveSettings) - - _abstract_supportedSettings = True - def _get_supportedSettings(self): - """The settings supported by the driver. - @rtype: list or tuple of L{DriverSetting} - """ - return () + self._unregisterConfigSaveAction() @classmethod def check(cls): @@ -90,199 +70,15 @@ def check(cls): """ return False - def isSupported(self,settingID): - """Checks whether given setting is supported by the driver. - @rtype: l{bool} - """ - for s in self.supportedSettings: - if s.id == settingID: return True - return False - - def getConfigSpec(self): - spec=deepcopy(config.confspec[self._configSection]["__many__"]) - for setting in self.supportedSettings: - if not setting.useConfig: - continue - spec[setting.id]=setting.configSpec - return spec - - def saveSettings(self): - """ - Saves the current settings for the driver to the configuration. - This method is also executed when the driver is loaded for the first time, - in order to populate the configuration with the initial settings.. - """ - conf=config.conf[self._configSection][self.name] - for setting in self.supportedSettings: - if not setting.useConfig: - continue - try: - conf[setting.id] = getattr(self,setting.id) - except UnsupportedConfigParameterError: - log.debugWarning("Unsupported setting %s; ignoring"%s.id, exc_info=True) - continue - if self.supportedSettings: - log.debug("Saved settings for {} {}".format(self.__class__.__name__, self.name)) - - def loadSettings(self, onlyChanged=False): - """ - Loads settings for this driver from the configuration. - This method assumes that the instance has attributes o/properties - corresponding with the name of every setting in L{supportedSettings}. - @param onlyChanged: When loading settings, only apply those for which - the value in the configuration differs from the current value. - @type onlyChanged: bool - """ - conf=config.conf[self._configSection][self.name] - for setting in self.supportedSettings: - if not setting.useConfig or conf.get(setting.id) is None: - continue - val=conf[setting.id] - if onlyChanged and getattr(self,setting.id) == val: - continue - try: - setattr(self,setting.id, val) - except UnsupportedConfigParameterError: - log.debugWarning("Unsupported setting %s; ignoring"%setting.name, exc_info=True) - continue - if self.supportedSettings: - log.debug( - ( - "Loaded changed settings for {} {}" - if onlyChanged else - "Loaded settings for {} {}" - ).format(self.__class__.__name__, self.name)) - + # Impl for abstract methods in AutoSettings class @classmethod - def _paramToPercent(cls, current, min, max): - """Convert a raw parameter value to a percentage given the current, minimum and maximum raw values. - @param current: The current value. - @type current: int - @param min: The minimum value. - @type current: int - @param max: The maximum value. - @type max: int - """ - return int(round(float(current - min) / (max - min) * 100)) + def getId(cls) -> str: + return cls.name @classmethod - def _percentToParam(cls, percent, min, max): - """Convert a percentage to a raw parameter value given the current percentage and the minimum and maximum raw parameter values. - @param percent: The current percentage. - @type percent: int - @param min: The minimum raw parameter value. - @type min: int - @param max: The maximum raw parameter value. - @type max: int - """ - return int(round(float(percent) / 100 * (max - min) + min)) - -class DriverSetting(AutoPropertyObject): - """Represents a synthesizer or braille display setting such as voice, variant or dot firmness. - """ - - def _get_configSpec(self): - """Returns the configuration specification of this particular setting for config file validator. - @rtype: str - """ - return "string(default={defaultVal})".format(defaultVal=self.defaultVal) + def getDisplayName(cls) -> str: + return cls.description - def __init__(self, id, displayNameWithAccelerator, - availableInSettingsRing=False, defaultVal=None, displayName=None, useConfig=True): - """ - @param id: internal identifier of the setting - @type id: str - @param displayNameWithAccelerator: the localized string shown in voice or braille settings dialog - @type displayNameWithAccelerator: str - @param availableInSettingsRing: Will this option be available in a settings ring? - @type availableInSettingsRing: bool - @param defaultVal: Specifies the default value for a driver setting. - @type param defaultVal: str or C{None} - @param displayName: the localized string used in synth settings ring or None to use displayNameWithAccelerator - @type displayName: str - @param useConfig: Whether the value of this option is loaded from and saved to NVDA's configuration. - Set this to C{False} if the driver deals with loading and saving. - @type useConfig: bool - """ - self.id = id - self.displayNameWithAccelerator = displayNameWithAccelerator - if not displayName: - # Strip accelerator from displayNameWithAccelerator. - displayName = displayNameWithAccelerator.replace("&","") - self.displayName = displayName - self.availableInSettingsRing = availableInSettingsRing - self.defaultVal = defaultVal - self.useConfig = useConfig - -class NumericDriverSetting(DriverSetting): - """Represents a numeric driver setting such as rate, volume, pitch or dot firmness.""" - - def _get_configSpec(self): - return "integer(default={defaultVal},min={minVal},max={maxVal})".format( - defaultVal=self.defaultVal,minVal=self.minVal,maxVal=self.maxVal) - - def __init__(self, id, displayNameWithAccelerator, availableInSettingsRing=False, - defaultVal=50, minVal=0, maxVal=100, minStep=1, normalStep=5, largeStep=10, - displayName=None, useConfig=True): - """ - @param defaultVal: Specifies the default value for a numeric driver setting. - @type defaultVal: int - @param minVal: Specifies the minimum valid value for a numeric driver setting. - @type minVal: int - @param maxVal: Specifies the maximum valid value for a numeric driver setting. - @type maxVal: int - @param minStep: Specifies the minimum step between valid values for each numeric setting. For example, if L{minStep} is set to 10, setting values can only be multiples of 10; 10, 20, 30, etc. - @type minStep: int - @param normalStep: Specifies the step between values that a user will normally prefer. This is used in the settings ring. - @type normalStep: int - @param largeStep: Specifies the step between values if a large adjustment is desired. This is used for pageUp/pageDown on sliders in the Voice Settings dialog. - @type largeStep: int - @note: If necessary, the step values will be normalised so that L{minStep} <= L{normalStep} <= L{largeStep}. - """ - super(NumericDriverSetting,self).__init__(id, displayNameWithAccelerator, availableInSettingsRing=availableInSettingsRing, - defaultVal=defaultVal, displayName=displayName, useConfig=useConfig) - self.minVal=minVal - self.maxVal=max(maxVal,self.defaultVal) - self.minStep=minStep - self.normalStep=max(normalStep,minStep) - self.largeStep=max(largeStep,self.normalStep) - -class BooleanDriverSetting(DriverSetting): - """Represents a boolean driver setting such as rate boost or automatic time sync. - """ - - def __init__(self, id, displayNameWithAccelerator, availableInSettingsRing=False, - displayName=None, defaultVal=False, useConfig=True): - """ - @param defaultVal: Specifies the default value for a boolean driver setting. - @type defaultVal: bool - """ - super(BooleanDriverSetting,self).__init__(id, displayNameWithAccelerator, availableInSettingsRing=availableInSettingsRing, - defaultVal=defaultVal, displayName=displayName, useConfig=useConfig) - - def _get_configSpec(self): - defaultVal = repr(self.defaultVal) if self.defaultVal is not None else self.defaultVal - return "boolean(default={defaultVal})".format(defaultVal=defaultVal) - -class UnsupportedConfigParameterError(NotImplementedError): - """ - Raised when changing or retrieving a driver setting that is unsupported for the connected device. - """ - -class StringParameterInfo(object): - """ - The base class used to represent a value of a string driver setting. - """ - - def __init__(self, id, displayName): - """ - @param id: The unique identifier of the value. - @type id: str - @param displayName: The name of the value, visible to the user. - @type displayName: str - """ - self.id = id - self.displayName = displayName - # Keep backwards compatibility - self.ID = id - self.name = displayName + @classmethod + def _getConfigSection(cls) -> str: + return cls._configSection diff --git a/source/globalCommands.py b/source/globalCommands.py index 0859a8597f8..b5b7031464e 100755 --- a/source/globalCommands.py +++ b/source/globalCommands.py @@ -7,6 +7,8 @@ import time import itertools +from typing import Optional + import tones import audioDucking import touchHandler @@ -2343,6 +2345,9 @@ def script_recognizeWithUwpOcr(self, gesture): # Translators: Describes a command. script_recognizeWithUwpOcr.__doc__ = _("Recognizes the content of the current navigator object with Windows 10 OCR") + _tempEnableScreenCurtain = True + _waitingOnScreenCurtainWarningDialog: Optional[wx.Dialog] = None + _toggleScreenCurtainMessage: Optional[str] = None @script( # Translators: Input help mode message for toggle report CLDR command. description=_("Toggles on and off the reporting of CLDR characters, such as emojis"), @@ -2364,44 +2369,131 @@ def script_toggleReportCLDR(self, gesture): # Translators: Describes a command. description=_( "Toggles the state of the screen curtain, " - "either by making the screen black or SHOWING the contents of the screen. " - "If pressed to enable once, the screen curtain is enabled until you restart NVDA. " - "If pressed tree times, it is enabled until you disable it" + "enable to make the screen black or disable to show the contents of the screen. " + "Pressed once, screen curtain is enabled until you restart NVDA. " + "Pressed twice, screen curtain is enabled until you disable it" ), category=SCRCAT_VISION ) def script_toggleScreenCurtain(self, gesture): - message = None - try: - screenCurtainName = "screenCurtain" - if not vision.getProviderClass(screenCurtainName).canStart(): + scriptCount = scriptHandler.getLastScriptRepeatCount() + if scriptCount == 0: # first call should reset last message + self._toggleScreenCurtainMessage = None + + from visionEnhancementProviders.screenCurtain import ScreenCurtainProvider + screenCurtainId = ScreenCurtainProvider.getSettings().getId() + screenCurtainProviderInfo = vision.handler.getProviderInfo(screenCurtainId) + alreadyRunning = bool(vision.handler.getProviderInstance(screenCurtainProviderInfo)) + + GlobalCommands._tempEnableScreenCurtain = scriptCount == 0 + + if self._waitingOnScreenCurtainWarningDialog: + # Already in the process of enabling the screen curtain, exit early. + # Ensure that the dialog is in the foreground, and read it again. + self._waitingOnScreenCurtainWarningDialog.Raise() + + # Key presses interrupt speech, so it maybe that the dialog wasn't + # announced properly (if the user triggered the gesture more + # than once). So we speak the objects to imitate the dialog getting + # focus again. It might be useful to have something like this in a + # script: see https://github.com/nvaccess/nvda/issues/9147#issuecomment-454278313 + speech.cancelSpeech() + speech.speakObject( + api.getForegroundObject(), + reason=controlTypes.REASON_FOCUS + ) + speech.speakObject( + api.getFocusObject(), + reason=controlTypes.REASON_FOCUS + ) + return + + if scriptCount >= 2 and self._toggleScreenCurtainMessage: + # Only the first two presses have actions, all subsequent presses should just repeat the last outcome. + # This is important when not showing warning dialog, otherwise the script completion message can be + # suppressed. + # Must happen after the code to raise / read the warning dialog, since if there is a warning dialog + # it takes preference, and there shouldn't be a valid completion message in this case anyway. + ui.message( + self._toggleScreenCurtainMessage, + speechPriority=speech.priorities.Spri.NOW + ) + return + + # Disable if running + if ( + alreadyRunning + and scriptCount == 0 # a second press might be trying to achieve non temp enable + ): + # Translators: Reported when the screen curtain is disabled. + message = _("Screen curtain disabled") + try: + vision.handler.terminateProvider(screenCurtainProviderInfo) + except Exception: + # If the screen curtain was enabled, we do not expect exceptions. + log.error("Screen curtain termination error", exc_info=True) + # Translators: Reported when the screen curtain could not be enabled. + message = _("Could not disable screen curtain") + finally: + self._toggleScreenCurtainMessage = message + ui.message(message, speechPriority=speech.priorities.Spri.NOW) + return + elif ( # enable it + scriptCount in (0, 1) # 1 press (temp enable) or 2 presses (enable) + ): + # Check if screen curtain is available, exit early if not. + if not screenCurtainProviderInfo.providerClass.canStart(): # Translators: Reported when the screen curtain is not available. message = _("Screen curtain not available") + self._toggleScreenCurtainMessage = message + ui.message(message, speechPriority=speech.priorities.Spri.NOW) return - scriptCount = scriptHandler.getLastScriptRepeatCount() - if scriptCount == 0 and screenCurtainName in vision.handler.providers: - vision.handler.terminateProvider(screenCurtainName) - # Translators: Reported when the screen curtain is disabled. - message = _("Screen curtain disabled") - elif scriptCount in (0, 2): - temporary = scriptCount == 0 - if not vision.handler.initializeProvider( - screenCurtainName, - temporary=temporary, - ): + + def _enableScreenCurtain(doEnable: bool = True): + self._waitingOnScreenCurtainWarningDialog = None + if not doEnable: + return # exit early with no ui.message because the user has decided to abort. + + tempEnable = GlobalCommands._tempEnableScreenCurtain + # Translators: Reported when the screen curtain is enabled. + enableMessage = _("Screen curtain enabled") + if tempEnable: + # Translators: Reported when the screen curtain is temporarily enabled. + enableMessage = _("Temporary Screen curtain, enabled until next restart") + + try: + vision.handler.initializeProvider( + screenCurtainProviderInfo, + temporary=tempEnable, + ) + except Exception: + log.error("Screen curtain initialization error", exc_info=True) # Translators: Reported when the screen curtain could not be enabled. - message = _("Could not enable screen curtain") - return - else: - if temporary: - # Translators: Reported when the screen curtain is temporarily enabled. - message = _("Temporary Screen curtain, enabled until next restart") - else: - # Translators: Reported when the screen curtain is enabled. - message = _("Screen curtain enabled") - finally: - if message is not None: - ui.message(message, speechPriority=speech.priorities.Spri.NOW) + enableMessage = _("Could not enable screen curtain") + finally: + self._toggleScreenCurtainMessage = enableMessage + ui.message(enableMessage, speechPriority=speech.priorities.Spri.NOW) + + # Show warning if necessary and do enable. + settingsStorage = ScreenCurtainProvider.getSettings() + if settingsStorage.warnOnLoad: + from visionEnhancementProviders.screenCurtain import WarnOnLoadDialog + parent = gui.mainFrame + dlg = WarnOnLoadDialog( + screenCurtainSettingsStorage=settingsStorage, + parent=parent + ) + self._waitingOnScreenCurtainWarningDialog = dlg + gui.runScriptModalDialog( + dlg, + lambda res: wx.CallLater( + millis=100, + callableObj=_enableScreenCurtain, + doEnable=res == wx.YES + ) + ) + else: + _enableScreenCurtain() __gestures = { # Basic diff --git a/source/gui/nvdaControls.py b/source/gui/nvdaControls.py index ca9aeb8dec4..a0a08dcf326 100644 --- a/source/gui/nvdaControls.py +++ b/source/gui/nvdaControls.py @@ -3,14 +3,15 @@ #Copyright (C) 2016-2018 NV Access Limited, Derek Riemer #This file is covered by the GNU General Public License. #See the file COPYING for more details. + from ctypes.wintypes import BOOL from typing import Any, Tuple, Optional - import wx from comtypes import GUID from wx.lib.mixins import listctrl as listmix -from gui import accPropServer -from gui.dpiScalingHelper import DpiScalingHelperMixin +from . import accPropServer +from .dpiScalingHelper import DpiScalingHelperMixin +from . import guiHelper import oleacc import winUser import winsound @@ -305,6 +306,11 @@ def _addButtons(self, buttonHelper): ) cancel.Bind(wx.EVT_BUTTON, lambda evt: self.EndModal(wx.CANCEL)) + def _addContents(self, contentsSizer: guiHelper.BoxSizerHelper): + """Adds additional contents to the dialog, before the buttons. + Subclasses may implement this method. + """ + def _setIcon(self, type): try: iconID = self._DIALOG_TYPE_ICON_ID_MAP[type] @@ -331,14 +337,15 @@ def __init__(self, parent, title, message, dialogType=DIALOG_TYPE_STANDARD): self._setIcon(dialogType) self._setSound(dialogType) self.Bind(wx.EVT_SHOW, self._onShowEvt, source=self) + self.Bind(wx.EVT_ACTIVATE, self._onDialogActivated, source=self) mainSizer = wx.BoxSizer(wx.VERTICAL) - from . import guiHelper contentsSizer = guiHelper.BoxSizerHelper(parent=self, orientation=wx.VERTICAL) text = wx.StaticText(self, label=message) text.Wrap(self.scaleSize(self.GetSize().Width)) contentsSizer.addItem(text) + self._addContents(contentsSizer) buttonHelper = guiHelper.ButtonHelper(wx.HORIZONTAL) self._addButtons(buttonHelper) @@ -353,6 +360,9 @@ def __init__(self, parent, title, message, dialogType=DIALOG_TYPE_STANDARD): self.SetSizer(mainSizer) self.CentreOnScreen() + def _onDialogActivated(self, evt): + evt.Skip() + def _onShowEvt(self, evt): """ :type evt: wx.ShowEvent diff --git a/source/gui/settingsDialogs.py b/source/gui/settingsDialogs.py index f6ed840c5e2..8db386adff4 100644 --- a/source/gui/settingsDialogs.py +++ b/source/gui/settingsDialogs.py @@ -8,11 +8,11 @@ # See the file COPYING for more details. import logging -from abc import abstractmethod -import os +from abc import abstractmethod, ABCMeta import copy import re import wx +from vision.providerBase import VisionEnhancementProviderSettings from wx.lib import scrolledpanel from wx.lib.expando import ExpandoTextCtrl import wx.lib.newevent @@ -20,6 +20,7 @@ import logHandler import installer from synthDriverHandler import * +from synthDriverHandler import SynthDriver, getSynth import config import languageHandler import speech @@ -33,6 +34,10 @@ import braille import brailleTables import brailleInput +import vision +import vision.providerInfo +import vision.providerBase +from typing import Callable, List, Optional, Any import core import keyboardHandler import characterProcessing @@ -43,7 +48,9 @@ updateCheck = None import inputCore from . import nvdaControls -from driverHandler import * +from autoSettingsUtils.utils import UnsupportedConfigParameterError +from autoSettingsUtils.autoSettings import AutoSettings +from autoSettingsUtils.driverSetting import BooleanDriverSetting, NumericDriverSetting, DriverSetting from UIAUtils import shouldUseUIAConsole import touchHandler import winVersion @@ -268,38 +275,42 @@ class SettingsPanel(wx.Panel, DpiScalingHelperMixin, metaclass=guiHelper.SIPABCM title="" panelDescription=u"" - def __init__(self, parent): + def __init__(self, parent: wx.Window): """ @param parent: The parent for this panel; C{None} for no parent. - @type parent: wx.Window """ if gui._isDebug(): startTime = time.time() wx.Panel.__init__(self, parent, wx.ID_ANY) DpiScalingHelperMixin.__init__(self, self.GetHandle()) + self._buildGui() + + if gui._isDebug(): + elapsedSeconds = time.time() - startTime + panelName = self.__class__.__qualname__ + log.debug(f"Loading {panelName} took {elapsedSeconds:.2f} seconds") + + def _buildGui(self): self.mainSizer=wx.BoxSizer(wx.VERTICAL) self.settingsSizer=wx.BoxSizer(wx.VERTICAL) self.makeSettings(self.settingsSizer) - self.mainSizer.Add(self.settingsSizer, flag=wx.ALL) + self.mainSizer.Add(self.settingsSizer, flag=wx.ALL | wx.EXPAND) self.mainSizer.Fit(self) self.SetSizer(self.mainSizer) - if gui._isDebug(): - log.debug("Loading %s took %.2f seconds"%(self.__class__.__name__, time.time() - startTime)) @abstractmethod - def makeSettings(self, sizer): + def makeSettings(self, sizer: wx.BoxSizer): """Populate the panel with settings controls. Subclasses must override this method. @param sizer: The sizer to which to add the settings controls. - @type sizer: wx.Sizer """ raise NotImplementedError def onPanelActivated(self): """Called after the panel has been activated (i.e. de corresponding category is selected in the list of categories). For example, this might be used for resource intensive tasks. - Sub-classes should extendthis method. + Sub-classes should extend this method. """ self.Show() @@ -500,7 +511,10 @@ def _getCategoryPanel(self, catId): raise ValueError("Unable to create panel for unknown category ID: {}".format(catId)) panel = cls(parent=self.container) panel.Hide() - self.containerSizer.Add(panel, flag=wx.ALL, border=guiHelper.SPACE_BETWEEN_ASSOCIATED_CONTROL_HORIZONTAL) + self.containerSizer.Add( + panel, flag=wx.ALL | wx.EXPAND, + border=guiHelper.SPACE_BETWEEN_ASSOCIATED_CONTROL_HORIZONTAL + ) self.catIdToInstanceMap[catId] = panel panelWidth = panel.Size[0] availableWidth = self.containerSizer.GetSize()[0] @@ -1004,6 +1018,7 @@ def driver(self): return self._driverRef() def __call__(self,evt): + evt.Skip() # allow other handlers to also process this event. val=evt.GetSelection() setattr(self.driver,self.setting.id,val) @@ -1014,6 +1029,7 @@ def __init__(self,driver,setting,container): super(StringDriverSettingChanger,self).__init__(driver,setting) def __call__(self,evt): + evt.Skip() # allow other handlers to also process this event. # Quick workaround to deal with voice changes. if self.setting.id == "voice": # Cancel speech first so that the voice will change immediately instead of the change being queued. @@ -1030,169 +1046,284 @@ def __call__(self,evt): getattr(self.container,"_%ss"%self.setting.id)[evt.GetSelection()].id ) -class DriverSettingsMixin(object): + +class AutoSettingsMixin(metaclass=ABCMeta): """ - Mixin class that provides support for driver specific gui settings. - Derived classes should implement L{driver}. + Mixin class that provides support for driver/vision provider specific gui settings. + Derived classes should implement: + - L{getSettings} + - L{settingsSizer} + Derived classes likely need to inherit from L{SettingsPanel}, in particular + the following methods must be provided: + - makeSettings + - onPanelActivated + @note: This mixin uses self.lastControl and self.sizerDict to keep track of the + controls added / and maintain ordering. + If you plan to maintain other controls in the same panel care will need to be taken. """ def __init__(self, *args, **kwargs): - self.sizerDict={} - self.lastControl=None - super(DriverSettingsMixin,self).__init__(*args,**kwargs) - self._curDriverRef = weakref.ref(self.driver) + """ + Mixin init, forwards args to other base class. + The other base class is likely L{gui.SettingsPanel}. + @param args: Positional args to passed to other base class. + @param kwargs: Keyword args to passed to other base class. + """ + self.sizerDict = {} + self.lastControl = None + super(AutoSettingsMixin, self).__init__(*args, **kwargs) + # because settings instances can be of type L{Driver} as well, we have to handle + # showing settings for non-instances. Because of this, we must reacquire a reference + # to the settings class whenever we wish to use it (via L{getSettings}) in case the instance changes. + self._currentSettingsRef = weakref.ref(self.getSettings()) - @property - def driver(self): - raise NotImplementedError + settingsSizer: wx.BoxSizer + + @abstractmethod + def getSettings(self) -> AutoSettings: + ... + + @abstractmethod + def makeSettings(self, sizer: wx.BoxSizer): + """Populate the panel with settings controls. + @note: Normally classes also inherit from settingsDialogs.SettingsPanel. + @param sizer: The sizer to which to add the settings controls. + """ + ... + + def _getSettingsStorage(self) -> Any: + """ Override to change storage object for setting values.""" + return self.getSettings() @classmethod def _setSliderStepSizes(cls, slider, setting): slider.SetLineSize(setting.minStep) slider.SetPageSize(setting.largeStep) - def makeSliderSettingControl(self,setting): + def _makeSliderSettingControl( + self, + setting: NumericDriverSetting, + settingsStorage: Any + ) -> wx.BoxSizer: """Constructs appropriate GUI controls for given L{DriverSetting} such as label and slider. @param setting: Setting to construct controls for - @type setting: L{DriverSetting} - @returns: WXSizer containing newly created controls. - @rtype: L{wx.BoxSizer} + @param settingsStorage: where to get initial values / set values. + This param must have an attribute with a name matching setting.id. + In most cases it will be of type L{AutoSettings} + @return: wx.BoxSizer containing newly created controls. """ labeledControl = guiHelper.LabeledControlHelper( self, - "%s:"%setting.displayNameWithAccelerator, + f"{setting.displayNameWithAccelerator}:", nvdaControls.EnhancedInputSlider, minValue=setting.minVal, maxValue=setting.maxVal ) lSlider=labeledControl.control - setattr(self,"%sSlider"%setting.id,lSlider) - lSlider.Bind(wx.EVT_SLIDER,DriverSettingChanger(self.driver,setting)) - self._setSliderStepSizes(lSlider,setting) - lSlider.SetValue(getattr(self.driver,setting.id)) + setattr(self, f"{setting.id}Slider", lSlider) + lSlider.Bind(wx.EVT_SLIDER, DriverSettingChanger( + settingsStorage, setting + )) + self._setSliderStepSizes(lSlider, setting) + lSlider.SetValue(getattr(settingsStorage, setting.id)) if self.lastControl: lSlider.MoveAfterInTabOrder(self.lastControl) self.lastControl=lSlider return labeledControl.sizer - def makeStringSettingControl(self,setting): - """Same as L{makeSliderSettingControl} but for string settings. Returns sizer with label and combobox.""" - - labelText="%s:"%setting.displayNameWithAccelerator + def _makeStringSettingControl( + self, + setting: DriverSetting, + settingsStorage: Any + ): + """ + Same as L{_makeSliderSettingControl} but for string settings displayed in a wx.Choice control + Options for the choice control come from the availableXstringvalues property + (Dict[id, StringParameterInfo]) on the instance returned by self.getSettings() + The id of the value is stored on settingsStorage. + Returns sizer with label and combobox. + """ + labelText = f"{setting.displayNameWithAccelerator}:" + stringSettingAttribName = f"_{setting.id}s" setattr( self, - "_%ss"%setting.id, + stringSettingAttribName, # Settings are stored as an ordered dict. # Therefore wrap this inside a list call. - list(getattr(self.driver,"available%ss"%setting.id.capitalize()).values()) + list(getattr( + self.getSettings(), + f"available{setting.id.capitalize()}s" + ).values()) ) - l=getattr(self,"_%ss"%setting.id) - labeledControl=guiHelper.LabeledControlHelper( + stringSettings = getattr(self, stringSettingAttribName) + labeledControl = guiHelper.LabeledControlHelper( self, labelText, wx.Choice, - choices=[x.displayName for x in l] + choices=[x.displayName for x in stringSettings] ) lCombo = labeledControl.control - setattr(self,"%sList"%setting.id,lCombo) + setattr(self, f"{setting.id}List", lCombo) try: - cur=getattr(self.driver,setting.id) - i=[x.id for x in l].index(cur) - lCombo.SetSelection(i) + cur = getattr(settingsStorage, setting.id) + selectionIndex = [ + x.id for x in stringSettings + ].index(cur) + lCombo.SetSelection(selectionIndex) except ValueError: pass - lCombo.Bind(wx.EVT_CHOICE,StringDriverSettingChanger(self.driver,setting,self)) + lCombo.Bind( + wx.EVT_CHOICE, + StringDriverSettingChanger(settingsStorage, setting, self) + ) if self.lastControl: lCombo.MoveAfterInTabOrder(self.lastControl) - self.lastControl=lCombo + self.lastControl = lCombo return labeledControl.sizer - def makeBooleanSettingControl(self,setting): - """Same as L{makeSliderSettingControl} but for boolean settings. Returns checkbox.""" - checkbox=wx.CheckBox(self,wx.ID_ANY,label=setting.displayNameWithAccelerator) - setattr(self,"%sCheckbox"%setting.id,checkbox) - checkbox.Bind(wx.EVT_CHECKBOX, - lambda evt: setattr(self.driver,setting.id,evt.IsChecked())) - checkbox.SetValue(getattr(self.driver,setting.id)) + def _makeBooleanSettingControl( + self, + setting: BooleanDriverSetting, + settingsStorage: Any + ): + """ + Same as L{_makeSliderSettingControl} but for boolean settings. Returns checkbox. + """ + checkbox = wx.CheckBox(self, label=setting.displayNameWithAccelerator) + setattr(self, f"{setting.id}Checkbox", checkbox) + + def _onCheckChanged(evt: wx.CommandEvent): + evt.Skip() # allow other handlers to also process this event. + setattr(settingsStorage, setting.id, evt.IsChecked()) + + checkbox.Bind(wx.EVT_CHECKBOX, _onCheckChanged) + checkbox.SetValue(getattr( + settingsStorage, + setting.id + )) if self.lastControl: checkbox.MoveAfterInTabOrder(self.lastControl) self.lastControl=checkbox return checkbox def updateDriverSettings(self, changedSetting=None): - """Creates, hides or updates existing GUI controls for all of supported settings.""" - #firstly check already created options - for name,sizer in self.sizerDict.items(): + """ + Creates, hides or updates existing GUI controls for all of supported settings. + """ + settingsInst = self.getSettings() + settingsStorage = self._getSettingsStorage() + # firstly check already created options + for name, sizer in self.sizerDict.items(): if name == changedSetting: # Changing a setting shouldn't cause that setting itself to disappear. continue - if not self.driver.isSupported(name): + if not settingsInst.isSupported(name): self.settingsSizer.Hide(sizer) - #Create new controls, update already existing - for setting in self.driver.supportedSettings: + # Create new controls, update already existing + if gui._isDebug(): + log.debug(f"Current sizerDict: {self.sizerDict!r}") + log.debug(f"Current supportedSettings: {self.getSettings().supportedSettings!r}") + for setting in settingsInst.supportedSettings: if setting.id == changedSetting: # Changing a setting shouldn't cause that setting's own values to change. continue - if setting.id in self.sizerDict: #update a value - self.settingsSizer.Show(self.sizerDict[setting.id]) - if isinstance(setting,NumericDriverSetting): - getattr(self,"%sSlider"%setting.id).SetValue(getattr(self.driver,setting.id)) - elif isinstance(setting,BooleanDriverSetting): - getattr(self,"%sCheckbox"%setting.id).SetValue(getattr(self.driver,setting.id)) - else: - l=getattr(self,"_%ss"%setting.id) - lCombo=getattr(self,"%sList"%setting.id) - try: - cur=getattr(self.driver,setting.id) - i=[x.id for x in l].index(cur) - lCombo.SetSelection(i) - except ValueError: - pass - else: #create a new control - if isinstance(setting,NumericDriverSetting): - settingMaker=self.makeSliderSettingControl - elif isinstance(setting,BooleanDriverSetting): - settingMaker=self.makeBooleanSettingControl - else: - settingMaker=self.makeStringSettingControl - try: - s=settingMaker(setting) - except UnsupportedConfigParameterError: - log.debugWarning("Unsupported setting %s; ignoring"%setting.id, exc_info=True) - continue - self.sizerDict[setting.id]=s - self.settingsSizer.Insert(len(self.sizerDict)-1,s,border=10,flag=wx.BOTTOM) - #Update graphical layout of the dialog + if setting.id in self.sizerDict: # update a value + self._updateValueForControl(setting, settingsStorage) + else: # create a new control + self._createNewControl(setting, settingsStorage) + # Update graphical layout of the dialog self.settingsSizer.Layout() + def _createNewControl(self, setting, settingsStorage): + settingMaker = self._getSettingMaker(setting) + try: + s = settingMaker(setting, settingsStorage) + except UnsupportedConfigParameterError: + log.debugWarning(f"Unsupported setting {setting.id}; ignoring", exc_info=True) + else: + self.sizerDict[setting.id] = s + self.settingsSizer.Insert( + len(self.sizerDict) - 1, + s, + border=10, + flag=wx.BOTTOM + ) + + def _getSettingMaker(self, setting): + if isinstance(setting, NumericDriverSetting): + settingMaker = self._makeSliderSettingControl + elif isinstance(setting, BooleanDriverSetting): + settingMaker = self._makeBooleanSettingControl + else: + settingMaker = self._makeStringSettingControl + return settingMaker + + def _updateValueForControl(self, setting, settingsStorage): + self.settingsSizer.Show(self.sizerDict[setting.id]) + if isinstance(setting, NumericDriverSetting): + getattr(self, f"{setting.id}Slider").SetValue( + getattr(settingsStorage, setting.id) + ) + elif isinstance(setting, BooleanDriverSetting): + getattr(self, f"{setting.id}Checkbox").SetValue( + getattr(settingsStorage, setting.id) + ) + else: + options = getattr(self, f"_{setting.id}s") + lCombo = getattr(self, f"{setting.id}List") + try: + cur = getattr(settingsStorage, setting.id) + indexOfItem = [x.id for x in options].index(cur) + lCombo.SetSelection(indexOfItem) + except ValueError: + pass + def onDiscard(self): - #unbind change events for string settings as wx closes combo boxes on cancel - for setting in self.driver.supportedSettings: - if isinstance(setting,(NumericDriverSetting,BooleanDriverSetting)): continue - getattr(self,"%sList"%setting.id).Unbind(wx.EVT_CHOICE) - #restore settings - self.driver.loadSettings() + # unbind change events for string settings as wx closes combo boxes on cancel + settingsInst = self.getSettings() + for setting in settingsInst.supportedSettings: + if isinstance(setting, (NumericDriverSetting, BooleanDriverSetting)): + continue + getattr(self, f"{setting.id}List").Unbind(wx.EVT_CHOICE) + # restore settings + settingsInst.loadSettings() def onSave(self): - self.driver.saveSettings() + self.getSettings().saveSettings() - def onPanelActivated(self): - if not self._curDriverRef(): + def refreshGui(self): + if not self._currentSettingsRef(): if gui._isDebug(): log.debug("refreshing panel") self.sizerDict.clear() self.settingsSizer.Clear(delete_windows=True) - self._curDriverRef = weakref.ref(self.driver) + self._currentSettingsRef = weakref.ref(self.getSettings()) self.makeSettings(self.settingsSizer) - super(DriverSettingsMixin,self).onPanelActivated() -class VoiceSettingsPanel(DriverSettingsMixin, SettingsPanel): + def onPanelActivated(self): + """Called after the panel has been activated + @note: Normally classes also inherit from settingsDialogs.SettingsPanel. + """ + self.refreshGui() + super().onPanelActivated() + + +#: DriverSettingsMixin name is provided or backwards compatibility. +# The name DriverSettingsMixin should be considered deprecated, use AutoSettingsMixin instead. +DriverSettingsMixin = AutoSettingsMixin + + +class VoiceSettingsPanel(AutoSettingsMixin, SettingsPanel): # Translators: This is the label for the voice settings panel. title = _("Voice") @property def driver(self): - return getSynth() + synth: SynthDriver = getSynth() + return synth + + def getSettings(self) -> AutoSettings: + return self.driver def makeSettings(self, settingsSizer): # Construct synthesizer settings @@ -1202,67 +1333,113 @@ def makeSettings(self, settingsSizer): # Translators: This is the label for a checkbox in the # voice settings panel (if checked, text will be read using the voice for the language of the text). autoLanguageSwitchingText = _("Automatic language switching (when supported)") - self.autoLanguageSwitchingCheckbox = settingsSizerHelper.addItem(wx.CheckBox(self,label=autoLanguageSwitchingText)) - self.autoLanguageSwitchingCheckbox.SetValue(config.conf["speech"]["autoLanguageSwitching"]) + self.autoLanguageSwitchingCheckbox = settingsSizerHelper.addItem( + wx.CheckBox( + self, + label=autoLanguageSwitchingText + )) + self.autoLanguageSwitchingCheckbox.SetValue( + config.conf["speech"]["autoLanguageSwitching"] + ) # Translators: This is the label for a checkbox in the - # voice settings panel (if checked, different voices for dialects will be used to read text in that dialect). - autoDialectSwitchingText =_("Automatic dialect switching (when supported)") - self.autoDialectSwitchingCheckbox=settingsSizerHelper.addItem(wx.CheckBox(self,label=autoDialectSwitchingText)) - self.autoDialectSwitchingCheckbox.SetValue(config.conf["speech"]["autoDialectSwitching"]) + # voice settings panel (if checked, different voices for dialects will be used to + # read text in that dialect). + autoDialectSwitchingText = _("Automatic dialect switching (when supported)") + self.autoDialectSwitchingCheckbox = settingsSizerHelper.addItem( + wx.CheckBox(self, label=autoDialectSwitchingText) + ) + self.autoDialectSwitchingCheckbox.SetValue( + config.conf["speech"]["autoDialectSwitching"] + ) # Translators: This is the label for a combobox in the # voice settings panel (possible choices are none, some, most and all). punctuationLabelText = _("Punctuation/symbol &level:") - symbolLevelLabels=characterProcessing.SPEECH_SYMBOL_LEVEL_LABELS - symbolLevelChoices =[symbolLevelLabels[level] for level in characterProcessing.CONFIGURABLE_SPEECH_SYMBOL_LEVELS] - self.symbolLevelList = settingsSizerHelper.addLabeledControl(punctuationLabelText, wx.Choice, choices=symbolLevelChoices) + symbolLevelLabels = characterProcessing.SPEECH_SYMBOL_LEVEL_LABELS + symbolLevelChoices = [ + symbolLevelLabels[level] for level in characterProcessing.CONFIGURABLE_SPEECH_SYMBOL_LEVELS + ] + self.symbolLevelList = settingsSizerHelper.addLabeledControl( + punctuationLabelText, wx.Choice, choices=symbolLevelChoices + ) curLevel = config.conf["speech"]["symbolLevel"] - self.symbolLevelList.SetSelection(characterProcessing.CONFIGURABLE_SPEECH_SYMBOL_LEVELS.index(curLevel)) + self.symbolLevelList.SetSelection( + characterProcessing.CONFIGURABLE_SPEECH_SYMBOL_LEVELS.index(curLevel) + ) # Translators: This is the label for a checkbox in the # voice settings panel (if checked, text will be read using the voice for the language of the text). trustVoiceLanguageText = _("Trust voice's language when processing characters and symbols") - self.trustVoiceLanguageCheckbox = settingsSizerHelper.addItem(wx.CheckBox(self,label=trustVoiceLanguageText)) + self.trustVoiceLanguageCheckbox = settingsSizerHelper.addItem( + wx.CheckBox(self, label=trustVoiceLanguageText) + ) self.trustVoiceLanguageCheckbox.SetValue(config.conf["speech"]["trustVoiceLanguage"]) # Translators: This is the label for a checkbox in the # voice settings panel (if checked, data from the unicode CLDR will be used # to speak emoji descriptions). - includeCLDRText = _("Include Unicode Consortium data (including emoji) when processing characters and symbols") - self.includeCLDRCheckbox = settingsSizerHelper.addItem(wx.CheckBox(self,label=includeCLDRText)) + includeCLDRText = _( + "Include Unicode Consortium data (including emoji) when processing characters and symbols" + ) + self.includeCLDRCheckbox = settingsSizerHelper.addItem( + wx.CheckBox(self, label=includeCLDRText) + ) self.includeCLDRCheckbox.SetValue(config.conf["speech"]["includeCLDR"]) - # Translators: This is a label for a setting in voice settings (an edit box to change voice pitch for capital letters; the higher the value, the pitch will be higher). - capPitchChangeLabelText=_("Capital pitch change percentage") - self.capPitchChangeEdit=settingsSizerHelper.addLabeledControl(capPitchChangeLabelText, nvdaControls.SelectOnFocusSpinCtrl, - min=int(config.conf.getConfigValidation(("speech", self.driver.name, "capPitchChange")).kwargs["min"]), - max=int(config.conf.getConfigValidation(("speech", self.driver.name, "capPitchChange")).kwargs["max"]), + minPitchChange = int(config.conf.getConfigValidation( + ("speech", self.driver.name, "capPitchChange") + ).kwargs["min"]) + + maxPitchChange = int(config.conf.getConfigValidation( + ("speech", self.driver.name, "capPitchChange") + ).kwargs["max"]) + + # Translators: This is a label for a setting in voice settings (an edit box to change + # voice pitch for capital letters; the higher the value, the pitch will be higher). + capPitchChangeLabelText = _("Capital pitch change percentage") + self.capPitchChangeEdit = settingsSizerHelper.addLabeledControl( + capPitchChangeLabelText, + nvdaControls.SelectOnFocusSpinCtrl, + min=minPitchChange, + max=maxPitchChange, initial=config.conf["speech"][self.driver.name]["capPitchChange"]) # Translators: This is the label for a checkbox in the # voice settings panel. sayCapForCapsText = _("Say &cap before capitals") - self.sayCapForCapsCheckBox = settingsSizerHelper.addItem(wx.CheckBox(self,label=sayCapForCapsText)) - self.sayCapForCapsCheckBox.SetValue(config.conf["speech"][self.driver.name]["sayCapForCapitals"]) + self.sayCapForCapsCheckBox = settingsSizerHelper.addItem( + wx.CheckBox(self, label=sayCapForCapsText) + ) + self.sayCapForCapsCheckBox.SetValue( + config.conf["speech"][self.driver.name]["sayCapForCapitals"] + ) # Translators: This is the label for a checkbox in the # voice settings panel. beepForCapsText =_("&Beep for capitals") - self.beepForCapsCheckBox = settingsSizerHelper.addItem(wx.CheckBox(self, label = beepForCapsText)) - self.beepForCapsCheckBox.SetValue(config.conf["speech"][self.driver.name]["beepForCapitals"]) + self.beepForCapsCheckBox = settingsSizerHelper.addItem( + wx.CheckBox(self, label=beepForCapsText) + ) + self.beepForCapsCheckBox.SetValue( + config.conf["speech"][self.driver.name]["beepForCapitals"] + ) # Translators: This is the label for a checkbox in the # voice settings panel. useSpellingFunctionalityText = _("Use &spelling functionality if supported") - self.useSpellingFunctionalityCheckBox = settingsSizerHelper.addItem(wx.CheckBox(self, label = useSpellingFunctionalityText)) - self.useSpellingFunctionalityCheckBox.SetValue(config.conf["speech"][self.driver.name]["useSpellingFunctionality"]) + self.useSpellingFunctionalityCheckBox = settingsSizerHelper.addItem( + wx.CheckBox(self, label=useSpellingFunctionalityText) + ) + self.useSpellingFunctionalityCheckBox.SetValue( + config.conf["speech"][self.driver.name]["useSpellingFunctionality"] + ) def onSave(self): - DriverSettingsMixin.onSave(self) + AutoSettingsMixin.onSave(self) - config.conf["speech"]["autoLanguageSwitching"]=self.autoLanguageSwitchingCheckbox.IsChecked() - config.conf["speech"]["autoDialectSwitching"]=self.autoDialectSwitchingCheckbox.IsChecked() + config.conf["speech"]["autoLanguageSwitching"] = self.autoLanguageSwitchingCheckbox.IsChecked() + config.conf["speech"]["autoDialectSwitching"] = self.autoDialectSwitchingCheckbox.IsChecked() config.conf["speech"]["symbolLevel"]=characterProcessing.CONFIGURABLE_SPEECH_SYMBOL_LEVELS[self.symbolLevelList.GetSelection()] config.conf["speech"]["trustVoiceLanguage"]=self.trustVoiceLanguageCheckbox.IsChecked() currentIncludeCLDR = config.conf["speech"]["includeCLDR"] @@ -2693,7 +2870,17 @@ def onOk(self, evt): port = self.possiblePorts[self.portsList.GetSelection()][0] config.conf["braille"][display]["port"] = port if not braille.handler.setDisplayByName(display): - gui.messageBox(_("Could not load the %s display.")%display, _("Braille Display Error"), wx.OK|wx.ICON_WARNING, self) + + gui.messageBox( + # Translators: The message in a dialog presented when NVDA is unable to load the selected + # braille display. + message=_(f"Could not load the {display} display."), + # Translators: The title in a dialog presented when NVDA is unable to load the selected + # braille display. + caption=_("Braille Display Error"), + style=wx.OK | wx.ICON_WARNING, + parent=self + ) return if self.IsModal(): @@ -2702,15 +2889,19 @@ def onOk(self, evt): self.Parent.updateCurrentDisplay() super(BrailleDisplaySelectionDialog, self).onOk(evt) -class BrailleSettingsSubPanel(DriverSettingsMixin, SettingsPanel): + +class BrailleSettingsSubPanel(AutoSettingsMixin, SettingsPanel): @property def driver(self): return braille.handler.display + def getSettings(self) -> AutoSettings: + return self.driver + def makeSettings(self, settingsSizer): - if gui._isDebug(): - startTime = time.time() + shouldDebugGui = gui._isDebug() + startTime = 0 if not shouldDebugGui else time.time() # Construct braille display specific settings self.updateDriverSettings() @@ -2728,8 +2919,11 @@ def makeSettings(self, settingsSizer): self.outTableList.SetSelection(selection) except: pass - if gui._isDebug(): - log.debug("Loading output tables completed, now at %.2f seconds from start"%(time.time() - startTime)) + if shouldDebugGui: + timePassed = time.time() - startTime + log.debug( + f"Loading output tables completed, now at {timePassed:.2f} seconds from start" + ) # Translators: The label for a setting in braille settings to select the input table (the braille table used to type braille characters on a braille keyboard). inputLabelText = _("&Input table:") @@ -2741,12 +2935,17 @@ def makeSettings(self, settingsSizer): self.inTableList.SetSelection(selection) except: pass - if gui._isDebug(): - log.debug("Loading input tables completed, now at %.2f seconds from start"%(time.time() - startTime)) + if shouldDebugGui: + timePassed = time.time() - startTime + log.debug( + f"Loading input tables completed, now at {timePassed:.2f} seconds from start" + ) # Translators: The label for a setting in braille settings to expand the current word under cursor to computer braille. expandAtCursorText = _("E&xpand to computer braille for the word at the cursor") - self.expandAtCursorCheckBox = sHelper.addItem(wx.CheckBox(self, wx.ID_ANY, label=expandAtCursorText)) + self.expandAtCursorCheckBox = sHelper.addItem( + wx.CheckBox(self, wx.ID_ANY, label=expandAtCursorText) + ) self.expandAtCursorCheckBox.SetValue(config.conf["braille"]["expandAtCursor"]) # Translators: The label for a setting in braille settings to show the cursor. @@ -2757,7 +2956,9 @@ def makeSettings(self, settingsSizer): # Translators: The label for a setting in braille settings to enable cursor blinking. cursorBlinkLabelText = _("Blink cursor") - self.cursorBlinkCheckBox = sHelper.addItem(wx.CheckBox(self, label=cursorBlinkLabelText)) + self.cursorBlinkCheckBox = sHelper.addItem( + wx.CheckBox(self, label=cursorBlinkLabelText) + ) self.cursorBlinkCheckBox.Bind(wx.EVT_CHECKBOX, self.onBlinkCursorChange) self.cursorBlinkCheckBox.SetValue(config.conf["braille"]["cursorBlink"]) if not self.showCursorCheckBox.GetValue(): @@ -2765,10 +2966,17 @@ def makeSettings(self, settingsSizer): # Translators: The label for a setting in braille settings to change cursor blink rate in milliseconds (1 second is 1000 milliseconds). cursorBlinkRateLabelText = _("Cursor blink rate (ms)") - minBlinkRate = int(config.conf.getConfigValidation(("braille", "cursorBlinkRate")).kwargs["min"]) + minBlinkRate = int(config.conf.getConfigValidation( + ("braille", "cursorBlinkRate") + ).kwargs["min"]) maxBlinkRate = int(config.conf.getConfigValidation(("braille", "cursorBlinkRate")).kwargs["max"]) - self.cursorBlinkRateEdit = sHelper.addLabeledControl(cursorBlinkRateLabelText, nvdaControls.SelectOnFocusSpinCtrl, - min=minBlinkRate, max=maxBlinkRate, initial=config.conf["braille"]["cursorBlinkRate"]) + self.cursorBlinkRateEdit = sHelper.addLabeledControl( + cursorBlinkRateLabelText, + nvdaControls.SelectOnFocusSpinCtrl, + min=minBlinkRate, + max=maxBlinkRate, + initial=config.conf["braille"]["cursorBlinkRate"] + ) if not self.showCursorCheckBox.GetValue() or not self.cursorBlinkCheckBox.GetValue() : self.cursorBlinkRateEdit.Disable() @@ -2799,12 +3007,22 @@ def makeSettings(self, settingsSizer): if gui._isDebug(): log.debug("Loading cursor settings completed, now at %.2f seconds from start"%(time.time() - startTime)) + minTimeout = int(config.conf.getConfigValidation( + ("braille", "messageTimeout") + ).kwargs["min"]) + maxTimeOut = int(config.conf.getConfigValidation( + ("braille", "messageTimeout") + ).kwargs["max"]) + # Translators: The label for a setting in braille settings to change how long a message stays on the braille display (in seconds). messageTimeoutText = _("Message &timeout (sec)") - self.messageTimeoutEdit = sHelper.addLabeledControl(messageTimeoutText, nvdaControls.SelectOnFocusSpinCtrl, - min=int(config.conf.getConfigValidation(("braille", "messageTimeout")).kwargs["min"]), - max=int(config.conf.getConfigValidation(("braille", "messageTimeout")).kwargs["max"]), - initial=config.conf["braille"]["messageTimeout"]) + self.messageTimeoutEdit = sHelper.addLabeledControl( + messageTimeoutText, + nvdaControls.SelectOnFocusSpinCtrl, + min=minTimeout, + max=maxTimeOut, + initial=config.conf["braille"]["messageTimeout"] + ) # Translators: The label for a setting in braille settings to display a message on the braille display indefinitely. noMessageTimeoutLabelText = _("Show &messages indefinitely") @@ -2854,7 +3072,7 @@ def makeSettings(self, settingsSizer): log.debug("Finished making settings, now at %.2f seconds from start"%(time.time() - startTime)) def onSave(self): - DriverSettingsMixin.onSave(self) + AutoSettingsMixin.onSave(self) config.conf["braille"]["translationTable"] = self.outTableNames[self.outTableList.GetSelection()] brailleInput.handler.table = self.inTables[self.inTableList.GetSelection()] config.conf["braille"]["expandAtCursor"] = self.expandAtCursorCheckBox.GetValue() @@ -2888,6 +3106,410 @@ def onBlinkCursorChange(self, evt): def onNoMessageTimeoutChange(self, evt): self.messageTimeoutEdit.Enable(not evt.IsChecked()) + +def showStartErrorForProviders( + parent: wx.Window, + providers: List[vision.providerInfo.ProviderInfo], +) -> None: + if not providers: + return + + if len(providers) == 1: + providerName = providers[0].displayName + # Translators: This message is presented when + # NVDA is unable to load a single vision enhancement provider. + message = _(f"Could not load the {providerName} vision enhancement provider") + else: + providerNames = ", ".join(provider.displayName for provider in providers) + # Translators: This message is presented when NVDA is unable to + # load multiple vision enhancement providers. + message = _(f"Could not load the following vision enhancement providers:\n{providerNames}") + gui.messageBox( + message, + # Translators: The title of the vision enhancement provider error message box. + _("Vision Enhancement Provider Error"), + wx.OK | wx.ICON_WARNING, + parent, + ) + + +def showTerminationErrorForProviders( + parent: wx.Window, + providers: List[vision.providerInfo.ProviderInfo], +) -> None: + if not providers: + return + + if len(providers) == 1: + providerName = providers[0].displayName + # Translators: This message is presented when + # NVDA is unable to gracefully terminate a single vision enhancement provider. + message = _(f"Could not gracefully terminate the {providerName} vision enhancement provider") + else: + providerNames = ", ".join(provider.displayName for provider in providers) + # Translators: This message is presented when + # NVDA is unable to terminate multiple vision enhancement providers. + message = _( + "Could not gracefully terminate the following vision enhancement providers:\n" + f"{providerNames }" + ) + gui.messageBox( + message, + # Translators: The title of the vision enhancement provider error message box. + _("Vision Enhancement Provider Error"), + wx.OK | wx.ICON_WARNING, + parent, + ) + + +class VisionProviderStateControl(vision.providerBase.VisionProviderStateControl): + """ + Gives settings panels for vision enhancement providers a way to control a + single vision enhancement provider, handling any error conditions in + a UX friendly way. + """ + def __init__( + self, + parent: wx.Window, + providerInfo: vision.providerInfo.ProviderInfo + ): + self._providerInfo = providerInfo + self._parent = parent + + def getProviderInfo(self) -> vision.providerInfo.ProviderInfo: + return self._providerInfo + + def getProviderInstance(self) -> Optional[vision.providerBase.VisionEnhancementProvider]: + return vision.handler.getProviderInstance(self._providerInfo) + + def startProvider( + self, + shouldPromptOnError: bool = True + ) -> bool: + """Initializes the provider, prompting user with the error if necessary. + @param shouldPromptOnError: True if the user should be presented with any errors that may occur. + @return: True on success + """ + success = self._doStartProvider() + if not success and shouldPromptOnError: + showStartErrorForProviders(self._parent, [self._providerInfo, ]) + return success + + def terminateProvider( + self, + shouldPromptOnError: bool = True + ) -> bool: + """Terminate the provider, prompting user with the error if necessary. + @param shouldPromptOnError: True if the user should be presented with any errors that may occur. + @return: True on success + """ + success = self._doTerminate() + if not success and shouldPromptOnError: + showTerminationErrorForProviders(self._parent, [self._providerInfo, ]) + return success + + def _doStartProvider(self) -> bool: + """Attempt to start the provider, catching any errors. + @return True on successful termination. + """ + try: + vision.handler.initializeProvider(self._providerInfo) + return True + except Exception: + log.error( + f"Could not initialize the {self._providerInfo.providerId} vision enhancement provider", + exc_info=True + ) + return False + + def _doTerminate(self) -> bool: + """Attempt to terminate the provider, catching any errors. + @return True on successful termination. + """ + try: + # Terminating a provider from the gui should never save the settings. + # This is because termination happens on the fly when unchecking check boxes. + # Saving settings would be harmful if a user opens the vision panel, + # then changes some settings and disables the provider. + vision.handler.terminateProvider(self._providerInfo, saveSettings=False) + return True + except Exception: + log.error( + f"Could not terminate the {self._providerInfo.providerId} vision enhancement provider", + exc_info=True + ) + return False + + +class VisionSettingsPanel(SettingsPanel): + settingsSizerHelper: guiHelper.BoxSizerHelper + providerPanelInstances: List[SettingsPanel] + initialProviders: List[vision.providerInfo.ProviderInfo] + # Translators: This is the label for the vision panel + title = _("Vision") + + # Translators: This is a label appearing on the vision settings panel. + panelDescription = _("Configure visual aids.") + + def _createProviderSettingsPanel( + self, + providerInfo: vision.providerInfo.ProviderInfo + ) -> Optional[SettingsPanel]: + settingsPanelCls = providerInfo.providerClass.getSettingsPanelClass() + if not settingsPanelCls: + if gui._isDebug(): + log.debug(f"Using default panel for providerId: {providerInfo.providerId}") + settingsPanelCls = VisionProviderSubPanel_Wrapper + else: + if gui._isDebug(): + log.debug(f"Using custom panel for providerId: {providerInfo.providerId}") + + providerControl = VisionProviderStateControl(parent=self, providerInfo=providerInfo) + try: + return settingsPanelCls( + parent=self, + providerControl=providerControl + ) + # Broad except used since we can not know what exceptions a provider might throw. + # We should be able to continue despite a buggy provider. + except Exception: + log.debug(f"Error creating providerPanel: {settingsPanelCls!r}", exc_info=True) + return None + + def makeSettings(self, settingsSizer: wx.BoxSizer): + self.initialProviders = vision.handler.getActiveProviderInfos() + self.providerPanelInstances = [] + self.settingsSizerHelper = guiHelper.BoxSizerHelper(self, sizer=settingsSizer) + self.settingsSizerHelper.addItem(wx.StaticText(self, label=self.panelDescription)) + + for providerInfo in vision.handler.getProviderList(reloadFromSystem=True): + providerSizer = self.settingsSizerHelper.addItem( + wx.StaticBoxSizer(wx.StaticBox(self, label=providerInfo.displayName), wx.VERTICAL), + flag=wx.EXPAND + ) + if len(self.providerPanelInstances) > 0: + settingsSizer.AddSpacer(guiHelper.SPACE_BETWEEN_VERTICAL_DIALOG_ITEMS) + + settingsPanel = self._createProviderSettingsPanel(providerInfo) + if not settingsPanel: + continue + + providerSizer.Add(settingsPanel, flag=wx.EXPAND) + self.providerPanelInstances.append(settingsPanel) + + def safeInitProviders( + self, + providers: List[vision.providerInfo.ProviderInfo] + ) -> None: + """Initializes one or more providers in a way that is gui friendly, + showing an error if appropriate. + """ + errorProviders: List[vision.providerInfo.ProviderInfo] = [] + for provider in providers: + with VisionProviderStateControl(self, provider) as control: + success = control.startProvider(shouldPromptOnError=False) + if not success: + errorProviders.append(provider) + showStartErrorForProviders(self, errorProviders) + + def safeTerminateProviders( + self, + providers: List[vision.providerInfo.ProviderInfo], + verbose: bool = False + ) -> None: + """Terminates one or more providers in a way that is gui friendly, + @verbose: Whether to show a termination error. + @returns: Whether termination succeeded for all providers. + """ + errorProviders: List[vision.providerInfo.ProviderInfo] = [] + for provider in providers: + success = VisionProviderStateControl(self, provider).terminateProvider(shouldPromptOnError=False) + if not success: + errorProviders.append(provider) + if verbose: + showTerminationErrorForProviders(self, errorProviders) + + def refreshPanel(self): + self.Freeze() + # trigger a refresh of the settings + self.onPanelActivated() + self._sendLayoutUpdatedEvent() + self.Thaw() + + def onPanelActivated(self): + super().onPanelActivated() + + def onDiscard(self): + for panel in self.providerPanelInstances: + try: + panel.onDiscard() + # Broad except used since we can not know what exceptions a provider might throw. + # We should be able to continue despite a buggy provider. + except Exception: + log.debug(f"Error discarding providerPanel: {panel.__class__!r}", exc_info=True) + + providersToInitialize = [ + provider for provider in self.initialProviders + if not bool(vision.handler.getProviderInstance(provider)) + ] + self.safeInitProviders(providersToInitialize) + initialProviderIds = [ + providerInfo.providerId for providerInfo in self.initialProviders + ] + providersToTerminate = [ + provider for provider in vision.handler.getActiveProviderInfos() + if provider.providerId not in initialProviderIds + ] + self.safeTerminateProviders(providersToTerminate) + + def onSave(self): + for panel in self.providerPanelInstances: + try: + panel.onSave() + # Broad except used since we can not know what exceptions a provider might throw. + # We should be able to continue despite a buggy provider. + except Exception: + log.debug(f"Error saving providerPanel: {panel.__class__!r}", exc_info=True) + self.initialProviders = vision.handler.getActiveProviderInfos() + + +class VisionProviderSubPanel_Settings( + AutoSettingsMixin, + SettingsPanel +): + + _settingsCallable: Callable[[], VisionEnhancementProviderSettings] + + def __init__( + self, + parent: wx.Window, + *, # Make next argument keyword only + settingsCallable: Callable[[], vision.providerBase.VisionEnhancementProviderSettings] + ): + """ + @param settingsCallable: A callable that returns an instance to a VisionEnhancementProviderSettings. + This will usually be a weakref, but could be any callable taking no arguments. + """ + self._settingsCallable = settingsCallable + super().__init__(parent=parent) + + def getSettings(self) -> AutoSettings: + settings = self._settingsCallable() + return settings + + def makeSettings(self, settingsSizer): + # Construct vision enhancement provider settings + self.updateDriverSettings() + + @property + def hasOptions(self) -> bool: + return bool(self.sizerDict) + + +class VisionProviderSubPanel_Wrapper( + SettingsPanel +): + + _checkBox: wx.CheckBox + + def __init__( + self, + parent: wx.Window, + providerControl: VisionProviderStateControl + ): + self._providerControl = providerControl + self._providerSettings: Optional[VisionProviderSubPanel_Settings] = None + self._providerSettingsSizer = wx.BoxSizer(orient=wx.VERTICAL) + super().__init__(parent=parent) + + def makeSettings(self, settingsSizer): + self._checkBox = wx.CheckBox( + self, + # Translators: Enable checkbox on a vision enhancement provider on the vision settings category panel + label=_("Enable") + ) + settingsSizer.Add(self._checkBox) + self._optionsSizer = wx.BoxSizer(orient=wx.VERTICAL) + self._optionsSizer.AddSpacer(size=self.scaleSize(10)) + # Translators: Options label on a vision enhancement provider on the vision settings category panel + self._optionsText = wx.StaticText(self, label=_("Options:")) + self._optionsSizer.Add(self._optionsText) + self._optionsSizer.Add( + self._providerSettingsSizer, + border=self.scaleSize(15), + flag=wx.LEFT | wx.EXPAND, + proportion=1.0 + ) + settingsSizer.Add( + self._optionsSizer, + flag=wx.EXPAND, + proportion=1.0 + ) + self._checkBox.SetValue(bool(self._providerControl.getProviderInstance())) + if self._createProviderSettings(): + self._checkBox.Bind(wx.EVT_CHECKBOX, self._enableToggle) + else: + self._checkBox.Bind(wx.EVT_CHECKBOX, self._nonEnableableGUI) + self._updateOptionsVisibility() + + def _updateOptionsVisibility(self): + hasProviderOptions = bool(self._providerSettings) and self._providerSettings.hasOptions + if hasProviderOptions: + self.settingsSizer.Show(self._optionsSizer, recursive=True) + else: + self.settingsSizer.Hide(self._optionsSizer, recursive=True) + self._sendLayoutUpdatedEvent() + + def _createProviderSettings(self): + try: + getSettingsCallable = self._providerControl.getProviderInfo().providerClass.getSettings + self._providerSettings = VisionProviderSubPanel_Settings( + self, + settingsCallable=getSettingsCallable + ) + self._providerSettingsSizer.Add(self._providerSettings, flag=wx.EXPAND, proportion=1.0) + # Broad except used since we can not know what exceptions a provider might throw. + # We should be able to continue despite a buggy provider. + except Exception: + log.error("unable to create provider settings", exc_info=True) + return False + return True + + def _nonEnableableGUI(self, evt): + wx.MessageBox( + # Translators: Shown when there is an error showing the GUI for a vision enhancement provider + _("Unable to configure user interface for Vision Enhancement Provider, it can not be enabled."), + parent=self, + ) + self._checkBox.SetValue(False) + + def _enableToggle(self, evt): + shouldBeRunning = evt.IsChecked() + if shouldBeRunning and not self._providerControl.startProvider(): + self._checkBox.SetValue(False) + self._updateOptionsVisibility() + return + elif not shouldBeRunning and not self._providerControl.terminateProvider(): + # When there is an error on termination, don't leave the checkbox checked. + # The provider should not be left configured to startup. + self._checkBox.SetValue(False) + self._updateOptionsVisibility() + return + # Able to successfully start / terminate: + self._providerSettings.updateDriverSettings() + self._providerSettings.refreshGui() + self._updateOptionsVisibility() + + def onDiscard(self): + if self._providerSettings: + self._providerSettings.onDiscard() + + def onSave(self): + log.debug(f"calling VisionProviderSubPanel_Wrapper") + if self._providerSettings: + self._providerSettings.onSave() + + """ The name of the config profile currently being edited, if any. This is set when the currently edited configuration profile is determined and returned to None when the dialog is destroyed. This can be used by an AppModule for NVDA to identify and announce @@ -2901,6 +3523,7 @@ class NVDASettingsDialog(MultiCategorySettingsDialog): GeneralSettingsPanel, SpeechSettingsPanel, BrailleSettingsPanel, + VisionSettingsPanel, KeyboardSettingsPanel, MouseSettingsPanel, ReviewCursorPanel, diff --git a/source/inputCore.py b/source/inputCore.py index bbb28780fea..9bc890eb82d 100644 --- a/source/inputCore.py +++ b/source/inputCore.py @@ -597,7 +597,7 @@ def __init__(self, obj, ancestors): # Vision enhancement provider import vision - for provider in vision.handler.providers.values(): + for provider in vision.handler.getActiveProviderInstances(): if isinstance(provider, baseObject.ScriptableObject): self.addObj(provider) diff --git a/source/scriptHandler.py b/source/scriptHandler.py index 146b82e9189..88a4bb26c01 100644 --- a/source/scriptHandler.py +++ b/source/scriptHandler.py @@ -103,7 +103,7 @@ def findScript(gesture): return func # Vision enhancement provider level - for provider in vision.handler.providers.values(): + for provider in vision.handler.getActiveProviderInstances(): if isinstance(provider, baseObject.ScriptableObject): func = _getObjScript(provider, gesture, globalMapScripts) if func: diff --git a/source/synthDriverHandler.py b/source/synthDriverHandler.py index 4caba389f32..78d52ff911a 100644 --- a/source/synthDriverHandler.py +++ b/source/synthDriverHandler.py @@ -395,12 +395,13 @@ def loadSettings(self, onlyChanged=False): ).format(self.name)) def _get_initialSettingsRingSetting (self): - if not self.isSupported("rate") and len(self.supportedSettings)>0: + supportedSettings = list(self.supportedSettings) + if not self.isSupported("rate") and len(supportedSettings) > 0: #Choose first as an initial one - for i,s in enumerate(self.supportedSettings): + for i, s in enumerate(supportedSettings): if s.availableInSettingsRing: return i return None - for i,s in enumerate(self.supportedSettings): + for i, s in enumerate(supportedSettings): if s.id == "rate": return i return None diff --git a/source/vision/__init__.py b/source/vision/__init__.py index 64dccc68ab6..70fbd8b8627 100644 --- a/source/vision/__init__.py +++ b/source/vision/__init__.py @@ -10,74 +10,31 @@ Add-ons can provide their own provider using modules in the visionEnhancementProviders package containing a L{VisionEnhancementProvider} class. """ - -from .constants import Role -from .visionHandler import VisionHandler, getProviderClass -import pkgutil +from .visionHandler import VisionHandler import visionEnhancementProviders import config -from logHandler import log -from typing import List, Tuple +from typing import Optional + +handler: Optional[VisionHandler] = None -def initialize(): +def initialize() -> None: global handler config.addConfigDirsToPythonPackagePath(visionEnhancementProviders) handler = VisionHandler() -def pumpAll(): +def pumpAll() -> None: """Runs tasks at the end of each core cycle.""" if handler and handler.extensionPoints: handler.extensionPoints.post_coreCycle.notify() -def terminate(): +def terminate() -> None: global handler handler.terminate() handler = None -def getProviderList( - onlyStartable: bool = True -) -> List[Tuple[str, str, List[Role]]]: - """Gets a list of available vision enhancement names with their descriptions as well as supported roles. - @param onlyStartable: excludes all providers for which the check method returns C{False}. - @return: list of tuples with provider names, provider descriptions, and supported roles. - See L{constants.Role} for the available roles. - """ - providerList = [] - for loader, name, isPkg in pkgutil.iter_modules(visionEnhancementProviders.__path__): - if name.startswith('_'): - continue - try: - provider = getProviderClass(name) - except Exception: - # Purposely catch everything. - # A provider can raise whatever exception it likes, - # therefore it is unknown what to expect. - log.error( - "Error while importing vision enhancement provider %s" % name, - exc_info=True - ) - continue - try: - if not onlyStartable or provider.canStart(): - providerList.append(( - provider.name, - provider.description, - list(provider.supportedRoles) - )) - else: - log.debugWarning("Vision enhancement provider %s reports as unable to start, excluding" % provider.name) - except Exception: - # Purposely catch everything else as we don't want one failing provider - # make it impossible to list all the others. - log.error("", exc_info=True) - # Sort the providers alphabetically by name. - providerList.sort(key=lambda d: d[1].lower()) - return providerList - - def _isDebug() -> bool: return config.conf["debugLog"]["vision"] diff --git a/source/vision/constants.py b/source/vision/constants.py index 5e4ab323a9d..92c59f82250 100644 --- a/source/vision/constants.py +++ b/source/vision/constants.py @@ -10,28 +10,6 @@ from enum import Enum -class Role(str, Enum): - """ - A role that could be fulfilled by a vision enhancement provider. - """ - # This should be a string enum when Python 3 arrives. - MAGNIFIER = "magnifier" - HIGHLIGHTER = "highlighter" - COLORENHANCER = "colorEnhancer" - - -ROLE_DESCRIPTIONS = { - # Translators: The name for a vision enhancement provider that magnifies (a part of) the screen. - Role.MAGNIFIER: _("Magnifier"), - # Translators: The name for a vision enhancement provider that highlights important areas on screen, - # such as the focus, caret or review cursor location. - Role.HIGHLIGHTER: _("Highlighter"), - # Translators: The name for a vision enhancement provider that enhances the color presentation. - # (i.e. color inversion, gray scale coloring, etc.) - Role.COLORENHANCER: _("Color enhancer"), -} - - class Context(str, Enum): """Context for events received by providers. Typically this informs of the cause of the event. diff --git a/source/vision/exceptions.py b/source/vision/exceptions.py new file mode 100644 index 00000000000..544e5a6ecf4 --- /dev/null +++ b/source/vision/exceptions.py @@ -0,0 +1,15 @@ +# 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) 2019 NV Access Limited, Babbage B.V. + +"""Module containing exceptions for the vision framework. +""" + + +class ProviderTerminateException(RuntimeError): + ... + + +class ProviderInitException(RuntimeError): + ... diff --git a/source/vision/providerBase.py b/source/vision/providerBase.py index fb7937be195..57e11e1c1b8 100644 --- a/source/vision/providerBase.py +++ b/source/vision/providerBase.py @@ -7,35 +7,138 @@ """Module within the vision framework that contains the base vision enhancement provider class. """ -import driverHandler from abc import abstractmethod -from .constants import Role + +from autoSettingsUtils.autoSettings import AutoSettings +from baseObject import AutoPropertyObject from .visionHandlerExtensionPoints import EventExtensionPoints -from typing import FrozenSet +from typing import Optional, Any -class VisionEnhancementProvider(driverHandler.Driver): - """A class for vision enhancement providers. +class VisionEnhancementProviderSettings(AutoSettings): + """ + Base class for settings for a vision enhancement provider. + Ensure that the following are implemented: + - AutoSettings.getId: + This is case sensitive. Used in the config file. Does not have to match the module name. + - AutoSettings.getDisplayName: + The string that should appear in the GUI as the name. + - AutoSettings._get_supportedSettings: + The settings for your provider, the returned list is permitted to change during + start / termination of the provider. + The implementation must handle how to modify the returned settings based on external (software, + hardware) dependencies. + @note + If the vision enhancement provider has settings, it will provide an implementation of this class. + The provider will hold a reference to an instance of this class, this is accessed through the class method + L{VisionEnhancementProvider.getSettings}. + One way to handle settings that are strictly runtime: + - During initialization, the vision enhancement provider can instruct the settings instance what it should + expose using the L{utoSettings._get_supportedSettings} property. + - "_exampleProvider_autoGui.py" provides an example of this. + """ + def __init__(self): + super().__init__() + self.initSettings() # ensure that settings are loaded at construction time. + + @classmethod + def _getConfigSection(cls) -> str: + return "vision" # all providers should be in the "vision" section. + + +class VisionProviderStateControl: + """ Stub showing the interface for controlling the start/termination of a single provider. + Implementors of this class should handle the outcome when things go wrong. """ - _configSection = "vision" + @abstractmethod + def getProviderInfo(self): + """ + @return: The provider info + @rtype: providerInfo.ProviderInfo + """ + + @abstractmethod + def getProviderInstance(self): + """Gets an instance for the provider if it already exists + @rtype: Optional[VisionEnhancementProvider] + """ + + @abstractmethod + def startProvider(self, shouldPromptOnError: bool) -> bool: + """Initializes the provider, prompting user with the error if necessary. + @param shouldPromptOnError: True if the user should be presented with any errors that may occur. + @return: True on success + """ + + @abstractmethod + def terminateProvider(self, shouldPromptOnError: bool) -> bool: + """Terminate the provider, prompting user with the error if necessary. + @param shouldPromptOnError: True if the user should be presented with any errors that may occur. + @return: True on success + """ + + +class VisionEnhancementProvider(AutoPropertyObject): + """A class for vision enhancement providers. + Derived classes should implement: + - terminate: + How to shutdown the provider + - registerEventExtensionPoints: + Allows the provider to receive updates form NVDA + - canStart: + Checks startup dependencies are satisfied + - getSettings: + Returns your implementation of VisionEnhancementProviderSettings + Optional: To provide a custom GUI, return a SettingsPanel class type from: + - getSettingsPanelClass + """ cachePropertiesByDefault = True - #: Override of supportedSettings to be a class property. - supportedSettings = () - #: The roles supported by this provider. - #: This attribute is currently not used, - #: but might be later for presentational purposes. - supportedRoles: FrozenSet[Role] = frozenset() - - def reinitialize(self): - """Reinitializes a vision enhancement provider, reusing the same instance. + + @classmethod + @abstractmethod + def getSettings(cls) -> VisionEnhancementProviderSettings: + """ + @remarks: The L{VisionEnhancementProviderSettings} class should be implemented to define the settings + for your provider + """ + ... + + @classmethod + def getSettingsPanelClass(cls) -> Optional[Any]: + """Returns the class to be used in order to construct a settingsPanel instance for the provider. + The returned class must have a constructor which accepts: + - parent: wx.Window + - providerControl: VisionProviderStateControl + EG: + ``` python + class mySettingsPanel(gui.settingsDialogs.SettingsPanel): + def __init__(self, parent: wx.Window, providerControl: VisionProviderStateControl): + super().__init__(parent=parent) + ``` + @rtype: Optional[SettingsPanel] + @remarks: When None is returned, L{gui.settingsDialogs.VisionProviderSubPanel_Wrapper} is used. + """ + return None + + def reinitialize(self) -> None: + """Reinitialize a vision enhancement provider, reusing the same instance. This base implementation simply calls terminate and __init__ consecutively. """ self.terminate() self.__init__() @abstractmethod - def registerEventExtensionPoints(self, extensionPoints: EventExtensionPoints): + def terminate(self) -> None: + """Terminate this provider. + This should be used for any required clean up. + @precondition: L{initialize} has been called. + @postcondition: This provider can no longer be used. + """ + ... + + @abstractmethod + def registerEventExtensionPoints(self, extensionPoints: EventExtensionPoints) -> None: """ Called at provider initialization time, this method should register the provider to the several event extension points that it is interested in. @@ -44,14 +147,10 @@ def registerEventExtensionPoints(self, extensionPoints: EventExtensionPoints): as it might be called again several times between initialization and termination. @param extensionPoints: An object containing available extension points as attributes. """ - pass + ... @classmethod @abstractmethod def canStart(cls) -> bool: """Returns whether this provider is able to start.""" return False - - @classmethod - def check(cls) -> bool: - return cls.canStart() diff --git a/source/vision/providerInfo.py b/source/vision/providerInfo.py new file mode 100644 index 00000000000..bcc9bd1f30e --- /dev/null +++ b/source/vision/providerInfo.py @@ -0,0 +1,19 @@ +# 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) 2019 NV Access Limited +from dataclasses import dataclass +from typing import Type +from vision import providerBase + +ProviderIdT = str +ModuleNameT = str +DisplayNameT = str + + +@dataclass +class ProviderInfo: + providerId: ProviderIdT + moduleName: ModuleNameT + displayName: DisplayNameT + providerClass: Type[providerBase.VisionEnhancementProvider] diff --git a/source/vision/visionHandler.py b/source/vision/visionHandler.py index b565cecaa6c..64c53d849a8 100644 --- a/source/vision/visionHandler.py +++ b/source/vision/visionHandler.py @@ -9,7 +9,7 @@ The vision handler is the core of the vision framework. See the documentation of L{VisionHandler} for more details about what it does. """ - +from . import providerInfo from .constants import Context from .providerBase import VisionEnhancementProvider from .visionHandlerExtensionPoints import EventExtensionPoints @@ -21,10 +21,11 @@ from logHandler import log import visionEnhancementProviders import queueHandler -from typing import Type, Dict, List +from typing import Type, Dict, List, Optional +from . import exceptions -def getProviderClass( +def _getProviderClass( moduleName: str, caseSensitive: bool = True ) -> Type[VisionEnhancementProvider]: @@ -48,18 +49,42 @@ def getProviderClass( raise initialException +def _getProvidersFromFileSystem(): + for loader, moduleName, isPkg in pkgutil.iter_modules(visionEnhancementProviders.__path__): + if moduleName.startswith('_'): + continue + try: + # Get each piece of info in a new statement so any exceptions raised identifies the line correctly. + provider = _getProviderClass(moduleName) + providerSettings = provider.getSettings() + providerId = providerSettings.getId() + displayName = providerSettings.getDisplayName() + yield providerInfo.ProviderInfo( + providerId=providerId, + moduleName=moduleName, + displayName=displayName, + providerClass=provider + ) + except Exception: # Purposely catch everything as we don't know what a provider might raise. + log.error( + f"Error while importing vision enhancement provider module {moduleName}", + exc_info=True + ) + continue + + class VisionHandler(AutoPropertyObject): """The singleton vision handler is the core of the vision framework. It performs the following tasks: - * It keeps track of active vision enhancement providers in the L{providers} dictionary. - * It processes initialization and termnation of providers. + * It keeps track of active vision enhancement _providers in the L{_providers} dictionary. + * It processes initialization and termination of providers. * It receives certain events from the core of NVDA, delegating them to the appropriate extension points. """ def __init__(self): - self.providers: Dict[str, VisionEnhancementProvider] = dict() + self._providers: Dict[providerInfo.ProviderIdT, VisionEnhancementProvider] = dict() self.extensionPoints: EventExtensionPoints = EventExtensionPoints() queueHandler.queueFunction(queueHandler.eventQueue, self.postGuiInit) @@ -68,98 +93,162 @@ def postGuiInit(self) -> None: This is executed on the main thread by L{__init__} using the events queue. This ensures that the gui is fully initialized before providers are initialized that might rely on it. """ + self._updateAllProvidersList() self.handleConfigProfileSwitch() config.post_configProfileSwitch.register(self.handleConfigProfileSwitch) - def terminateProvider(self, providerName: str) -> bool: + _allProviders: List[providerInfo.ProviderInfo] = [] + + def _updateAllProvidersList(self): + self._allProviders = list(_getProvidersFromFileSystem()) + # Sort the providers alphabetically by name. + self._allProviders.sort(key=lambda info: info.displayName.lower()) + + def getProviderList( + self, + onlyStartable: bool = True, + reloadFromSystem: bool = False, + ) -> List[providerInfo.ProviderInfo]: + """Gets a list of available vision enhancement provider information + @param onlyStartable: excludes all providers for which the check method returns C{False}. + @param reloadFromSystem: ensure the list is fresh. Providers may have been added to the file system. + @return: List of available providers + """ + if reloadFromSystem or not self._allProviders: + self._updateAllProvidersList() + + providerList = [] + for provider in self._allProviders: + try: + providerCanStart = provider.providerClass.canStart() + except Exception: # Purposely catch everything as we don't know what a provider might raise. + log.error(f"Error calling canStart for provider {provider.moduleName}", exc_info=True) + else: + if not onlyStartable or providerCanStart: + providerList.append(provider) + else: + log.debugWarning( + f"Excluding Vision enhancement provider module {provider.moduleName} which is unable to start" + ) + return providerList + + def getProviderInfo(self, providerId: providerInfo.ProviderIdT) -> Optional[providerInfo.ProviderInfo]: + for p in self._allProviders: + if p.providerId == providerId: + return p + raise LookupError(f"Provider with id ({providerId}) does not exist.") + + def getActiveProviderInstances(self): + return list(self._providers.values()) + + def getActiveProviderInfos(self) -> List[providerInfo.ProviderInfo]: + activeProviderInfos = [ + self.getProviderInfo(p) for p in self._providers + ] + return list(activeProviderInfos) + + def getProviderInstance( + self, + provider: providerInfo.ProviderInfo + ) -> Optional[VisionEnhancementProvider]: + return self._providers.get(provider.providerId) + + def terminateProvider( + self, + provider: providerInfo.ProviderInfo, + saveSettings: bool = True + ) -> None: """Terminates a currently active provider. - @param providerName: The provider to terminate. - @returns: Whether termination succeeded or failed. - When termnation fails, return False so the caller knows that something failed. - Yet, the provider wil lbe removed from the providers dictionary, - so its instance goes out of scope and wil lbe garbage collected. + When termination fails, an exception is raised. + Yet, the provider will be removed from the providers dictionary, + so its instance goes out of scope and wil lbe garbage collected. + @param provider: The provider to terminate. + @param saveSettings: Whether settings should be saved on termination. """ - success = True - # Remove the provider from the providers dictionary. - providerInstance = self.providers.pop(providerName, None) + providerId = provider.providerId + # Remove the provider from the _providers dictionary. + providerInstance = self._providers.pop(providerId, None) if not providerInstance: - log.warning("Tried to terminate uninitialized provider %s" % providerName) - return False + raise exceptions.ProviderTerminateException( + f"Tried to terminate uninitialized provider {providerId!r}" + ) + exception = None + if saveSettings: + try: + providerInstance.getSettings().saveSettings() + except Exception: + log.error(f"Error while saving settings during termination of {providerId}") try: providerInstance.terminate() - except Exception: + except Exception as e: # Purposely catch everything. # A provider can raise whatever exception, # therefore it is unknown what to expect. - log.error("Error while terminating vision provider %s" % providerName, exc_info=True) - success = False + exception = e # Copy the configured providers before mutating the list. - # If we don't, configobj won't be aware of changes the list. + # If we don't, configobj won't be aware of changes in the list. configuredProviders: List = config.conf['vision']['providers'][:] try: - configuredProviders.remove(providerName) + configuredProviders.remove(providerId) config.conf['vision']['providers'] = configuredProviders except ValueError: pass # As we cant rely on providers to de-register themselves from extension points when terminating them, # Re-create our extension points instance and ask active providers to reregister. self.extensionPoints = EventExtensionPoints() - for providerInst in self.providers.values(): + for providerInst in self._providers.values(): try: providerInst.registerEventExtensionPoints(self.extensionPoints) except Exception: - log.error("Error while registering to extension points for provider %s" % providerName, exc_info=True) - return success + log.error(f"Error while registering to extension points for provider {providerId}", exc_info=True) + if exception: + raise exception - def initializeProvider(self, providerName: str, temporary: bool = False) -> bool: + def initializeProvider( + self, + provider: providerInfo.ProviderInfo, + temporary: bool = False + ) -> None: """ Enables and activates the supplied provider. - @param providerName: The name of the registered provider. + @param provider: The provider to initialize. @param temporary: Whether the selected provider is enabled temporarily (e.g. as a fallback). This defaults to C{False}. If C{True}, no changes will be performed to the configuration. - @returns: Whether initializing the requested provider succeeded. + @note: On error, an an Exception is raised. """ - providerCls = None - providerInst = self.providers.pop(providerName, None) + providerId = provider.providerId + providerInst = self._providers.pop(providerId, None) if providerInst is not None: - providerCls = type(providerInst) try: providerInst.reinitialize() - except Exception: - # Purposely catch everything. - # A provider can raise whatever exception, - # therefore it is unknown what to expect. - log.error("Error while reinitializing provider %s" % providerName, exc_info=True) - return False + except Exception as e: + log.error(f"Error while re-initialising {providerId}") + raise e else: + providerCls = provider.providerClass + if not providerCls.canStart(): + raise exceptions.ProviderInitException( + f"Trying to initialize provider {providerId} which reported being unable to start" + ) + # Initialize the provider. + providerInst = providerCls() + # Register extension points. try: - providerCls = getProviderClass(providerName) - if not providerCls.canStart(): - log.error("Trying to initialize provider %s which reported being unable to start" % providerName) - return False - # Initialize the provider. - providerInst = providerCls() - # Register extension points. + providerInst.registerEventExtensionPoints(self.extensionPoints) + except Exception as registerEventExtensionPointsException: + log.error( + f"Error while registering to extension points for provider: {providerId}", + ) try: - providerInst.registerEventExtensionPoints(self.extensionPoints) - except Exception as registerEventExtensionPointsException: - log.error(f"Error while registering to extension points for provider {providerName}", exc_info=True) - try: - providerInst.terminate() - except Exception: - log.error("Error while registering to extension points for provider %s" % providerName, exc_info=True) - raise registerEventExtensionPointsException - except Exception: - # Purposely catch everything. - # A provider can raise whatever exception, - # therefore it is unknown what to expect. - log.error("Error while initializing provider %s" % providerName, exc_info=True) - return False - providerInst.initSettings() - if not temporary and providerCls.name not in config.conf['vision']['providers']: - config.conf['vision']['providers'] = config.conf['vision']['providers'][:] + [providerCls.name] - self.providers[providerName] = providerInst + providerInst.terminate() + except Exception: + log.error( + f"Error terminating provider {providerId} after registering to extension points", exc_info=True) + raise registerEventExtensionPointsException + if not temporary and providerId not in config.conf['vision']['providers']: + config.conf['vision']['providers'] = config.conf['vision']['providers'][:] + [providerId] + self._providers[providerId] = providerInst try: self.initialFocus() except Exception: @@ -168,14 +257,13 @@ def initializeProvider(self, providerName: str, temporary: bool = False) -> bool # We should handle this more gracefully, since this is no reason # to stop a provider from loading successfully. log.debugWarning("Error in initial focus after provider load", exc_info=True) - return True def terminate(self) -> None: self.extensionPoints = None config.post_configProfileSwitch.unregister(self.handleConfigProfileSwitch) - for instance in self.providers.values(): + for instance in self._providers.values(): instance.terminate() - self.providers.clear() + self._providers.clear() def handleUpdate(self, obj, property: str) -> None: self.extensionPoints.post_objectUpdate.notify(obj=obj, property=property) @@ -205,16 +293,30 @@ def handleMouseMove(self, obj, x: int, y: int) -> None: def handleConfigProfileSwitch(self) -> None: configuredProviders = set(config.conf['vision']['providers']) - curProviders = set(self.providers) + curProviders = set(self._providers) providersToInitialize = configuredProviders - curProviders providersToTerminate = curProviders - configuredProviders - for provider in providersToTerminate: - self.terminateProvider(provider) - for provider in providersToInitialize: - self.initializeProvider(provider) + for providerId in providersToTerminate: + try: + providerInfo = self.getProviderInfo(providerId) + self.terminateProvider(providerInfo) + except Exception: + log.error( + f"Could not terminate the {providerId} vision enhancement providerId", + exc_info=True + ) + for providerId in providersToInitialize: + try: + providerInfo = self.getProviderInfo(providerId) + self.initializeProvider(providerInfo) + except Exception: + log.error( + f"Could not initialize the {providerId} vision enhancement providerId", + exc_info=True + ) def initialFocus(self) -> None: if not api.getDesktopObject(): - # focus/review hasn't yet been initialised. + # focus/review hasn't yet been initialized. return self.handleGainFocus(api.getFocusObject()) diff --git a/source/visionEnhancementProviders/NVDAHighlighter.py b/source/visionEnhancementProviders/NVDAHighlighter.py index 974b71a250b..701a79d67d5 100644 --- a/source/visionEnhancementProviders/NVDAHighlighter.py +++ b/source/visionEnhancementProviders/NVDAHighlighter.py @@ -5,10 +5,14 @@ # Copyright (C) 2018-2019 NV Access Limited, Babbage B.V., Takuya Nishimoto """Default highlighter based on GDI Plus.""" +from typing import Optional, Tuple +from autoSettingsUtils.autoSettings import SupportedSettingType import vision -from vision.constants import Role, Context +from vision.constants import Context from vision.util import getContextRect +from vision.visionHandlerExtensionPoints import EventExtensionPoints +from vision import providerBase from windowUtils import CustomWindow import wx import gui @@ -184,60 +188,187 @@ def refresh(self): winUser.user32.InvalidateRect(self.handle, None, True) -class VisionEnhancementProvider(vision.providerBase.VisionEnhancementProvider): - name = "NVDAHighlighter" - # Translators: Description for NVDA's built-in screen highlighter. - description = _("NVDA Highlighter") - supportedRoles = frozenset([Role.HIGHLIGHTER]) - supportedContexts = (Context.FOCUS, Context.NAVIGATOR, Context.BROWSEMODE) - _ContextStyles = { - Context.FOCUS: DASH_BLUE, - Context.NAVIGATOR: SOLID_PINK, - Context.FOCUS_NAVIGATOR: SOLID_BLUE, - Context.BROWSEMODE: SOLID_YELLOW, - } - refreshInterval = 100 - customWindowClass = HighlightWindow +_contextOptionLabelsWithAccelerators = { + # Translators: shown for a highlighter setting that toggles + # highlighting the system focus. + Context.FOCUS: _("Highlight system fo&cus"), + # Translators: shown for a highlighter setting that toggles + # highlighting the browse mode cursor. + Context.BROWSEMODE: _("Highlight browse &mode cursor"), + # Translators: shown for a highlighter setting that toggles + # highlighting the navigator object. + Context.NAVIGATOR: _("Highlight navigator &object"), +} +_supportedContexts = (Context.FOCUS, Context.NAVIGATOR, Context.BROWSEMODE) + + +class NVDAHighlighterSettings(providerBase.VisionEnhancementProviderSettings): # Default settings for parameters - highlightFocus = True - highlightNavigator = True - highlightBrowseMode = True - - _contextOptionLabelsWithAccelerators = { - # Translators: shown for a highlighter setting that toggles - # highlighting the system focus. - Context.FOCUS: _("Highlight system fo&cus"), - # Translators: shown for a highlighter setting that toggles - # highlighting the browse mode cursor. - Context.BROWSEMODE: _("Highlight browse &mode cursor"), - # Translators: shown for a highlighter setting that toggles - # highlighting the navigator object. - Context.NAVIGATOR: _("Highlight navigator &object"), - } + highlightFocus = False + highlightNavigator = False + highlightBrowseMode = False @classmethod - def _get_supportedSettings(cls): + def getId(cls) -> str: + return "NVDAHighlighter" + + @classmethod + def getDisplayName(cls) -> str: + # Translators: Description for NVDA's built-in screen highlighter. + return _("Focus Highlight") + + def _get_supportedSettings(self) -> SupportedSettingType: return [ driverHandler.BooleanDriverSetting( 'highlight%s' % (context[0].upper() + context[1:]), - cls._contextOptionLabelsWithAccelerators[context], + _contextOptionLabelsWithAccelerators[context], defaultVal=True ) - for context in cls.supportedContexts + for context in _supportedContexts ] - @classmethod + +class NVDAHighlighterGuiPanel( + gui.AutoSettingsMixin, + gui.SettingsPanel +): + _enableCheckSizer: wx.BoxSizer + _enabledCheckbox: wx.CheckBox + + from gui.settingsDialogs import VisionProviderStateControl + + def __init__( + self, + parent: wx.Window, + providerControl: VisionProviderStateControl + ): + self._providerControl = providerControl + super().__init__(parent) + + def _buildGui(self): + self.mainSizer = wx.BoxSizer(wx.VERTICAL) + + self._enabledCheckbox = wx.CheckBox( + self, + # Translators: The label for a checkbox that enables / disables focus highlighting + # in the NVDA Highlighter vision settings panel. + label=_("&Enable Highlighting"), + style=wx.CHK_3STATE + ) + + self.mainSizer.Add(self._enabledCheckbox) + self.mainSizer.AddSpacer(size=self.scaleSize(10)) + # this options separator is done with text rather than a group box because a groupbox is too verbose, + # but visually some separation is helpful, since the rest of the options are really sub-settings. + self.optionsText = wx.StaticText( + self, + # Translators: The label for a group box containing the NVDA highlighter options. + label=_("Options:") + ) + self.mainSizer.Add(self.optionsText) + + self.lastControl = self.optionsText + self.settingsSizer = wx.BoxSizer(wx.VERTICAL) + self.makeSettings(self.settingsSizer) + self.mainSizer.Add(self.settingsSizer, border=self.scaleSize(15), flag=wx.LEFT | wx.EXPAND) + self.mainSizer.Fit(self) + self.SetSizer(self.mainSizer) + + def getSettings(self) -> NVDAHighlighterSettings: + # AutoSettingsMixin uses the getSettings method (via getSettingsStorage) to get the instance which is + # used to get / set attributes. The attributes must match the id's of the settings. + # We want them set on our settings instance. + return VisionEnhancementProvider.getSettings() + + def makeSettings(self, sizer: wx.BoxSizer): + self.updateDriverSettings() + # bind to all check box events + self.Bind(wx.EVT_CHECKBOX, self._onCheckEvent) + self._updateEnabledState() + + def onPanelActivated(self): + self.lastControl = self.optionsText + + def _updateEnabledState(self): + settings = self._getSettingsStorage() + settingsToTriggerActivation = [ + settings.highlightBrowseMode, + settings.highlightFocus, + settings.highlightNavigator, + ] + if any(settingsToTriggerActivation): + if all(settingsToTriggerActivation): + self._enabledCheckbox.Set3StateValue(wx.CHK_CHECKED) + else: + self._enabledCheckbox.Set3StateValue(wx.CHK_UNDETERMINED) + self._ensureEnableState(True) + else: + self._enabledCheckbox.Set3StateValue(wx.CHK_UNCHECKED) + self._ensureEnableState(False) + + def _ensureEnableState(self, shouldBeEnabled: bool): + currentlyEnabled = bool(self._providerControl.getProviderInstance()) + if shouldBeEnabled and not currentlyEnabled: + self._providerControl.startProvider() + elif not shouldBeEnabled and currentlyEnabled: + self._providerControl.terminateProvider() + + def _onCheckEvent(self, evt: wx.CommandEvent): + settingsStorage = self._getSettingsStorage() + if evt.GetEventObject() is self._enabledCheckbox: + settingsStorage.highlightBrowseMode = evt.IsChecked() + settingsStorage.highlightFocus = evt.IsChecked() + settingsStorage.highlightNavigator = evt.IsChecked() + self._ensureEnableState(evt.IsChecked()) + self.updateDriverSettings() + else: + self._updateEnabledState() + providerInst: Optional[NVDAHighlighter] = self._providerControl.getProviderInstance() + if providerInst: + providerInst.refresh() + + +class NVDAHighlighter(providerBase.VisionEnhancementProvider): + _ContextStyles = { + Context.FOCUS: DASH_BLUE, + Context.NAVIGATOR: SOLID_PINK, + Context.FOCUS_NAVIGATOR: SOLID_BLUE, + Context.BROWSEMODE: SOLID_YELLOW, + } + _refreshInterval = 100 + customWindowClass = HighlightWindow + _settings = NVDAHighlighterSettings() + + enabledContexts: Tuple[Context] # type info for autoprop: L{_get_enableContexts} + + @classmethod # override + def getSettings(cls) -> NVDAHighlighterSettings: + return cls._settings + + @classmethod # override + def getSettingsPanelClass(cls): + """Returns the class to be used in order to construct a settings panel for the provider. + @return: Optional[SettingsPanel] + @remarks: When None is returned, L{gui.settingsDialogs.VisionProviderSubPanel_Wrapper} is used. + """ + return NVDAHighlighterGuiPanel + + @classmethod # override def canStart(cls) -> bool: return True - def registerEventExtensionPoints(self, extensionPoints): + def registerEventExtensionPoints( # override + self, + extensionPoints: EventExtensionPoints + ) -> None: extensionPoints.post_focusChange.register(self.handleFocusChange) extensionPoints.post_reviewMove.register(self.handleReviewMove) extensionPoints.post_browseModeMove.register(self.handleBrowseModeMove) def __init__(self): - super(VisionEnhancementProvider, self).__init__() + super().__init__() + log.debug("Starting NVDAHighlighter") self.contextToRectMap = {} winGDI.gdiPlusInitialize() self.window = None @@ -249,6 +380,7 @@ def __init__(self): self._highlighterThread.start() def terminate(self): + log.debug("Terminating NVDAHighlighter") if self._highlighterThread: if not winUser.user32.PostThreadMessageW(self._highlighterThread.ident, winUser.WM_QUIT, 0, 0): raise WinError() @@ -256,13 +388,13 @@ def terminate(self): self._highlighterThread = None winGDI.gdiPlusTerminate() self.contextToRectMap.clear() - super(VisionEnhancementProvider, self).terminate() + super().terminate() def _run(self): if vision._isDebug(): log.debug("Starting NVDAHighlighter thread") window = self.window = self.customWindowClass(self) - self.timer = winUser.WinTimer(window.handle, 0, self.refreshInterval, None) + self.timer = winUser.WinTimer(window.handle, 0, self._refreshInterval, None) msg = MSG() while winUser.getMessage(byref(msg), None, 0, 0): winUser.user32.TranslateMessage(byref(msg)) @@ -313,6 +445,9 @@ def _get_enabledContexts(self): """Gets the contexts for which the highlighter is enabled. """ return tuple( - context for context in self.supportedContexts - if getattr(self, 'highlight%s' % (context[0].upper() + context[1:])) + context for context in _supportedContexts + if getattr(self.getSettings(), 'highlight%s' % (context[0].upper() + context[1:])) ) + + +VisionEnhancementProvider = NVDAHighlighter diff --git a/source/visionEnhancementProviders/_exampleProvider_autoGui.py b/source/visionEnhancementProviders/_exampleProvider_autoGui.py new file mode 100644 index 00000000000..e132e56fc6f --- /dev/null +++ b/source/visionEnhancementProviders/_exampleProvider_autoGui.py @@ -0,0 +1,192 @@ +# 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) 2019 NV Access Limited + +from vision import providerBase +import driverHandler +import wx +from autoSettingsUtils.utils import StringParameterInfo +from autoSettingsUtils.autoSettings import SupportedSettingType +from typing import Optional, Type, Any, List + +"""Example provider, which demonstrates using the automatically constructed GUI. Rename this file, removing + the first underscore to test it with NVDA. + +For examples of overriding the GUI and using a custom implementation, see NVDAHighlighter or ScreenCurtain. + +This example imagines that some settings are "always available", while the availability of others is unknown +until "runtime". +This might be because the provider must interface with an external application or device. +""" + + +class AutoGuiTestSettings(providerBase.VisionEnhancementProviderSettings): + + #: dictionary of the setting id's available when provider is running. + _availableRuntimeSettings = [ + ] + + # The following settings can be configured prior to runtime in this example + shouldDoX: bool + shouldDoY: bool + amountOfZ: int + nameOfSomething: str + + availableNameofsomethings = { + "n1": StringParameterInfo(id="n1", displayName="name one"), + "n2": StringParameterInfo(id="n2", displayName="name two"), + "n3": StringParameterInfo(id="n3", displayName="name three"), + "n4": StringParameterInfo(id="n4", displayName="name four"), + } + + # The following settings are runtime only in this example + runtimeOnlySetting_externalValueLoad: int + runtimeOnlySetting_localDefault: int + + @classmethod + def getId(cls) -> str: + return "exampleOfAutoGui" # Note: this does not have to match the name of the module. + + @classmethod + def getDisplayName(cls) -> str: + return "Example Provider with Auto Gui" # Should normally be translated with _() method. + + @classmethod + def getPreInitSettings(cls) -> SupportedSettingType: + """Get settings that can be configured before the provider is initialized. + This is a class method because it does not rely on any instance state in this class. + """ + return [ + driverHandler.BooleanDriverSetting( + "shouldDoX", # value stored in matching property name on class + "Should Do X", + defaultVal=True + ), + driverHandler.BooleanDriverSetting( + "shouldDoY", # value stored in matching property name on class + "Should Do Y", + defaultVal=False + ), + driverHandler.NumericDriverSetting( + "amountOfZ", # value stored in matching property name on class + "Amount of Z", + defaultVal=11 + ), + driverHandler.DriverSetting( + # options for this come from a property with name generated by + # f"available{settingID.capitalize()}s" + # Note: + # First letter of Id becomes capital, the rest lowercase. + # the 's' character on the end. + # result: 'availableNameofsomethings' + "nameOfSomething", # value stored in matching property name on class + "Name of something", + ) + ] + + def clearRuntimeSettingAvailability(self): + self._availableRuntimeSettings = [] + + def addRuntimeSettingsAvailibility(self, settingIDs: List[str]): + self._availableRuntimeSettings.extend(settingIDs) + # ensure any previously saved settings are loaded from config file: + self._initSpecificSettings(self, self._getAvailableRuntimeSettings()) + + def _hasFeature(self, settingID: str) -> bool: + return settingID in self._availableRuntimeSettings + + def _getAvailableRuntimeSettings(self) -> SupportedSettingType: + settings = [] + if self._hasFeature("runtimeOnlySetting_externalValueLoad"): + settings.extend([ + driverHandler.NumericDriverSetting( + "runtimeOnlySetting_externalValueLoad", # value stored in matching property name on class + "Runtime Only amount, external value load", + # no GUI default + ), + ]) + if self._hasFeature("runtimeOnlySetting_localDefault"): + settings.extend([ + driverHandler.NumericDriverSetting( + "runtimeOnlySetting_localDefault", # value stored in matching property name on class + "Runtime Only amount, local default", + defaultVal=50, + ), + ]) + return settings + + def _get_supportedSettings(self) -> SupportedSettingType: + settings = [] + settings.extend(self.getPreInitSettings()) + settings.extend(self._getAvailableRuntimeSettings()) + return settings + + +class AutoGuiTestProvider(providerBase.VisionEnhancementProvider): + _settings = AutoGuiTestSettings() + + @classmethod + def canStart(cls): + return True # Check any dependencies (Windows version, Hardware access, Installed applications) + + @classmethod + def getSettingsPanelClass(cls) -> Optional[Type]: + """Returns the instance to be used in order to construct a settings panel for the provider. + @return: Optional[SettingsPanel] + @remarks: When None is returned, L{gui.settingsDialogs.VisionProviderSubPanel_Wrapper} is used. + """ + return None # No custom GUI + + @classmethod + def getSettings(cls) -> AutoGuiTestSettings: + return cls._settings + + def __init__(self): + super().__init__() + self._initRuntimeOnlySettings() + self._showCurrentConfig() + + def _initRuntimeOnlySettings(self): + """ This method might query another application for its capabilities and initialise these configuration + options. + """ + settings = self.getSettings() + settings.addRuntimeSettingsAvailibility([ + "runtimeOnlySetting_localDefault", + "runtimeOnlySetting_externalValueLoad" + ]) + + # load and set values from the external source, this will override values loaded from config. + settings.runtimeOnlySetting_externalValueLoad = self._getValueFromDeviceOrOtherApplication( + "runtimeOnlySetting_externalValueLoad" + ) + + def _getValueFromDeviceOrOtherApplication(self, settingId: str) -> Any: + """ This method might connect to another application / device and fetch default values.""" + if settingId == "runtimeOnlySetting_externalValueLoad": + return 75 + return None + + def _showCurrentConfig(self): + """Simple mechanism to test updating values.""" + result = ( + f"AutoGuiTestProvider:\n" + f"x: {self._settings.shouldDoX}\n" + f"y: {self._settings.shouldDoY}\n" + f"z: {self._settings.amountOfZ}\n" + f"name: {self._settings.nameOfSomething}\n" + f"runtimeOnlySetting_externalValueLoad: {self._settings.runtimeOnlySetting_externalValueLoad}\n" + f"runtimeOnlySetting_localDefault: {self._settings.runtimeOnlySetting_localDefault}\n" + ) + wx.MessageBox(result, caption="started") + + def terminate(self): + self._settings.clearRuntimeSettingAvailability() + super().terminate() + + def registerEventExtensionPoints(self, extensionPoints): + pass + + +VisionEnhancementProvider = AutoGuiTestProvider diff --git a/source/visionEnhancementProviders/readme.md b/source/visionEnhancementProviders/readme.md new file mode 100644 index 00000000000..4fe396e4c64 --- /dev/null +++ b/source/visionEnhancementProviders/readme.md @@ -0,0 +1,45 @@ +## Vision Enhancement Providers + +These modules use the "vision framework" to augment visual information presented to the user. +For more information about the implementation of a provider see vision.providerBase.VisionEnhancementProvider +(in source/vision/providerBase.py) + +Two of the built-in examples are: +- NVDA Highlighter which will react to changes in focus and draw a rectangle outline around the focused object. +- Screen Curtain which when enabled makes the screen black for privacy reasons. + +A vision enhancement provider module should have a class called `VisionEnhancementProvider`. +To make identifying a provider that is causing errors easier, name your provider class something descriptive and set +`VisionEnhancementProvider = MyProviderClass` at the bottom of your module. +See the NVDAHighlighter module as an example. +EG: + +``` +class MyProviderClass: + ... + +VisionEnhancementProvider = MyProviderClass +print(VisionEnhancementProvider.__qualname__) # prints: MyProviderClass +``` + +### Provider settings + +Providers must provide an VisionEnhancementProviderSettings object (via VisionEnhancementProvider.getSettings). +This VisionEnhancementProviderSettings instance then provides the DriverSettings objects via the supportedSettings +property. +These are used to save / load settings for the provider. + +### Providing a GUI + +A GUI can be built automatically from the DriverSettings objects accessed via the VisionEnhancementProviderSettings. +Alternatively the provider can supply a custom settings panel implementation via the getSettingsPanelClass class method. +A custom settings panel must return a class type derived from gui.SettingsPanel which will take responsibility for +building the GUI. +For an example see NVDAHighlighter or ScreenCurtain. + +#### Automatic GUI building + +The provider settings (described above) are also used to automatically construct a GUI for the provider when +getSettingsPanelClass returns None. + +See exampleProvider_autoGui.py \ No newline at end of file diff --git a/source/visionEnhancementProviders/screenCurtain.py b/source/visionEnhancementProviders/screenCurtain.py index b297abdbaed..387a4f782d3 100644 --- a/source/visionEnhancementProviders/screenCurtain.py +++ b/source/visionEnhancementProviders/screenCurtain.py @@ -8,15 +8,24 @@ """ import vision +from vision import providerBase import winVersion from ctypes import Structure, windll, c_float, POINTER, WINFUNCTYPE, WinError from ctypes.wintypes import BOOL +from autoSettingsUtils.driverSetting import BooleanDriverSetting +from autoSettingsUtils.autoSettings import SupportedSettingType +import wx +import gui +from logHandler import log +from typing import Optional, Type class MAGCOLOREFFECT(Structure): _fields_ = (("transform", c_float * 5 * 5),) +# homogeneous matrix for a 4-space transformation (red, green, blue, opacity). +# https://docs.microsoft.com/en-gb/windows/win32/gdiplus/-gdiplus-using-a-color-matrix-to-transform-a-single-color-use TRANSFORM_BLACK = MAGCOLOREFFECT() TRANSFORM_BLACK.transform[4][4] = 1.0 @@ -32,19 +41,28 @@ class Magnification: _magnification = windll.Magnification - _MagInitializeFuncType = WINFUNCTYPE(BOOL) - _MagUninitializeFuncType = WINFUNCTYPE(BOOL) + # Set full screen color effect _MagSetFullscreenColorEffectFuncType = WINFUNCTYPE(BOOL, POINTER(MAGCOLOREFFECT)) _MagSetFullscreenColorEffectArgTypes = ((1, "effect"),) + + # Get full screen color effect _MagGetFullscreenColorEffectFuncType = WINFUNCTYPE(BOOL, POINTER(MAGCOLOREFFECT)) _MagGetFullscreenColorEffectArgTypes = ((2, "effect"),) + + # show system cursor _MagShowSystemCursorFuncType = WINFUNCTYPE(BOOL, BOOL) _MagShowSystemCursorArgTypes = ((1, "showCursor"),) + # initialize + _MagInitializeFuncType = WINFUNCTYPE(BOOL) MagInitialize = _MagInitializeFuncType(("MagInitialize", _magnification)) MagInitialize.errcheck = _errCheck + + # uninitialize + _MagUninitializeFuncType = WINFUNCTYPE(BOOL) MagUninitialize = _MagUninitializeFuncType(("MagUninitialize", _magnification)) MagUninitialize.errcheck = _errCheck + try: MagSetFullscreenColorEffect = _MagSetFullscreenColorEffectFuncType( ("MagSetFullscreenColorEffect", _magnification), @@ -66,26 +84,236 @@ class Magnification: MagShowSystemCursor.errcheck = _errCheck -class VisionEnhancementProvider(vision.providerBase.VisionEnhancementProvider): - name = "screenCurtain" - # Translators: Description of a vision enhancement provider that disables output to the screen, - # making it black. - description = _("Screen Curtain") - supportedRoles = frozenset([vision.constants.Role.COLORENHANCER]) +# Translators: Description of a vision enhancement provider that disables output to the screen, +# making it black. +screenCurtainTranslatedName = _("Screen Curtain") + +warnOnLoadCheckBoxText = ( + # Translators: Description for a screen curtain setting that shows a warning when loading + # the screen curtain. + _(f"Always &show a warning when loading {screenCurtainTranslatedName}") +) + + +class ScreenCurtainSettings(providerBase.VisionEnhancementProviderSettings): + + warnOnLoad: bool + + @classmethod + def getId(cls) -> str: + return "screenCurtain" + + @classmethod + def getDisplayName(cls) -> str: + return screenCurtainTranslatedName + + def _get_supportedSettings(self) -> SupportedSettingType: + return [ + BooleanDriverSetting( + "warnOnLoad", + warnOnLoadCheckBoxText, + defaultVal=True + ), + ] + + +warnOnLoadText = _( + # Translators: A warning shown when activating the screen curtain. + # the translation of "Screen Curtain" should match the "translated name" + "Enabling Screen Curtain will make the screen of your computer completely black. " + "Ensure you will be able to navigate without any use of your screen before continuing. " + "\n\n" + "Do you wish to continue?" +) + + +class WarnOnLoadDialog(gui.nvdaControls.MessageDialog): + + showWarningOnLoadCheckBox: wx.CheckBox + noButton: wx.Button + + def __init__( + self, + screenCurtainSettingsStorage: ScreenCurtainSettings, + parent, + title=_("Warning"), + message=warnOnLoadText, + dialogType=gui.nvdaControls.MessageDialog.DIALOG_TYPE_WARNING + ): + self._settingsStorage = screenCurtainSettingsStorage + super().__init__(parent, title, message, dialogType) + self.noButton.SetFocus() + + def _addContents(self, contentsSizer): + self.showWarningOnLoadCheckBox: wx.CheckBox = wx.CheckBox( + self, + label=warnOnLoadCheckBoxText + ) + contentsSizer.addItem(self.showWarningOnLoadCheckBox) + self.showWarningOnLoadCheckBox.SetValue( + self._settingsStorage.warnOnLoad + ) + + def _addButtons(self, buttonHelper): + yesButton = buttonHelper.addButton( + self, + id=wx.ID_YES, + # Translators: A button in the screen curtain warning dialog which allows the user to + # agree to enabling the curtain. + label=_("&Yes") + ) + yesButton.Bind(wx.EVT_BUTTON, lambda evt: self._exitDialog(wx.YES)) + + noButton: wx.Button = buttonHelper.addButton( + self, + id=wx.ID_NO, + # Translators: A button in the screen curtain warning dialog which allows the user to + # disagree to enabling the curtain. + label=_("&No") + ) + noButton.SetDefault() + noButton.Bind(wx.EVT_BUTTON, lambda evt: self._exitDialog(wx.NO)) + self.noButton = noButton # so we can manually set the focus. + + def _exitDialog(self, result: int): + """ + @param result: either wx.YES or wx.No + """ + if result == wx.YES: + settingsStorage = self._settingsStorage + settingsStorage.warnOnLoad = self.showWarningOnLoadCheckBox.IsChecked() + settingsStorage._saveSpecificSettings(settingsStorage, settingsStorage.supportedSettings) + self.EndModal(result) + + def _onDialogActivated(self, evt): + # focus is normally set to the first child, however, we want people to easily be able to cancel this + # dialog + super()._onDialogActivated(evt) + self.noButton.SetFocus() + + def _onShowEvt(self, evt): + """When no other dialogs have been opened first, focus lands in the wrong place (on the checkbox), + so we correct it after the dialog is opened. + """ + if evt.IsShown(): + self.noButton.SetFocus() + super()._onShowEvt(evt) + + +class ScreenCurtainGuiPanel( + gui.AutoSettingsMixin, + gui.SettingsPanel, +): + + _enabledCheckbox: wx.CheckBox + _enableCheckSizer: wx.BoxSizer + + from gui.settingsDialogs import VisionProviderStateControl + + def __init__( + self, + parent, + providerControl: VisionProviderStateControl + ): + self._providerControl = providerControl + super().__init__(parent) + + def _buildGui(self): + self.mainSizer = wx.BoxSizer(wx.VERTICAL) + + self._enabledCheckbox = wx.CheckBox( + self, + # Translators: option to enable screen curtain in the vision settings panel + label=_("Make screen black (immediate effect)") + ) + isProviderActive = bool(self._providerControl.getProviderInstance()) + self._enabledCheckbox.SetValue(isProviderActive) + + self.mainSizer.Add(self._enabledCheckbox) + self.mainSizer.AddSpacer(size=self.scaleSize(10)) + # this options separator is done with text rather than a group box because a groupbox is too verbose, + # but visually some separation is helpful, since the rest of the options are really sub-settings. + self.optionsText = wx.StaticText( + self, + # Translators: The label for a group box containing the NVDA highlighter options. + label=_("Options:") + ) + self.mainSizer.Add(self.optionsText) + self.lastControl = self.optionsText + self.settingsSizer = wx.BoxSizer(wx.VERTICAL) + self.makeSettings(self.settingsSizer) + self.mainSizer.Add(self.settingsSizer, border=self.scaleSize(15), flag=wx.LEFT | wx.EXPAND) + self.mainSizer.Fit(self) + self.SetSizer(self.mainSizer) + + def getSettings(self) -> ScreenCurtainSettings: + return ScreenCurtainProvider.getSettings() + + def makeSettings(self, sizer: wx.BoxSizer): + self.updateDriverSettings() + self.Bind(wx.EVT_CHECKBOX, self._onCheckEvent) + + def onPanelActivated(self): + self.lastControl = self._enabledCheckbox + + def _onCheckEvent(self, evt: wx.CommandEvent): + if evt.GetEventObject() is self._enabledCheckbox: + self._ensureEnableState(evt.IsChecked()) + + def _ensureEnableState(self, shouldBeEnabled: bool): + currentlyEnabled = bool(self._providerControl.getProviderInstance()) + if shouldBeEnabled and not currentlyEnabled: + confirmed = self.confirmInitWithUser() + if not confirmed or not self._providerControl.startProvider(): + self._enabledCheckbox.SetValue(False) + elif not shouldBeEnabled and currentlyEnabled: + self._providerControl.terminateProvider() + + def confirmInitWithUser(self) -> bool: + settingsStorage = self._getSettingsStorage() + if not settingsStorage.warnOnLoad: + return True + parent = self + with WarnOnLoadDialog( + screenCurtainSettingsStorage=settingsStorage, + parent=parent + ) as dlg: + res = dlg.ShowModal() + # WarnOnLoadDialog can change settings, reload them + self.updateDriverSettings() + return res == wx.YES + + +class ScreenCurtainProvider(providerBase.VisionEnhancementProvider): + _settings = ScreenCurtainSettings() @classmethod def canStart(cls): return winVersion.isFullScreenMagnificationAvailable() + @classmethod + def getSettingsPanelClass(cls) -> Optional[Type]: + """Returns the instance to be used in order to construct a settings panel for the provider. + @return: Optional[SettingsPanel] + @remarks: When None is returned, L{gui.settingsDialogs.VisionProviderSubPanel_Wrapper} is used. + """ + return ScreenCurtainGuiPanel + + @classmethod + def getSettings(cls) -> ScreenCurtainSettings: + return cls._settings + def __init__(self): - super(VisionEnhancementProvider, self).__init__() + super().__init__() + log.debug(f"Starting ScreenCurtain") Magnification.MagInitialize() Magnification.MagSetFullscreenColorEffect(TRANSFORM_BLACK) Magnification.MagShowSystemCursor(False) def terminate(self): + log.debug(f"Terminating ScreenCurtain") try: - super(VisionEnhancementProvider, self).terminate() + super().terminate() finally: Magnification.MagShowSystemCursor(True) Magnification.MagUninitialize() @@ -93,3 +321,6 @@ def terminate(self): def registerEventExtensionPoints(self, extensionPoints): # The screen curtain isn't interested in any events pass + + +VisionEnhancementProvider = ScreenCurtainProvider diff --git a/tests/checkPot.py b/tests/checkPot.py index a84dd41a1d2..60e60012ea0 100644 --- a/tests/checkPot.py +++ b/tests/checkPot.py @@ -81,8 +81,6 @@ 'Insufficient Privileges', 'Synthesizer Error', 'Dictionary Entry Error', - 'Could not load the %s display.', - 'Braille Display Error', 'word', 'Taskbar', '%s items', diff --git a/user_docs/en/userGuide.t2t b/user_docs/en/userGuide.t2t index a2d19f21b27..b7e7fdc4369 100644 --- a/user_docs/en/userGuide.t2t +++ b/user_docs/en/userGuide.t2t @@ -29,8 +29,9 @@ Major highlights include: - Support for modern Windows Operating Systems including both 32 and 64 bit variants - Ability to run on Windows logon and other secure screens - Announcing controls and text while using touch gestures -- Support for common accessibility interfaces such as Microsoft Active Accessibility, Java Access Bridge, IAccessible2 and UI Automation (UI Automation only supported in Windows 7 and later) +- Support for common accessibility interfaces such as Microsoft Active Accessibility, Java Access Bridge, IAccessible2 and UI Automation - Support for Windows Command Prompt and console applications +- The ability to highlight the system focus - ++ Internationalization ++[Internationalization] @@ -292,6 +293,7 @@ For example, if you are typing into an editable text field, the editable text fi The most common way of navigating around Windows with NVDA is to simply move the system focus using standard Windows keyboard commands, such as pressing tab and shift+tab to move forward and back between controls, pressing alt to get to the menu bar and then using the arrows to navigate menus, and using alt+tab to move between running applications. As you do this, NVDA will report information about the object with focus, such as its name, type, value, state, description, keyboard shortcut and positional information. +When [Focus Highlight #VisionFocusHighlight] is enabled, the location of the current system focus is also exposed visually. There are some key commands that are useful when moving with the System focus: %kc:beginInclude @@ -347,6 +349,7 @@ Similarly, a toolbar contains controls, so you must move inside the toolbar to a The object currently being reviewed is called the navigator object. Once you navigate to an object, you can review its content using the [text review commands #ReviewingText] while in [Object review mode #ObjectReview]. +When [Focus Highlight #VisionFocusHighlight] is enabled, the location of the current navigator object is also exposed visually. By default, the navigator object moves along with the System focus, though this behavior can be toggled on and off. Note that braille follows both the [focus #SystemFocus] and [caret #SystemCaret] as well as object navigation and text review by default. @@ -488,10 +491,12 @@ This includes documents in the following applications: - Adobe Flash - Supported books in Amazon Kindle for PC - + Browse mode is also optionally available for Microsoft Word documents. In browse mode, the content of the document is made available in a flat representation that can be navigated with the cursor keys as if it were a normal text document. All of NVDA's [system caret #SystemCaret] key commands will work in this mode; e.g. say all, report formatting, table navigation commands, etc. +When [Focus Highlight #VisionFocusHighlight] is enabled, the location of the virtual browse mode caret is also exposed visually. Information such as whether text is a link, heading, etc. is reported along with the text as you move. Sometimes, you will need to interact directly with controls in these documents. @@ -745,6 +750,33 @@ Dot 8 translates any braille input and presses the enter key. Pressing dot 7 + dot 8 translates any braille input, but without adding a space or pressing enter. %kc:endInclude ++ Vision +[Vision] +While NVDA is primairly aimed at blind or vision impaired people how primarily use speech and/or braille to operate a computer, it also provides built-in facilities to change the contents of the screen. +Within NVDA, such a visual aid is called a vision enhancement provider. + +NVDA offers several built-in vision enhancement providers which are described below. +Additional vision enhancement providers can be provided in [NVDA add-ons #AddonsManager]. + +NVDA's vision settings can be changed in the [vision category #VisionSettings] of the [NVDA Settings #NVDASettings] dialog. + +++ Focus Highlight ++[VisionFocusHighlight] +Focus Highlight can help to identify the [system focus #SystemFocus], [navigator object #ObjectNavigation] and [browse mode #BrowseMode] positions. +These positions are highlighted with a colored rectangle outline. +- Solid blue highlights a combined navigator object and system focus location (e.g. because [the navigator object follows the system focus #ReviewCursorFollowFocus]). +- Dashed blue highlights just the system focus object. +- Solid pink highlights just the navigator object. +- Solid yellow highlights the virtual caret used in browse mode (where there is no physical caret such as in web browsers). +- + +When Focus Highlight is enabled in the [vision category #VisionSettings] of the [NVDA Settings #NVDASettings] dialog, you can [change whether or not to highlight the focus, navigator object or browse mode caret #VisionSettingsFocusHighlight] + +++ Screen Curtain ++[VisionScreenCurtain] +As a blind or vision impaired user, it is often not possible or necessary to see the contents of the screen. +Furthermore, it might be hard to ensure that there isn't someone looking over your shoulder. +For this situation, NVDA contains a feature called "screen curtain" which can be enabled to make the screen black. + +You can enable the Screen Curtain in the [vision category #VisionSettings] of the [NVDA Settings #NVDASettings] dialog. + + Content Recognition +[ContentRecognition] When authors don't provide sufficient information for a screen reader user to determine the content of something, various tools can be used to attempt to recognize the content from an image. NVDA supports the optical character recognition (OCR) functionality built into Windows 10 to recognize text from images. @@ -1060,7 +1092,7 @@ If this is enabled, NVDA will inform you when there is a pending update on start You can also manually install the pending update from the Exit NVDA dialog (if enabled), from the NVDA menu, or when you perform a new check from the Help menu. +++ Speech Settings (NVDA+control+v) +++[SpeechSettings] -The speech category in the NVDA Settings dialog contains options that lets you change the speech synthesizer as well as voice characteristics for the chosen synthesizer. +The Speech category in the NVDA Settings dialog contains options that lets you change the speech synthesizer as well as voice characteristics for the chosen synthesizer. For a quicker alternative way of controlling speech parameters from anywhere, please see the [Synth Settings Ring #SynthSettingsRing] section. The Speech Settings category contains the following options: @@ -1324,6 +1356,41 @@ This option won't be available if your braille display only supports automatic p You may consult the documentation for your braille display in the section [Supported Braille Displays #SupportedBrailleDisplays] to check for more details on the supported types of communication and available ports. ++++ Vision +++[VisionSettings] +The Vision category in the NVDA Settings dialog allows you to enable, disable and configure [visual aids #Vision]. + +Note that the available options in this category could be extended by [NVDA add-ons #AddonsManager]. +By default, this settings category contains the following options: + +==== Focus Highlight ====[VisionSettingsFocusHighlight] +The check boxes in the Focus Highlight grouping control the behavior of NVDA's built-in [Focus Highlight #VisionFocusHighlight] facility. + +- Enable Highlighting: Toggles Focus Highlight on and off. +- Highlight system focus: toggles whether the [system focus #SystemFocus] will be highlighted. +- Highlight navigator object: toggles whether the [navigator object #ObjectNavigation] will be highlighted. +- Highlight browse mode cursor: Toggles whether the [virtual browse mode cursor #BrowseMode] will be highlighted. +- + +Note that checking and unchecking the "Enable Highlighting" check box wil also change the state of the tree other check boxes accordingly. +Therefore, if "Enable Highlighting" is off and you check this check box, the other tree check boxes will also be checked automatically. +If you only want to highlight the focus and leave the navigator object and browse mode check boxes unchecked, the state of the "Enable Highlighting" check box will be half checked. + +==== Screen Curtain ====[VisionSettingsScreenCurtain] +You can enable the [Screen Curtain #VisionScreenCurtain] by checking the "Make screen black (immediate effect)" check box. +A warning that your screen will become black after activation will be displayed. +Before continuing (selecting "Yes"), ensure you have enabled speech / braille and will be able to control your computer without the use of the screen. +Select "No" if you no longer wish to enable the Screen Curtain. +If you are sure, you can choose the Yes button to enable the screen curtain. +If you no longer want to see this warning message every time, you can change this behavior in the dialog that displays the message. +You can always restore the warning by checking the "Always show a warning when loading Screen Curtain" check box next to the "Make screen black" check box. + +To toggle the SCreen Curtain from anywhere, please assign a custom gesture using the [Input Gestures dialog #InputGestures]. + +==== Settings for third party visual aids ====[VisionSettingsThirdPartyVisualAids] +Additional vision enhancement providers can be provided in [NVDA add-ons #AddonsManager]. +When these providers have adjustable settings, they will be shown in this settings category in separate groupings. +For the supported settings per provider, please refer to de documentation for that provider. + +++ Keyboard (NVDA+control+k) +++[KeyboardSettings] The Keyboard category in the NVDA Settings dialog contains options that sets how NVDA behaves as you use and type on your keyboard. This settings category contains the following options: @@ -1685,7 +1752,7 @@ If you wish to distribute custom code to others, you should package it as an NVD ==== Open Developer Scratchpad Directory ====[AdvancedSettingsOpenScratchpadDir] This button opens the directory where you can place custom code while developing it. This button is only enabled if NVDA is configured to enable loading custom code from the Developer Scratchpad Directory. - + ==== Use UI automation to access Microsoft Word document controls when available ====[AdvancedSettingsUseUiaForWord] When this option is enabled, NVDA will try to use the Microsoft UI Automation accessibility api in order to fetch information from Microsoft Word document controls. This includes in Microsoft Word itself, and also the Microsoft Outlook message viewer and composer.