diff --git a/addon/globalPlugins/virtualRevision.py b/addon/globalPlugins/virtualRevision.py index 90d8eb0..994bbe8 100644 --- a/addon/globalPlugins/virtualRevision.py +++ b/addon/globalPlugins/virtualRevision.py @@ -20,6 +20,62 @@ SCRCAT_TEXTREVIEW = None +def _isTermControl(obj): + # True when obj is a Windows Terminal "TermControl" (the UIA element that hosts the text + # of cmd/PowerShell/WSL tabs and panes). Reading UIAElement.currentClassName can raise a + # COM error on some objects, and this runs against every object we inspect, so it must never + # let an exception escape and abort the review command. + try: + return bool(getattr(obj, "UIAElement", None)) and obj.UIAElement.currentClassName == "TermControl" + except Exception: + return False + + +def _findTermControl(obj): + # Return the focused object, or its nearest ancestor, that is a TermControl, else None. + # NVDA normally puts focus directly on the TermControl, so this usually returns obj itself, + # but walking up a few ancestors covers hosts that focus a child of the control. Capturing + # straight from the focused control (rather than searching down from the foreground window) + # is what makes every tab and split pane work, regardless of the host window's class name. + node = obj + depth = 0 + while node is not None and depth < 20: + if _isTermControl(node): + return node + try: + node = node.parent + except Exception: + break + depth += 1 + return None + + +def _isDecorativeTerminalLine(line): + # True for lines whose only non-whitespace characters are in the Unicode Box Drawing + # block (U+2500-U+257F): the borders, separators, and corners that TUI apps paint. + # Content lines that happen to contain a "│" border still survive because the + # letters/digits inside fall outside the range. + stripped = line.strip() + if not stripped: + return True + return all("─" <= ch <= "╿" for ch in stripped) + + +def _cleanTerminalText(text): + # Terminal cells are space-padded to the column width, and TUI apps reserve fixed + # rows/columns for borders, status bars, input, etc. Captured as a UNIT_STORY this + # turns into long runs of blank padding and box-border-only lines that bury the actual + # content when the review window is navigated line by line. Strip per-line trailing + # whitespace and drop lines that carry no real content. + lines = [] + for line in text.split("\n"): + stripped = line.rstrip() + if _isDecorativeTerminalLine(stripped): + continue + lines.append(stripped) + return "\n".join(lines) + + def obtainUWPWindowText(): foreground = api.getForegroundObject() desktop = api.getDesktopObject() @@ -28,14 +84,10 @@ def obtainUWPWindowText(): while curObject: if curObject.name is not None: uwpTextList.append(curObject.name) - if ( - hasattr(curObject, "UIAElement") - and curObject.UIAElement - and curObject.UIAElement.currentClassName == "TermControl" - ): + if _isTermControl(curObject): info = curObject.makeTextInfo(textInfos.POSITION_FIRST) info.expand(textInfos.UNIT_STORY) - text = info.clipboardText.rstrip() + text = _cleanTerminalText(info.clipboardText) uwpTextList.append(text) if curObject.simpleFirstChild: curObject = curObject.simpleFirstChild @@ -56,14 +108,10 @@ def obtainUWPWindowText(): curObject = parent.simpleNext except AttributeError: continue - if ( - hasattr(foreground, "UIAElement") - and foreground.UIAElement - and foreground.UIAElement.currentClassName == "TermControl" - ): + if _isTermControl(foreground): info = foreground.makeTextInfo(textInfos.POSITION_FIRST) info.expand(textInfos.UNIT_STORY) - text = info.clipboardText.rstrip() + text = _cleanTerminalText(info.clipboardText) uwpTextList.append(text) return uwpTextList @@ -82,8 +130,19 @@ def script_virtualWindowReview(self, gesture): # In case of universal apps, traverse child elements. text = None obj = api.getFocusObject() + # Modern command-line windows (Windows Terminal, and any XAML/UWP terminal host) expose + # their text through a "TermControl" UIA element instead of the legacy console buffer. + # Detect it from the focused control and capture straight from there, so every tab and + # split pane works no matter what the host window's class name is. Checked first because + # the terminal control's HWND class often matches the UWP prefixes below, and that branch's + # generic child walk is slower and can miss the active pane. + term = _findTermControl(obj) + if term is not None: + info = term.makeTextInfo(textInfos.POSITION_FIRST) + info.expand(textInfos.UNIT_STORY) + text = _cleanTerminalText(info.clipboardText) # Because it may take a while to iterate through elements, play abeep to alert users of this fact and the fact it's a UWP screen. - if obj.windowClassName.startswith(("Windows.UI.Core", "Windows.UI.Input.InputSite")): + elif obj.windowClassName.startswith(("Windows.UI.Core", "Windows.UI.Input.InputSite")): import tones tones.beep(400, 300)