|
7 | 7 | from typing import ( |
8 | 8 | Any, |
9 | 9 | Callable, |
| 10 | + Generator, |
10 | 11 | Union, |
11 | 12 | cast, |
12 | 13 | ) |
|
52 | 53 | from abc import ABCMeta, abstractmethod |
53 | 54 | import globalVars |
54 | 55 | from typing import Optional |
| 56 | +from textUtils import WideStringOffsetConverter |
55 | 57 |
|
56 | 58 |
|
57 | 59 | def reportPassThrough(treeInterceptor,onlyIfChanged=True): |
@@ -439,10 +441,24 @@ def _iterNodesByType(self,itemType,direction="next",pos=None): |
439 | 441 |
|
440 | 442 | def _iterNotLinkBlock(self, direction="next", pos=None): |
441 | 443 | 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 |
442 | 452 |
|
443 | 453 | def _quickNavScript(self,gesture, itemType, direction, errorMessage, readUnit): |
444 | 454 | if itemType=="notLinkBlock": |
445 | 455 | 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) |
446 | 462 | else: |
447 | 463 | iterFactory=lambda direction,info: self._iterNodesByType(itemType,direction,info) |
448 | 464 | info=self.selection |
@@ -949,6 +965,30 @@ def _get_disableAutoPassThrough(self): |
949 | 965 | # Translators: Message presented when the browse mode element is not found. |
950 | 966 | prevError=_("no previous tab") |
951 | 967 | ) |
| 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 | +) |
952 | 992 | del qn |
953 | 993 |
|
954 | 994 |
|
@@ -2001,6 +2041,129 @@ def _iterNotLinkBlock(self, direction="next", pos=None): |
2001 | 2041 | yield TextInfoQuickNavItem("notLinkBlock", self, textRange) |
2002 | 2042 | item1=item2 |
2003 | 2043 |
|
| 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 | + |
2004 | 2167 | __gestures={ |
2005 | 2168 | "kb:alt+upArrow": "collapseOrExpandControl", |
2006 | 2169 | "kb:alt+downArrow": "collapseOrExpandControl", |
|
0 commit comments