Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
85 changes: 72 additions & 13 deletions addon/globalPlugins/virtualRevision.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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
Expand All @@ -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

Expand All @@ -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)
Expand Down