Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

UI Automation in Windows Console: Use UIA by default on Windows 10 version 2004 and later #10716

Closed
wants to merge 8 commits into from
Closed
Show file tree
Hide file tree
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
3 changes: 2 additions & 1 deletion source/NVDAObjects/UIA/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
import textInfos
from logHandler import log
from UIAUtils import *
from UIAUtils import shouldUseUIAConsole
from NVDAObjects.window import Window
from NVDAObjects import NVDAObjectTextInfo, InvalidNVDAObject
from NVDAObjects.behaviors import (
Expand Down Expand Up @@ -929,7 +930,7 @@ def findOverlayClasses(self,clsList):
# Support Windows Console's UIA interface
if (
self.windowClassName == "ConsoleWindowClass"
and config.conf['UIA']['winConsoleImplementation'] == "UIA"
and shouldUseUIAConsole()
):
from . import winConsoleUIA
winConsoleUIA.findExtraOverlayClasses(self, clsList)
Expand Down
234 changes: 122 additions & 112 deletions source/NVDAObjects/UIA/winConsoleUIA.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
# A part of NonVisual Desktop Access (NVDA)
# This file is covered by the GNU General Public License.
# See the file COPYING for more details.
# Copyright (C) 2019 Bill Dengler
# Copyright (C) 2019-2020 Bill Dengler

import ctypes
import NVDAHelper
Expand All @@ -12,13 +12,13 @@

from comtypes import COMError
from UIAUtils import isTextRangeOffscreen
from winVersion import isWin10
from . import UIATextInfo
from ..behaviors import KeyboardHandlerBasedTypedCharSupport
from ..window import Window


class consoleUIATextInfo(UIATextInfo):

def __init__(self, obj, position, _rangeObj=None):
# We want to limit textInfos to just the visible part of the console.
# Therefore we specifically handle POSITION_FIRST, POSITION_LAST and POSITION_ALL.
Expand Down Expand Up @@ -48,8 +48,58 @@ def __init__(self, obj, position, _rangeObj=None):
_rangeObj = first._rangeObj
super(consoleUIATextInfo, self).__init__(obj, position, _rangeObj)

def move(self, unit, direction, endPoint=None):
oldInfo = None
if self.basePosition != textInfos.POSITION_CARET:
# Insure we haven't gone beyond the visible text.
# UIA adds thousands of blank lines to the end of the console.
boundingInfo = self.obj.makeTextInfo(textInfos.POSITION_ALL)
oldInfo = self.copy()
res = self._move(unit, direction, endPoint)
# Console textRanges have access to the entire console buffer.
# However, we want to limit ourselves to onscreen text.
# Therefore, if the textInfo was originally visible,
# but we are now above or below the visible range,
# Restore the original textRange and pretend the move didn't work.
if oldInfo:
try:
if (
(
self.compareEndPoints(boundingInfo, "startToStart") < 0
or self.compareEndPoints(boundingInfo, "startToEnd") >= 0
)
and not (
oldInfo.compareEndPoints(boundingInfo, "startToStart") < 0
or oldInfo.compareEndPoints(boundingInfo, "startToEnd") >= 0
)
):
self._rangeObj = oldInfo._rangeObj
return 0
except (COMError, RuntimeError):
pass
return res

def _move(self, unit, direction, endPoint=None):
"Perform a move without respect to bounding."
return super(consoleUIATextInfo, self).move(unit, direction, endPoint)

def __ne__(self, other):
"""Support more accurate caret move detection."""
return not self == other

def _get_text(self):
# #10036: return a space if the text range is empty.
# Consoles don't actually store spaces, the character is merely left blank.
res = super(consoleUIATextInfo, self)._get_text()
if not res:
return ' '
else:
return res


class consoleUIATextInfoPre2004(consoleUIATextInfo):
def collapse(self, end=False):
"""Works around a UIA bug on Windows 10 1803 and later."""
"""Works around a UIA bug on Windows 10 1803 to 2004."""
# When collapsing, consoles seem to incorrectly push the start of the
# textRange back one character.
# Correct this by bringing the start back up to where the end is.
Expand All @@ -62,13 +112,62 @@ def collapse(self, end=False):
UIAHandler.TextPatternRangeEndpoint_Start
)

def move(self, unit, direction, endPoint=None):
oldInfo = None
if self.basePosition != textInfos.POSITION_CARET:
# Insure we haven't gone beyond the visible text.
# UIA adds thousands of blank lines to the end of the console.
boundingInfo = self.obj.makeTextInfo(textInfos.POSITION_ALL)
oldInfo = self.copy()
def compareEndPoints(self, other, which):
"""Works around a UIA bug on Windows 10 1803 to 2004."""
# Even when a console textRange's start and end have been moved to the
# same position, the console incorrectly reports the end as being
# past the start.
# Compare to the start (not the end) when collapsed.
selfEndPoint, otherEndPoint = which.split("To")
if selfEndPoint == "end" and self._isCollapsed():
selfEndPoint = "start"
if otherEndPoint == "End" and other._isCollapsed():
otherEndPoint = "Start"
which = f"{selfEndPoint}To{otherEndPoint}"
return super().compareEndPoints(other, which=which)

def setEndPoint(self, other, which):
"""Override of L{textInfos.TextInfo.setEndPoint}.
Works around a UIA bug on Windows 10 1803 to 2004 that means we can
not trust the "end" endpoint of a collapsed (empty) text range
for comparisons.
"""
selfEndPoint, otherEndPoint = which.split("To")
# In this case, there is no need to check if self is collapsed
# since the point of this method is to change its text range, modifying the "end" endpoint of a collapsed
# text range is fine.
if otherEndPoint == "End" and other._isCollapsed():
otherEndPoint = "Start"
which = f"{selfEndPoint}To{otherEndPoint}"
return super().setEndPoint(other, which=which)

def expand(self, unit):
if unit == textInfos.UNIT_WORD:
# UIA doesn't implement word movement, so we need to do it manually.
lineInfo = self.copy()
lineInfo.expand(textInfos.UNIT_LINE)
offset = self._getCurrentOffsetInThisLine(lineInfo)
start, end = self._getWordOffsetsInThisLine(offset, lineInfo)
wordEndPoints = (
(offset - start) * -1,
end - offset - 1
)
if wordEndPoints[0]:
self._rangeObj.MoveEndpointByUnit(
UIAHandler.TextPatternRangeEndpoint_Start,
UIAHandler.NVDAUnitsToUIAUnits[textInfos.UNIT_CHARACTER],
wordEndPoints[0]
)
if wordEndPoints[1]:
self._rangeObj.MoveEndpointByUnit(
UIAHandler.TextPatternRangeEndpoint_End,
UIAHandler.NVDAUnitsToUIAUnits[textInfos.UNIT_CHARACTER],
wordEndPoints[1]
)
else:
return super(consoleUIATextInfo, self).expand(unit)

def _move(self, unit, direction, endPoint=None):
if unit == textInfos.UNIT_WORD and direction != 0:
# UIA doesn't implement word movement, so we need to do it manually.
# Relative to the current line, calculate our offset
Expand Down Expand Up @@ -128,98 +227,8 @@ def move(self, unit, direction, endPoint=None):
# after moving.
# Therefore manually collapse.
self.collapse()
# Console textRanges have access to the entire console buffer.
# However, we want to limit ourselves to onscreen text.
# Therefore, if the textInfo was originally visible,
# but we are now above or below the visible range,
# Restore the original textRange and pretend the move didn't work.
if oldInfo:
try:
if (
(
self.compareEndPoints(boundingInfo, "startToStart") < 0
or self.compareEndPoints(boundingInfo, "startToEnd") >= 0
)
and not (
oldInfo.compareEndPoints(boundingInfo, "startToStart") < 0
or oldInfo.compareEndPoints(boundingInfo, "startToEnd") >= 0
)
):
self._rangeObj = oldInfo._rangeObj
return 0
except (COMError, RuntimeError):
pass
return res

def expand(self, unit):
if unit == textInfos.UNIT_WORD:
# UIA doesn't implement word movement, so we need to do it manually.
lineInfo = self.copy()
lineInfo.expand(textInfos.UNIT_LINE)
offset = self._getCurrentOffsetInThisLine(lineInfo)
start, end = self._getWordOffsetsInThisLine(offset, lineInfo)
wordEndPoints = (
(offset - start) * -1,
end - offset - 1
)
if wordEndPoints[0]:
self._rangeObj.MoveEndpointByUnit(
UIAHandler.TextPatternRangeEndpoint_Start,
UIAHandler.NVDAUnitsToUIAUnits[textInfos.UNIT_CHARACTER],
wordEndPoints[0]
)
if wordEndPoints[1]:
self._rangeObj.MoveEndpointByUnit(
UIAHandler.TextPatternRangeEndpoint_End,
UIAHandler.NVDAUnitsToUIAUnits[textInfos.UNIT_CHARACTER],
wordEndPoints[1]
)
else:
return super(consoleUIATextInfo, self).expand(unit)

def compareEndPoints(self, other, which):
"""Works around a UIA bug on Windows 10 1803 and later."""
# Even when a console textRange's start and end have been moved to the
# same position, the console incorrectly reports the end as being
# past the start.
# Compare to the start (not the end) when collapsed.
selfEndPoint, otherEndPoint = which.split("To")
if selfEndPoint == "end" and self._isCollapsed():
selfEndPoint = "start"
if otherEndPoint == "End" and other._isCollapsed():
otherEndPoint = "Start"
which = f"{selfEndPoint}To{otherEndPoint}"
return super().compareEndPoints(other, which=which)

def setEndPoint(self, other, which):
"""Override of L{textInfos.TextInfo.setEndPoint}.
Works around a UIA bug on Windows 10 1803 and later that means we can
not trust the "end" endpoint of a collapsed (empty) text range
for comparisons.
"""
selfEndPoint, otherEndPoint = which.split("To")
# In this case, there is no need to check if self is collapsed
# since the point of this method is to change its text range, modifying the "end" endpoint of a collapsed
# text range is fine.
if otherEndPoint == "End" and other._isCollapsed():
otherEndPoint = "Start"
which = f"{selfEndPoint}To{otherEndPoint}"
return super().setEndPoint(other, which=which)

def _isCollapsed(self):
"""Works around a UIA bug on Windows 10 1803 and later that means we
cannot trust the "end" endpoint of a collapsed (empty) text range
for comparisons.
Instead we check to see if we can get the first character from the
text range. A collapsed range will not have any characters
and will return an empty string."""
return not bool(self._rangeObj.getText(1))

def _get_isCollapsed(self):
# To decide if the textRange is collapsed,
# Check if it has no text.
return self._isCollapsed()

def _getCurrentOffsetInThisLine(self, lineInfo):
"""
Given a caret textInfo expanded to line, returns the index into the
Expand Down Expand Up @@ -258,18 +267,19 @@ def _getWordOffsetsInThisLine(self, offset, lineInfo):
min(end.value, max(1, lineTextLen - 2))
)

def __ne__(self, other):
"""Support more accurate caret move detection."""
return not self == other
def _isCollapsed(self):
"""Works around a UIA bug on Windows 10 1803 to 2004 that means we
cannot trust the "end" endpoint of a collapsed (empty) text range
for comparisons.
Instead we check to see if we can get the first character from the
text range. A collapsed range will not have any characters
and will return an empty string."""
return not bool(self._rangeObj.getText(1))

def _get_text(self):
# #10036: return a space if the text range is empty.
# Consoles don't actually store spaces, the character is merely left blank.
res = super(consoleUIATextInfo, self)._get_text()
if not res:
return ' '
else:
return res
def _get_isCollapsed(self):
# To decide if the textRange is collapsed,
# Check if it has no text.
return self._isCollapsed()


class consoleUIAWindow(Window):
Expand Down Expand Up @@ -302,7 +312,7 @@ def _get_TextInfo(self):
on NVDAObjects.UIA.UIA
consoleUIATextInfo fixes expand/collapse, implements word movement, and
bounds review to the visible text."""
return consoleUIATextInfo
return consoleUIATextInfo if isWin10(2004) else consoleUIATextInfoPre2004

def _getTextLines(self):
# This override of _getTextLines takes advantage of the fact that
Expand Down
23 changes: 22 additions & 1 deletion source/UIAUtils.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
# A part of NonVisual Desktop Access (NVDA)
# Copyright (C) 2015-2016 NV Access Limited
# Copyright (C) 2015-2020 NV Access Limited, Bill Dengler
# This file is covered by the GNU General Public License.
# See the file COPYING for more details.

import operator
from comtypes import COMError
import config
import ctypes
import UIAHandler
from winVersion import isWin10

def createUIAMultiPropertyCondition(*dicts):
"""
Expand Down Expand Up @@ -224,3 +226,22 @@ def getValue(self,ID,ignoreMixedValues=False):
if not ignoreMixedValues and val==UIAHandler.handler.ReservedMixedAttributeValue:
raise UIAMixedAttributeError
return val


def shouldUseUIAConsole(setting=None):
"""Determines whether to use UIA in the Windows Console.
@param setting: the config value to base this check on (if not provided,
it is retrieved from config).
"""
if not setting:
setting = config.conf['UIA']['winConsoleImplementation']
if setting == "legacy":
return False
elif setting == "UIA":
return True
# #7497: Windows 10 Fall Creators Update has an incomplete UIA
# implementation for console windows, therefore for now we should
# ignore it.
# It does not implement caret/selection, and probably has no
# new text events.
return isWin10(2004)
10 changes: 4 additions & 6 deletions source/_UIAHandler.py
Original file line number Diff line number Diff line change
Expand Up @@ -500,12 +500,10 @@ def IUIAutomationNotificationEventHandler_HandleNotificationEvent(

def _isBadUIAWindowClassName(self, windowClass):
"Given a windowClassName, returns True if this is a known problematic UIA implementation."
# #7497: Windows 10 Fall Creators Update has an incomplete UIA
# implementation for console windows, therefore for now we should
# ignore it.
# It does not implement caret/selection, and probably has no new text
# events.
if windowClass == "ConsoleWindowClass" and config.conf['UIA']['winConsoleImplementation'] != "UIA":
if (
windowClass == "ConsoleWindowClass"
and not UIAUtils.shouldUseUIAConsole()
):
return True
return windowClass in badUIAWindowClassNames

Expand Down
Loading