Repository: nvaccess/nvda

Issue:
Some UIA text ranges have no apparent bottom
<!-- Please read the text in this edit field before filling it in.
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/blob/master/devDocs/githubIssueTemplateExplanationAndExamples.md
Please also note that the NVDA project has a Citizen and Contributor Code of Conduct which can be found at https://github.com/nvaccess/nvda/blob/master/CODE_OF_CONDUCT.md. NV Access expects that all contributors and other community members read and abide by the rules set out in this document while participating or contributing to this project. This includes creating or commenting on issues and pull requests. 

Each of the questions and sections below start with multiple hash symbols (#). Place your answers and information on the blank line below each question.
-->

### Steps to reproduce:
In various modern UIA text providers (notepad with UIA forceably enabled with `nav.appModule.isGoodUIAWindow = lambda hwnd: True` in console), an upcoming/private build of conhost, and Word/Edgium with UIA enabled):

1. Navigate to the last line of text with the review cursor.
2. Attempt to move to the next line of text.

### Actual behavior:
The review cursor is moved to the end of the current line, and "bottom" is not announced. Technical: `UIATextInfo.move` (`IUIAutomationTextRange::move`) returns 1.

### Expected behavior:
"bottom" is announced.

### Context:
It seems that, per Microsoft Word's behaviour (other UIA providers are copying this): when moving a degenerate (collapsed) range past the end of the document, the correct UIA behaviour is to permit the move and move to the end of the requested unit, whereas expanded ranges should not permit the move when attempting to go past the end. Is this correct UIA behaviour (the docs seem unclear) and therefore an NVDA bug, or are providers incorrectly implementing this (so I should raise it upstream)? Cc @csauls, @michaelDCurran and @leonardder.


Discussion:
Also CC @carlos-zamora (he is implementing according to Word's behaviour in conhost as part of microsoft/terminal#11122).
A few notes from my findings:
- Moving a **degenerate** range forward on the bottom line always succeeds and results in a degenerate range at the end of the document
- Moving a **non-degenerate** range similarly fails to move, but modifies the text range to still "expand" by the given unit. Seems like the philosophy is "you're already on the last line, there is no next full line to move to"
- This is specifically an observation for UIA text providers. Particularly, we've tested MS Word and Notepad. If you use the legacy pattern, you may get a different behavior.

This leads me to believe that there might have been a change in design or expected behavior for UIA. Nonetheless, I'm inclined to say that this is the correct behavior as I have yet found a UIA text provider that behaves differently.
@codeofdusk Can you reproduce this with older versions of NVDA?
Looking around, I agree with the assessment by @carlos-zamora that much of the review cursor code was written without taking UIA into account (it's been present since at least commit 200bf5c2c3 in 2009).

Changing the code in `globalCommands.script_review_previousLine`/`nextLine`/etc to collapse first, then expand by line, then move yields correct behaviour in Word and modern conhost, but breaks review in MSAA controls.

Resolving patch:
diff --git a/source/globalCommands.py b/source/globalCommands.py
--- a/source/globalCommands.py
+++ b/source/globalCommands.py
@@ -1324,18 +1324,23 @@ def script_review_currentLine(self,gesture):
 		category=SCRCAT_TEXTREVIEW,
 		gestures=("kb:numpad9", "kb(laptop):NVDA+downArrow", "ts(text):flickDown")
 	)
-	def script_review_nextLine(self,gesture):
-		info=api.getReviewPosition().copy()
-		info.expand(textInfos.UNIT_LINE)
-		info.collapse()
-		res=info.move(textInfos.UNIT_LINE,1)
-		if res==0:
+	def script_review_nextLine(self, gesture):
+		origInfo = api.getReviewPosition().copy()
+		origInfo.collapse()
+		info = origInfo.copy()
+		res = info.move(textInfos.UNIT_LINE, 1)
+		newLine = info.copy()
+		newLine.expand(textInfos.UNIT_LINE)
+		# #12808: Some implementations of move forward by one line may succeed one more time than expected,
+		# landing on the exclusive end of the document.
+		# Therefore, verify that expanding after the move does result in being on a new line,
+		# i.e. the new line starts after the original review cursor position.
+		if res == 0 or newLine.start <= origInfo.start:
 			# Translators: a message reported when review cursor is at the bottom line of the current navigator object.
 			ui.reviewMessage(_("Bottom"))
 		else:
 			api.setReviewPosition(info)
-		info.expand(textInfos.UNIT_LINE)
-		speech.speakTextInfo(info, unit=textInfos.UNIT_LINE, reason=controlTypes.OutputReason.CARET)
+		speech.speakTextInfo(newLine, unit=textInfos.UNIT_LINE, reason=controlTypes.OutputReason.CARET)
 
 	@script(
 		# Translators: Input help mode message for move review cursor to bottom line command.
@@ -1395,18 +1400,23 @@ def script_review_currentWord(self,gesture):
 		category=SCRCAT_TEXTREVIEW,
 		gestures=("kb:numpad6", "kb(laptop):NVDA+control+rightArrow", "ts(text):2finger_flickRight")
 	)
-	def script_review_nextWord(self,gesture):
-		info=api.getReviewPosition().copy()
-		info.expand(textInfos.UNIT_WORD)
-		info.collapse()
-		res=info.move(textInfos.UNIT_WORD,1)
-		if res==0:
+	def script_review_nextWord(self, gesture):
+		origInfo = api.getReviewPosition().copy()
+		origInfo.collapse()
+		info = origInfo.copy()
+		res = info.move(textInfos.UNIT_WORD, 1)
+		newWord = info.copy()
+		newWord.expand(textInfos.UNIT_WORD)
+		# #12808: Some implementations of move forward by one word may succeed one more time than expected,
+		# landing on the exclusive end of the document.
+		# Therefore, verify that expanding after the move does result in being on a new word,
+		# i.e. the new word starts after the original review cursor position.
+		if res == 0 or newWord.start <= origInfo.start:
 			# Translators: a message reported when review cursor is at the bottom line of the current navigator object.
 			ui.reviewMessage(_("Bottom"))
 		else:
 			api.setReviewPosition(info)
-		info.expand(textInfos.UNIT_WORD)
-		speech.speakTextInfo(info, reason=controlTypes.OutputReason.CARET, unit=textInfos.UNIT_WORD)
+		speech.speakTextInfo(newWord, unit=textInfos.UNIT_WORD, reason=controlTypes.OutputReason.CARET)
 
 	@script(
 		description=_(


Test patch: