Repository: nvaccess/nvda

Issue:
Incorrect behavior of "review bottom line" in Windows Console
<!--
Please thoroughly read NVDA's wiki article on how to fill in this template, including how to provide the required files.
Issues may be closed if the required information is not present.
https://github.com/nvaccess/nvda/wiki/Github-issue-template-explanation-and-examples
-->

### Steps to reproduce:
1. Open Command Prompt.
2. Invoke "review bottom line" (desktop layout: shift+numpad 9).

### Actual behavior:
On Windows 10 1803, the last line of output is read as expected, but the review cursor is placed out of bounds: after invoking "review bottom line", blank lines at the end of the output become accessible that previously weren't.

On Windows 10 1903, NVDA says blank. "read current line" also reports blank, but attempting to review the next line says "bottom" followed by the last line of output.

### Expected behavior:
The last line of output is reported and the review cursor is placed there. The review cursor stays within the console bounds (i.e. no blank lines are exposed when the script is invoked).

### System configuration
#### NVDA installed/portable/running from source:
Installed and source.

#### NVDA version:
Latest `master` branch.

#### Windows version:
Windows 10 version 1803 and 1903.

#### Name and version of other software in use when reproducing the issue:
Windows Console (Command Prompt)

#### Other information about your system:

### Other questions
#### Does the issue still occur after restarting your PC?
Yes.

#### Have you tried any other versions of NVDA? If so, please report their behaviors.
No.


Discussion:
This issue is likely caused by an unreliable or incorrect `lastVisiRange`. See the initialization for a `ConsoleUIATextInfo` at `POSITION_LAST` (around line 35 of `NVDAObjects/UIA/winConsoleUIA.py`).

Cc @feerrenrut @leonardder @michaelDCurran

Resolving patch:
diff --git a/source/NVDAObjects/UIA/winConsoleUIA.py b/source/NVDAObjects/UIA/winConsoleUIA.py
--- a/source/NVDAObjects/UIA/winConsoleUIA.py
+++ b/source/NVDAObjects/UIA/winConsoleUIA.py
@@ -18,26 +18,35 @@
 
 
 class consoleUIATextInfo(UIATextInfo):
-	#: At least on Windows 10 1903, expanding then collapsing the text info
-	#: caused review to get stuck, so disable it.
-	#: There may be no need to disable this anymore, but doing so doesn't seem
-	#: to do much good either.
-	_expandCollapseBeforeReview = False
 
 	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.
+		# 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.
+		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
 		super(consoleUIATextInfo, self).__init__(obj, position, _rangeObj)
-		# Re-implement POSITION_FIRST and POSITION_LAST in terms of
-		# visible ranges to fix review top/bottom scripts.
-		if position == textInfos.POSITION_FIRST:
-			visiRanges = self.obj.UIATextPattern.GetVisibleRanges()
-			firstVisiRange = visiRanges.GetElement(0)
-			self._rangeObj = firstVisiRange
-			self.collapse()
-		elif position == textInfos.POSITION_LAST:
-			visiRanges = self.obj.UIATextPattern.GetVisibleRanges()
-			lastVisiRange = visiRanges.GetElement(visiRanges.length - 1)
-			self._rangeObj = lastVisiRange
-			self.collapse(True)
 
 	def collapse(self, end=False):
 		"""Works around a UIA bug on Windows 10 1803 and later."""
@@ -54,14 +63,12 @@ def collapse(self, end=False):
 			)
 
 	def move(self, unit, direction, endPoint=None):
-		oldRange = 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.
-			visiRanges = self.obj.UIATextPattern.GetVisibleRanges()
-			visiLength = visiRanges.length
-			if visiLength > 0:
-				oldRange = self._rangeObj.clone()
+			boundingInfo = self.obj.makeTextInfo(textInfos.POSITION_ALL)
+			oldInfo = self.copy()
 		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
@@ -116,16 +123,32 @@ def move(self, unit, direction, endPoint=None):
 		else:  # moving by a unit other than word
 			res = super(consoleUIATextInfo, self).move(unit, direction,
 														endPoint)
-		try:
-			if (
-				oldRange
-				and isTextRangeOffscreen(self._rangeObj, visiRanges)
-				and not isTextRangeOffscreen(oldRange, visiRanges)
-			):
-				self._rangeObj = oldRange
-				return 0
-		except (COMError, RuntimeError):
-			pass
+		if not endPoint:
+			# #10191: IUIAutomationTextRange::move in consoles does not correctly produce a collapsed range
+			# 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):
@@ -207,12 +230,12 @@ def _getCurrentOffsetInThisLine(self, lineInfo):
 		# position a textInfo from the start of the line up to the current position.
 		charInfo = lineInfo.copy()
 		charInfo.setEndPoint(self, "endToStart")
-		text = charInfo.text
+		text = charInfo._rangeObj.getText(-1)
 		offset = textUtils.WideStringOffsetConverter(text).wideStringLength
 		return offset
 
 	def _getWordOffsetsInThisLine(self, offset, lineInfo):
-		lineText = lineInfo.text or u" "
+		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: u' ', 0xa0: u' '})
@@ -282,11 +305,12 @@ def _get_TextInfo(self):
 		return consoleUIATextInfo
 
 	def _getTextLines(self):
-		# Filter out extraneous empty lines from UIA
-		ptr = self.UIATextPattern.GetVisibleRanges()
-		res = [ptr.GetElement(i).GetText(-1) for i in range(ptr.length)]
-		return res
-
+		# This override of _getTextLines takes advantage of the fact that
+		# the console text contains linefeeds for every line
+		# Thus a simple string splitlines is much faster than splitting by unit line.
+		ti = self.makeTextInfo(textInfos.POSITION_ALL)
+		text = ti.text or ""
+		return text.splitlines()
 
 def findExtraOverlayClasses(obj, clsList):
 	if obj.UIAElement.cachedAutomationId == "Text Area":
diff --git a/source/globalCommands.py b/source/globalCommands.py
--- a/source/globalCommands.py
+++ b/source/globalCommands.py
@@ -1026,9 +1026,8 @@ def script_review_top(self,gesture):
 
 	def script_review_previousLine(self,gesture):
 		info=api.getReviewPosition().copy()
-		if info._expandCollapseBeforeReview:
-			info.expand(textInfos.UNIT_LINE)
-			info.collapse()
+		info.expand(textInfos.UNIT_LINE)
+		info.collapse()
 		res=info.move(textInfos.UNIT_LINE,-1)
 		if res==0:
 			# Translators: a message reported when review cursor is at the top line of the current navigator object.
@@ -1058,9 +1057,8 @@ def script_review_currentLine(self,gesture):
 
 	def script_review_nextLine(self,gesture):
 		info=api.getReviewPosition().copy()
-		if info._expandCollapseBeforeReview:
-			info.expand(textInfos.UNIT_LINE)
-			info.collapse()
+		info.expand(textInfos.UNIT_LINE)
+		info.collapse()
 		res=info.move(textInfos.UNIT_LINE,1)
 		if res==0:
 			# Translators: a message reported when review cursor is at the bottom line of the current navigator object.
@@ -1086,9 +1084,8 @@ def script_review_bottom(self,gesture):
 
 	def script_review_previousWord(self,gesture):
 		info=api.getReviewPosition().copy()
-		if info._expandCollapseBeforeReview:
-			info.expand(textInfos.UNIT_WORD)
-			info.collapse()
+		info.expand(textInfos.UNIT_WORD)
+		info.collapse()
 		res=info.move(textInfos.UNIT_WORD,-1)
 		if res==0:
 			# Translators: a message reported when review cursor is at the top line of the current navigator object.
@@ -1117,9 +1114,8 @@ def script_review_currentWord(self,gesture):
 
 	def script_review_nextWord(self,gesture):
 		info=api.getReviewPosition().copy()
-		if info._expandCollapseBeforeReview:
-			info.expand(textInfos.UNIT_WORD)
-			info.collapse()
+		info.expand(textInfos.UNIT_WORD)
+		info.collapse()
 		res=info.move(textInfos.UNIT_WORD,1)
 		if res==0:
 			# Translators: a message reported when review cursor is at the bottom line of the current navigator object.
@@ -1148,9 +1144,8 @@ def script_review_previousCharacter(self,gesture):
 		lineInfo=api.getReviewPosition().copy()
 		lineInfo.expand(textInfos.UNIT_LINE)
 		charInfo=api.getReviewPosition().copy()
-		if charInfo._expandCollapseBeforeReview:
-			charInfo.expand(textInfos.UNIT_CHARACTER)
-			charInfo.collapse()
+		charInfo.expand(textInfos.UNIT_CHARACTER)
+		charInfo.collapse()
 		res=charInfo.move(textInfos.UNIT_CHARACTER,-1)
 		if res==0 or charInfo.compareEndPoints(lineInfo,"startToStart")<0:
 			# Translators: a message reported when review cursor is at the leftmost character of the current navigator object's text.
@@ -1195,9 +1190,8 @@ def script_review_nextCharacter(self,gesture):
 		lineInfo=api.getReviewPosition().copy()
 		lineInfo.expand(textInfos.UNIT_LINE)
 		charInfo=api.getReviewPosition().copy()
-		if charInfo._expandCollapseBeforeReview:
-			charInfo.expand(textInfos.UNIT_CHARACTER)
-			charInfo.collapse()
+		charInfo.expand(textInfos.UNIT_CHARACTER)
+		charInfo.collapse()
 		res=charInfo.move(textInfos.UNIT_CHARACTER,1)
 		if res==0 or charInfo.compareEndPoints(lineInfo,"endToEnd")>=0:
 			# Translators: a message reported when review cursor is at the rightmost character of the current navigator object's text.
diff --git a/source/textInfos/__init__.py b/source/textInfos/__init__.py
--- a/source/textInfos/__init__.py
+++ b/source/textInfos/__init__.py
@@ -280,10 +280,6 @@ class TextInfo(baseObject.AutoPropertyObject):
 	@type bookmark: L{Bookmark}
 	"""
 
-	#: whether this textInfo should be expanded then collapsed around its enclosing unit before review.
-	#: This can be problematic for some implementations.
-	_expandCollapseBeforeReview = True
-
 	def __init__(self,obj,position):
 		"""Constructor.
 		Subclasses must extend this, calling the superclass method first.


Test patch: