Skip to content

Commit d32acb6

Browse files
authored
Merge 9a5dec6 into e71916d
2 parents e71916d + 9a5dec6 commit d32acb6

6 files changed

Lines changed: 272 additions & 0 deletions

File tree

source/browseMode.py

Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
from typing import (
88
Any,
99
Callable,
10+
Generator,
1011
Union,
1112
cast,
1213
)
@@ -52,6 +53,7 @@
5253
from abc import ABCMeta, abstractmethod
5354
import globalVars
5455
from typing import Optional
56+
from textUtils import WideStringOffsetConverter
5557

5658

5759
def reportPassThrough(treeInterceptor,onlyIfChanged=True):
@@ -439,10 +441,24 @@ def _iterNodesByType(self,itemType,direction="next",pos=None):
439441

440442
def _iterNotLinkBlock(self, direction="next", pos=None):
441443
raise NotImplementedError
444+
445+
def _iterTextStyle(
446+
self,
447+
kind: str,
448+
direction: str = "next",
449+
pos: textInfos.TextInfo = None,
450+
) -> Generator[TextInfoQuickNavItem, None, None]:
451+
raise NotImplementedError
442452

443453
def _quickNavScript(self,gesture, itemType, direction, errorMessage, readUnit):
444454
if itemType=="notLinkBlock":
445455
iterFactory=self._iterNotLinkBlock
456+
elif itemType in ["sameStyle", "differentStyle"]:
457+
def iterFactory(
458+
direction: str,
459+
info: textInfos.TextInfo,
460+
) -> Generator[TextInfoQuickNavItem, None, None]:
461+
return self._iterTextStyle(itemType, direction, info)
446462
else:
447463
iterFactory=lambda direction,info: self._iterNodesByType(itemType,direction,info)
448464
info=self.selection
@@ -949,6 +965,30 @@ def _get_disableAutoPassThrough(self):
949965
# Translators: Message presented when the browse mode element is not found.
950966
prevError=_("no previous tab")
951967
)
968+
qn(
969+
"sameStyle",
970+
key=None,
971+
# Translators: Input help message for a quick navigation command in browse mode.
972+
nextDoc=_("moves to the next same style text"),
973+
# Translators: Message presented when the browse mode element is not found.
974+
nextError=_("No next same style text"),
975+
# Translators: Input help message for a quick navigation command in browse mode.
976+
prevDoc=_("moves to the previous same style text"),
977+
# Translators: Message presented when the browse mode element is not found.
978+
prevError=_("No previous same style text")
979+
)
980+
qn(
981+
"differentStyle",
982+
key=None,
983+
# Translators: Input help message for a quick navigation command in browse mode.
984+
nextDoc=_("moves to the next different style text"),
985+
# Translators: Message presented when the browse mode element is not found.
986+
nextError=_("No next different style text"),
987+
# Translators: Input help message for a quick navigation command in browse mode.
988+
prevDoc=_("moves to the previous different style text"),
989+
# Translators: Message presented when the browse mode element is not found.
990+
prevError=_("No previous different style text")
991+
)
952992
del qn
953993

954994

@@ -2001,6 +2041,129 @@ def _iterNotLinkBlock(self, direction="next", pos=None):
20012041
yield TextInfoQuickNavItem("notLinkBlock", self, textRange)
20022042
item1=item2
20032043

2044+
STYLE_ATTRIBUTES = frozenset([
2045+
'background-color',
2046+
'color',
2047+
'font-family',
2048+
'font-size',
2049+
'bold',
2050+
'italic',
2051+
'marked',
2052+
'strikethrough',
2053+
'text-line-through-style',
2054+
'underline',
2055+
'text-underline-style',
2056+
])
2057+
2058+
def _extractStyles(
2059+
self,
2060+
info: textInfos.TextInfo,
2061+
) -> "textInfos.TextInfo.TextWithFieldsT":
2062+
"""
2063+
This function calls TextInfo.getTextWithFields(), and then processes fields in the following way:
2064+
1. Highlighted(marked) text is currently reported as Role.MARKED_CONTENT, and not formatChange.
2065+
For ease of further handling we create a new boolean format field "marked"
2066+
and set its value according to presence of Role.MARKED_CONTENT.
2067+
2. Then we drop all control fields, leaving only formatChange fields and text.
2068+
"""
2069+
stack = [{}]
2070+
result = []
2071+
for field in info.getTextWithFields():
2072+
if isinstance(field, textInfos.FieldCommand):
2073+
if field.command == 'controlStart':
2074+
style = {**stack[-1]}
2075+
if field.field.get('role') == controlTypes.Role.MARKED_CONTENT:
2076+
style['marked'] = True
2077+
stack.append(style)
2078+
elif field.command == 'controlEnd':
2079+
del stack[-1]
2080+
elif field.command == 'formatChange':
2081+
field.field = {
2082+
k: v
2083+
for k, v in {**field.field, **stack[-1]}.items()
2084+
if k in self.STYLE_ATTRIBUTES
2085+
}
2086+
result.append(field)
2087+
else:
2088+
raise RuntimeError()
2089+
elif isinstance(field, str):
2090+
result.append(field)
2091+
else:
2092+
raise RuntimeError
2093+
return result
2094+
2095+
def _iterTextStyle(
2096+
self,
2097+
kind: str,
2098+
direction: str = "next",
2099+
pos: textInfos.TextInfo = None
2100+
) -> Generator[TextInfoQuickNavItem, None, None]:
2101+
sameStyle = kind == 'sameStyle'
2102+
initialTextInfo = pos.copy()
2103+
initialTextInfo.collapse()
2104+
result = initialTextInfo.move(textInfos.UNIT_CHARACTER, 1, endPoint='end')
2105+
if result == 0:
2106+
result = initialTextInfo.move(textInfos.UNIT_CHARACTER, -1, endPoint='start')
2107+
if result == 0:
2108+
# Translators: Error message for same/different style quick navigation command
2109+
ui.message(_("Cannot determine current style"))
2110+
raise RuntimeError
2111+
styles = self._extractStyles(initialTextInfo)
2112+
if (
2113+
len(styles) == 0
2114+
or not isinstance(styles[0], textInfos.FieldCommand)
2115+
or styles[0].command != 'formatChange'
2116+
):
2117+
# Translators: Error message for same/different style quick navigation commands
2118+
ui.message(_("Cannot determine current style"))
2119+
raise RuntimeError
2120+
initialStyle = styles[0]
2121+
2122+
firstParagraph = True
2123+
paragraph = pos.copy()
2124+
tmpInfo = pos.copy()
2125+
tmpInfo.expand(textInfos.UNIT_PARAGRAPH)
2126+
paragraph.setEndPoint(tmpInfo, which='endToEnd' if direction == 'next' else 'startToStart')
2127+
while True:
2128+
if not paragraph.isCollapsed:
2129+
styles = self._extractStyles(paragraph)
2130+
firstStyleWithinParagraph = True
2131+
iterationRange = range(len(styles)) if direction == 'next' else range(len(styles) - 1, -1, -1)
2132+
for i in iterationRange:
2133+
if not isinstance(styles[i], textInfos.FieldCommand):
2134+
continue
2135+
if (
2136+
(styles[i].field == initialStyle.field) == sameStyle
2137+
and (
2138+
not firstStyleWithinParagraph
2139+
or not firstParagraph
2140+
)
2141+
):
2142+
# Found text that matches desired style!
2143+
startOffset = sum([
2144+
WideStringOffsetConverter(s).wideStringLength
2145+
for s in styles[:i]
2146+
if isinstance(s, str)
2147+
])
2148+
endOffset = WideStringOffsetConverter(styles[i + 1]).wideStringLength
2149+
textRange = paragraph.copy()
2150+
textRange.collapse()
2151+
textRange.move(textInfos.UNIT_CHARACTER, startOffset)
2152+
textRange.move(textInfos.UNIT_CHARACTER, endOffset, endPoint='end')
2153+
yield TextInfoQuickNavItem(kind, self, textRange)
2154+
firstStyleWithinParagraph = False
2155+
firstParagraph = False
2156+
if direction == 'next':
2157+
paragraph.collapse(end=True)
2158+
else:
2159+
paragraph.collapse(end=False)
2160+
result = paragraph.move(textInfos.UNIT_CHARACTER, -1)
2161+
if result == 0:
2162+
return
2163+
paragraph.expand(textInfos.UNIT_PARAGRAPH)
2164+
if paragraph.isCollapsed:
2165+
return
2166+
20042167
__gestures={
20052168
"kb:alt+upArrow": "collapseOrExpandControl",
20062169
"kb:alt+downArrow": "collapseOrExpandControl",

tests/system/libraries/SystemTestSpy/speechSpyGlobalPlugin.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,23 @@ def set_configValue(self, keyPath: ConfKeyPath, val: ConfKeyVal):
106106
ultimateKey = keyPath[-1]
107107
penultimateConf[ultimateKey] = val
108108

109+
def assignGesture(
110+
self,
111+
gesture: str,
112+
module: str,
113+
className: str,
114+
script: Optional[str],
115+
replace: bool = False
116+
):
117+
import inputCore
118+
inputCore.manager.userGestureMap.add(
119+
gesture,
120+
module,
121+
className,
122+
script,
123+
replace,
124+
)
125+
109126
fakeTranslations: typing.Optional[gettext.NullTranslations] = None
110127

111128
def override_translationString(self, invariantString: str, replacementString: str):

tests/system/robot/chromeTests.py

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2473,3 +2473,89 @@ def test_i13307():
24732473
]),
24742474
message="jumping into region with aria-labelledby should speak label",
24752475
)
2476+
2477+
2478+
def test_styleNav():
2479+
""" Tests that same style and different style navigation work correctly in browse mode.
2480+
Bydefault these commands don't have assigned gestures,
2481+
so we will assign temporary gestures just for testing.
2482+
"""
2483+
spy: "NVDASpyLib" = _NvdaLib.getSpyLib()
2484+
spy.assignGesture(
2485+
"kb:s",
2486+
"browseMode",
2487+
'BrowseModeTreeInterceptor',
2488+
"nextSameStyle",
2489+
)
2490+
2491+
spy.assignGesture(
2492+
"kb:shift+s",
2493+
"browseMode",
2494+
'BrowseModeTreeInterceptor',
2495+
"previousSameStyle",
2496+
)
2497+
spy.assignGesture(
2498+
"kb:d",
2499+
"browseMode",
2500+
'BrowseModeTreeInterceptor',
2501+
"nextDifferentStyle",
2502+
)
2503+
2504+
spy.assignGesture(
2505+
"kb:shift+d",
2506+
"browseMode",
2507+
'BrowseModeTreeInterceptor',
2508+
"previousDifferentStyle",
2509+
)
2510+
2511+
_chrome.prepareChrome("""
2512+
<p>Hello world!</p>
2513+
<p>This text is <b>bold</b></p>
2514+
<p>Second line is <font size="15pt">large</font></p>
2515+
<p>Third line is <mark>highlighted</mark></p>
2516+
<p>Fourth line is <b>bold again</b></p>
2517+
<p>End of document.</p>
2518+
""")
2519+
# For some reason we need to send Control+RightArrow;
2520+
# otherwise getting "Test page load complete" as actual speech
2521+
actualSpeech, actualBraille = _NvdaLib.getSpeechAndBrailleAfterKey("control+rightArrow")
2522+
actualSpeech, actualBraille = _NvdaLib.getSpeechAndBrailleAfterKey("s")
2523+
_asserts.strings_match(actualSpeech, "Hello world!")
2524+
actualSpeech, actualBraille = _NvdaLib.getSpeechAndBrailleAfterKey("shift+d")
2525+
_asserts.strings_match(actualSpeech, "No previous different style text")
2526+
actualSpeech, actualBraille = _NvdaLib.getSpeechAndBrailleAfterKey("s")
2527+
_asserts.strings_match(actualSpeech, "This text is")
2528+
actualSpeech, actualBraille = _NvdaLib.getSpeechAndBrailleAfterKey("s")
2529+
_asserts.strings_match(actualSpeech, "Second line is")
2530+
actualSpeech, actualBraille = _NvdaLib.getSpeechAndBrailleAfterKey("shift+d")
2531+
_asserts.strings_match(actualSpeech, "bold")
2532+
actualSpeech, actualBraille = _NvdaLib.getSpeechAndBrailleAfterKey("s")
2533+
_asserts.strings_match(actualSpeech, "bold again")
2534+
actualSpeech, actualBraille = _NvdaLib.getSpeechAndBrailleAfterKey("s")
2535+
_asserts.strings_match(actualSpeech, "No next same style text")
2536+
actualSpeech, actualBraille = _NvdaLib.getSpeechAndBrailleAfterKey("d")
2537+
_asserts.strings_match(actualSpeech, "End of document.")
2538+
actualSpeech, actualBraille = _NvdaLib.getSpeechAndBrailleAfterKey("d")
2539+
_asserts.strings_match(actualSpeech, "No next different style text")
2540+
for s in [
2541+
"Second line is",
2542+
"Third line is",
2543+
"Fourth line is",
2544+
][::-1]:
2545+
actualSpeech, actualBraille = _NvdaLib.getSpeechAndBrailleAfterKey("shift+s")
2546+
_asserts.strings_match(actualSpeech, s)
2547+
actualSpeech, actualBraille = _NvdaLib.getSpeechAndBrailleAfterKey("d")
2548+
_asserts.strings_match(actualSpeech, "large")
2549+
actualSpeech, actualBraille = _NvdaLib.getSpeechAndBrailleAfterKey("shift+s")
2550+
_asserts.strings_match(actualSpeech, "No previous same style text")
2551+
actualSpeech, actualBraille = _NvdaLib.getSpeechAndBrailleAfterKey("s")
2552+
_asserts.strings_match(actualSpeech, "No next same style text")
2553+
2554+
actualSpeech, actualBraille = _NvdaLib.getSpeechAndBrailleAfterKey("d")
2555+
_asserts.strings_match(actualSpeech, "Third line is")
2556+
actualSpeech, actualBraille = _NvdaLib.getSpeechAndBrailleAfterKey("d")
2557+
_asserts.strings_match(actualSpeech, "highlighted highlighted")
2558+
actualSpeech, actualBraille = _NvdaLib.getSpeechAndBrailleAfterKey("shift+s")
2559+
_asserts.strings_match(actualSpeech, "No previous same style text")
2560+
actualSpeech, actualBraille = _NvdaLib.getSpeechAndBrailleAfterKey("s")
2561+
_asserts.strings_match(actualSpeech, "No next same style text")

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+
styleNav
160+
[Documentation] Same style navigation
161+
test_styleNav

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+
- Added same style and different style quick navigation commands, not assigned to any gestures. (#16000, @mltony)
1011
-
1112

1213

user_docs/en/userGuide.t2t

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -850,6 +850,8 @@ Here is a list of available commands:
850850
- Article
851851
- Grouping
852852
- Tab
853+
- Same style text
854+
- Different style text
853855
-
854856

855857
Keep in mind that there are two commands for each type of element, for moving forward in the document and backward in the document, and you must assign gestures to both commands in order to be able to quickly navigate in both directions.

0 commit comments

Comments
 (0)