From 6c711fdb70548fbfb641a6322d8581a8764d0937 Mon Sep 17 00:00:00 2001 From: Christopher Toth Date: Tue, 3 Dec 2024 21:00:37 -0500 Subject: [PATCH] Add new extension points for keyboard input and speech pause (#17428) This commit introduces two new extension points: inputCore.decide_handleRawKey Called before any NVDA processing of raw keyboard events Allows add-ons to intercept and optionally block keyboard events Provides full context: vkCode, scanCode, extended flag, and press/release state Implemented in both keyDown and keyUp event handlers Returns True to allow normal processing, False to block the key speech.extensions.speechPaused Notifies when speech is paused or resumed Provides boolean 'switch' parameter (True=paused, False=resumed) Added corresponding unit tests to verify functionality Integrated into existing pauseSpeech() function Technical Details: Added documentation for both extension points in developerGuide.md Updated speech/init.py to expose the new speechPaused extension point Added test case in test_speech.py to verify extension point behavior Maintains backwards compatibility with existing extensions These additions enable add-ons to: Implement advanced keyboard interception/modification React to speech pause state changes Link to issue number: N/A - New feature addition for extensibility Summary of the issue: NVDA needed additional extension points to allow add-ons to: Intercept and control raw keyboard events before NVDA processing Monitor and react to speech pause state changes Description of user facing changes No direct user-facing changes Enables add-on developers to create more sophisticated keyboard handling and speech feedback features All changes are API-level additions that maintain backwards compatibility Description of development approach Raw Keyboard Extension Point: Added decide_handleRawKey extension point in inputCore.py Integrated into both keyDown and keyUp event handlers in keyboardHandler.py Full keyboard event context provided (vkCode, scanCode, extended, pressed) Boolean return value controls event propagation Speech Pause Extension Point: Added speechPaused extension point in speech/extensions.py Integrated into existing pauseSpeech() function Provides pause state through boolean parameter Documentation: Added entries in developerGuide.md Updated relevant module documentation Added changelog entry --- projectDocs/dev/developerGuide/developerGuide.md | 2 ++ source/inputCore.py | 16 ++++++++++++++++ source/keyboardHandler.py | 14 ++++++++++++++ source/speech/__init__.py | 3 ++- source/speech/extensions.py | 8 ++++++++ source/speech/speech.py | 3 ++- tests/unit/test_speech.py | 9 +++++++++ user_docs/en/changes.md | 4 +++- 8 files changed, 56 insertions(+), 3 deletions(-) diff --git a/projectDocs/dev/developerGuide/developerGuide.md b/projectDocs/dev/developerGuide/developerGuide.md index 918f902bf63..b471171e2df 100644 --- a/projectDocs/dev/developerGuide/developerGuide.md +++ b/projectDocs/dev/developerGuide/developerGuide.md @@ -1361,6 +1361,7 @@ For examples of how to define and use new extension points, please see the code | Type |Extension Point |Description| |---|---|---| +|`Decider` |`decide_handleRawKey` |Notifies when a raw keyboard event is received, before any NVDA processing, allowing other code to decide if it should be handled.| |`Decider` |`decide_executeGesture` |Notifies when a gesture is about to be executed, allowing other code to decide if it should be.| ### logHandler {#logHandlerExtPts} @@ -1382,6 +1383,7 @@ For examples of how to define and use new extension points, please see the code |`Action` |`speechCanceled` |Triggered when speech is canceled.| |`Action` |`pre_speechCanceled` |Triggered before speech is canceled.| |`Action` |`pre_speech` |Triggered before NVDA handles prepared speech.| +|`Action` |`post_speechPaused` |Triggered when speech is paused or resumed.| |`Filter` |`filter_speechSequence` |Allows components or add-ons to filter speech sequence before it passes to the synth driver.| ### synthDriverHandler {#synthDriverHandlerExtPts} diff --git a/source/inputCore.py b/source/inputCore.py index 57f1814916f..54a638c739f 100644 --- a/source/inputCore.py +++ b/source/inputCore.py @@ -452,6 +452,22 @@ def __eq__(self, other: Any) -> bool: return NotImplemented +decide_handleRawKey = extensionPoints.Decider() +""" +Notifies when a raw keyboard event is received, before any NVDA processing. +Handlers can decide whether the key should be processed by NVDA and/or passed to the OS. +:param vkCode: The virtual key code +:type vkCode: int +:param scanCode: The scan code +:type scanCode: int +:param extended: Whether this is an extended key +:type extended: bool +:param pressed: Whether this is a key press or release +:type pressed: bool +:return: True to allow normal processing, False to block the key +:rtype: bool +""" + decide_executeGesture = extensionPoints.Decider() """ Notifies when a gesture is about to be executed, diff --git a/source/keyboardHandler.py b/source/keyboardHandler.py index e70879acb71..d06ddcf08c8 100644 --- a/source/keyboardHandler.py +++ b/source/keyboardHandler.py @@ -165,6 +165,13 @@ def shouldUseToUnicodeEx(focus: Optional["NVDAObject"] = None): def internal_keyDownEvent(vkCode, scanCode, extended, injected): """Event called by winInputHook when it receives a keyDown.""" + if not inputCore.decide_handleRawKey.decide( + vkCode=vkCode, + scanCode=scanCode, + extended=extended, + pressed=True, + ): + return False gestureExecuted = False try: global \ @@ -313,6 +320,13 @@ def internal_keyDownEvent(vkCode, scanCode, extended, injected): def internal_keyUpEvent(vkCode, scanCode, extended, injected): """Event called by winInputHook when it receives a keyUp.""" + if not inputCore.decide_handleRawKey.decide( + vkCode=vkCode, + scanCode=scanCode, + extended=extended, + pressed=False, + ): + return False try: global \ lastNVDAModifier, \ diff --git a/source/speech/__init__.py b/source/speech/__init__.py index 61ea0d35734..7090bd8ab50 100644 --- a/source/speech/__init__.py +++ b/source/speech/__init__.py @@ -63,7 +63,7 @@ spellTextInfo, splitTextIndentation, ) -from .extensions import speechCanceled +from .extensions import speechCanceled, post_speechPaused from .priorities import Spri from .types import ( @@ -142,6 +142,7 @@ "spellTextInfo", "splitTextIndentation", "speechCanceled", + "post_speechPaused", ] import synthDriverHandler diff --git a/source/speech/extensions.py b/source/speech/extensions.py index 13b2e42c4f2..ae74f3115a1 100644 --- a/source/speech/extensions.py +++ b/source/speech/extensions.py @@ -22,6 +22,14 @@ Handlers are called without arguments. """ +post_speechPaused = Action() +""" +Notifies when speech is paused. + +:param switch: True if speech is paused, False if speech is resumed. +:type switch: bool +""" + pre_speech = Action() """ Notifies when code attempts to speak text. diff --git a/source/speech/speech.py b/source/speech/speech.py index 32d3fd3cc06..49992c0b03f 100644 --- a/source/speech/speech.py +++ b/source/speech/speech.py @@ -27,7 +27,7 @@ from textUtils import unicodeNormalize from textUtils.uniscribe import splitAtCharacterBoundaries from . import manager -from .extensions import speechCanceled, pre_speechCanceled, pre_speech +from .extensions import speechCanceled, post_speechPaused, pre_speechCanceled, pre_speech from .extensions import filter_speechSequence from .commands import ( # Commands that are used in this file. @@ -211,6 +211,7 @@ def cancelSpeech(): def pauseSpeech(switch): getSynth().pause(switch) + post_speechPaused.notify(switch=switch) _speechState.isPaused = switch _speechState.beenCanceled = False diff --git a/tests/unit/test_speech.py b/tests/unit/test_speech.py index e91f4d3b12d..a50557f1c4e 100644 --- a/tests/unit/test_speech.py +++ b/tests/unit/test_speech.py @@ -16,7 +16,9 @@ _getSpellingSpeechAddCharMode, _getSpellingSpeechWithoutCharMode, cancelSpeech, + pauseSpeech, speechCanceled, + post_speechPaused, ) from speech.commands import ( BeepCommand, @@ -591,3 +593,10 @@ def test_speechCanceledExtensionPoint(self): speechCanceled, ): cancelSpeech() + + def test_post_speechPausedExtensionPoint(self): + with actionTester(self, post_speechPaused, switch=True): + pauseSpeech(True) + + with actionTester(self, post_speechPaused, switch=False): + pauseSpeech(False) diff --git a/user_docs/en/changes.md b/user_docs/en/changes.md index 91db2ba0bc2..87b5c38bcc3 100644 --- a/user_docs/en/changes.md +++ b/user_docs/en/changes.md @@ -113,7 +113,9 @@ Add-ons will need to be re-tested and have their manifest updated. * Retrieving the `labeledBy` property now works for: * objects in applications implementing the `labelled-by` IAccessible2 relation. (#17436, @michaelweghorn) * UIA elements supporting the corresponding `LabeledBy` UIA property. (#17442, @michaelweghorn) - +* Added the following extension points (#17428, @ctoth): + * `inputCore.decide_handleRawKey`: called on each keypress + * `speech.extensions.post_speechPaused`: called when speech is paused or unpaused #### API Breaking Changes