diff --git a/developerGuide.t2t b/developerGuide.t2t index 29cedd1374e..a8c455188f9 100644 --- a/developerGuide.t2t +++ b/developerGuide.t2t @@ -257,18 +257,16 @@ From anywhere, you can now press NVDA+shift+v to have NVDA's version spoken and # Developer guide example 2 import globalPluginHandler +from scriptHandler import script import ui import versionInfo class GlobalPlugin(globalPluginHandler.GlobalPlugin): + @scriptHandler.script(gesture="kb:NVDA+shift+v") def script_announceNVDAVersion(self, gesture): ui.message(versionInfo.version) - __gestures={ - "kb:NVDA+shift+v": "announceNVDAVersion", - } - --- end --- ``` @@ -276,14 +274,16 @@ This Global Plugin file starts with two comment lines, which describe what the f It then imports the globalPluginHandler module, so that the Global Plugin has access to the base GlobalPlugin class. -It also imports a few other modules, namely ui and versionInfo, which this specific plugin needs in order for it to perform the necessary actions to announce the version. +It also imports a few other modules, namely ui, versionInfo and scriptHandler, which this specific plugin needs in order for it to perform the necessary actions to announce the version. Next, it defines a class called GlobalPlugin, which is inherited from globalPluginHandler.GlobalPlugin. Inside this class, it defines 1 or more events, scripts or gesture bindings. -In this example, it defines a script method that performs the version announcement, and provides a binding from NVDA+shift+v to this script. +In this example, it defines a script method that performs the version announcement. +The script decorator from the scriptHandler module is used to assign the NVDA+shift+v shortcut to this script. However, the details of the script and its binding are not important for the purposes of this example. The most important part is the class itself. +More information about scripts and the script decorator can be found in the [Defining script properties #DefiningScriptProperties] section of this guide. As with other examples in this guide, remember to delete the created Global Plugin when finished testing and then restart NVDA or reload plugins, so that original functionality is restored. @@ -349,17 +349,13 @@ A script method takes two arguments: As well as the actual script method, some form of gesture binding must be defined, so that NVDA knows what input should execute the script. -To bind a gesture to a script, a special "__gestures" Python dictionary can be defined as a class variable on the App Module, Global Plugin or NVDA Object. -These dictionaries should contain gesture identifier strings pointing to the name of the requested script, without the "script_" prefix. - -There are more advanced ways of binding gestures in a more dynamic fashion, though the __gestures dictionary is the simplest. - A gesture identifier string is a simple string representation of a piece of input. -It consists of a two leter character code denoting the source of the input, an optional device in brackets, a colon (:) and one or more names separated by a plus (+) denoting the actual keys or input values. +It consists of a two letter character code denoting the source of the input, an optional device in brackets, a colon (:) and one or more names separated by a plus (+) denoting the actual keys or input values. Some examples of gesture string identifiers are: - "kb:NVDA+shift+v" - "br(freedomScientific):leftWizWheelUp" +- "br(alva.BC640):t3" - "kb(laptop):NVDA+t" - @@ -374,6 +370,9 @@ When NVDA receives input, it looks for a matching gesture binding in a particula Once a gesture binding is found, the script is executed and no further bindings are used, nore is that particular gesture passed on automatically to the Operating System. The order for gesture binding lookup is: +- The user specific gesture map +- The locale specific gesture map +- The braille display driver specific gesture map - Loaded Global Plugins - App Module of the active application - Tree Interceptor of the NVDA Object with focus if any; e.g. a virtualBuffer @@ -381,18 +380,60 @@ The order for gesture binding lookup is: - Global Commands (built in commands like quitting NVDA, object navigation commands, etc.) - -You should specify a description of the script in the function's docstring which describes the command for users. -For example, this is reported to users when in Input Help mode and shown in the Input Gestures dialog. -You specify the docstring by setting a "__doc__" attribute on the script function. -The script will not appear in the Input Gestures dialog unless this is specified. ++++ Defining script properties +++[DefiningScriptProperties] +For NVDA 2018.3 and above, the recommended way to set script properties is by means of the so called script decorator. +In short, a decorator is a function that modifies the behavior of a particular function. +The script decorator modifies the script in such a way that it will be properly bound to the desired gestures. +Furthermore, it ensures that the script is listed with the description you specify, and that it is categorised under the desired category in the input gestures dialog. -You can also specify a category for a script so that it can be grouped with other similar scripts. -For example, a script in a global plugin which adds browse mode quick navigation keys may be categorized under the "Browse mode" category. -For individual scripts, this is done by setting a "category" attribute on the script function to a string containing the name of the category. -You can also set the "scriptCategory" attribute on the plugin class, which will be used for scripts which do not specify a category. -There are constants for common categories prefixed with SCRCAT_ in the inputCore and globalCommands modules. -The script will be listed under the specified category in the Input Gestures dialog. -If no category is specified, the script will be categorized under "Miscellaneous". +In order for you to use the script decorator, you will have to import it from the scriptHandler module. +``` +from scriptHandler import script +``` + +After that, just above your script definition, add the script decorator, providing it the desired arguments. +For example: +``` +--- start --- + @script( + description=_("Speaks the date and time"), + category=inputCore.SCRCAT_MISC, + gestures=["kb:NVDA+shift+t", "kb:NVDA+alt+r"] + ) + def script_sayDateTime(self, gesture): + +--- end --- +``` + +In this example, your script will be listed in the input gestures dialog under the "Miscellaneous" category. +It will have the description "Speaks the date and time", and will be bound to the "NVDA+shift+t" and "NVDA+alt+r" key combinations on the keyboard. + +The following keyword arguments can be used when applying the script decorator: +- description: A short, translatable string which describes the command for users. + This is reported to users when in Input Help mode and shown in the input gestures dialog. + The script will not appear in the Input Gestures dialog unless you specify a description. +- category: The category of the script in order for it to be grouped with other similar scripts. + For example, a script in a global plugin which adds browse mode quick navigation keys may be categorized under the "Browse mode" category. + The category can be set for individual scripts, but you can also set the "scriptCategory" attribute on the plugin class, which will be used for scripts which do not specify a category. + There are constants for common categories prefixed with SCRCAT_ in the inputCore and globalCommands modules, which can also be specified. + The script will be listed under the specified category in the Input Gestures dialog. + If no category is specified, the script will be categorized under "Miscellaneous". +- gesture: A string containing a single gesture associated with this script, e.g. "kb:NVDA+shift+r". +- gestures: A string list of multiple gestures associated with this script, e.g. ["kb:NVDA+shift+r", "kb:NVDA+alt+t"]. + When both gesture and gestures are specified, they are combined. + Either gesture, or any item in gestures can be used to trigger the script. +- canPropagate: A boolean indicating whether this script should also apply when it belongs to a focus ancestor object. + For example, this can be used when you want to specify a script on a particular foreground object, or another object in the focus ancestry which is not the current focus object. + This option defaults to False. +- bypassInputHelp: A boolean indicating whether this script should run when input help is active. + This option defaults to False. +- + +Though the script decorator makes the script definition process a lot easier, there are more ways of binding gestures and setting script properties. +For example, a special "__gestures" Python dictionary can be defined as a class variable on an App Module, Global Plugin or NVDA Object. +This dictionary should contain gesture identifier strings pointing to the name of the requested script, without the "script_" prefix. +You can also specify a description of the script in the function's docstring. +Furthermore, an alternative way of specifying the script's category is by means of setting a "category" attribute on the script function to a string containing the name of the category. ++ Example 3: A Global Plugin to Find out Window Class and Control ID ++ The following Global Plugin allows you to press NVDA+leftArrow to have the window class of the current focus announced, and NVDA+rightArrow to have the window control ID of the current focus announced. @@ -409,28 +450,32 @@ Once saved in the right place, either restart NVDA or choose Reload Plugins foun #Developer guide example 3 import globalPluginHandler +from scriptHandler import script import ui import api class GlobalPlugin(globalPluginHandler.GlobalPlugin): + @script( + description=_("Announces the window class name of the current focus object"), + gesture="kb:NVDA+leftArrow" + ) def script_announceWindowClassName(self, gesture): focusObj = api.getFocusObject() name = focusObj.name windowClassName = focusObj.windowClassName ui.message("class for %s window: %s" % (name, windowClassName)) + @script( + description=_("Announces the window control ID of the current focus object"), + gesture="kb:NVDA+rightArrow" + ) def script_announceWindowControlID(self, gesture): focusObj = api.getFocusObject() name = focusObj.name windowControlID = focusObj.windowControlID ui.message("Control ID for %s window: %d" % (name, windowControlID)) - __gestures = { - "kb:NVDA+leftArrow": "announceWindowClassName", - "kb:NVDA+rightArrow": "announceWindowControlID", - } - --- end --- ``` @@ -538,6 +583,7 @@ The following code can be copied and pasted in to a text file, then saved in the ``` --- start --- import appModuleHandler +from scriptHandler import script from NVDAObjects.IAccessible import IAccessible import controlTypes import ui @@ -550,13 +596,10 @@ class AppModule(appModuleHandler.AppModule): class EnhancedEditField(IAccessible): + @script(gesture="kb:NVDA+l") def script_reportLength(self, gesture): ui.message("%d" % len(self.value)) - __gestures = { - "kb:NVDA+l": "reportLength", - } - --- end --- ``` diff --git a/source/NVDAObjects/window/excel.py b/source/NVDAObjects/window/excel.py index 07220388f80..87ef5ec0db3 100755 --- a/source/NVDAObjects/window/excel.py +++ b/source/NVDAObjects/window/excel.py @@ -857,8 +857,6 @@ def __init__(self,windowHandle=None,excelWindowObject=None,excelWorksheetObject= self.excelWindowObject=excelWindowObject self.excelWorksheetObject=excelWorksheetObject super(ExcelWorksheet,self).__init__(windowHandle=windowHandle) - for gesture in self.__changeSelectionGestures: - self.bindGesture(gesture, "changeSelection") def _get_name(self): return self.excelWorksheetObject.name @@ -878,32 +876,7 @@ def _get_states(self): states.add(controlTypes.STATE_PROTECTED) return states - def script_changeSelection(self,gesture): - oldSelection=api.getFocusObject() - gesture.send() - import eventHandler - import time - newSelection=None - curTime=startTime=time.time() - while (curTime-startTime)<=0.15: - if scriptHandler.isScriptWaiting(): - # Prevent lag if keys are pressed rapidly - return - if eventHandler.isPendingEvents('gainFocus'): - return - newSelection=self._getSelection() - if newSelection and newSelection!=oldSelection: - break - api.processPendingEvents(processEventQueue=False) - time.sleep(0.015) - curTime=time.time() - if newSelection: - if oldSelection.parent==newSelection.parent: - newSelection.parent=oldSelection.parent - eventHandler.executeEvent('gainFocus',newSelection) - script_changeSelection.canPropagate=True - - __changeSelectionGestures = ( + @scriptHandler.script(gestures=( "kb:tab", "kb:shift+tab", "kb:enter", @@ -948,7 +921,30 @@ def script_changeSelection(self,gesture): "kb:control+a", "kb:control+v", "kb:shift+f11", - ) + ), canPropagate=True) + def script_changeSelection(self,gesture): + oldSelection=api.getFocusObject() + gesture.send() + import eventHandler + import time + newSelection=None + curTime=startTime=time.time() + while (curTime-startTime)<=0.15: + if scriptHandler.isScriptWaiting(): + # Prevent lag if keys are pressed rapidly + return + if eventHandler.isPendingEvents('gainFocus'): + return + newSelection=self._getSelection() + if newSelection and newSelection!=oldSelection: + break + api.processPendingEvents(processEventQueue=False) + time.sleep(0.015) + curTime=time.time() + if newSelection: + if oldSelection.parent==newSelection.parent: + newSelection.parent=oldSelection.parent + eventHandler.executeEvent('gainFocus',newSelection) class ExcelCellTextInfo(NVDAObjectTextInfo): diff --git a/source/baseObject.py b/source/baseObject.py index e16669979b7..0889ac06611 100755 --- a/source/baseObject.py +++ b/source/baseObject.py @@ -1,6 +1,6 @@ #baseObject.py #A part of NonVisual Desktop Access (NVDA) -#Copyright (C) 2007-2017 NV Access Limited +#Copyright (C) 2007-2018 NV Access Limited, Christopher Toth, Babbage B.V. #This file is covered by the GNU General Public License. #See the file COPYING for more details. @@ -128,6 +128,23 @@ def invalidateCaches(cls): for instance in cls.__instances.keys(): instance.invalidateCache() +class ScriptableType(AutoPropertyType): + """A metaclass used for collecting and caching gestures on a ScriptableObject""" + + def __new__(meta, name, bases, dict): + cls = super(ScriptableType, meta).__new__(meta, name, bases, dict) + gestures = getattr(cls, "_%s__gestures" % cls.__name__, {}) + # Python 3 incompatible. + for name, script in dict.iteritems(): + if not name.startswith('script_'): + continue + scriptName = name[len("script_"):] + if hasattr(script, 'gestures'): + for gesture in script.gestures: + gestures[gesture] = scriptName + setattr(cls, "_%s__gestures" % cls.__name__, gestures) + return cls + class ScriptableObject(AutoPropertyObject): """A class that implements NVDA's scripting interface. Input gestures are bound to scripts such that the script will be executed when the appropriate input gesture is received. @@ -143,16 +160,24 @@ class ScriptableObject(AutoPropertyObject): @type scriptCategory: basestring """ + __metaclass__ = ScriptableType + def __init__(self): #: Maps input gestures to script functions. #: @type: dict self._gestureMap = {} # Bind gestures specified on the class. + # This includes gestures specified on decorated scripts. + # This does not include the gestures that are added when creating a DynamicNVDAObjectType. for cls in reversed(self.__class__.__mro__): try: self.bindGestures(getattr(cls, "_%s__gestures" % cls.__name__)) except AttributeError: pass + try: + self.bindGestures(cls._scriptDecoratorGestures) + except AttributeError: + pass super(ScriptableObject, self).__init__() def bindGesture(self, gestureIdentifier, scriptName): diff --git a/source/scriptHandler.py b/source/scriptHandler.py index db0acbfef5b..445cfd75f1c 100644 --- a/source/scriptHandler.py +++ b/source/scriptHandler.py @@ -231,3 +231,62 @@ def isCurrentScript(scriptFunc): return False return givenFunc==realFunc +def script( + description="", + category=None, + gesture=None, + gestures=None, + canPropagate=False, + bypassInputHelp=False, + resumeSayAllMode=None +): + """Define metadata for a script. + This function is to be used as a decorator to set metadata used by the scripting system and gesture editor. + It can only decorate methods which name start swith "script_" + @param description: A short translatable description of the script to be used in the gesture editor, etc. + @type description: string + @param category: The category of the script displayed in the gesture editor. + @type category: string + @param gesture: A gesture associated with this script. + @type gesture: string + @param gestures: A list of gestures associated with this script + @type gestures: list(string) + @param canPropagate: Whether this script should also apply when it belongs to a focus ancestor object. + @type canPropagate: bool + @param bypassInputHelp: Whether this script should run when input help is active. + @type bypassInputHelp: bool + @param resumeSayAllMode: The say all mode that should be resumed when active before executing this script. + One of the C{sayAllHandler.CURSOR_*} constants. + @type resumeSayAllMode: int + """ + if gestures is None: + gestures = [] + def script_decorator(decoratedScript): + # Scripts are unbound instance methods in python 2 and functions in python 3. + # Therefore, we use inspect.isroutine to check whether a script is either a function or instance method. + if not inspect.isroutine(decoratedScript): + log.warning( + "Using the script decorator is unsupported for %r" % decoratedScript, + stack_info=True + ) + return decoratedScript + if not decoratedScript.__name__.startswith("script_"): + log.warning( + "Can't apply script decorator to %r which name does not start with 'script_'" % decoratedScript.__name__, + stack_info=True + ) + return decoratedScript + decoratedScript.__doc__ = description + if category is not None: + decoratedScript.category = category + if gesture is not None: + gestures.append(gesture) + if gestures: + decoratedScript.gestures = gestures + decoratedScript.canPropagate = canPropagate + decoratedScript.bypassInputHelp = bypassInputHelp + if resumeSayAllMode is not None: + decoratedScript.resumeSayAllMode = resumeSayAllMode + return decoratedScript + return script_decorator + diff --git a/tests/unit/test_baseObject.py b/tests/unit/test_baseObject.py new file mode 100644 index 00000000000..3fda3cc0af3 --- /dev/null +++ b/tests/unit/test_baseObject.py @@ -0,0 +1,111 @@ +#tests/unit/test_baseObject.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) 2018 NV Access Limited, Babbage B.V. + +"""Unit tests for the baseObject module, its classes and their derivatives.""" + +import unittest +from baseObject import ScriptableObject +from objectProvider import PlaceholderNVDAObject +from scriptHandler import script + +class NVDAObjectWithDecoratedScript(PlaceholderNVDAObject): + """An object with a decorated script.""" + + @script(gestures=["kb:a"]) + def script_alpha(self, gesture): + return + +class NVDAObjectWithGesturesDictionary(PlaceholderNVDAObject): + """An object with a script that is bound to a gesture in a L{__gestures} dictionary.""" + + def script_bravo(self, gesture): + return + + __gestures = { + "kb:b": "bravo" + } + +class NVDAObjectWithDecoratedScriptAndGesturesDictionary(PlaceholderNVDAObject): + """An object with a decorated script + and a script that is bound to a gesture in a L{__gestures} dictionary. + """ + + @script(gestures=["kb:c"]) + def script_charlie(self, gesture): + return + + def script_delta(self, gesture): + return + + __gestures = { + "kb:d": "delta", + } + +class SubclassedNVDAObjectWithDecoratedScriptAndGesturesDictionary( + NVDAObjectWithDecoratedScript, + NVDAObjectWithGesturesDictionary, + NVDAObjectWithDecoratedScriptAndGesturesDictionary +): + """An object with decorated scripts and L{__gestures} dictionaries, based on subclassing.""" + + @script(gestures=["kb:e"]) + def script_echo(self, gesture): + return + + def script_foxtrot(self, gesture): + return + + __gestures = { + "kb:f": "foxtrot", + } + +class DynamicNVDAObjectWithDecoratedScriptAndGesturesDictionary(PlaceholderNVDAObject): + """An object with decorated scripts and L{__gestures} dictionaries, + using the chooseOverlayClasses logic to construct a dynamic object.""" + + def findOverlayClasses(self, clsList): + clsList.extend([ + NVDAObjectWithDecoratedScript, + NVDAObjectWithGesturesDictionary, + NVDAObjectWithDecoratedScriptAndGesturesDictionary + ]) + + @script(gestures=["kb:g"]) + def script_golf(self, gesture): + return + + def script_hotel(self, gesture): + return + + __gestures = { + "kb:h": "hotel", + } + +class TestScriptableObject(unittest.TestCase): + """A test that verifies whether scripts are properly bound to associated gestures.""" + + def test_decoratedScript(self): + obj = NVDAObjectWithDecoratedScript() + self.assertIn("kb:a", obj._gestureMap) + + def test_gesturesDictionary(self): + obj = NVDAObjectWithGesturesDictionary() + self.assertIn("kb:b", obj._gestureMap) + + def test_decoratedScriptAndGesturesDictionary(self): + obj = NVDAObjectWithDecoratedScriptAndGesturesDictionary() + self.assertIn("kb:c", obj._gestureMap) + self.assertIn("kb:d", obj._gestureMap) + + def test_decoratedScriptsAndGestureDictionariesIfSubclassed(self): + obj = SubclassedNVDAObjectWithDecoratedScriptAndGesturesDictionary() + for key in ("a", "b", "c", "d", "e", "f"): + self.assertIn("kb:%s" % key, obj._gestureMap) + + def test_decoratedScriptsAndGestureDictionariesIfDynamic(self): + obj = DynamicNVDAObjectWithDecoratedScriptAndGesturesDictionary() + for key in ("a", "b", "c", "d", "g", "h"): + self.assertIn("kb:%s" % key, obj._gestureMap) diff --git a/tests/unit/test_scriptHandler.py b/tests/unit/test_scriptHandler.py new file mode 100644 index 00000000000..72ddbb31c8c --- /dev/null +++ b/tests/unit/test_scriptHandler.py @@ -0,0 +1,35 @@ +#tests/unit/test_scriptHandler.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) 2018 NV Access Limited, Babbage B.V. + +"""Unit tests for the scriptHandler module.""" + +import unittest +from scriptHandler import * +from inputCore import SCRCAT_MISC +from sayAllHandler import CURSOR_CARET + +class TestScriptDecorator(unittest.TestCase): + """A test that verifies the functionality of the L{scriptHandler.script} decorator.""" + + def test_scriptdecoration(self): + @script( + description="description", + category=SCRCAT_MISC, + gesture="kb:a", + gestures=["kb:b", "kb:c"], + canPropagate=True, + bypassInputHelp=True, + resumeSayAllMode=CURSOR_CARET + ) + def script_test(self, gesture): + return + + self.assertEqual(script_test.__doc__, "description") + self.assertEqual(script_test.category, SCRCAT_MISC) + self.assertItemsEqual(script_test.gestures, ["kb:a", "kb:b", "kb:c"]) + self.assertTrue(script_test.canPropagate) + self.assertTrue(script_test.bypassInputHelp) + self.assertEqual(script_test.resumeSayAllMode, CURSOR_CARET)