Repository: nvaccess/nvda

Issue:
Braille input with NVDA isn't always correct
<!-- 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:

1. Open NVDA braille settings and choose, for example, "unified English braille grade 1" as an input braille table (generally relevant for all braille tables).
2. (for UEB) input with a braille display keyboard: ⠐⠣⠠⠉⠐⠜ (5-126-6-14-5-345). Another example: ⠐⠣⠼⠁⠐⠜ (5-126-3456-1-5-345). And many other examples.

### Actual behavior:

For the first example: NVDA says: "Input isn't supported" when I try to input the caps indicator (dot 6) after '(' (dots 5-126) and ignores my input.
For the second example: NVDA says "Input isn't supported" when I try to input the number sign (3456) after '(' (5-126) and ignores my input.
Generally: something goes wrong with input multicellular signs after parentheses, quotes, brackets, dashes and other symbols. 

### Expected behavior:

Input should be processed correctly, number signs, caps indicators and other parts of multicellular symbols shouldn't be ignored.


### System configuration
#### NVDA installed/portable/running from source:
Installed
#### NVDA version:
2021.1
#### Windows version:
10
#### Name and version of other software in use when reproducing the issue:

#### Other information about your system:
I use the Focus 40 (4th generation) braille display.
### Other questions
#### Does the issue still occur after restarting your computer?
Yes
#### Have you tried any other versions of NVDA? If so, please report their behaviors.

#### If add-ons are disabled, is your problem still occurring?
Yes (including BrailleExtender)
#### Does the issue still occur after you run the COM Registration Fixing Tool in NVDA's tools menu?

### Other comments

I've found this problem when working on Russian Liblouis tables, but it's actual for all input tables.
It doesn't seem to be a problem of Liblouis because back translation in other programs used Liblouis (including lou_translate.exe) works correctly. I think that the problem is in how NVDA processes Liblouis tables.


Discussion:
@leonardder Do you have any insight into this issue? I'd love to know if someone with a different braille display can reproduce the issue.

Resolving patch:
diff --git a/source/brailleInput.py b/source/brailleInput.py
--- a/source/brailleInput.py
+++ b/source/brailleInput.py
@@ -44,6 +44,10 @@
 UNICODE_BRAILLE_PROTECTED = u"⣿" # All dots down
 
 
+def _isDebugForBrailleInput():
+	return config.conf["debugLog"]["brailleInput"]
+
+
 class BrailleInputHandler(AutoPropertyObject):
 	"""Handles braille input.
 	"""
@@ -118,7 +122,21 @@ def _get_currentFocusIsTextObj(self):
 	useContractedForCurrentFocus: bool
 
 	def _get_useContractedForCurrentFocus(self):
-		return self._table.contracted and self.currentFocusIsTextObj and not self.currentModifiers
+		return self._table.contracted and not self.inputKeysUsingEmulation
+
+	# Provided by auto property: L{_get_inputKeysUsingEmulation}
+	inputKeysUsingEmulation: bool
+
+	def _get_inputKeysUsingEmulation(self):
+		"""
+		Returns whether keyboard gesture emulation should be used to input keyboard keys.
+		If this is C{False}, L{sendChars} is used.
+		This should only apply when:
+			* No modifiers are pressed
+			* We are in a text field
+		"""
+		return not self.currentFocusIsTextObj or self.currentModifiers
+
 
 	def _translate(self, endWord: bool) -> bool:
 		"""Translate buffered braille up to the cursor.
@@ -134,39 +152,51 @@ def _translate(self, endWord: bool) -> bool:
 		pos = self.untranslatedStart + self.untranslatedCursorPos
 		data = u"".join([chr(cell | LOUIS_DOTS_IO_START) for cell in self.bufferBraille[:pos]])
 		mode = louis.dotsIO | louis.noUndefinedDots
-		if (not self.currentFocusIsTextObj or self.currentModifiers) and self._table.contracted:
+		if self.inputKeysUsingEmulation and self._table.contracted:
 			mode |=  louis.partialTrans
 		self.bufferText = louis.backTranslate(
 			[os.path.join(brailleTables.TABLES_DIR, self._table.fileName),
 			"braille-patterns.cti"],
 			data, mode=mode)[0]
 		newText = self.bufferText[oldTextLen:]
+		if _isDebugForBrailleInput():
+			log.debug(f"bufferText after translation = {self.bufferText}, newText = {newText}")
 		if newText:
 			# New text was generated by the cells just entered.
 			if self.useContractedForCurrentFocus or self.currentModifiers:
 				# For contracted braille, an entire word is sent at once.
 				# Don't speak characters as this is sent.
 				# Also, suppress typed characters when emulating a command gesture.
+				if _isDebugForBrailleInput():
+					log.debug("Suppressing typed characters for contracted input")
 				speech._suppressSpeakTypedCharacters(len(newText))
 			else:
 				self._uncontSentTime = time.time()
 			self.untranslatedStart = pos
 			self.untranslatedCursorPos = 0
-			if self.currentModifiers or not self.currentFocusIsTextObj:
+			if _isDebugForBrailleInput():
+				log.debug(
+					f"currentModifiers = {self.currentModifiers}, currentFocusIsTextObj = {self.currentFocusIsTextObj}"
+				)
+			if self.inputKeysUsingEmulation:
 				if len(newText)>1:
 					# Emulation of multiple characters at once is unsupported
 					# Clear newText, so this function returns C{False} if not at end of word
+					if _isDebugForBrailleInput():
+						log.debug(f"Clearing newText, was {newText}")
 					newText = u""
 				else:
 					self.emulateKey(newText)
 			else:
 				self.sendChars(newText)
 
-		if endWord or (newText and (not self.currentFocusIsTextObj or self.currentModifiers)):
+		if endWord or (newText and (self.currentModifiers or not self.useContractedForCurrentFocus)):
 			# We only need to buffer one word.
 			# Clear the previous word (anything before the cursor) from the buffer.
+			if _isDebugForBrailleInput():
+				log.debug(f"Clearing previous word from buffer")
 			del self.bufferBraille[:pos]
-			self.bufferText = u""
+			self.bufferText = ""
 			self.cellsWithText.clear()
 			self.currentModifiers.clear()
 			self.untranslatedStart = 0
@@ -174,8 +204,12 @@ def _translate(self, endWord: bool) -> bool:
 
 		if newText or endWord:
 			self._updateUntranslated()
+			if _isDebugForBrailleInput():
+				log.debug("Translate returning True")
 			return True
 
+		if _isDebugForBrailleInput():
+			log.debug("Translate returning False")
 		return False
 
 	def _translateForReportContractedCell(self, pos):
@@ -237,8 +271,12 @@ def _reportUntranslated(self, pos):
 	def input(self, dots: int):
 		"""Handle one cell of braille input.
 		"""
+		if _isDebugForBrailleInput():
+			log.debug(f"Braille input received: 0X{dots:02x}")
 		# Insert the newly entered cell into the buffer at the cursor position.
 		pos = self.untranslatedStart + self.untranslatedCursorPos
+		if _isDebugForBrailleInput():
+			log.debug(f"Inserting 0X{dots:02x} at position {pos} in bufferBraille")
 		self.bufferBraille.insert(pos, dots)
 		self.untranslatedCursorPos += 1
 		# Space ends the word.
@@ -249,10 +287,17 @@ def input(self, dots: int):
 		# This is because later cells can change characters produced by previous cells.
 		# For example, in English grade 2, "tg" produces just "tg",
 		# but "tgr" produces "together".
+		if _isDebugForBrailleInput():
+			log.debug(
+				f"useContractedForCurrentFocus = {self.useContractedForCurrentFocus}, "
+				f"input is end of word = {endWord}"
+			)
 		if not self.useContractedForCurrentFocus or endWord:
 			if self._translate(endWord):
 				if not endWord:
 					self.cellsWithText.add(pos)
+					if _isDebugForBrailleInput():
+						log.debug(f"Added {pos} to cellsWithText, now {self.cellsWithText}")
 			elif self.bufferText and not self.useContractedForCurrentFocus:
 				# Translators: Reported when translation didn't succeed due to unsupported input.
 				speech.speakMessage(_("Unsupported input"))
@@ -374,6 +419,8 @@ def emulateKey(self, key: str, withModifiers: bool = True):
 		@param withModifiers: Whether this key emulation should include the modifiers that are held virtually.
 			Note that this method does not take care of clearing L{self.currentModifiers}.
 		"""
+		if _isDebugForBrailleInput():
+			log.debug(f"Emulating {key}")
 		if withModifiers:
 			# The emulated key should be the last item in the identifier string.
 			keys = list(self.currentModifiers)
@@ -384,13 +431,19 @@ def emulateKey(self, key: str, withModifiers: bool = True):
 		try:
 			inputCore.manager.emulateGesture(keyboardHandler.KeyboardInputGesture.fromName(gesture))
 		except:
-			log.debugWarning("Unable to emulate %r, falling back to sending unicode characters"%gesture, exc_info=True)
+			if _isDebugForBrailleInput():
+				log.debugWarning(
+					f"Unable to emulate {gesture}, falling back to sending unicode characters",
+					exc_info=True
+				)
 			self.sendChars(key)
 
 	def sendChars(self, chars: str):
 		"""Sends the provided unicode characters to the system.
 		@param chars: The characters to send to the system.
 		"""
+		if _isDebugForBrailleInput():
+			log.debug(f"Sending characters: {chars}")
 		inputs = []
 		chars = ''.join(
 			ch if ord(ch) <= 0xffff else ''.join(
diff --git a/source/config/configSpec.py b/source/config/configSpec.py
--- a/source/config/configSpec.py
+++ b/source/config/configSpec.py
@@ -262,6 +262,7 @@
 	speechManager = boolean(default=false)
 	synthDriver = boolean(default=false)
 	nvwave = boolean(default=false)
+	brailleInput = boolean(default=false)
 
 [uwpOcr]
 	language = string(default="")
diff --git a/source/gui/settingsDialogs.py b/source/gui/settingsDialogs.py
--- a/source/gui/settingsDialogs.py
+++ b/source/gui/settingsDialogs.py
@@ -2818,6 +2818,7 @@ def __init__(self, parent):
 			"speechManager",
 			"synthDriver",
 			"nvwave",
+			"brailleInput",
 		]
 		# Translators: This is the label for a list in the
 		#  Advanced settings panel


Test patch: