Repository: nvaccess/nvda

Issue:
Windows 10 Calculator: repeated typed character announcement if speak typed characters is enabled
Hi,

Technical discussion from #5760, but way different:

### Steps to reproduce:
1. Start Windows 10 Calculator.
2. From Calculator, enable speak typed characters (NvDA+number row 2).
3. Enter some calculations.

### Actual behavior:
NVDA may repeat typed characters twice.

### Expected behavior:
NVDA does not repeat typed characters.

### System configuration
#### NVDA installed/portable/running from source:
Installed

#### NVDA version:
Alpha.16982

#### Windows version:
Windows 10 Version 1903 (build 18362)

#### Name and version of other software in use when reproducing the issue:
Calculator

#### Other information about your system:
Main development computer for Windows 10 App Essentials add-on

### Other questions
#### Does the issue still occur after restarting your PC?
Yes

#### Have you tried any other versions of NVDA?
Yes - 2019.1, 2018.4.1

### Technical information:
This is caused by UIA notification event from Calculator: whenever characters are entered (at least for calculations), Calculator asks UIA to fire notification event with display string containing current expression. This results in repetitions. In older Calculator versions, live region change event was responsible for repetitions.

Thanks.


Discussion:

Resolving patch:
diff --git a/source/appModules/calculator.py b/source/appModules/calculator.py
new file mode 100644
--- /dev/null
+++ b/source/appModules/calculator.py
@@ -0,0 +1,103 @@
+# A part of NonVisual Desktop Access (NVDA)
+# Copyright (C) 2020 NV Access Limited, Joseph Lee
+# This file is covered by the GNU General Public License.
+# See the file COPYING for more details.
+
+"""App module for Windows 10 Calculator"""
+
+import appModuleHandler
+import api
+from NVDAObjects.UIA import UIA
+import queueHandler
+import ui
+import scriptHandler
+
+# #9428: do not announce current values until calculations are done in order to avoid repetitions.
+noCalculatorEntryAnnouncements = [
+	# Display field with Calculator set to full screen mode.
+	"CalculatorResults",
+	# In the middle of a calculation expression entry.
+	"CalculatorExpression",
+	# Results display with Calculator set to compact overlay i.e. always on top mode.
+	"CalculatorAlwaysOnTopResults",
+	# Calculator expressions with Calculator set to always on top mode.
+	"ExpressionContainer",
+	# Date range selector.
+	"ContentPresenter",
+	# Briefly shown when closing date calculation calendar.
+	"Light Dismiss",
+]
+
+
+class AppModule(appModuleHandler.AppModule):
+
+	_shouldAnnounceResult = False
+	# Name change says the same thing multiple times for some items.
+	_resultsCache = ""
+
+	def event_nameChange(self, obj, nextHandler):
+		if not isinstance(obj, UIA):
+			return
+		# No, announce value changes immediately except for calculator results and expressions.
+		if (
+			obj.UIAAutomationId in noCalculatorEntryAnnouncements
+			or obj.UIAElement.cachedClassName == "LandmarkTarget"
+		):
+			self._shouldAnnounceResult = False
+		# For the rest:
+		elif (
+			obj.UIAAutomationId not in noCalculatorEntryAnnouncements
+			and obj.name != self._resultsCache
+		):
+			# For unit conversion, UIA notification event presents much better messages.
+			# For date calculation, live region change event is also fired for difference between dates.
+			if obj.UIAAutomationId not in ("Value1", "Value2", "DateDiffAllUnitsResultLabel"):
+				ui.message(obj.name)
+			self._resultsCache = obj.name
+		if not self._shouldAnnounceResult:
+			return
+		self._shouldAnnounceResult = False
+		nextHandler()
+
+	def event_UIA_notification(self, obj, nextHandler, activityId=None, **kwargs):
+		try:
+			shouldAnnounceNotification = (
+				obj.previous.UIAAutomationId in
+				("numberPad", "UnitConverterRootGrid")
+			)
+		except AttributeError:
+			resultElement = api.getForegroundObject().children[1].lastChild
+			# Redesigned in 2019 due to introduction of "always on top" i.e. compact overlay mode.
+			if resultElement.UIAElement.cachedClassName != "LandmarkTarget":
+				resultElement = resultElement.parent.children[1]
+			shouldAnnounceNotification = (
+				resultElement
+				and resultElement.firstChild
+				and resultElement.firstChild.UIAAutomationId not in noCalculatorEntryAnnouncements
+			)
+		# Display updated activity ID seen when entering calculations should be ignored
+		# as as it is redundant if speak typed characters is on.
+		if shouldAnnounceNotification or activityId != "DisplayUpdated":
+			nextHandler()
+
+	# A list of native commands to handle calculator result announcement.
+	_calculatorResultGestures = ("kb:enter", "kb:numpadEnter", "kb:escape")
+
+	@scriptHandler.script(gestures=_calculatorResultGestures)
+	def script_calculatorResult(self, gesture):
+		# To prevent double focus announcement, check where we are.
+		focus = api.getFocusObject()
+		gesture.send()
+		# In redstone, calculator result keeps firing name change,
+		# so tell it to do so if and only if enter has been pressed.
+		self._shouldAnnounceResult = True
+		# Hack: only announce display text when an actual calculator button (usually equals button) is pressed.
+		# In redstone, pressing enter does not move focus to equals button.
+		if isinstance(focus, UIA):
+			if focus.UIAAutomationId == "CalculatorResults":
+				queueHandler.queueFunction(queueHandler.eventQueue, focus.reportFocus)
+			else:
+				resultsScreen = api.getForegroundObject().children[1].lastChild
+				if isinstance(resultsScreen, UIA) and resultsScreen.UIAElement.cachedClassName == "LandmarkTarget":
+					# And no, do not allow focus to move.
+					queueHandler.queueFunction(queueHandler.eventQueue, resultsScreen.firstChild.reportFocus)


Test patch: