From 8af4bf4256e6ccc2df414978678d0399f5349143 Mon Sep 17 00:00:00 2001 From: mltony <34804124+mltony@users.noreply.github.com> Date: Mon, 15 Apr 2024 19:05:22 -0700 Subject: [PATCH] Style navigation QuickNav command (#16050) Closes #16000 Summary of the issue: Adding jump to same style and jump to different style QuickNav commands. Description of user facing changes Adding jump to same style and jump to different style QuickNav commands. They are not assigned to any keyboard gestures by default. Description of development approach Scanning document by paragraph in the desired direction and analyzing styles within each paragraph. --- source/browseMode.py | 326 ++++++++++++++++++ source/textInfos/offsets.py | 2 +- .../SystemTestSpy/speechSpyGlobalPlugin.py | 17 + tests/system/robot/chromeTests.py | 86 +++++ tests/system/robot/chromeTests.robot | 3 + tests/unit/test_textInfos.py | 32 +- user_docs/en/changes.t2t | 2 + user_docs/en/userGuide.t2t | 2 + 8 files changed, 460 insertions(+), 10 deletions(-) diff --git a/source/browseMode.py b/source/browseMode.py index 90a3a1fff75..0c11d406b74 100644 --- a/source/browseMode.py +++ b/source/browseMode.py @@ -7,6 +7,7 @@ from typing import ( Any, Callable, + Generator, Union, cast, ) @@ -449,6 +450,14 @@ def _iterNodesByType(self,itemType,direction="next",pos=None): def _iterNotLinkBlock(self, direction="next", pos=None): raise NotImplementedError + + def _iterTextStyle( + self, + kind: str, + direction: documentBase._Movement = documentBase._Movement.NEXT, + pos: textInfos.TextInfo | None = None, + ) -> Generator[TextInfoQuickNavItem, None, None]: + raise NotImplementedError def _iterSimilarParagraph( self, @@ -494,6 +503,12 @@ def iterFactory(direction: str, pos: textInfos.TextInfo) -> Generator[TextInfoQu direction=_Movement(direction), pos=pos, ) + elif itemType in ["sameStyle", "differentStyle"]: + def iterFactory( + direction: documentBase._Movement, + info: textInfos.TextInfo | None, + ) -> Generator[TextInfoQuickNavItem, None, None]: + return self._iterTextStyle(itemType, direction, info) else: iterFactory=lambda direction,info: self._iterNodesByType(itemType,direction,info) info=self.selection @@ -1085,6 +1100,30 @@ def _get_disableAutoPassThrough(self): prevError=_("no previous vertically aligned paragraph"), readUnit=textInfos.UNIT_PARAGRAPH, ) +qn( + "sameStyle", + key=None, + # Translators: Input help message for a quick navigation command in browse mode. + nextDoc=_("moves to the next same style text"), + # Translators: Message presented when the browse mode element is not found. + nextError=_("No next same style text"), + # Translators: Input help message for a quick navigation command in browse mode. + prevDoc=_("moves to the previous same style text"), + # Translators: Message presented when the browse mode element is not found. + prevError=_("No previous same style text") +) +qn( + "differentStyle", + key=None, + # Translators: Input help message for a quick navigation command in browse mode. + nextDoc=_("moves to the next different style text"), + # Translators: Message presented when the browse mode element is not found. + nextError=_("No next different style text"), + # Translators: Input help message for a quick navigation command in browse mode. + prevDoc=_("moves to the previous different style text"), + # Translators: Message presented when the browse mode element is not found. + prevError=_("No previous different style text") +) del qn @@ -2137,6 +2176,293 @@ def _iterNotLinkBlock(self, direction="next", pos=None): yield TextInfoQuickNavItem("notLinkBlock", self, textRange) item1=item2 + STYLE_ATTRIBUTES = frozenset([ + "background-color", + "color", + "font-family", + "font-size", + "bold", + "italic", + "marked", + "strikethrough", + "text-line-through-style", + "underline", + "text-underline-style", + ]) + + def _extractStyles( + self, + info: textInfos.TextInfo, + ) -> "textInfos.TextInfo.TextWithFieldsT": + """ + This function calls TextInfo.getTextWithFields(), and then processes fields in the following way: + 1. Highlighted (marked) text is currently reported as Role.MARKED_CONTENT, and not formatChange. + For ease of further handling we create a new boolean format field "marked" + and set its value according to presence of Role.MARKED_CONTENT. + 2. Then we drop all control fields, leaving only formatChange fields and text. + @raise RuntimeError: found unknown command in getTextWithFields() + """ + from NVDAObjects.UIA.wordDocument import WordBrowseModeDocument + from NVDAObjects.window.winword import WordDocumentTreeInterceptor + microsoftWordMode: bool = isinstance(self, (WordBrowseModeDocument, WordDocumentTreeInterceptor)) + stack: list[textInfos.FormatField] = [{}] + result: "textInfos.TextInfo.TextWithFieldsT" = [] + reportFormattingOptions = ( + "reportFontName", + "reportFontSize", + "reportFontAttributes", + "reportSuperscriptsAndSubscripts", + "reportHighlight", + "reportColor", + "reportStyle", + "reportLinks", + ) + formatConfig = dict() + for i in config.conf["documentFormatting"]: + formatConfig[i] = i in reportFormattingOptions + + fields = info.getTextWithFields(formatConfig) + for field in fields: + if isinstance(field, textInfos.FieldCommand): + if field.command == "controlStart": + style = {**stack[-1]} + role = field.field.get("role") + if role == controlTypes.Role.MARKED_CONTENT: + style["marked"] = True + elif role == controlTypes.Role.LINK and microsoftWordMode: + # Due to #16196 and #11427, ignoring color of links in MSWord, since it is reported incorrectly. + style["color"] = "MSWordLinkColor" + stack.append(style) + elif field.command == "controlEnd": + del stack[-1] + elif field.command == "formatChange": + field.field = { + k: v + for k, v in {**field.field, **stack[-1]}.items() + if k in self.STYLE_ATTRIBUTES + } + result.append(field) + else: + raise RuntimeError("Unrecognized command in the field") + elif isinstance(field, str): + result.append(field) + else: + raise RuntimeError("Unrecognized field in TextInfo.getTextWithFields()") + return result + + def _mergeIdenticalStyles( + self, + sequence: "textInfos.TextInfo.TextWithFieldsT", + ) -> "textInfos.TextInfo.TextWithFieldsT": + """ + This function is used to postprocess styles output of _extractStyles function. + Raw output of _extractStyles function might contain identical styles, + since textInfos might contain formatChange fields for other reasons + rather than style change. + This function removes redundant formatChange fields and merges str items as appropriate. + """ + currentStyle = None + redundantIndices = set() + for i, item in enumerate(sequence): + if i == 0: + currentStyle = item + elif isinstance(item, textInfos.FieldCommand): + if item.field == currentStyle.field: + redundantIndices.add(i) + currentStyle = item + sequence = [item for i, item in enumerate(sequence) if i not in redundantIndices] + # Now merging adjacent strings + result = [] + for k, g in itertools.groupby(sequence, key=type): + if k == str: + result.append("".join(g)) + else: + result.extend(list(g)) + return result + + def _expandStyle( + self, + textRange: textInfos.TextInfo, + style: dict, + direction: documentBase._Movement, + ): + """ + Given textRange in given style, this function expands textRange + in the desired direction as long as all text still belongs to the same style. + This function can expand textInfos across paragraphs. + """ + resultInfo = textRange.copy() + paragraphInfo = textRange.copy() + paragraphInfo.collapse() + paragraphInfo.expand(textInfos.UNIT_PARAGRAPH) + compareResult = textRange.compareEndPoints( + paragraphInfo, + "endToEnd" if direction == documentBase._Movement.NEXT else "startToStart" + ) + if compareResult != 0: + # initial text range is not even touching end of paragraph in the desired direction, + # so no need to expand, since style ends within the same paragraph. + return textRange + MAX_ITER_LIMIT = 1000 + for __ in range(MAX_ITER_LIMIT): + if not self._moveToNextParagraph(paragraphInfo, direction): + break + styles = self._mergeIdenticalStyles(self._extractStyles(paragraphInfo)) + if direction == documentBase._Movement.NEXT: + iteration = range(len(styles)) + else: + iteration = range(len(styles) - 1, -1, -1) + for i in iteration: + if isinstance(styles[i], str): + continue + if styles[i].field != style.field: + # We found the end of current style + startIndex = sum(len(s) for s in styles[:i] if isinstance(s, str)) + endIndex = startIndex + len(styles[i + 1]) + if direction == documentBase._Movement.NEXT: + startInfo = paragraphInfo.moveToCodepointOffset(startIndex) + resultInfo.setEndPoint(startInfo, which="endToEnd") + else: + endInfo = paragraphInfo.moveToCodepointOffset(endIndex) + resultInfo.setEndPoint(endInfo, which="startToStart") + return resultInfo + else: + resultInfo.setEndPoint( + paragraphInfo, + which="endToEnd" if direction == documentBase._Movement.NEXT else "startToStart", + ) + return resultInfo + + def _moveToNextParagraph( + self, + paragraph: textInfos.TextInfo, + direction: documentBase._Movement, + ) -> bool: + oldParagraph = paragraph.copy() + if direction == documentBase._Movement.NEXT: + try: + paragraph.collapse(end=True) + except RuntimeError: + # Microsoft Word raises RuntimeError when collapsing textInfo to the last character of the document. + return False + else: + paragraph.collapse(end=False) + result = paragraph.move(textInfos.UNIT_CHARACTER, -1) + if result == 0: + return False + paragraph.expand(textInfos.UNIT_PARAGRAPH) + if paragraph.isCollapsed: + return False + if ( + direction == documentBase._Movement.NEXT + and paragraph.compareEndPoints(oldParagraph, "startToStart") <= 0 + ): + # Sometimes in Microsoft word it just selects the same last paragraph repeatedly + return False + return True + + def _iterTextStyle( + self, + kind: str, + direction: documentBase._Movement = documentBase._Movement.NEXT, + pos: textInfos.TextInfo | None = None + ) -> Generator[TextInfoQuickNavItem, None, None]: + if direction not in [ + documentBase._Movement.NEXT, + documentBase._Movement.PREVIOUS, + ]: + raise RuntimeError(f"direction must be either next or previous; got {direction}") + sameStyle = kind == "sameStyle" + + initialTextInfo = pos.copy() + initialTextInfo.collapse() + if direction == documentBase._Movement.PREVIOUS: + # If going backwards, need to include character at the cursor. + if 0 == initialTextInfo.move(textInfos.UNIT_CHARACTER, 1, endPoint="end"): + return + paragraph = initialTextInfo.copy() + tmpInfo = initialTextInfo.copy() + tmpInfo.expand(textInfos.UNIT_PARAGRAPH) + paragraph.setEndPoint( + tmpInfo, + which="endToEnd" if direction == documentBase._Movement.NEXT else "startToStart", + ) + # At this point paragraphInfo represents incomplete paragraph: + # if direction == "next", it spans from cursor to the end of current paragraph + # if direction == "previous" then it spans from the beginning of current paragraph until cursor+1 + # For all following iterations paragraph will represent a complete paragraph. + styles = self._mergeIdenticalStyles(self._extractStyles(paragraph)) + initialStyle = styles[0 if direction == documentBase._Movement.NEXT else -2] + # Creating currentTextInfo - text written in initialStyle in this paragraph. + currentTextInfo = initialTextInfo.copy() + if direction == documentBase._Movement.NEXT: + endInfo = paragraph.moveToCodepointOffset(len(styles[1])) + currentTextInfo.setEndPoint(endInfo, "endToEnd") + else: + startInfo = paragraph.moveToCodepointOffset(len(paragraph.text) - len(styles[-1])) + currentTextInfo.setEndPoint(startInfo, "startToStart") + # Now expand it to other paragraph in desired direction if applicable. + currentTextInfo = self._expandStyle(currentTextInfo, initialStyle, direction) + # At this point currentTextInfo represents textInfo written in the same style; may span across paragraphs + # We collapse it in the desired direction + try: + currentTextInfo.collapse(end=direction == documentBase._Movement.NEXT) + except RuntimeError: + # Microsoft Word raises RuntimeError when collapsing textInfo to the last character of the document. + return + # And now compute incomplete paragraph spanning from relevant end of currentTextInfo + # until the end/beginning of the paragraph. + paragraph = currentTextInfo.copy() + tmpInfo = currentTextInfo.copy() + tmpInfo.expand(textInfos.UNIT_PARAGRAPH) + if tmpInfo.isCollapsed: + return + else: + paragraph.setEndPoint( + tmpInfo, + which="endToEnd" if direction == documentBase._Movement.NEXT else "startToStart", + ) + + MAX_ITER_LIMIT = 1000 + for __ in range(MAX_ITER_LIMIT): + if not paragraph.isCollapsed: + styles = self._mergeIdenticalStyles(self._extractStyles(paragraph)) + iterationRange = ( + range(len(styles)) + if direction == documentBase._Movement.NEXT + else range(len(styles) - 1, -1, -1) + ) + for i in iterationRange: + if not isinstance(styles[i], textInfos.FieldCommand): + continue + if (styles[i].field == initialStyle.field) == sameStyle: + # Found text that matches desired style! + startIndex = sum([ + len(s) + for s in styles[:i] + if isinstance(s, str) + ]) + endIndex = startIndex + len(styles[i + 1]) + startInfo = paragraph.moveToCodepointOffset(startIndex) + endInfo = paragraph.moveToCodepointOffset(endIndex) + textRange = startInfo.copy() + textRange.setEndPoint(endInfo, "endToEnd") + needToExpand = ( + ( + direction == documentBase._Movement.NEXT + and paragraph.compareEndPoints(textRange, "endToEnd") == 0 + ) + or ( + direction == documentBase._Movement.PREVIOUS + and paragraph.compareEndPoints(textRange, "startToStart") == 0 + ) + ) + if needToExpand: + textRange = self._expandStyle(textRange, styles[i], direction) + yield TextInfoQuickNavItem(kind, self, textRange, OutputReason.CARET) + if not self._moveToNextParagraph(paragraph, direction): + return + __gestures={ "kb:alt+upArrow": "collapseOrExpandControl", "kb:alt+downArrow": "collapseOrExpandControl", diff --git a/source/textInfos/offsets.py b/source/textInfos/offsets.py index 65a4a9ecfd3..7b41915de69 100755 --- a/source/textInfos/offsets.py +++ b/source/textInfos/offsets.py @@ -709,6 +709,6 @@ def moveToCodepointOffset( codepointOffset: int, ) -> Self: result = self.copy() - encodedOffset = self._getOffsetEncoder().strToEncodedOffsets(codepointOffset) + encodedOffset = self._startOffset + self._getOffsetEncoder().strToEncodedOffsets(codepointOffset) result._startOffset = result._endOffset = encodedOffset return result diff --git a/tests/system/libraries/SystemTestSpy/speechSpyGlobalPlugin.py b/tests/system/libraries/SystemTestSpy/speechSpyGlobalPlugin.py index f6177b1f716..e39a7c42dbc 100644 --- a/tests/system/libraries/SystemTestSpy/speechSpyGlobalPlugin.py +++ b/tests/system/libraries/SystemTestSpy/speechSpyGlobalPlugin.py @@ -106,6 +106,23 @@ def set_configValue(self, keyPath: ConfKeyPath, val: ConfKeyVal): ultimateKey = keyPath[-1] penultimateConf[ultimateKey] = val + def assignGesture( + self, + gesture: str, + module: str, + className: str, + script: Optional[str], + replace: bool = False + ): + import inputCore + inputCore.manager.userGestureMap.add( + gesture, + module, + className, + script, + replace, + ) + fakeTranslations: typing.Optional[gettext.NullTranslations] = None def override_translationString(self, invariantString: str, replacementString: str): diff --git a/tests/system/robot/chromeTests.py b/tests/system/robot/chromeTests.py index 727282d5756..5008663d424 100644 --- a/tests/system/robot/chromeTests.py +++ b/tests/system/robot/chromeTests.py @@ -2522,3 +2522,89 @@ def test_textParagraphNavigation(): _asserts.strings_match(actualSpeech, p) actualSpeech = _chrome.getSpeechAfterKey("shift+p") _asserts.strings_match(actualSpeech, "no previous text paragraph") + + +def test_styleNav(): + """ Tests that same style and different style navigation work correctly in browse mode. + By default these commands don't have assigned gestures, + so we will assign temporary gestures just for testing. + """ + spy: "NVDASpyLib" = _NvdaLib.getSpyLib() + spy.assignGesture( + "kb:s", + "browseMode", + "BrowseModeTreeInterceptor", + "nextSameStyle", + ) + + spy.assignGesture( + "kb:shift+s", + "browseMode", + "BrowseModeTreeInterceptor", + "previousSameStyle", + ) + spy.assignGesture( + "kb:d", + "browseMode", + "BrowseModeTreeInterceptor", + "nextDifferentStyle", + ) + + spy.assignGesture( + "kb:shift+d", + "browseMode", + "BrowseModeTreeInterceptor", + "previousDifferentStyle", + ) + + _chrome.prepareChrome(""" +

Hello world!

+

This text is bold

+

Second line is large

+

Third line is highlighted

+

Fourth line is bold again

+

End of document.

+ """) + # For some reason we need to send Control+RightArrow; + # otherwise getting "Test page load complete" as actual speech + actualSpeech, actualBraille = _NvdaLib.getSpeechAndBrailleAfterKey("control+rightArrow") + actualSpeech, actualBraille = _NvdaLib.getSpeechAndBrailleAfterKey("s") + _asserts.strings_match(actualSpeech, "Hello world!") + actualSpeech, actualBraille = _NvdaLib.getSpeechAndBrailleAfterKey("shift+d") + _asserts.strings_match(actualSpeech, "No previous different style text") + actualSpeech, actualBraille = _NvdaLib.getSpeechAndBrailleAfterKey("s") + _asserts.strings_match(actualSpeech, "This text is") + actualSpeech, actualBraille = _NvdaLib.getSpeechAndBrailleAfterKey("s") + _asserts.strings_match(actualSpeech, "Second line is") + actualSpeech, actualBraille = _NvdaLib.getSpeechAndBrailleAfterKey("shift+d") + _asserts.strings_match(actualSpeech, "bold") + actualSpeech, actualBraille = _NvdaLib.getSpeechAndBrailleAfterKey("s") + _asserts.strings_match(actualSpeech, "bold again") + actualSpeech, actualBraille = _NvdaLib.getSpeechAndBrailleAfterKey("s") + _asserts.strings_match(actualSpeech, "No next same style text") + actualSpeech, actualBraille = _NvdaLib.getSpeechAndBrailleAfterKey("d") + _asserts.strings_match(actualSpeech, "End of document.") + actualSpeech, actualBraille = _NvdaLib.getSpeechAndBrailleAfterKey("d") + _asserts.strings_match(actualSpeech, "No next different style text") + for s in [ + "Second line is", + "Third line is", + "Fourth line is", + ][::-1]: + actualSpeech, actualBraille = _NvdaLib.getSpeechAndBrailleAfterKey("shift+s") + _asserts.strings_match(actualSpeech, s) + actualSpeech, actualBraille = _NvdaLib.getSpeechAndBrailleAfterKey("d") + _asserts.strings_match(actualSpeech, "large") + actualSpeech, actualBraille = _NvdaLib.getSpeechAndBrailleAfterKey("shift+s") + _asserts.strings_match(actualSpeech, "No previous same style text") + actualSpeech, actualBraille = _NvdaLib.getSpeechAndBrailleAfterKey("s") + _asserts.strings_match(actualSpeech, "No next same style text") + + actualSpeech, actualBraille = _NvdaLib.getSpeechAndBrailleAfterKey("d") + _asserts.strings_match(actualSpeech, "Third line is") + actualSpeech, actualBraille = _NvdaLib.getSpeechAndBrailleAfterKey("d") + _asserts.strings_match(actualSpeech, "highlighted highlighted") + actualSpeech, actualBraille = _NvdaLib.getSpeechAndBrailleAfterKey("shift+s") + _asserts.strings_match(actualSpeech, "No previous same style text") + actualSpeech, actualBraille = _NvdaLib.getSpeechAndBrailleAfterKey("s") + _asserts.strings_match(actualSpeech, "No next same style text") diff --git a/tests/system/robot/chromeTests.robot b/tests/system/robot/chromeTests.robot index da1eb390919..7892b0b1414 100644 --- a/tests/system/robot/chromeTests.robot +++ b/tests/system/robot/chromeTests.robot @@ -159,3 +159,6 @@ i13307 textParagraphNavigation [Documentation] Text paragraph navigation test_textParagraphNavigation +styleNav + [Documentation] Same style navigation + test_styleNav diff --git a/tests/unit/test_textInfos.py b/tests/unit/test_textInfos.py index 76134c19c40..eb2677a7569 100644 --- a/tests/unit/test_textInfos.py +++ b/tests/unit/test_textInfos.py @@ -221,11 +221,24 @@ class TestMoveToCodepointOffsetInOffsetsTextInfo(unittest.TestCase): "utf_32_le", ] - def runTestImpl(self, text: str, target: str, encoding: str): + prefixes = [ + "", + "a\n", + "0123456789", + "\r\n\r\n", + "Привет ", + "🤦😊👍", + ] + + def runTestImpl(self, prefix: str, text: str, target: str, encoding: str): self.assertTrue(target in text, "Invalid test case", ) - obj = BasicTextProvider(text=text, encoding=encoding) + prefixOffset = textUtils.getOffsetConverter(encoding)(prefix).encodedStringLength + obj = BasicTextProvider(text=prefix + text, encoding=encoding) info = obj.makeTextInfo(Offsets(0, 0)) - info.expand(textInfos.UNIT_STORY) + info._startOffset = info._endOffset = prefixOffset + storyInfo = info.copy() + storyInfo.expand(textInfos.UNIT_STORY) + info.setEndPoint(storyInfo, "endToEnd") s = info.text self.assertEqual(text, s) i = s.index(target) @@ -236,18 +249,19 @@ def runTestImpl(self, text: str, target: str, encoding: str): resultInfo.setEndPoint(endInfo, "endToEnd") self.assertEqual(resultInfo.text, target) - def runTestAllEncodings(self, text: str, target: str): + def runTestAllEncodingsAllPrefixes(self, text: str, target: str): for encoding in self.encodings: - self.runTestImpl(text, target, encoding) + for prefix in self.prefixes: + self.runTestImpl(prefix, text, target, encoding) def test_simple(self): - self.runTestAllEncodings("Hello, world!", "world") + self.runTestAllEncodingsAllPrefixes("Hello, world!", "world") def test_russian(self): - self.runTestAllEncodings("Привет, мир!", "мир") + self.runTestAllEncodingsAllPrefixes("Привет, мир!", "мир") def test_chinese(self): - self.runTestAllEncodings("前往另一种语言写成的文章。", "文") + self.runTestAllEncodingsAllPrefixes("前往另一种语言写成的文章。", "文") def test_smileyFace(self): - self.runTestAllEncodings("😂0😂", "0") + self.runTestAllEncodingsAllPrefixes("😂0😂", "0") diff --git a/user_docs/en/changes.t2t b/user_docs/en/changes.t2t index 6b5ca337a1d..bccd45c0500 100644 --- a/user_docs/en/changes.t2t +++ b/user_docs/en/changes.t2t @@ -15,6 +15,8 @@ What's New in NVDA - toggle button (#16001, @mltony) - progress bar (#16001, @mltony) - math formula (#16001, @mltony) + - same style text (#16000, @mltony) + - different style text (#16000, @mltony) - - - Reporting row and column headers is now supported in contenteditable HTML elements. (#14113) diff --git a/user_docs/en/userGuide.t2t b/user_docs/en/userGuide.t2t index 80eee5ef157..16cdf3c6918 100644 --- a/user_docs/en/userGuide.t2t +++ b/user_docs/en/userGuide.t2t @@ -885,6 +885,8 @@ Here is a list of available commands: - Progress bar - Math formula - Vertically aligned paragraph +- Same style text +- Different style text - 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.