Repository: nvaccess/nvda

Issue:
NVDA unable to track cursor in the GMail message composer when using Chrome
When composing a new Email or replying to an Email within GMail using Chrome, NVDA does not properly track the cursor.  Specifically, NVDA does not read the character on which the cursor is located.  To replicate this:
1. Using Chrome open GMail in standard view.
2. Compose a new message.
3. Type some text in the message area.
4. Attempt to edit text.



Discussion:
Can you be more specific as to exactly what you're seeing here? When you
"attempt to edit text", what exactly are you pressing and what does NVDA
report? if you use the left and right arrow keys, does NVDA report nothing
or does it report the incorrect character (e.g. the one before/after)?

Cursor tracking does seem to be a bit unreliable for me in Chrome stable.
It is a lot better in Chrome Canary, though. However, even in Chrome
stable, I still get characters reported, though often the character I was
previously on instead of the character at the new position.

Assuming we understand the issue, and Jamie has described it correctly. We think this issue is improving with updates to Chrome, while annoying this seems to be reasonably usable in Chrome canary. This would be a large chunk of uncertain work to work around in nvda. Applying a P3 label. Any questions or clarifications welcome. 

Technical:
A quick approach to reducing the frequency that this occurs could be to increase the time out used to wait for the caret to move.

Resolving patch:
diff --git a/source/config/configSpec.py b/source/config/configSpec.py
--- a/source/config/configSpec.py
+++ b/source/config/configSpec.py
@@ -1,6 +1,6 @@
 # -*- coding: UTF-8 -*-
 #A part of NonVisual Desktop Access (NVDA)
-#Copyright (C) 2006-2016 NV Access Limited
+#Copyright (C) 2006-2017 NV Access Limited
 #This file is covered by the GNU General Public License.
 #See the file COPYING for more details.
 
@@ -192,6 +192,9 @@
 
 [upgrade]
 	newLaptopKeyboardLayout = boolean(default=false)
+
+[editableText]
+	caretMoveTimeoutMs = integer(min=0, max=2000, default=100)
 """).format(latestSchemaVersion=latestSchemaVersion)
 
 #: The configuration specification
diff --git a/source/editableText.py b/source/editableText.py
--- a/source/editableText.py
+++ b/source/editableText.py
@@ -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) 2006-2012 NV Access Limited
+#Copyright (C) 2006-2017 NV Access Limited
 
 """Common support for editable text.
 @note: If you want editable text functionality for an NVDAObject,
@@ -21,6 +21,7 @@
 from scriptHandler import isScriptWaiting, willSayAllResume
 import textInfos
 import controlTypes
+from logHandler import log
 
 class EditableText(ScriptableObject):
 	"""Provides scripts to report appropriately when moving the caret in editable text fields.
@@ -42,37 +43,69 @@ class EditableText(ScriptableObject):
 	#: Whether or not to announce text found before the caret on a new line (e.g. auto numbering)
 	announceNewLineText=True
 
-	def _hasCaretMoved(self, bookmark, retryInterval=0.01, timeout=0.03):
+	def _hasCaretMoved(self, bookmark, retryInterval=0.01, timeout=None, origWord=None):
 		"""
 		Waits for the caret to move, for a timeout to elapse, or for a new focus event or script to be queued.
 		@param bookmark: a bookmark representing the position of the caret before  it was instructed to move
 		@type bookmark: bookmark
 		@param retryInterval: the interval of time in seconds this method should  wait before checking the caret each time.
 		@type retryInterval: float 
-		@param timeout: the over all amount of time in seconds the method should wait before giving up completely.
+		@param timeout: the over all amount of time in seconds the method should wait before giving up completely,
+			C{None} to use the value from the configuration.
 		@type timeout: float
+		@param origWord: The word at the caret before the movement command,
+			C{None} if the word at the caret should not be used to detect movement.
+			This is intended for use with the delete key.
 		@return: a tuple containing a boolean denoting whether this method timed out, and  a TextInfo representing the old or updated caret position or None if interupted by a script or focus event.
 		@rtype: tuple
- 		"""
+		"""
+		if timeout is None:
+			timeoutMs = config.conf["editableText"]["caretMoveTimeoutMs"]
+		else:
+			# This function's arguments are in seconds, but we want ms.
+			timeoutMs = timeout * 1000
+		# time.sleep accepts seconds, so retryInterval is in seconds.
+		# Convert to integer ms to avoid floating point precision errors when adding to elapsed.
+		retryMs = int(retryInterval * 1000)
 		elapsed = 0
 		newInfo=None
-		while elapsed < timeout:
+		while True:
 			if isScriptWaiting():
 				return (False,None)
 			api.processPendingEvents(processEventQueue=False)
 			if eventHandler.isPendingEvents("gainFocus"):
+				log.debug("Focus event. Elapsed: %d ms" % elapsed)
 				return (True,None)
-			#The caret may stop working as the focus jumps, we want to stay in the while loop though
+			# If the focus changes after this point, fetching the caret may fail,
+			# but we still want to stay in this loop.
 			try:
 				newInfo = self.makeTextInfo(textInfos.POSITION_CARET)
-				newBookmark = newInfo.bookmark
 			except (RuntimeError,NotImplementedError):
-				newInfo=None
-			else:
-				if newBookmark!=bookmark:
-					return (True,newInfo)
+				newInfo = None
+			# Caret events are unreliable in some controls.
+			# Try to detect with bookmarks.
+			newBookmark = None
+			if newInfo:
+				try:
+					newBookmark = newInfo.bookmark
+				except (RuntimeError,NotImplementedError):
+					pass
+			if newBookmark and newBookmark!=bookmark:
+				log.debug("Caret move detected using bookmarks. Elapsed: %d ms" % elapsed)
+				return (True, newInfo)
+			if origWord is not None and newInfo:
+				# When pressing delete, bookmarks might not be enough to detect caret movement.
+				wordInfo = newInfo.copy()
+				wordInfo.expand(textInfos.UNIT_WORD)
+				word = wordInfo.text
+				if word != origWord:
+					log.debug("Word at caret changed. Elapsed: %d ms" % elapsed)
+					return (True, newInfo)
+			if elapsed >= timeoutMs:
+				break
 			time.sleep(retryInterval)
-			elapsed += retryInterval
+			elapsed += retryMs
+		log.debug("Caret didn't move before timeout. Elapsed: %d ms" % elapsed)
 		return (False,newInfo)
 
 	def _caretScriptPostMovedHelper(self, speakUnit, gesture, info=None):
@@ -198,9 +231,11 @@ def script_caret_delete(self,gesture):
 			gesture.send()
 			return
 		bookmark=info.bookmark
+		info.expand(textInfos.UNIT_WORD)
+		word=info.text
 		gesture.send()
 		# We'll try waiting for the caret to move, but we don't care if it doesn't.
-		caretMoved,newInfo=self._hasCaretMoved(bookmark)
+		caretMoved,newInfo=self._hasCaretMoved(bookmark,origWord=word)
 		self._caretScriptPostMovedHelper(textInfos.UNIT_CHARACTER,gesture,newInfo)
 		braille.handler.handleCaretMove(self)
 


Test patch: