-
-
Notifications
You must be signed in to change notification settings - Fork 659
/
Copy pathwinConsoleUIA.py
481 lines (436 loc) · 17.8 KB
/
winConsoleUIA.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
# 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-2023 Bill Dengler, Leonard de Ruijter
import api
import braille
import config
import controlTypes
import ctypes
import NVDAHelper
import speech
import textInfos
import textUtils
import UIAHandler
from comtypes import COMError
from diffHandler import prefer_difflib
from logHandler import log
from typing import (
Optional,
)
from UIAHandler.utils import _getConhostAPILevel
from UIAHandler.constants import WinConsoleAPILevel
from . import UIA, UIATextInfo
from ..behaviors import EnhancedTermTypedCharSupport, KeyboardHandlerBasedTypedCharSupport
from ..window import Window
class ConsoleUIATextInfo(UIATextInfo):
"A TextInfo implementation for consoles with an IMPROVED, but not FORMATTED, API level."
def __init__(self, obj, position, _rangeObj=None):
collapseToEnd = 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.
if not _rangeObj and position in (
textInfos.POSITION_FIRST,
textInfos.POSITION_LAST,
textInfos.POSITION_ALL,
):
try:
_rangeObj, collapseToEnd = self._getBoundingRange(obj, position)
except (COMError, RuntimeError):
# We couldn't bound the console.
log.warning("Couldn't get bounding range for console", exc_info=True)
# Fall back to presenting the entire buffer.
_rangeObj, collapseToEnd = None, None
super(ConsoleUIATextInfo, self).__init__(obj, position, _rangeObj)
if collapseToEnd is not None:
self.collapse(end=collapseToEnd)
def _getBoundingRange(self, obj, position):
"""Returns the UIA text range to which the console should be bounded,
and whether the textInfo should be collapsed after instantiation."""
# microsoft/terminal#4495: In newer consoles,
# IUIAutomationTextRange::getVisibleRanges returns a reliable contiguous range.
_rangeObj = obj.UIATextPattern.GetVisibleRanges().GetElement(0)
collapseToEnd = None
if position == textInfos.POSITION_FIRST:
collapseToEnd = False
elif position == textInfos.POSITION_LAST:
# The exclusive end hangs off the end of the visible ranges.
# Move back one character to remain within bounds.
_rangeObj.MoveEndpointByUnit(
UIAHandler.TextPatternRangeEndpoint_End,
UIAHandler.NVDAUnitsToUIAUnits["character"],
-1,
)
collapseToEnd = True
return (_rangeObj, collapseToEnd)
def move(self, unit, direction, endPoint=None):
oldInfo = None
if self.basePosition != textInfos.POSITION_CARET:
# Ensure 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 _get_text(self) -> str:
# #14689: IMPROVED and END_INCLUSIVE UIA consoles have many blank lines,
# which slows speech dictionary processing to a halt
res = super()._get_text()
stripRes = res.rstrip("\r\n")
IGNORE_TRAILING_WHITESPACE_LENGTH = 100
if len(res) - len(stripRes) > IGNORE_TRAILING_WHITESPACE_LENGTH:
return stripRes
else:
return res
def __ne__(self, other):
"""Support more accurate caret move detection."""
return not self == other
class ConsoleUIATextInfoWorkaroundEndInclusive(ConsoleUIATextInfo):
"""Implementation of various workarounds for pre-microsoft/terminal#4018
conhost: fixes expand/collapse, uses rangeFromPoint instead of broken
GetVisibleRanges for bounding, and implements word movement support."""
def _getBoundingRange(self, obj, position):
# We could use IUIAutomationTextRange::getVisibleRanges, but it seems very broken in consoles
# once more than a few screens worth of content has been written to the console.
# Therefore we resort to using IUIAutomationTextPattern::rangeFromPoint
# for the top left, and bottom right of the console window.
_rangeObj = None
if position is textInfos.POSITION_FIRST:
_rangeObj = self.__class__(obj, obj.location.topLeft)._rangeObj
elif position is textInfos.POSITION_LAST:
# Asking for the range at the bottom right of the window
# Seems to sometimes ignore the x coordinate.
# Therefore use the bottom left, then move to the last character on that line.
tempInfo = self.__class__(obj, obj.location.bottomLeft)
tempInfo.expand(textInfos.UNIT_LINE)
# We must pull back the end by one character otherwise when we collapse to end,
# a console bug results in a textRange covering the entire console buffer!
# Strangely the *very* last character is a special blank point
# so we never seem to miss a real character.
UIATextInfo.move(tempInfo, textInfos.UNIT_CHARACTER, -1, endPoint="end")
tempInfo.setEndPoint(tempInfo, "startToEnd")
_rangeObj = tempInfo._rangeObj
elif position is textInfos.POSITION_ALL:
first = self.__class__(obj, textInfos.POSITION_FIRST)
last = self.__class__(obj, textInfos.POSITION_LAST)
first.setEndPoint(last, "endToEnd")
_rangeObj = first._rangeObj
return (_rangeObj, None)
def collapse(self, end=False):
"""Works around a UIA bug on conhost versions before microsoft/terminal#4018.
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."""
oldInfo = self.copy()
super(ConsoleUIATextInfo, self).collapse(end=end)
if not end:
self._rangeObj.MoveEndpointByRange(
UIAHandler.TextPatternRangeEndpoint_Start,
oldInfo._rangeObj,
UIAHandler.TextPatternRangeEndpoint_Start,
)
def compareEndPoints(self, other, which):
"""Works around a UIA bug on conhost versions before microsoft/terminal#4018.
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 conhost versions before microsoft/terminal#4018 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:
# On conhost versions before microsoft/terminal#4018, UIA doesn't implement word
# movement, so we need to do it manually.
# Relative to the current line, calculate our offset
# and the current word's offsets.
lineInfo = self.copy()
lineInfo.expand(textInfos.UNIT_LINE)
offset = self._getCurrentOffsetInThisLine(lineInfo)
start, end = self._getWordOffsetsInThisLine(offset, lineInfo)
if direction > 0:
# Moving in a forward direction, we can just jump to the
# end offset of the current word and we're done.
res = self.move(
textInfos.UNIT_CHARACTER,
end - offset,
endPoint=endPoint,
)
else:
# Moving backwards
wordStartDistance = (offset - start) * -1
if wordStartDistance < 0:
# We are after the beginning of a word.
# So first move back to the start of the word.
self.move(
textInfos.UNIT_CHARACTER,
wordStartDistance,
endPoint=endPoint,
)
offset += wordStartDistance
# Try to move one character back before the start of the word.
res = self.move(textInfos.UNIT_CHARACTER, -1, endPoint=endPoint)
if res == 0:
return 0
offset -= 1
# We are now positioned within the previous word.
if offset < 0:
# We've moved on to the previous line.
# Recalculate the current offset based on the new line we are now on.
lineInfo = self.copy()
lineInfo.expand(textInfos.UNIT_LINE)
offset = self._getCurrentOffsetInThisLine(lineInfo)
# Finally using the new offset,
# Calculate the current word offsets and move to the start of
# this word if we are not already there.
start, end = self._getWordOffsetsInThisLine(offset, lineInfo)
wordStartDistance = (offset - start) * -1
if wordStartDistance < 0:
self.move(
textInfos.UNIT_CHARACTER,
wordStartDistance,
endPoint=endPoint,
)
else: # moving by a unit other than word
res = super(ConsoleUIATextInfo, self).move(unit, direction, endPoint)
if not endPoint:
# #10191: IUIAutomationTextRange::move in consoles does not correctly produce a collapsed range
# after moving.
# Therefore manually collapse.
self.collapse()
return res
def _getCurrentOffsetInThisLine(self, lineInfo):
"""
Given a caret textInfo expanded to line, returns the index into the
line where the caret is located.
This is necessary since Uniscribe requires indices into the text to
find word boundaries, but UIA only allows for relative movement.
"""
# position a textInfo from the start of the line up to the current position.
charInfo = lineInfo.copy()
charInfo.setEndPoint(self, "endToStart")
text = charInfo._rangeObj.getText(-1)
offset = textUtils.WideStringOffsetConverter(text).encodedStringLength
return offset
def _getWordOffsetsInThisLine(self, offset, lineInfo):
lineText = lineInfo._rangeObj.getText(-1)
# Convert NULL and non-breaking space to space to make sure
# that words will break on them
lineText = lineText.translate({0: " ", 0xA0: " "})
start = ctypes.c_int()
end = ctypes.c_int()
# Uniscribe does some strange things when you give it a string with
# not more than two alphanumeric chars in a row.
# Inject two alphanumeric characters at the end to fix this.
lineText += "xx"
lineTextLen = textUtils.WideStringOffsetConverter(lineText).encodedStringLength
NVDAHelper.localLib.calculateWordOffsets(
lineText,
lineTextLen,
offset,
ctypes.byref(start),
ctypes.byref(end),
)
return (
start.value,
min(end.value, max(1, lineTextLen - 2)),
)
def _isCollapsed(self):
"""Works around a UIA bug on conhost versions before microsoft/terminal#4018 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 _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()._get_text()
if not res:
return " "
else:
return res
class consoleUIAWindow(Window):
# This is the parent of the console text area, which sometimes gets focus after the text area.
shouldAllowUIAFocusEvent = False
class WinConsoleUIA(KeyboardHandlerBasedTypedCharSupport):
#: Disable the name as it won't be localized
name = ""
def _get_apiLevel(self) -> WinConsoleAPILevel:
"""
This property shows which of several console UIA workarounds are
needed in a given conhost instance.
See the comments on the WinConsoleAPILevel enum for details.
"""
self.apiLevel = _getConhostAPILevel(self.windowHandle)
return self.apiLevel
def _get__caretMovementTimeoutMultiplier(self):
"On older consoles, the caret can take a while to move."
return 1 if self.apiLevel >= WinConsoleAPILevel.IMPROVED else 1.5
def _get_windowThreadID(self):
# #10113: Windows forces the thread of console windows to match the thread of the first attached process.
# However, To correctly handle speaking of typed characters,
# NVDA really requires the real thread the window was created in,
# I.e. a thread inside conhost.
from IAccessibleHandler.internalWinEventHandler import consoleWindowsToThreadIDs
threadID = consoleWindowsToThreadIDs.get(self.windowHandle, 0)
if not threadID:
threadID = super().windowThreadID
return threadID
def _get_TextInfo(self):
"""Overriding _get_TextInfo and thus the ConsoleUIATextInfo property
on NVDAObjects.UIA.UIA
ConsoleUIATextInfo bounds review to the visible text.
ConsoleUIATextInfoWorkaroundEndInclusive fixes expand/collapse and implements
word movement."""
if self.apiLevel >= WinConsoleAPILevel.FORMATTED:
return UIATextInfo # No TextInfo workarounds needed
elif self.apiLevel >= WinConsoleAPILevel.IMPROVED:
return ConsoleUIATextInfo
else:
return ConsoleUIATextInfoWorkaroundEndInclusive
def _get_devInfo(self):
info = super().devInfo
info.append(f"API level: {self.apiLevel} ({self.apiLevel.name})")
return info
def _get_diffAlgo(self):
if self.apiLevel < WinConsoleAPILevel.FORMATTED:
# #12974: These consoles are constrained to onscreen text.
# Use Difflib to reduce choppiness in reading.
return prefer_difflib()
else:
return super().diffAlgo
def detectPossibleSelectionChange(self):
try:
return super().detectPossibleSelectionChange()
except COMError:
# microsoft/terminal#5399: when attempting to compare text ranges
# from the standard and alt mode buffers, E_FAIL is returned.
# Downgrade this to a debugWarning.
log.debugWarning(
(
"Exception raised when comparing selections, "
"probably due to a switch to/from the alt buffer."
),
exc_info=True,
)
def event_UIA_notification(self, **kwargs):
"""
In Windows Sun Valley 2 (SV2 M2), UIA notification events will be sent
to announce new text. Block these for now to avoid double-reporting of
text changes.
@note: In the longer term, NVDA should leverage these events in place
of the current LiveText strategy, as performance will likely be
significantly improved and #11002 can be completely mitigated.
"""
log.debugWarning(f"Notification event blocked to avoid double-report: {kwargs}")
def findExtraOverlayClasses(obj, clsList):
if obj.UIAAutomationId == "Text Area":
clsList.append(WinConsoleUIA)
elif obj.UIAAutomationId == "Console Window":
clsList.append(consoleUIAWindow)
class _DiffBasedWinTerminalUIA(EnhancedTermTypedCharSupport):
"""
An overlay class for Windows Terminal (wt.exe) that uses diffing to speak
new text.
"""
def event_UIA_notification(self, **kwargs):
"Block notification events when diffing to prevent double reporting."
log.debugWarning(f"Notification event blocked to avoid double-report: {kwargs}")
class _NotificationsBasedWinTerminalUIA(UIA):
"""
An overlay class for Windows Terminal (wt.exe) that uses UIA notification
events provided by the application to speak new text.
"""
#: Override the role, which is controlTypes.Role.STATICTEXT by default.
role = controlTypes.Role.TERMINAL
#: New line text is announced using UIA notification events
announceNewLineText = False
def event_UIA_notification(
self,
notificationKind: Optional[int] = None,
notificationProcessing: Optional[int] = UIAHandler.NotificationProcessing_CurrentThenMostRecent,
displayString: Optional[str] = None,
activityId: Optional[str] = None,
):
# Do not announce output from background terminals.
if self.appModule != api.getFocusObject().appModule:
return
braille.handler.handleUpdate(self)
# microsoft/terminal#12358: Automatic reading of terminal output
# is provided by UIA notifications. If the user does not want
# automatic reporting of dynamic output, suppress this notification.
if not config.conf["presentation"]["reportDynamicContentChanges"]:
return
for line in displayString.splitlines():
if line and not line.isspace(): # Don't say "blank" during autoread
speech.speakText(line)