Skip to content

Commit b594079

Browse files
authored
Merge 809f4e5 into dc23f9c
2 parents dc23f9c + 809f4e5 commit b594079

8 files changed

Lines changed: 202 additions & 1 deletion

File tree

source/browseMode.py

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,19 +10,22 @@
1010
Union,
1111
cast,
1212
)
13+
from collections.abc import Generator
1314
import os
1415
import itertools
1516
import collections
1617
import winsound
1718
import time
1819
import weakref
20+
import re
1921

2022
import wx
2123
import core
2224
import winUser
2325
import mouseHandler
2426
from logHandler import log
2527
import documentBase
28+
from documentBase import _Movement
2629
import review
2730
import inputCore
2831
import scriptHandler
@@ -440,9 +443,55 @@ def _iterNodesByType(self,itemType,direction="next",pos=None):
440443
def _iterNotLinkBlock(self, direction="next", pos=None):
441444
raise NotImplementedError
442445

446+
MAX_ITERATIONS_FOR_SIMILAR_PARAGRAPH = 100000
447+
def _iterSimilarParagraph(
448+
self,
449+
kind: str,
450+
paragraphFunction: Callable[[textInfos.TextInfo], Optional[Any]],
451+
desiredValue: Optional[Any],
452+
direction: _Movement,
453+
pos: textInfos.TextInfo,
454+
) -> Generator[TextInfoQuickNavItem, None, None]:
455+
if direction not in [_Movement.NEXT, _Movement.PREVIOUS]:
456+
raise RuntimeError
457+
info = pos.copy()
458+
info.collapse()
459+
info.expand(textInfos.UNIT_PARAGRAPH)
460+
if desiredValue is None:
461+
desiredValue = paragraphFunction(info)
462+
for i in range(self.MAX_ITERATIONS_FOR_SIMILAR_PARAGRAPH):
463+
# move by one paragraph in the desired direction
464+
info.collapse(end=direction == _Movement.NEXT)
465+
if direction == _Movement.PREVIOUS:
466+
if info.move(textInfos.UNIT_CHARACTER, -1) == 0:
467+
return
468+
info.expand(textInfos.UNIT_PARAGRAPH)
469+
if info.isCollapsed:
470+
return
471+
value = paragraphFunction(info)
472+
if value == desiredValue:
473+
yield TextInfoQuickNavItem(kind, self, info.copy())
474+
475+
443476
def _quickNavScript(self,gesture, itemType, direction, errorMessage, readUnit):
444477
if itemType=="notLinkBlock":
445478
iterFactory=self._iterNotLinkBlock
479+
elif itemType == "textParagraph":
480+
punctuationMarksRegex = re.compile(
481+
config.conf["virtualBuffers"]["textParagraphRegex"],
482+
)
483+
484+
def paragraphFunc(info: textInfos.TextInfo) -> bool:
485+
return punctuationMarksRegex.search(info.text) is not None
486+
487+
def iterFactory(direction: str, pos: textInfos.TextInfo) -> Generator[TextInfoQuickNavItem, None, None]:
488+
return self._iterSimilarParagraph(
489+
kind="textParagraph",
490+
paragraphFunction=paragraphFunc,
491+
desiredValue=True,
492+
direction=_Movement(direction),
493+
pos=pos,
494+
)
446495
else:
447496
iterFactory=lambda direction,info: self._iterNodesByType(itemType,direction,info)
448497
info=self.selection
@@ -949,6 +998,19 @@ def _get_disableAutoPassThrough(self):
949998
# Translators: Message presented when the browse mode element is not found.
950999
prevError=_("no previous tab")
9511000
)
1001+
qn(
1002+
"textParagraph",
1003+
key="p",
1004+
# Translators: Input help message for a quick navigation command in browse mode.
1005+
nextDoc=_("moves to the next text paragraph"),
1006+
# Translators: Message presented when the browse mode element is not found.
1007+
nextError=_("no next text paragraph"),
1008+
# Translators: Input help message for a quick navigation command in browse mode.
1009+
prevDoc=_("moves to the previous text paragraph"),
1010+
# Translators: Message presented when the browse mode element is not found.
1011+
prevError=_("no previous text paragraph"),
1012+
readUnit=textInfos.UNIT_PARAGRAPH,
1013+
)
9521014
del qn
9531015

9541016

source/config/configDefaults.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
# A part of NonVisual Desktop Access (NVDA)
2+
# Copyright (C) 2006-2024 NV Access Limited
3+
# This file is covered by the GNU General Public License.
4+
# See the file COPYING for more details.
5+
6+
DEFAULT_TEXT_PARAGRAPH_REGEX = (
7+
r"({lookBehind}{optQuote}{punc}{optQuote}{optWiki}{lookAhead}|{punc2}|{cjk})".format(
8+
# Look behind clause ensures that we have a text character before text punctuation mark.
9+
# We have a positive lookBehind \w that resolves to a text character in any language,
10+
# coupled with negative lookBehind \d that excludes digits.
11+
lookBehind=r'(?<=\w)(?<!\d)',
12+
# In some cases quote or closing parenthesis might appear right before or right after text punctuation.
13+
# For example:
14+
# > He replied, "That's wonderful."
15+
optQuote=r'["”»)]?',
16+
# Actual punctuation marks that suggest end of sentence.
17+
# We don't include symbols like comma and colon, because of too many false positives.
18+
# We include question mark and exclamation mark below in punc2.
19+
punc=r'[.…]{1,3}',
20+
# On Wikipedia references appear right after period in sentences, the following clause takes this
21+
# into account. For example:
22+
# > On his father's side, he was a direct descendant of John Churchill.[3]
23+
optWiki=r'(\[\d+\])*',
24+
# LookAhead clause checks that punctuation mark must be followed by either space,
25+
# or newLine symbol or end of string.
26+
lookAhead=r'(?=[\r\n  ]|$)',
27+
# Include question mark and exclamation mark with no extra conditions, since they don't trigger as many false positives.
28+
punc2=r'[?!]',
29+
# We also check for CJK full-width punctuation marks without any extra rules.
30+
cjk=r'[.!?:;]',
31+
)
32+
)

source/config/configSpec.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77

88
from io import StringIO
99
from configobj import ConfigObj
10+
from . import configDefaults
1011

1112
#: The version of the schema outlined in this file. Increment this when modifying the schema and
1213
#: provide an upgrade step (@see profileUpgradeSteps.py). An upgrade step does not need to be added when
@@ -183,6 +184,7 @@
183184
enableOnPageLoad = boolean(default=true)
184185
autoFocusFocusableElements = boolean(default=False)
185186
loadChromiumVBufOnBusyState = featureFlag(optionsEnum="BoolFlag", behaviorOfDefault="enabled")
187+
textParagraphRegex = string(default="{configDefaults.DEFAULT_TEXT_PARAGRAPH_REGEX}")
186188
187189
[touch]
188190
enabled = boolean(default=true)

source/gui/settingsDialogs.py

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
import os
1414
from enum import IntEnum
1515
from locale import strxfrm
16-
16+
import re
1717
import typing
1818
import wx
1919
from NVDAState import WritePaths
@@ -3295,6 +3295,34 @@ def __init__(self, parent):
32953295

32963296
self.Layout()
32973297

3298+
# Translators: This is the label for a textfield in the
3299+
# browse mode settings panel.
3300+
textParagraphRegexLabelText = _("Regular expression for text paragraph navigation")
3301+
self.textParagraphRegexEdit = sHelper.addLabeledControl(
3302+
textParagraphRegexLabelText,
3303+
wxCtrlClass=wx.TextCtrl,
3304+
size=(self.Parent.scaleSize(300), -1),
3305+
)
3306+
self.textParagraphRegexEdit.SetValue(config.conf["virtualBuffers"]["textParagraphRegex"])
3307+
self.bindHelpEvent("TextParagraphRegexEdit", self.textParagraphRegexEdit)
3308+
3309+
def isValid(self) -> bool:
3310+
regex = self.textParagraphRegexEdit.GetValue()
3311+
try:
3312+
re.compile(regex)
3313+
except re.error as e:
3314+
log.debugWarning("Failed to compile text paragraph regex", exc_info=True)
3315+
gui.messageBox(
3316+
# Translators: Message shown when invalid text paragraph regex entered
3317+
_("Failed to compile text paragraph regular expression: %s") % str(e),
3318+
# Translators: The title of the message box
3319+
_("Error"),
3320+
wx.OK | wx.ICON_ERROR,
3321+
self,
3322+
)
3323+
return False
3324+
return super().isValid()
3325+
32983326
def onOpenScratchpadDir(self,evt):
32993327
path=config.getScratchpadDir(ensureExists=True)
33003328
os.startfile(path)
@@ -3394,6 +3422,9 @@ def onSave(self):
33943422
for index,key in enumerate(self.logCategories):
33953423
config.conf['debugLog'][key]=self.logCategoriesList.IsChecked(index)
33963424
config.conf["featureFlag"]["playErrorSound"] = self.playErrorSoundCombo.GetSelection()
3425+
config.conf["virtualBuffers"]["textParagraphRegex"] = (
3426+
self.textParagraphRegexEdit.GetValue()
3427+
)
33973428

33983429

33993430
class AdvancedPanel(SettingsPanel):

tests/system/robot/chromeTests.py

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2473,3 +2473,52 @@ def test_i13307():
24732473
]),
24742474
message="jumping into region with aria-labelledby should speak label",
24752475
)
2476+
2477+
2478+
def test_textParagraphNavigation():
2479+
_chrome.prepareChrome("""
2480+
<!-- First a bunch of paragraphs that don't match text regex -->
2481+
<p>Header</p>
2482+
<p>Liberal MP: 1904–1908</p>
2483+
<p>.</p>
2484+
<p>…</p>
2485+
<p>5.</p>
2486+
<p>test....</p>
2487+
<p>a.b</p>
2488+
<p></p>
2489+
<!-- Now a bunch of matching paragraphs -->
2490+
<p>Hello, world!</p>
2491+
<p>He replied, "That's wonderful."</p>
2492+
<p>He replied, "That's wonderful".</p>
2493+
<p>He replied, "That's wonderful."[4]</p>
2494+
<p>Предложение по-русски.</p>
2495+
<p>我不会说中文!</p>
2496+
<p>Bye-bye, world!</p>
2497+
""")
2498+
2499+
expectedParagraphs = [
2500+
# Tests exclamation sign
2501+
"Hello, world!",
2502+
# Tests Period with preceding quote
2503+
"He replied, That's wonderful.",
2504+
# Tests period with trailing quote
2505+
"He replied, That's wonderful .",
2506+
# Tests wikipedia-style reference
2507+
"He replied, That's wonderful. 4",
2508+
# Tests compatibility with Russian Cyrillic script
2509+
"Предложение по-русски.",
2510+
# Tests regex condition for CJK full width character terminators
2511+
"我不会说中文!",
2512+
"Bye-bye, world!",
2513+
]
2514+
for p in expectedParagraphs:
2515+
actualSpeech = _chrome.getSpeechAfterKey("p")
2516+
_asserts.strings_match(actualSpeech, p)
2517+
actualSpeech = _chrome.getSpeechAfterKey("p")
2518+
_asserts.strings_match(actualSpeech, "no next text paragraph")
2519+
2520+
for p in expectedParagraphs[-2::-1]:
2521+
actualSpeech = _chrome.getSpeechAfterKey("shift+p")
2522+
_asserts.strings_match(actualSpeech, p)
2523+
actualSpeech = _chrome.getSpeechAfterKey("shift+p")
2524+
_asserts.strings_match(actualSpeech, "no previous text paragraph")

tests/system/robot/chromeTests.robot

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,3 +156,6 @@ ARIA switch role
156156
i13307
157157
[Documentation] ensure aria-labelledby on a landmark or region is automatically spoken when jumping inside from outside using focus in browse mode
158158
test_i13307
159+
textParagraphNavigation
160+
[Documentation] Text paragraph navigation
161+
test_textParagraphNavigation

user_docs/en/changes.t2t

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ What's New in NVDA
77

88
== New Features ==
99
- In Windows 11, NVDA will announce alerts from voice typing and suggested actions including the top suggestion when copying data such as phone numbers to the clipboard (Windows 11 2022 Update and later). (#16009, @josephsl)
10+
- New Quick Navigation command ``p`` for jumping to next/previous text paragraph in browse mode. (#15998, @mltony)
1011
-
1112

1213

user_docs/en/userGuide.t2t

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -828,6 +828,7 @@ The following keys by themselves jump to the next available element, while addin
828828
- o: embedded object (audio and video player, application, dialog, etc.)
829829
- 1 to 6: headings at levels 1 to 6 respectively
830830
- a: annotation (comment, editor revision, etc.)
831+
- ``p``: text paragraph
831832
- w: spelling error
832833
-
833834
To move to the beginning or end of containing elements such as lists and tables:
@@ -842,6 +843,22 @@ If you want to use these while still being able to use your cursor keys to read
842843
To toggle single letter navigation on and off for the current document, press NVDA+shift+space.
843844
%kc:endInclude
844845

846+
+++ Text navigation command +++[TextNavigationCommand]
847+
848+
As mentioned above, you can jupm to next or previous text paragraph by pressing ``p`` or ``shift+p``. Here we define text paragraph to be a paragraph that appears to be written in complete sentences. This can be useful to find the beginning of readable content on various webpages, such as:
849+
- News websites
850+
- Forums
851+
- Blog posts
852+
-
853+
854+
These commands can also be helpful for skipping certain kinds of clutter, such as:
855+
- Ads
856+
- Menus
857+
- Headers
858+
-
859+
860+
Please note, however, that while NVDA tries its best to identify text paragraphs, the algorithm is not perfect and can make mistakes.
861+
845862
+++ Other navigation commands +++[OtherNavigationCommands]
846863

847864
In addition to the quick navigation commands listed above, NVDA has commands that have no default keys assigned.
@@ -2594,6 +2611,10 @@ This option allows you to specify if NVDA will play an error sound in case an er
25942611
Choosing Only in test versions (default) makes NVDA play error sounds only if the current NVDA version is a test version (alpha, beta or run from source).
25952612
Choosing Yes allows to enable error sounds whatever your current NVDA version is.
25962613

2614+
==== Regular expression for text paragraph quick navigation commands ====[TextParagraphRegexEdit]
2615+
2616+
This field allows users to customize regular expression for detecting text paragraphs in browse mode. [Text navigation command#TextNavigationCommand] would search for paragraphs matched by this regular expression.
2617+
25972618
++ miscellaneous Settings ++[MiscSettings]
25982619
Besides the [NVDA Settings #NVDASettings] dialog, The Preferences sub-menu of the NVDA Menu contains several other items which are outlined below.
25992620

0 commit comments

Comments
 (0)