diff --git a/source/NVDAObjects/UIA/wordDocument.py b/source/NVDAObjects/UIA/wordDocument.py index 75579a22a86..9f7a3deb94a 100644 --- a/source/NVDAObjects/UIA/wordDocument.py +++ b/source/NVDAObjects/UIA/wordDocument.py @@ -856,7 +856,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 fa1466997d2..e3ef5bbd30f 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. @@ -2825,8 +2874,6 @@ def _iterTextStyle( return __gestures = { # noqa: RUF012 - "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 9079e66f685..54750f0232b 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 21db115e781..1b3ede8ff69 100644 --- a/user_docs/en/changes.md +++ b/user_docs/en/changes.md @@ -15,6 +15,7 @@ * A new "Say all reads by" speech setting lets you choose whether say all reads by sentence, paragraph or line; say all now reads by sentence by default where supported. (#13420, #9179, #13971, @LeonarddeR) * A new command, assigned to `NVDA+control+x`, copies the last spoken information to the clipboard. (#19385, @Cary-rowen) * The duration of indentation beeps can now be configured via a new "Indent tone duration (ms)" spin control in the Document Formatting settings panel. (#19353, @Mubashir78) +* 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 diff --git a/user_docs/en/userGuide.md b/user_docs/en/userGuide.md index 84ee99967ed..fbf995f779d 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} |.| |---|---|