From 20c19ce6a6d9d21e552c03335a2a151667491d8f Mon Sep 17 00:00:00 2001 From: Leonard de Ruijter Date: Fri, 31 Jul 2026 17:58:52 +0200 Subject: [PATCH 1/2] Dispatch browse mode alt+arrow gestures between collapse or expand and sentence navigation alt+upArrow and alt+downArrow now run script_moveBySentence_back/forward, which collapse or expand the focusable control at the caret when it takes these keys itself and navigate by sentence otherwise. Co-Authored-By: Claude Fable 5 --- source/NVDAObjects/UIA/wordDocument.py | 2 +- source/browseMode.py | 51 +++++++++- source/cursorManager.py | 16 +++- tests/unit/test_browseModeSentenceDispatch.py | 92 +++++++++++++++++++ user_docs/en/changes.md | 1 + user_docs/en/userGuide.md | 8 +- 6 files changed, 161 insertions(+), 9 deletions(-) create mode 100644 tests/unit/test_browseModeSentenceDispatch.py diff --git a/source/NVDAObjects/UIA/wordDocument.py b/source/NVDAObjects/UIA/wordDocument.py index 915265f45a8..79fa46ca942 100644 --- a/source/NVDAObjects/UIA/wordDocument.py +++ b/source/NVDAObjects/UIA/wordDocument.py @@ -860,7 +860,7 @@ def _caretMoveBySentenceHelper(self, gesture: inputCore.InputGesture, direction: info = self._moveBySentenceWithObjectModel(direction) else: # Legacy object model not available. - # Translators: a message when navigating by sentence is unavailable in MS Word + # Translators: a message when navigating by sentence is unavailable in the current document ui.message(_("Navigating by sentence not supported in this document")) gesture.send() return diff --git a/source/browseMode.py b/source/browseMode.py index 8ede490556d..708745466e7 100644 --- a/source/browseMode.py +++ b/source/browseMode.py @@ -2053,6 +2053,55 @@ def _collapseOrExpandControl_scriptHelper(self, gesture: inputCore.InputGesture) self.passThrough = False reportPassThrough(self) + _EXPAND_OR_POPUP_STATES = frozenset( + { + controlTypes.State.COLLAPSED, + controlTypes.State.EXPANDED, + controlTypes.State.AUTOCOMPLETE, + controlTypes.State.HASPOPUP, + controlTypes.State.HASPOPUP_DIALOG, + controlTypes.State.HASPOPUP_GRID, + controlTypes.State.HASPOPUP_LIST, + controlTypes.State.HASPOPUP_TREE, + }, + ) + """States indicating that a control consumes alt+upArrow and alt+downArrow itself.""" + + def _isExpandableControlAtCaret(self) -> bool: + """Whether the focusable control at the caret handles alt+upArrow and alt+downArrow itself. + + :return: ``True`` to collapse/expand the control, ``False`` to navigate by sentence. + """ + obj = self.currentFocusableNVDAObject + if obj is None or obj == self.rootNVDAObject: + return False + return obj.role in self.ALWAYS_SWITCH_TO_PASS_THROUGH_ROLES or not obj.states.isdisjoint( + self._EXPAND_OR_POPUP_STATES, + ) + + def getAlternativeScript( + self, + gesture: inputCore.InputGesture, + script: scriptHandler._ScriptFunctionT | None, + ) -> scriptHandler._ScriptFunctionT | None: + """Hand the sentence navigation gestures to the control at the caret when it takes them itself. + + :param gesture: The triggering gesture. + :param script: The script bound to the gesture. + :return: The script to run instead, which may be the one that was passed in. + """ + if ( + not self.passThrough + and script + in ( + self.script_moveBySentence_back, + self.script_moveBySentence_forward, + ) + and self._isExpandableControlAtCaret() + ): + return self.script_collapseOrExpandControl + return super().getAlternativeScript(gesture, script) + def _tabOverride(self, direction): """Override the tab order if the virtual caret is not within the currently focused node. This is done because many nodes are not focusable and it is thus possible for the virtual caret to be unsynchronised with the focus. @@ -2826,8 +2875,6 @@ def _iterTextStyle( return __gestures = { - "kb:alt+upArrow": "collapseOrExpandControl", - "kb:alt+downArrow": "collapseOrExpandControl", "kb:tab": "tab", "kb:shift+tab": "shiftTab", "kb:shift+,": "moveToStartOfContainer", diff --git a/source/cursorManager.py b/source/cursorManager.py index 827cf0d4741..6746460faa7 100644 --- a/source/cursorManager.py +++ b/source/cursorManager.py @@ -446,13 +446,25 @@ def script_moveByLine_forward(self, gesture): script_moveByLine_forward.resumeSayAllMode = sayAll.CURSOR.CARET + def _moveBySentence_scriptHelper(self, gesture: InputGesture, direction: int) -> None: + """Move the caret by sentence, reporting documents whose text info has no sentence support. + + :param gesture: The triggering gesture. + :param direction: 1 to move to the next sentence, -1 to move to the previous one. + """ + try: + self._caretMovementScriptHelper(gesture, textInfos.UNIT_SENTENCE, direction) + except NotImplementedError: + # Translators: a message when navigating by sentence is unavailable in the current document + ui.message(_("Navigating by sentence not supported in this document")) + def script_moveBySentence_back(self, gesture): - self._caretMovementScriptHelper(gesture, textInfos.UNIT_SENTENCE, -1) + self._moveBySentence_scriptHelper(gesture, -1) script_moveBySentence_back.resumeSayAllMode = sayAll.CURSOR.CARET def script_moveBySentence_forward(self, gesture): - self._caretMovementScriptHelper(gesture, textInfos.UNIT_SENTENCE, 1) + self._moveBySentence_scriptHelper(gesture, 1) script_moveBySentence_forward.resumeSayAllMode = sayAll.CURSOR.CARET diff --git a/tests/unit/test_browseModeSentenceDispatch.py b/tests/unit/test_browseModeSentenceDispatch.py new file mode 100644 index 00000000000..aedf4fa90bd --- /dev/null +++ b/tests/unit/test_browseModeSentenceDispatch.py @@ -0,0 +1,92 @@ +# A part of NonVisual Desktop Access (NVDA) +# Copyright (C) 2026 NV Access Limited, Leonard de Ruijter +# This file may be used under the terms of the GNU General Public License, version 2 or later, as modified by the NVDA license. +# For full terms and any additional permissions, see the NVDA license file: https://github.com/nvaccess/nvda/blob/master/copying.txt + +"""Unit tests for browse mode alt+up/down sentence-vs-collapse/expand dispatch. + +Covers ``BrowseModeDocumentTreeInterceptor._isExpandableControlAtCaret``, the discriminator +that decides whether ``alt+upArrow``/``alt+downArrow`` should collapse/expand a control or +navigate by sentence, and ``getAlternativeScript``, which swaps the script accordingly. +""" + +import unittest +from types import SimpleNamespace + +import browseMode +from controlTypes import Role, State + +from .objectProvider import NVDAObjectWithRole + + +class _Interceptor(browseMode.BrowseModeDocumentTreeInterceptor): + """An interceptor carrying only the two objects the discriminator reads. + + ``super().__init__`` is skipped so that no virtual buffer is constructed. + """ + + def __init__(self, focusable: NVDAObjectWithRole | None, root: NVDAObjectWithRole): + self.currentFocusableNVDAObject = focusable + self.rootNVDAObject = root + self._passThrough = False + + +def _obj(role: Role, *states: State) -> NVDAObjectWithRole: + obj = NVDAObjectWithRole(role=role) + obj.states = frozenset(states) + return obj + + +class TestIsExpandableControlAtCaret(unittest.TestCase): + def setUp(self): + self.root = _obj(Role.DOCUMENT) + + def test_dispatch(self): + cases = ( + ("plain content, focusable is the root", self.root, False), + ("no focusable object", None, False), + ("combo box", _obj(Role.COMBOBOX), True), + ("slider", _obj(Role.SLIDER), True), + ("button offering autocompletion", _obj(Role.BUTTON, State.AUTOCOMPLETE), True), + ("collapsed button", _obj(Role.BUTTON, State.COLLAPSED), True), + ("expanded button", _obj(Role.BUTTON, State.EXPANDED), True), + ("button with a popup", _obj(Role.BUTTON, State.HASPOPUP), True), + ("button opening a list", _obj(Role.BUTTON, State.HASPOPUP_LIST), True), + ("button opening a dialog", _obj(Role.BUTTON, State.HASPOPUP_DIALOG), True), + ("button opening a grid", _obj(Role.BUTTON, State.HASPOPUP_GRID), True), + ("button opening a tree", _obj(Role.BUTTON, State.HASPOPUP_TREE), True), + ("plain link", _obj(Role.LINK), False), + ("plain button", _obj(Role.BUTTON), False), + ) + for description, focusable, expected in cases: + with self.subTest(description): + interceptor = _Interceptor(focusable, self.root) + self.assertEqual(interceptor._isExpandableControlAtCaret(), expected) + + +class TestGetAlternativeScript(unittest.TestCase): + def setUp(self): + self.root = _obj(Role.DOCUMENT) + self.gesture = SimpleNamespace(isCharacter=False) + + def test_expandableControl_swapsToCollapseOrExpand(self): + interceptor = _Interceptor(_obj(Role.COMBOBOX), self.root) + for script in ( + interceptor.script_moveBySentence_back, + interceptor.script_moveBySentence_forward, + ): + with self.subTest(script.__name__): + self.assertEqual( + interceptor.getAlternativeScript(self.gesture, script), + interceptor.script_collapseOrExpandControl, + ) + + def test_plainContent_keepsSentenceScript(self): + interceptor = _Interceptor(self.root, self.root) + script = interceptor.script_moveBySentence_forward + self.assertEqual(interceptor.getAlternativeScript(self.gesture, script), script) + + def test_otherScript_isUntouched(self): + interceptor = _Interceptor(_obj(Role.COMBOBOX), self.root) + script = interceptor.script_collapseOrExpandControl + self.assertEqual(interceptor.getAlternativeScript(self.gesture, script), script) diff --git a/user_docs/en/changes.md b/user_docs/en/changes.md index c3db7163c0e..f6acba4108c 100644 --- a/user_docs/en/changes.md +++ b/user_docs/en/changes.md @@ -9,6 +9,7 @@ * A Word Segmentation Standard setting was added to the "Document Navigation" panel. (#18735, @CrazySteve0605, @Cary-rowen) * Word segmentation can also use the Windows built-in ICU library for boundary detection, improving navigation for Japanese and emoji. (#20343, #20494, @LeonarddeR) * By default, ICU is preferred over the legacy Windows segmentation wherever available, while Chinese word segmentation takes precedence for Chinese text. +* Sentence navigation (`alt+upArrow` and `alt+downArrow`) now works in many more situations, such as in most browse mode documents and in several edit controls. (#18901, @LeonarddeR) * Braille output for Chinese now includes spaces between words. (#18865, @CrazySteve0605, @Cary-rowen) * Added sequential two-flick touch gestures that combine two flicks performed in quick succession into a single gesture, increasing the number of touch gestures that can be bound to scripts. (#19938, @kefaslungu) * Twelve combinations are recognised: opposite-direction pairs (e.g. flick right then flick left) and perpendicular L-shaped pairs (e.g. flick right then flick up). diff --git a/user_docs/en/userGuide.md b/user_docs/en/userGuide.md index bd839bce0f2..d5612bcc4d3 100644 --- a/user_docs/en/userGuide.md +++ b/user_docs/en/userGuide.md @@ -788,8 +788,8 @@ NVDA provides the following key commands in relation to the system caret: |Report language |none |none |Reports text language. Pressing twice shows the information in a window| |Report link destination |`NVDA+k` |`NVDA+k` |Pressing once speaks the destination URL of the link at the current caret or focus position. Pressing twice shows it in a window for more careful review| |Report caret location |NVDA+numpadDelete |NVDA+delete |Reports information about the location of the text or object at the position of system caret. For example, this might include the percentage through the document, the distance from the edge of the page or the exact screen position. Pressing twice may provide further detail.| -|Next sentence |alt+downArrow |alt+downArrow |Moves the caret to the next sentence and announces it. (only supported in Microsoft Word and Outlook)| -|Previous sentence |alt+upArrow |alt+upArrow |Moves the caret to the previous sentence and announces it. (only supported in Microsoft Word and Outlook)| +|Next sentence |alt+downArrow |alt+downArrow |Moves the caret to the next sentence and announces it. (some documents are not supported)| +|Previous sentence |alt+upArrow |alt+upArrow |Moves the caret to the previous sentence and announces it. (some documents are not supported)| When within a table, the following key commands are also available: @@ -2320,10 +2320,10 @@ To toggle Unicode normalization from anywhere, please assign a custom gesture us This combo box lets you choose the unit of text that say all (continuous reading) advances by. This affects how frequently the text caret and view move during say all, and can also affect intonation in long blocks of text. When set to "Sentence where possible", NVDA reads sentence by sentence in controls and documents that support sentence boundaries, and automatically falls back to reading by line where sentence boundaries are not supported. -Sentence boundaries are supported in Microsoft Word and Outlook, in Rich Edit controls, and in browse mode documents such as web pages. +Sentence boundaries are supported in Microsoft Word and Outlook, in several edit controls, and in most browse mode documents such as web pages. When set to "Paragraph", NVDA reads paragraph by paragraph. When set to "Line", NVDA always reads line by line. -For example, when set to "Line", say all reads by line in Rich Edit controls such as WordPad or NVDA's log viewer, whereas the default reads these by sentence. +For example, when set to "Line", say all reads by line in Rich Edit controls such as NVDA's log viewer, whereas the default reads these by sentence. | . {.hideHeaderRow} |.| |---|---| From 1988fe20a0cece76df13e12296e0254be877bba9 Mon Sep 17 00:00:00 2001 From: Sean Budd Date: Fri, 28 Aug 2026 13:55:34 +1000 Subject: [PATCH 2/2] Update user_docs/en/userGuide.md --- user_docs/en/userGuide.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/user_docs/en/userGuide.md b/user_docs/en/userGuide.md index d5612bcc4d3..c06a860f6f2 100644 --- a/user_docs/en/userGuide.md +++ b/user_docs/en/userGuide.md @@ -788,8 +788,8 @@ NVDA provides the following key commands in relation to the system caret: |Report language |none |none |Reports text language. Pressing twice shows the information in a window| |Report link destination |`NVDA+k` |`NVDA+k` |Pressing once speaks the destination URL of the link at the current caret or focus position. Pressing twice shows it in a window for more careful review| |Report caret location |NVDA+numpadDelete |NVDA+delete |Reports information about the location of the text or object at the position of system caret. For example, this might include the percentage through the document, the distance from the edge of the page or the exact screen position. Pressing twice may provide further detail.| -|Next sentence |alt+downArrow |alt+downArrow |Moves the caret to the next sentence and announces it. (some documents are not supported)| -|Previous sentence |alt+upArrow |alt+upArrow |Moves the caret to the previous sentence and announces it. (some documents are not supported)| +| Next sentence | `alt+downArrow` | `alt+downArrow` | Moves the caret to the next sentence and announces it. Some documents are not supported | +| Previous sentence | `alt+upArrow` | `alt+upArrow` | Moves the caret to the previous sentence and announces it. Some documents are not supported | When within a table, the following key commands are also available: