Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion source/NVDAObjects/UIA/wordDocument.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
51 changes: 49 additions & 2 deletions source/browseMode.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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",
Expand Down
16 changes: 14 additions & 2 deletions source/cursorManager.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
92 changes: 92 additions & 0 deletions tests/unit/test_browseModeSentenceDispatch.py
Original file line number Diff line number Diff line change
@@ -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)
1 change: 1 addition & 0 deletions user_docs/en/changes.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
8 changes: 4 additions & 4 deletions user_docs/en/userGuide.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down Expand Up @@ -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} |.|
|---|---|
Expand Down
Loading