From 8b1ff14ae7e7ca950b40eb071983c4b72a064d80 Mon Sep 17 00:00:00 2001 From: Bill Dengler Date: Sat, 19 Sep 2020 14:37:41 -0400 Subject: [PATCH 01/53] Refactor LiveText to use diff-match-patch via IPC with a C++ process. --- source/NVDAObjects/UIA/winConsoleUIA.py | 8 -- source/NVDAObjects/behaviors.py | 124 +++++++++++++++++++++--- source/NVDAObjects/window/winConsole.py | 21 ++-- source/config/configSpec.py | 1 + source/gui/settingsDialogs.py | 11 +++ user_docs/en/userGuide.t2t | 8 +- 6 files changed, 139 insertions(+), 34 deletions(-) diff --git a/source/NVDAObjects/UIA/winConsoleUIA.py b/source/NVDAObjects/UIA/winConsoleUIA.py index ce7a4d275b4..d6cf2700eb8 100644 --- a/source/NVDAObjects/UIA/winConsoleUIA.py +++ b/source/NVDAObjects/UIA/winConsoleUIA.py @@ -1,4 +1,3 @@ -# NVDAObjects/UIA/winConsoleUIA.py # A part of NonVisual Desktop Access (NVDA) # This file is covered by the GNU General Public License. # See the file COPYING for more details. @@ -366,13 +365,6 @@ def _get_TextInfo(self): movement.""" return consoleUIATextInfo if self.is21H1Plus else consoleUIATextInfoPre21H1 - def _getTextLines(self): - # 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/NVDAObjects/behaviors.py b/source/NVDAObjects/behaviors.py index a3dfbe9a549..d00f701c2bb 100755 --- a/source/NVDAObjects/behaviors.py +++ b/source/NVDAObjects/behaviors.py @@ -1,5 +1,4 @@ # -*- coding: UTF-8 -*- -# NVDAObjects/behaviors.py # A part of NonVisual Desktop Access (NVDA) # This file is covered by the GNU General Public License. # See the file COPYING for more details. @@ -9,10 +8,13 @@ Behaviors described in this mix-in include providing table navigation commands for certain table rows, terminal input and output support, announcing notifications and suggestion items and so on. """ +import _winapi +import ctypes import os import time import threading -import difflib +import struct +import subprocess import tones import queueHandler import eventHandler @@ -29,6 +31,7 @@ import ui import braille import nvwave +from typing import List class ProgressBar(NVDAObject): @@ -219,6 +222,9 @@ class LiveText(NVDAObject): """ #: The time to wait before fetching text after a change event. STABILIZE_DELAY = 0 + #: Whether this object supports Diff-Match-Patch character diffing. + #: Set to False to use line diffing. + _supportsDMP = True # If the text is live, this is definitely content. presentationType = NVDAObject.presType_content @@ -228,6 +234,7 @@ def initOverlayClass(self): self._event = threading.Event() self._monitorThread = None self._keepMonitoring = False + self._pipe = None def startMonitoring(self): """Start monitoring for new text. @@ -263,15 +270,19 @@ def event_textChange(self): """ self._event.set() - def _getTextLines(self): - """Retrieve the text of this object in lines. + def _get_shouldUseDMP(self): + return self._supportsDMP and config.conf["terminals"]["useDMPWhenAvailable"] + + def _getText(self) -> str: + """Retrieve the text of this object. This will be used to determine the new text to speak. The base implementation uses the L{TextInfo}. However, subclasses should override this if there is a better way to retrieve the text. - @return: The current lines of text. - @rtype: list of str """ - return list(self.makeTextInfo(textInfos.POSITION_ALL).getTextInChunks(textInfos.UNIT_LINE)) + if hasattr(self, "_getTextLines"): + log.warning("LiveText._getTextLines is deprecated, please override _getText instead.") + return '\n'.join(self._getTextLines()) + return self.makeTextInfo(textInfos.POSITION_ALL).text def _reportNewLines(self, lines): """ @@ -287,12 +298,43 @@ def _reportNewText(self, line): """ speech.speakText(line) + def _initializeDMP(self): + subprocess.Popen("./nvda_dmp.exe", creationflags=subprocess.CREATE_NO_WINDOW) + time.sleep(0.1) # is there a better way to wait/check for the pipe? + # (try/except in a loop breaks the pipe on the CPP side) + self._pipe = _winapi.CreateFile( + "\\\\.\\pipe\\nvda_dmp", + _winapi.GENERIC_READ | _winapi.GENERIC_WRITE, + 0, + _winapi.NULL, + _winapi.OPEN_EXISTING, + 0, + _winapi.NULL, + ) + + def _terminateDMP(self): + _winapi.WriteFile(self._pipe, b"\x00\x00\x00\x00\x00\x00\x00\x00") + ctypes.windll.kernel32.DisconnectNamedPipe(self._pipe) + + def _writeToPipe(self, data): + bytes_acc = 0 + err = None + while bytes_acc < len(data) and not err: + bytes_written, err = _winapi.WriteFile(self._pipe, data[bytes_acc:]) + bytes_acc += bytes_written + def _monitor(self): + if self.shouldUseDMP: + try: + self._initializeDMP() + except Exception: + log.exception("Error initializing DMP, falling back to difflib") + self._supportsDmp = False try: - oldLines = self._getTextLines() + oldText = self._getText() except: - log.exception("Error getting initial lines") - oldLines = [] + log.exception("Error getting initial text") + oldText = "" while self._keepMonitoring: self._event.wait() @@ -307,9 +349,9 @@ def _monitor(self): self._event.clear() try: - newLines = self._getTextLines() + newText = self._getText() if config.conf["presentation"]["reportDynamicContentChanges"]: - outLines = self._calculateNewText(newLines, oldLines) + outLines = self._calculateNewText(newText, oldText) if len(outLines) == 1 and len(outLines[0].strip()) == 1: # This is only a single character, # which probably means it is just a typed character, @@ -317,15 +359,55 @@ def _monitor(self): del outLines[0] if outLines: queueHandler.queueFunction(queueHandler.eventQueue, self._reportNewLines, outLines) - oldLines = newLines + oldText = newText except: - log.exception("Error getting lines or calculating new text") + log.exception("Error getting or calculating new text") + + if self.shouldUseDMP: + try: + self._terminateDMP() + except Exception: + log.exception("Error stopping DMP") - def _calculateNewText(self, newLines, oldLines): + def _calculateNewText_dmp(self, newText: str, oldText: str) -> List[str]: + try: + if not newText and not oldText: + # Return an empty list here to avoid exiting + # nvda_dmp uses two zero-length texts as a sentinal value + return [] + old = oldText.encode("utf-8") + new = newText.encode("utf-8") + tl = struct.pack("=II", len(old), len(new)) + self._writeToPipe(tl) + self._writeToPipe(old) + self._writeToPipe(new) + buf = b"" + sizeb = b"" + SIZELEN = 4 + while len(sizeb) < SIZELEN: + sizeb = _winapi.ReadFile(self._pipe, SIZELEN - len(sizeb))[0] + if sizeb is None: + sizeb = b"" + (size,) = struct.unpack("=I", sizeb) + while len(buf) < size: + buf += _winapi.ReadFile(self._pipe, size - len(buf))[0] + return [ + line + for line in buf.decode("utf-8").splitlines() + if line and not line.isspace() + ] + except Exception: + log.exception("Exception in DMP, falling back to difflib") + self._supportsDMP = False + return self._calculateNewText_difflib(newText, oldText) + + def _calculateNewText_difflib(self, newLines: List[str], oldLines: List[str]) -> List[str]: outLines = [] prevLine = None - for line in difflib.ndiff(oldLines, newLines): + from difflib import ndiff + + for line in ndiff(oldLines, newLines): if line[0] == "?": # We're never interested in these. continue @@ -373,6 +455,16 @@ def _calculateNewText(self, newLines, oldLines): return outLines + def _calculateNewText(self, newText: str, oldText: str) -> List[str]: + return ( + self._calculateNewText_dmp(newText, oldText) + if self.shouldUseDMP + else self._calculateNewText_difflib( + newText.splitlines(), oldText.splitlines() + ) + ) + + class Terminal(LiveText, EditableText): """An object which both accepts text input and outputs text which should be reported automatically. This is an L{EditableText} object, diff --git a/source/NVDAObjects/window/winConsole.py b/source/NVDAObjects/window/winConsole.py index 96d3d93155b..c2ffb03d285 100644 --- a/source/NVDAObjects/window/winConsole.py +++ b/source/NVDAObjects/window/winConsole.py @@ -1,8 +1,7 @@ -#NVDAObjects/WinConsole.py -#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) 2007-2019 NV Access Limited, Bill Dengler +# 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) 2007-2020 NV Access Limited, Bill Dengler import winConsoleHandler from . import Window @@ -21,10 +20,14 @@ class WinConsole(Terminal, EditableTextWithoutAutoSelectDetection, Window): STABILIZE_DELAY = 0.03 def initOverlayClass(self): - # Legacy consoles take quite a while to send textChange events. - # This significantly impacts typing performance, so don't queue chars. if isinstance(self, KeyboardHandlerBasedTypedCharSupport): + # Legacy consoles take quite a while to send textChange events. + # This significantly impacts typing performance, so don't queue chars. self._supportsTextChange = False + else: + # Use line diffing to report changes in the middle of lines + # in non-enhanced legacy consoles. + self._supportsDMP = False def _get_windowThreadID(self): # #10113: Windows forces the thread of console windows to match the thread of the first attached process. @@ -69,8 +72,8 @@ def event_loseFocus(self): def event_nameChange(self): pass - def _getTextLines(self): - return winConsoleHandler.getConsoleVisibleLines() + def _getText(self): + return '\n'.join(winConsoleHandler.getConsoleVisibleLines()) def script_caret_backspaceCharacter(self, gesture): super(WinConsole, self).script_caret_backspaceCharacter(gesture) diff --git a/source/config/configSpec.py b/source/config/configSpec.py index df5ebe19db8..6e19d670938 100644 --- a/source/config/configSpec.py +++ b/source/config/configSpec.py @@ -219,6 +219,7 @@ [terminals] speakPasswords = boolean(default=false) keyboardSupportInLegacy = boolean(default=True) + useDMPWhenAvailable = boolean(default=True) [update] autoCheck = boolean(default=true) diff --git a/source/gui/settingsDialogs.py b/source/gui/settingsDialogs.py index b9a7f93b707..79c4dc8f94b 100644 --- a/source/gui/settingsDialogs.py +++ b/source/gui/settingsDialogs.py @@ -2527,6 +2527,14 @@ def __init__(self, parent): self.keyboardSupportInLegacyCheckBox.defaultValue = self._getDefaultValue(["terminals", "keyboardSupportInLegacy"]) self.keyboardSupportInLegacyCheckBox.Enable(winVersion.isWin10(1607)) + # Translators: This is the label for a checkbox in the + # Advanced settings panel. + label = _("Detect changes by c&haracter when available") + self.useDMPWhenAvailableCheckBox = terminalsGroup.addItem(wx.CheckBox(self, label=label)) + self.useDMPWhenAvailableCheckBox.SetValue(config.conf["terminals"]["useDMPWhenAvailable"]) + self.useDMPWhenAvailableCheckBox.defaultValue = self._getDefaultValue(["terminals", "useDMPWhenAvailable"]) + self.useDMPWhenAvailableCheckBox.Enable(winVersion.isWin10(1607)) + # Translators: This is the label for a group of advanced options in the # Advanced settings panel label = _("Speech") @@ -2644,6 +2652,7 @@ def haveConfigDefaultsBeenRestored(self): and self.winConsoleSpeakPasswordsCheckBox.IsChecked() == self.winConsoleSpeakPasswordsCheckBox.defaultValue and self.cancelExpiredFocusSpeechCombo.GetSelection() == self.cancelExpiredFocusSpeechCombo.defaultValue and self.keyboardSupportInLegacyCheckBox.IsChecked() == self.keyboardSupportInLegacyCheckBox.defaultValue + and self.useDMPWhenAvailableCheckBox.IsChecked() == self.useDMPWhenAvailableCheckBox.defaultValue and self.caretMoveTimeoutSpinControl.GetValue() == self.caretMoveTimeoutSpinControl.defaultValue and set(self.logCategoriesList.CheckedItems) == set(self.logCategoriesList.defaultCheckedItems) and True # reduce noise in diff when the list is extended. @@ -2657,6 +2666,7 @@ def restoreToDefaults(self): self.winConsoleSpeakPasswordsCheckBox.SetValue(self.winConsoleSpeakPasswordsCheckBox.defaultValue) self.cancelExpiredFocusSpeechCombo.SetSelection(self.cancelExpiredFocusSpeechCombo.defaultValue) self.keyboardSupportInLegacyCheckBox.SetValue(self.keyboardSupportInLegacyCheckBox.defaultValue) + self.useDMPWhenAvailableCheckBox.SetValue(self.useDMPWhenAvailableCheckBox.defaultValue) self.caretMoveTimeoutSpinControl.SetValue(self.caretMoveTimeoutSpinControl.defaultValue) self.logCategoriesList.CheckedItems = self.logCategoriesList.defaultCheckedItems self._defaultsRestored = True @@ -2673,6 +2683,7 @@ def onSave(self): config.conf["terminals"]["speakPasswords"] = self.winConsoleSpeakPasswordsCheckBox.IsChecked() config.conf["featureFlag"]["cancelExpiredFocusSpeech"] = self.cancelExpiredFocusSpeechCombo.GetSelection() config.conf["terminals"]["keyboardSupportInLegacy"]=self.keyboardSupportInLegacyCheckBox.IsChecked() + config.conf["terminals"]["useDMPWhenAvailable"] = self.useDMPWhenAvailableCheckBox.IsChecked() config.conf["editableText"]["caretMoveTimeoutMs"]=self.caretMoveTimeoutSpinControl.GetValue() for index,key in enumerate(self.logCategories): config.conf['debugLog'][key]=self.logCategoriesList.IsChecked(index) diff --git a/user_docs/en/userGuide.t2t b/user_docs/en/userGuide.t2t index b2c414bd199..baf10d7d98b 100644 --- a/user_docs/en/userGuide.t2t +++ b/user_docs/en/userGuide.t2t @@ -1835,10 +1835,16 @@ This setting controls whether characters are spoken by [speak typed characters # ==== Use the new typed character support in Windows Console when available ====[AdvancedSettingsKeyboardSupportInLegacy] This option enables an alternative method for detecting typed characters in Windows command consoles. While it improves performance and prevents some console output from being spelled out, it may be incompatible with some terminal programs. -This feature is available and enabled by default on Windows 10 versions 1607 and later when UI Automation is unavailable or disabled. +This feature is available and enabled by default on Windows 10 versions 1607and later when UI Automation is unavailable or disabled. Warning: with this option enabled, typed characters that do not appear onscreen, such as passwords, will not be suppressed. In untrusted environments, you may temporarily disable [speak typed characters #KeyboardSettingsSpeakTypedCharacters] and [speak typed words #KeyboardSettingsSpeakTypedWords] when entering passwords. +==== Detect changes by character when available ====[AdvancedSettingsUseDMPWhenAvailable] +This option enables an alternative method for detecting output changes in terminals. +It may improve performance when large volumes of text are written to the console and allow more accurate reporting of changes made in the middle of lines. +However, it may be incompatible with some applications. +This feature is available and enabled by default on Windows 10 versions 1607 and later. + ==== Attempt to cancel speech for expired focus events ====[CancelExpiredFocusSpeech] This option enables behaviour which attempts to cancel speech for expired focus events. In particular moving quickly through messages in Gmail with Chrome can cause NVDA to speak outdated information. From bddf45344edd3fa6b3309011ed63ee09a75d1f86 Mon Sep 17 00:00:00 2001 From: Bill Dengler Date: Mon, 21 Sep 2020 09:24:17 -0400 Subject: [PATCH 02/53] Explain magic numbers. --- source/NVDAObjects/behaviors.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/source/NVDAObjects/behaviors.py b/source/NVDAObjects/behaviors.py index d00f701c2bb..ab244779880 100755 --- a/source/NVDAObjects/behaviors.py +++ b/source/NVDAObjects/behaviors.py @@ -305,18 +305,18 @@ def _initializeDMP(self): self._pipe = _winapi.CreateFile( "\\\\.\\pipe\\nvda_dmp", _winapi.GENERIC_READ | _winapi.GENERIC_WRITE, - 0, - _winapi.NULL, + 0, # don't share + _winapi.NULL, # default security attributes _winapi.OPEN_EXISTING, - 0, - _winapi.NULL, + 0x80, # normal attributes + _winapi.NULL # no template ) def _terminateDMP(self): - _winapi.WriteFile(self._pipe, b"\x00\x00\x00\x00\x00\x00\x00\x00") + _winapi.WriteFile(self._pipe, struct.pack("=II", 0, 0)) ctypes.windll.kernel32.DisconnectNamedPipe(self._pipe) - def _writeToPipe(self, data): + def _writeToPipe(self, data: str): bytes_acc = 0 err = None while bytes_acc < len(data) and not err: From 59cfbb59ad99fdf84ec0f3789a4da753b8d850b2 Mon Sep 17 00:00:00 2001 From: Bill Dengler Date: Thu, 24 Sep 2020 15:59:18 -0400 Subject: [PATCH 03/53] switch to a Python implementation of nvda_dmp. This commit definitely requires that NVDA be run from source! --- source/NVDAObjects/behaviors.py | 42 +++++++++++---------------------- source/nvda_dmp.py | 21 +++++++++++++++++ 2 files changed, 35 insertions(+), 28 deletions(-) create mode 100644 source/nvda_dmp.py diff --git a/source/NVDAObjects/behaviors.py b/source/NVDAObjects/behaviors.py index ab244779880..6a3fed4df98 100755 --- a/source/NVDAObjects/behaviors.py +++ b/source/NVDAObjects/behaviors.py @@ -8,9 +8,8 @@ Behaviors described in this mix-in include providing table navigation commands for certain table rows, terminal input and output support, announcing notifications and suggestion items and so on. """ -import _winapi -import ctypes import os +import sys import time import threading import struct @@ -234,7 +233,7 @@ def initOverlayClass(self): self._event = threading.Event() self._monitorThread = None self._keepMonitoring = False - self._pipe = None + self._dmp = None def startMonitoring(self): """Start monitoring for new text. @@ -299,29 +298,16 @@ def _reportNewText(self, line): speech.speakText(line) def _initializeDMP(self): - subprocess.Popen("./nvda_dmp.exe", creationflags=subprocess.CREATE_NO_WINDOW) - time.sleep(0.1) # is there a better way to wait/check for the pipe? - # (try/except in a loop breaks the pipe on the CPP side) - self._pipe = _winapi.CreateFile( - "\\\\.\\pipe\\nvda_dmp", - _winapi.GENERIC_READ | _winapi.GENERIC_WRITE, - 0, # don't share - _winapi.NULL, # default security attributes - _winapi.OPEN_EXISTING, - 0x80, # normal attributes - _winapi.NULL # no template + self._dmp = subprocess.Popen( + (sys.executable, "nvda_dmp.py"), + bufsize=0, + stdin=subprocess.PIPE, + stdout=subprocess.PIPE ) def _terminateDMP(self): - _winapi.WriteFile(self._pipe, struct.pack("=II", 0, 0)) - ctypes.windll.kernel32.DisconnectNamedPipe(self._pipe) - - def _writeToPipe(self, data: str): - bytes_acc = 0 - err = None - while bytes_acc < len(data) and not err: - bytes_written, err = _winapi.WriteFile(self._pipe, data[bytes_acc:]) - bytes_acc += bytes_written + self._dmp.stdin.write(struct.pack("=II", 0, 0)) + self._dmp = None def _monitor(self): if self.shouldUseDMP: @@ -378,19 +364,19 @@ def _calculateNewText_dmp(self, newText: str, oldText: str) -> List[str]: old = oldText.encode("utf-8") new = newText.encode("utf-8") tl = struct.pack("=II", len(old), len(new)) - self._writeToPipe(tl) - self._writeToPipe(old) - self._writeToPipe(new) + self._dmp.stdin.write(tl) + self._dmp.stdin.write(old) + self._dmp.stdin.write(new) buf = b"" sizeb = b"" SIZELEN = 4 while len(sizeb) < SIZELEN: - sizeb = _winapi.ReadFile(self._pipe, SIZELEN - len(sizeb))[0] + sizeb = self._dmp.stdout.read(SIZELEN - len(sizeb)) if sizeb is None: sizeb = b"" (size,) = struct.unpack("=I", sizeb) while len(buf) < size: - buf += _winapi.ReadFile(self._pipe, size - len(buf))[0] + buf += self._dmp.stdout.read(size - len(buf)) return [ line for line in buf.decode("utf-8").splitlines() diff --git a/source/nvda_dmp.py b/source/nvda_dmp.py new file mode 100644 index 00000000000..ea0aa6f3a68 --- /dev/null +++ b/source/nvda_dmp.py @@ -0,0 +1,21 @@ +import struct +import sys + +from diff_match_patch import diff + + +if __name__ == "__main__": + while True: + oldLen, newLen = struct.unpack("=II", sys.stdin.buffer.read(8)) + if not oldLen and not newLen: + break + oldText = sys.stdin.buffer.read(oldLen).decode("utf-8") + newText = sys.stdin.buffer.read(newLen).decode("utf-8") + res = "" + for op, text in diff(oldText, newText, counts_only=False): + if (op == "=" and text.isspace()) or op == "+": + res += text + sys.stdout.buffer.write(struct.pack("=I", len(res))) + sys.stdout.buffer.write(res.encode("utf-8")) + sys.stdin.flush() + sys.stdout.flush() From 816ea00adc828fe96f085c730b4e6138a277070d Mon Sep 17 00:00:00 2001 From: Bill Dengler Date: Fri, 25 Sep 2020 20:59:42 -0400 Subject: [PATCH 04/53] Compile nvda_dmp as an exe. This also adds the DMP Python extension to the repo, but I'm unsure if it should be in a different location. --- source/NVDAObjects/behaviors.py | 7 ++++++- source/diff_match_patch.cp37-win32.pyd | Bin 0 -> 115712 bytes source/setup.py | 15 +++++++++++++++ 3 files changed, 21 insertions(+), 1 deletion(-) create mode 100644 source/diff_match_patch.cp37-win32.pyd diff --git a/source/NVDAObjects/behaviors.py b/source/NVDAObjects/behaviors.py index 6a3fed4df98..40f901f370a 100755 --- a/source/NVDAObjects/behaviors.py +++ b/source/NVDAObjects/behaviors.py @@ -298,8 +298,13 @@ def _reportNewText(self, line): speech.speakText(line) def _initializeDMP(self): + if hasattr(sys, "frozen"): + dmp_path = ("nvda_dmp.exe",) + else: + dmp_path = (sys.executable, "nvda_dmp.py") self._dmp = subprocess.Popen( - (sys.executable, "nvda_dmp.py"), + dmp_path, + creationflags=subprocess.CREATE_NO_WINDOW, bufsize=0, stdin=subprocess.PIPE, stdout=subprocess.PIPE diff --git a/source/diff_match_patch.cp37-win32.pyd b/source/diff_match_patch.cp37-win32.pyd new file mode 100644 index 0000000000000000000000000000000000000000..6f76175bd3d03a7ec5dad36e3b1f73ee4d20b23d GIT binary patch literal 115712 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

%;oNnOY_;Reu%p@K4 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 literal 0 HcmV?d00001 diff --git a/source/setup.py b/source/setup.py index ba6b0639e8c..e9dd9e97197 100755 --- a/source/setup.py +++ b/source/setup.py @@ -5,6 +5,7 @@ #This file is covered by the GNU General Public License. #See the file COPYING for more details. +from versionInfo import formatBuildVersionString, name, version, publisher import os import copy import gettext @@ -187,6 +188,20 @@ def getRecursiveDataFiles(dest,source,excludes=()): "company_name": publisher, }, ], + console=[ + { + "script": "nvda_dmp.py", + "uiAccess": False, + "icon_resources": [(1, "images/nvda.ico")], + "other_resources": [], # Populated at runtime + "version":formatBuildVersionString(), + "description": "NVDA Diff-match-patch proxy", + "product_name": name, + "product_version": version, + "copyright": copyright, + "company_name": publisher, + }, + ], options = {"py2exe": { "bundle_files": 3, "excludes": ["tkinter", From 74ac3b37329f9af293f7148df0fb140ce2c7b11d Mon Sep 17 00:00:00 2001 From: Bill Dengler Date: Sat, 26 Sep 2020 07:57:35 -0400 Subject: [PATCH 05/53] Move import. --- source/setup.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/source/setup.py b/source/setup.py index e9dd9e97197..d8d0d66437a 100755 --- a/source/setup.py +++ b/source/setup.py @@ -5,7 +5,6 @@ #This file is covered by the GNU General Public License. #See the file COPYING for more details. -from versionInfo import formatBuildVersionString, name, version, publisher import os import copy import gettext @@ -14,6 +13,8 @@ import py2exe as py2exeModule from glob import glob import fnmatch +# versionInfo names must be imported after Gettext +from versionInfo import formatBuildVersionString, name, version, publisher # noqa: E402 from versionInfo import * from py2exe import distutils_buildexe from py2exe.dllfinder import DllFinder From 7bc466eba9ca3b2e603958ba654e50d0391fb323 Mon Sep 17 00:00:00 2001 From: Bill Dengler Date: Sat, 26 Sep 2020 08:43:58 -0400 Subject: [PATCH 06/53] Uniformize ends of inserted chunks. --- source/nvda_dmp.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/source/nvda_dmp.py b/source/nvda_dmp.py index ea0aa6f3a68..66966bde2d0 100644 --- a/source/nvda_dmp.py +++ b/source/nvda_dmp.py @@ -1,3 +1,7 @@ +# A proxy to allow NVDA to use diff-match-patch without linking +# for licensing reasons. +# Copyright 2020 Bill Dengler + import struct import sys @@ -13,8 +17,8 @@ newText = sys.stdin.buffer.read(newLen).decode("utf-8") res = "" for op, text in diff(oldText, newText, counts_only=False): - if (op == "=" and text.isspace()) or op == "+": - res += text + if op == "+": + res += text.rstrip() + "\n" sys.stdout.buffer.write(struct.pack("=I", len(res))) sys.stdout.buffer.write(res.encode("utf-8")) sys.stdin.flush() From 1e4b3f42504dbc39329467c450e3482566e45d05 Mon Sep 17 00:00:00 2001 From: Bill Dengler Date: Sat, 26 Sep 2020 19:42:55 -0400 Subject: [PATCH 07/53] s/available/supported in config, for consistency with rest of code and to more accurately describe behaviour. --- source/NVDAObjects/behaviors.py | 2 +- source/config/configSpec.py | 2 +- source/gui/settingsDialogs.py | 16 ++++++++-------- user_docs/en/userGuide.t2t | 2 +- 4 files changed, 11 insertions(+), 11 deletions(-) diff --git a/source/NVDAObjects/behaviors.py b/source/NVDAObjects/behaviors.py index 40f901f370a..774d9205df7 100755 --- a/source/NVDAObjects/behaviors.py +++ b/source/NVDAObjects/behaviors.py @@ -270,7 +270,7 @@ def event_textChange(self): self._event.set() def _get_shouldUseDMP(self): - return self._supportsDMP and config.conf["terminals"]["useDMPWhenAvailable"] + return self._supportsDMP and config.conf["terminals"]["useDMPWhenSupported"] def _getText(self) -> str: """Retrieve the text of this object. diff --git a/source/config/configSpec.py b/source/config/configSpec.py index 6e19d670938..c4f94eda5df 100644 --- a/source/config/configSpec.py +++ b/source/config/configSpec.py @@ -219,7 +219,7 @@ [terminals] speakPasswords = boolean(default=false) keyboardSupportInLegacy = boolean(default=True) - useDMPWhenAvailable = boolean(default=True) + useDMPWhenSupported = boolean(default=True) [update] autoCheck = boolean(default=true) diff --git a/source/gui/settingsDialogs.py b/source/gui/settingsDialogs.py index 79c4dc8f94b..39cd5fa5ac0 100644 --- a/source/gui/settingsDialogs.py +++ b/source/gui/settingsDialogs.py @@ -2529,11 +2529,11 @@ def __init__(self, parent): # Translators: This is the label for a checkbox in the # Advanced settings panel. - label = _("Detect changes by c&haracter when available") - self.useDMPWhenAvailableCheckBox = terminalsGroup.addItem(wx.CheckBox(self, label=label)) - self.useDMPWhenAvailableCheckBox.SetValue(config.conf["terminals"]["useDMPWhenAvailable"]) - self.useDMPWhenAvailableCheckBox.defaultValue = self._getDefaultValue(["terminals", "useDMPWhenAvailable"]) - self.useDMPWhenAvailableCheckBox.Enable(winVersion.isWin10(1607)) + label = _("Detect changes by c&haracter when supported") + self.useDMPWhenSupportedCheckBox = terminalsGroup.addItem(wx.CheckBox(self, label=label)) + self.useDMPWhenSupportedCheckBox.SetValue(config.conf["terminals"]["useDMPWhenSupported"]) + self.useDMPWhenSupportedCheckBox.defaultValue = self._getDefaultValue(["terminals", "useDMPWhenSupported"]) + self.useDMPWhenSupportedCheckBox.Enable(winVersion.isWin10(1607)) # Translators: This is the label for a group of advanced options in the # Advanced settings panel @@ -2652,7 +2652,7 @@ def haveConfigDefaultsBeenRestored(self): and self.winConsoleSpeakPasswordsCheckBox.IsChecked() == self.winConsoleSpeakPasswordsCheckBox.defaultValue and self.cancelExpiredFocusSpeechCombo.GetSelection() == self.cancelExpiredFocusSpeechCombo.defaultValue and self.keyboardSupportInLegacyCheckBox.IsChecked() == self.keyboardSupportInLegacyCheckBox.defaultValue - and self.useDMPWhenAvailableCheckBox.IsChecked() == self.useDMPWhenAvailableCheckBox.defaultValue + and self.useDMPWhenSupportedCheckBox.IsChecked() == self.useDMPWhenSupportedCheckBox.defaultValue and self.caretMoveTimeoutSpinControl.GetValue() == self.caretMoveTimeoutSpinControl.defaultValue and set(self.logCategoriesList.CheckedItems) == set(self.logCategoriesList.defaultCheckedItems) and True # reduce noise in diff when the list is extended. @@ -2666,7 +2666,7 @@ def restoreToDefaults(self): self.winConsoleSpeakPasswordsCheckBox.SetValue(self.winConsoleSpeakPasswordsCheckBox.defaultValue) self.cancelExpiredFocusSpeechCombo.SetSelection(self.cancelExpiredFocusSpeechCombo.defaultValue) self.keyboardSupportInLegacyCheckBox.SetValue(self.keyboardSupportInLegacyCheckBox.defaultValue) - self.useDMPWhenAvailableCheckBox.SetValue(self.useDMPWhenAvailableCheckBox.defaultValue) + self.useDMPWhenSupportedCheckBox.SetValue(self.useDMPWhenSupportedCheckBox.defaultValue) self.caretMoveTimeoutSpinControl.SetValue(self.caretMoveTimeoutSpinControl.defaultValue) self.logCategoriesList.CheckedItems = self.logCategoriesList.defaultCheckedItems self._defaultsRestored = True @@ -2683,7 +2683,7 @@ def onSave(self): config.conf["terminals"]["speakPasswords"] = self.winConsoleSpeakPasswordsCheckBox.IsChecked() config.conf["featureFlag"]["cancelExpiredFocusSpeech"] = self.cancelExpiredFocusSpeechCombo.GetSelection() config.conf["terminals"]["keyboardSupportInLegacy"]=self.keyboardSupportInLegacyCheckBox.IsChecked() - config.conf["terminals"]["useDMPWhenAvailable"] = self.useDMPWhenAvailableCheckBox.IsChecked() + config.conf["terminals"]["useDMPWhenSupported"] = self.useDMPWhenSupportedCheckBox.IsChecked() config.conf["editableText"]["caretMoveTimeoutMs"]=self.caretMoveTimeoutSpinControl.GetValue() for index,key in enumerate(self.logCategories): config.conf['debugLog'][key]=self.logCategoriesList.IsChecked(index) diff --git a/user_docs/en/userGuide.t2t b/user_docs/en/userGuide.t2t index baf10d7d98b..344b610fd4e 100644 --- a/user_docs/en/userGuide.t2t +++ b/user_docs/en/userGuide.t2t @@ -1839,7 +1839,7 @@ This feature is available and enabled by default on Windows 10 versions 1607and Warning: with this option enabled, typed characters that do not appear onscreen, such as passwords, will not be suppressed. In untrusted environments, you may temporarily disable [speak typed characters #KeyboardSettingsSpeakTypedCharacters] and [speak typed words #KeyboardSettingsSpeakTypedWords] when entering passwords. -==== Detect changes by character when available ====[AdvancedSettingsUseDMPWhenAvailable] +==== Detect changes by character when supported ====[AdvancedSettingsUseDMPWhenSupported] This option enables an alternative method for detecting output changes in terminals. It may improve performance when large volumes of text are written to the console and allow more accurate reporting of changes made in the middle of lines. However, it may be incompatible with some applications. From 917a9033d45f9c0f3be507634c44ebe986681ee8 Mon Sep 17 00:00:00 2001 From: Bill Dengler Date: Sun, 27 Sep 2020 09:48:50 -0400 Subject: [PATCH 08/53] Add diffing algorithm to devInfo. --- source/NVDAObjects/behaviors.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/source/NVDAObjects/behaviors.py b/source/NVDAObjects/behaviors.py index 774d9205df7..6bd847a3697 100755 --- a/source/NVDAObjects/behaviors.py +++ b/source/NVDAObjects/behaviors.py @@ -272,6 +272,14 @@ def event_textChange(self): def _get_shouldUseDMP(self): return self._supportsDMP and config.conf["terminals"]["useDMPWhenSupported"] + def _get_devInfo(self): + info = super().devInfo + if self.shouldUseDMP: + info.append("diffing algorithm: character-based (Diff Match Patch)") + else: + info.append("diffing algorithm: line-based (difflib)") + return info + def _getText(self) -> str: """Retrieve the text of this object. This will be used to determine the new text to speak. From 5fbe33d48232ab1b35302770d36c377756057337 Mon Sep 17 00:00:00 2001 From: Bill Dengler Date: Sun, 27 Sep 2020 09:54:40 -0400 Subject: [PATCH 09/53] Fix docs/comments --- source/nvda_dmp.py | 2 +- user_docs/en/userGuide.t2t | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/source/nvda_dmp.py b/source/nvda_dmp.py index 66966bde2d0..fe73aaddb0a 100644 --- a/source/nvda_dmp.py +++ b/source/nvda_dmp.py @@ -12,7 +12,7 @@ while True: oldLen, newLen = struct.unpack("=II", sys.stdin.buffer.read(8)) if not oldLen and not newLen: - break + break # sentinal value oldText = sys.stdin.buffer.read(oldLen).decode("utf-8") newText = sys.stdin.buffer.read(newLen).decode("utf-8") res = "" diff --git a/user_docs/en/userGuide.t2t b/user_docs/en/userGuide.t2t index 344b610fd4e..3bd9c3cc76c 100644 --- a/user_docs/en/userGuide.t2t +++ b/user_docs/en/userGuide.t2t @@ -1835,7 +1835,7 @@ This setting controls whether characters are spoken by [speak typed characters # ==== Use the new typed character support in Windows Console when available ====[AdvancedSettingsKeyboardSupportInLegacy] This option enables an alternative method for detecting typed characters in Windows command consoles. While it improves performance and prevents some console output from being spelled out, it may be incompatible with some terminal programs. -This feature is available and enabled by default on Windows 10 versions 1607and later when UI Automation is unavailable or disabled. +This feature is available and enabled by default on Windows 10 versions 1607 and later when UI Automation is unavailable or disabled. Warning: with this option enabled, typed characters that do not appear onscreen, such as passwords, will not be suppressed. In untrusted environments, you may temporarily disable [speak typed characters #KeyboardSettingsSpeakTypedCharacters] and [speak typed words #KeyboardSettingsSpeakTypedWords] when entering passwords. From 998c851f96b0389b2a4ae3bca6ba781e05524911 Mon Sep 17 00:00:00 2001 From: Bill Dengler Date: Tue, 29 Sep 2020 12:59:17 -0400 Subject: [PATCH 10/53] Switch to a submodule. --- .gitmodules | 3 +++ include/nvda_dmp | 1 + source/diff_match_patch.cp37-win32.pyd | Bin 115712 -> 0 bytes source/nvda_dmp.py | 25 ------------------------- source/setup.py | 2 +- 5 files changed, 5 insertions(+), 26 deletions(-) create mode 160000 include/nvda_dmp delete mode 100644 source/diff_match_patch.cp37-win32.pyd delete mode 100644 source/nvda_dmp.py diff --git a/.gitmodules b/.gitmodules index 83bdb851c61..5fac0f727bc 100644 --- a/.gitmodules +++ b/.gitmodules @@ -43,3 +43,6 @@ [submodule "include/javaAccessBridge32"] path = include/javaAccessBridge32 url = https://github.com/nvaccess/javaAccessBridge32-bin.git +[submodule "include/nvda_dmp"] + path = include/nvda_dmp + url = https://github.com/codeofdusk/nvda_dmp diff --git a/include/nvda_dmp b/include/nvda_dmp new file mode 160000 index 00000000000..456fabb5bbe --- /dev/null +++ b/include/nvda_dmp @@ -0,0 +1 @@ +Subproject commit 456fabb5bbe22e7058ec05ef0ed99e2d6ecf77a5 diff --git a/source/diff_match_patch.cp37-win32.pyd b/source/diff_match_patch.cp37-win32.pyd deleted file mode 100644 index 6f76175bd3d03a7ec5dad36e3b1f73ee4d20b23d..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 115712 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

%;oNnOY_;Reu%p@K4 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 diff --git a/source/nvda_dmp.py b/source/nvda_dmp.py deleted file mode 100644 index fe73aaddb0a..00000000000 --- a/source/nvda_dmp.py +++ /dev/null @@ -1,25 +0,0 @@ -# A proxy to allow NVDA to use diff-match-patch without linking -# for licensing reasons. -# Copyright 2020 Bill Dengler - -import struct -import sys - -from diff_match_patch import diff - - -if __name__ == "__main__": - while True: - oldLen, newLen = struct.unpack("=II", sys.stdin.buffer.read(8)) - if not oldLen and not newLen: - break # sentinal value - oldText = sys.stdin.buffer.read(oldLen).decode("utf-8") - newText = sys.stdin.buffer.read(newLen).decode("utf-8") - res = "" - for op, text in diff(oldText, newText, counts_only=False): - if op == "+": - res += text.rstrip() + "\n" - sys.stdout.buffer.write(struct.pack("=I", len(res))) - sys.stdout.buffer.write(res.encode("utf-8")) - sys.stdin.flush() - sys.stdout.flush() diff --git a/source/setup.py b/source/setup.py index d8d0d66437a..bdde8599322 100755 --- a/source/setup.py +++ b/source/setup.py @@ -191,7 +191,7 @@ def getRecursiveDataFiles(dest,source,excludes=()): ], console=[ { - "script": "nvda_dmp.py", + "script": os.path.join("..", "include", "nvda_dmp", "nvda_dmp.py"), "uiAccess": False, "icon_resources": [(1, "images/nvda.ico")], "other_resources": [], # Populated at runtime From a7abf0208c132aa6908fb80876251e1cb496bb7a Mon Sep 17 00:00:00 2001 From: Bill Dengler Date: Tue, 29 Sep 2020 13:12:32 -0400 Subject: [PATCH 11/53] Fix source copies. --- source/NVDAObjects/behaviors.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/source/NVDAObjects/behaviors.py b/source/NVDAObjects/behaviors.py index 6bd847a3697..22b9b5f2cc9 100755 --- a/source/NVDAObjects/behaviors.py +++ b/source/NVDAObjects/behaviors.py @@ -309,7 +309,9 @@ def _initializeDMP(self): if hasattr(sys, "frozen"): dmp_path = ("nvda_dmp.exe",) else: - dmp_path = (sys.executable, "nvda_dmp.py") + dmp_path = (sys.executable, os.path.join( + "..", "include", "nvda_dmp", "nvda_dmp.py" + )) self._dmp = subprocess.Popen( dmp_path, creationflags=subprocess.CREATE_NO_WINDOW, From f12e2bad3a2ca80ff0023fec8df98eaaf5cd25ca Mon Sep 17 00:00:00 2001 From: Bill Dengler Date: Tue, 29 Sep 2020 18:43:00 -0400 Subject: [PATCH 12/53] Use absolute path to nvda_dmp. --- source/NVDAObjects/behaviors.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/source/NVDAObjects/behaviors.py b/source/NVDAObjects/behaviors.py index 22b9b5f2cc9..387903eba2c 100755 --- a/source/NVDAObjects/behaviors.py +++ b/source/NVDAObjects/behaviors.py @@ -30,6 +30,7 @@ import ui import braille import nvwave +import globalVars from typing import List class ProgressBar(NVDAObject): @@ -307,10 +308,10 @@ def _reportNewText(self, line): def _initializeDMP(self): if hasattr(sys, "frozen"): - dmp_path = ("nvda_dmp.exe",) + dmp_path = (os.path.join(globalVars.appDir, "nvda_dmp.exe"),) else: dmp_path = (sys.executable, os.path.join( - "..", "include", "nvda_dmp", "nvda_dmp.py" + globalVars.appDir, "..", "include", "nvda_dmp", "nvda_dmp.py" )) self._dmp = subprocess.Popen( dmp_path, From ba42885c0aaf7f695a088ab856fb350faf26a2dc Mon Sep 17 00:00:00 2001 From: Michael Curran Date: Wed, 30 Sep 2020 09:13:03 +1000 Subject: [PATCH 13/53] Allow NVDA to still function if a component changes the current directory (#11650) * nvda.pyw: ensure that all paths coming from commandline arguments are made absolute as soon as possible to protect against the current directory changing later on. Also store NVDA's app dir in globalVars. * Use the NVDA app dir rather than the current directory for relative paths. * Fix unit tests. * Remove all usage of os.getcwd and replace it with globalVars.appDir * Replace all remaining os.path.join("* calls with os.path.join(globalVars.appDir calls. * nvda.pyw: provide an absolute path to gettext.translate * nvda_slave: set globalVars.appDir, and provide an absolute path to gettext.translate * getDefaultLogFilePath no longer uses the current directory. * brailleTables: TABLES_DIR is no longer relative to the current directory. * ui.browsableMessage no longer uses a relative path to get to the html file. * Change all playWavefile calls to be non-relative * Fix linting issues * another relative wave file path * Fix linting issues * speechDictHandler: the path to builtin.dic is no longer relative. * config: slave_fileName is no longer relative * Lilli braille driver: path to dll is no longer relative. * Fix linting issues * nvda_slave: don't load nvdaRemote with a relative path. * Remove all usage of os.path.abspath, but add a couple of assertions in places where we can't be completely sure the path is absolute. * Fix translation comments * Add the ALTERED_LIBRARY_SEARCH_PATH constant to winKernel and use it in NVDAHelper and nvda_slave when loading NvDAHelperRemote. * Lili braille dirver: remove unneeded import. * Update what's new --- source/COMRegistrationFixes/__init__.py | 3 +- source/NVDAHelper.py | 18 +++++--- source/NVDAObjects/__init__.py | 5 ++- source/NVDAObjects/behaviors.py | 6 ++- source/addonHandler/__init__.py | 17 ++++---- source/brailleDisplayDrivers/lilli.py | 6 ++- source/brailleTables.py | 5 ++- source/browseMode.py | 7 +++- source/characterProcessing.py | 10 +++-- source/config/__init__.py | 9 ++-- source/core.py | 16 ++++--- source/fonts/__init__.py | 4 +- source/gui/__init__.py | 4 +- source/gui/installerGui.py | 16 ++++++- source/inputCore.py | 4 +- source/installer.py | 6 +-- source/logHandler.py | 6 +-- source/nvda.pyw | 27 ++++++++++-- source/nvda_slave.pyw | 42 +++++++++++++++---- source/speechDictHandler/__init__.py | 2 +- source/synthDrivers/_espeak.py | 7 ++-- source/systemUtils.py | 2 +- source/ui.py | 3 +- source/updateCheck.py | 4 +- .../screenCurtain.py | 6 ++- source/watchdog.py | 2 +- source/winKernel.py | 3 ++ tests/unit/__init__.py | 7 +++- user_docs/en/changes.t2t | 2 + 29 files changed, 176 insertions(+), 73 deletions(-) diff --git a/source/COMRegistrationFixes/__init__.py b/source/COMRegistrationFixes/__init__.py index 6f32f1d9d81..968be869653 100644 --- a/source/COMRegistrationFixes/__init__.py +++ b/source/COMRegistrationFixes/__init__.py @@ -8,6 +8,7 @@ import os import subprocess import winVersion +import globalVars from logHandler import log # Particular 64 bit / 32 bit system paths @@ -53,7 +54,7 @@ def applyRegistryPatch(fileName,wow64=False): log.debug("Applied registry patch: %s with %s"%(fileName,regedit)) -OLEACC_REG_FILE_PATH = os.path.abspath(os.path.join("COMRegistrationFixes", "oleaccProxy.reg")) +OLEACC_REG_FILE_PATH = os.path.join(globalVars.appDir, "COMRegistrationFixes", "oleaccProxy.reg") def fixCOMRegistrations(): """ Registers most common COM proxies, in case they had accidentally been unregistered or overwritten by 3rd party software installs/uninstalls. diff --git a/source/NVDAHelper.py b/source/NVDAHelper.py index 1c61eed23ca..3ab07dea725 100755 --- a/source/NVDAHelper.py +++ b/source/NVDAHelper.py @@ -17,6 +17,7 @@ WINFUNCTYPE, c_long, c_wchar, + windll, ) from ctypes.wintypes import * from comtypes import BSTR @@ -29,11 +30,11 @@ import time import globalVars -versionedLibPath='lib' +versionedLibPath = os.path.join(globalVars.appDir, 'lib') if os.environ.get('PROCESSOR_ARCHITEW6432') == 'ARM64': - versionedLib64Path = 'libArm64' + versionedLib64Path = os.path.join(globalVars.appDir, 'libArm64') else: - versionedLib64Path = 'lib64' + versionedLib64Path = os.path.join(globalVars.appDir, 'lib64') if getattr(sys,'frozen',None): # Not running from source. Libraries are in a version-specific directory versionedLibPath=os.path.join(versionedLibPath,versionInfo.version) @@ -510,8 +511,15 @@ def initialize(): if config.isAppX: log.info("Remote injection disabled due to running as a Windows Store Application") return - #Load nvdaHelperRemote.dll but with an altered search path so it can pick up other dlls in lib - h=windll.kernel32.LoadLibraryExW(os.path.abspath(os.path.join(versionedLibPath,u"nvdaHelperRemote.dll")),0,0x8) + # Load nvdaHelperRemote.dll + h = windll.kernel32.LoadLibraryExW( + os.path.join(versionedLibPath, "nvdaHelperRemote.dll"), + 0, + # Using an altered search path is necessary here + # As NVDAHelperRemote needs to locate dependent dlls in the same directory + # such as minhook.dll. + winKernel.LOAD_WITH_ALTERED_SEARCH_PATH + ) if not h: log.critical("Error loading nvdaHelperRemote.dll: %s" % WinError()) return diff --git a/source/NVDAObjects/__init__.py b/source/NVDAObjects/__init__.py index f0fff359c34..96237882e3c 100644 --- a/source/NVDAObjects/__init__.py +++ b/source/NVDAObjects/__init__.py @@ -8,6 +8,7 @@ """Module that contains the base NVDA object type with dynamic class creation support, as well as the associated TextInfo class.""" +import os import time import re import weakref @@ -31,6 +32,8 @@ import brailleInput import locationHelper import aria +import globalVars + class NVDAObjectTextInfo(textInfos.offsets.OffsetsTextInfo): """A default TextInfo which is used to enable text review of information about widgets that don't support text content. @@ -1030,7 +1033,7 @@ def _reportErrorInPreviousWord(self): # No error. return import nvwave - nvwave.playWaveFile(r"waves\textError.wav") + nvwave.playWaveFile(os.path.join(globalVars.appDir, "waves", "textError.wav")) def event_liveRegionChange(self): """ diff --git a/source/NVDAObjects/behaviors.py b/source/NVDAObjects/behaviors.py index a3dfbe9a549..e09d93ec28e 100755 --- a/source/NVDAObjects/behaviors.py +++ b/source/NVDAObjects/behaviors.py @@ -29,6 +29,8 @@ import ui import braille import nvwave +import globalVars + class ProgressBar(NVDAObject): @@ -796,7 +798,7 @@ def event_suggestionsOpened(self): # Translators: Announced in braille when suggestions appear when search term is entered in various search fields such as Start search box in Windows 10. braille.handler.message(_("Suggestions")) if config.conf["presentation"]["reportAutoSuggestionsWithSound"]: - nvwave.playWaveFile(r"waves\suggestionsOpened.wav") + nvwave.playWaveFile(os.path.join(globalVars.appDir, "waves", "suggestionsOpened.wav")) def event_suggestionsClosed(self): """Called when suggestions list or container is closed. @@ -804,7 +806,7 @@ def event_suggestionsClosed(self): By default NVDA will announce this via speech, braille or via a sound. """ if config.conf["presentation"]["reportAutoSuggestionsWithSound"]: - nvwave.playWaveFile(r"waves\suggestionsClosed.wav") + nvwave.playWaveFile(os.path.join(globalVars.appDir, "waves", "suggestionsClosed.wav")) class WebDialog(NVDAObject): """ diff --git a/source/addonHandler/__init__.py b/source/addonHandler/__init__.py index 48f127ccf12..d4e1aded665 100644 --- a/source/addonHandler/__init__.py +++ b/source/addonHandler/__init__.py @@ -97,7 +97,7 @@ def getIncompatibleAddons( def completePendingAddonRemoves(): """Removes any add-ons that could not be removed on the last run of NVDA""" - user_addons = os.path.abspath(os.path.join(globalVars.appArgs.configPath, "addons")) + user_addons = os.path.join(globalVars.appArgs.configPath, "addons") pendingRemovesSet=state['pendingRemovesSet'] for addonName in list(pendingRemovesSet): addonPath=os.path.join(user_addons,addonName) @@ -111,7 +111,7 @@ def completePendingAddonRemoves(): pendingRemovesSet.discard(addonName) def completePendingAddonInstalls(): - user_addons = os.path.abspath(os.path.join(globalVars.appArgs.configPath, "addons")) + user_addons = os.path.join(globalVars.appArgs.configPath, "addons") pendingInstallsSet=state['pendingInstallsSet'] for addonName in pendingInstallsSet: newPath=os.path.join(user_addons,addonName) @@ -123,7 +123,7 @@ def completePendingAddonInstalls(): pendingInstallsSet.clear() def removeFailedDeletions(): - user_addons = os.path.abspath(os.path.join(globalVars.appArgs.configPath, "addons")) + user_addons = os.path.join(globalVars.appArgs.configPath, "addons") for p in os.listdir(user_addons): if p.endswith(DELETEDIR_SUFFIX): path=os.path.join(user_addons,p) @@ -170,7 +170,7 @@ def _getDefaultAddonPaths(): @rtype: list(string) """ addon_paths = [] - user_addons = os.path.abspath(os.path.join(globalVars.appArgs.configPath, "addons")) + user_addons = os.path.join(globalVars.appArgs.configPath, "addons") if os.path.isdir(user_addons): addon_paths.append(user_addons) return addon_paths @@ -280,7 +280,7 @@ def __init__(self, path): @param path: the base directory for the addon data. @type path: string """ - self.path = os.path.abspath(path) + self.path = path self._extendedPackages = set() manifest_path = os.path.join(path, MANIFEST_FILENAME) with open(manifest_path, 'rb') as f: @@ -511,7 +511,8 @@ def getCodeAddon(obj=None, frameDist=1): if obj is None: obj = sys._getframe(frameDist) fileName = inspect.getfile(obj) - dir= os.path.abspath(os.path.dirname(fileName)) + assert os.path.isabs(fileName), f"Module file name {fileName} is not absolute" + dir = os.path.dirname(fileName) # if fileName is not a subdir of one of the addon paths # It does not belong to an addon. for p in _getDefaultAddonPaths(): @@ -523,7 +524,7 @@ def getCodeAddon(obj=None, frameDist=1): while curdir not in _getDefaultAddonPaths(): if curdir in _availableAddons: return _availableAddons[curdir] - curdir = os.path.abspath(os.path.join(curdir, "..")) + curdir = os.path.join(curdir, "..") # Not found! raise AddonError("Code does not belong to an addon") @@ -609,7 +610,7 @@ def __repr__(self): def createAddonBundleFromPath(path, destDir=None): """ Creates a bundle from a directory that contains a a addon manifest file.""" - basedir = os.path.abspath(path) + basedir = path # If caller did not provide a destination directory name # Put the bundle at the same level as the add-on's top-level directory, # That is, basedir/.. diff --git a/source/brailleDisplayDrivers/lilli.py b/source/brailleDisplayDrivers/lilli.py index 9158ec304ab..e94c85a1cd4 100644 --- a/source/brailleDisplayDrivers/lilli.py +++ b/source/brailleDisplayDrivers/lilli.py @@ -5,14 +5,16 @@ #Copyright (C) 2008-2017 NV Access Limited, Gianluca Casalino, Alberto Benassati, Babbage B.V. from typing import Optional, List +import os +import globalVars from logHandler import log -from ctypes import * +from ctypes import windll import inputCore import wx import braille try: - lilliDll=windll.LoadLibrary("brailleDisplayDrivers\\lilli.dll") + lilliDll = windll.LoadLibrary(os.path.join(globalVars.appDir, "brailleDisplayDrivers", "lilli.dll")) except: lilliDll=None diff --git a/source/brailleTables.py b/source/brailleTables.py index 010bf6aa380..2b9e5203222 100644 --- a/source/brailleTables.py +++ b/source/brailleTables.py @@ -6,11 +6,14 @@ """Manages information about available braille translation tables. """ +import os import collections from locale import strxfrm +import globalVars + #: The directory in which liblouis braille tables are located. -TABLES_DIR = r"louis\tables" +TABLES_DIR = os.path.join(globalVars.appDir, "louis", "tables") #: Information about a braille table. #: This has the following attributes: diff --git a/source/browseMode.py b/source/browseMode.py index 3f377057152..84697f3e30f 100644 --- a/source/browseMode.py +++ b/source/browseMode.py @@ -3,6 +3,7 @@ # This file is covered by the GNU General Public License. # See the file COPYING for more details. +import os import itertools import collections import winsound @@ -39,8 +40,10 @@ from NVDAObjects import NVDAObject import gui.contextHelp from abc import ABCMeta, abstractmethod +import globalVars from typing import Optional + REASON_QUICKNAV = OutputReason.QUICKNAV def reportPassThrough(treeInterceptor,onlyIfChanged=True): @@ -52,8 +55,8 @@ def reportPassThrough(treeInterceptor,onlyIfChanged=True): """ if not onlyIfChanged or treeInterceptor.passThrough != reportPassThrough.last: if config.conf["virtualBuffers"]["passThroughAudioIndication"]: - sound = r"waves\focusMode.wav" if treeInterceptor.passThrough else r"waves\browseMode.wav" - nvwave.playWaveFile(sound) + sound = "focusMode.wav" if treeInterceptor.passThrough else "browseMode.wav" + nvwave.playWaveFile(os.path.join(globalVars.appDir, "waves", sound)) else: if treeInterceptor.passThrough: # Translators: The mode to interact with controls in documents diff --git a/source/characterProcessing.py b/source/characterProcessing.py index cf23e26529b..b725c79bd8c 100644 --- a/source/characterProcessing.py +++ b/source/characterProcessing.py @@ -78,7 +78,7 @@ def __init__(self,locale): @type locale: string """ self._entries = {} - fileName=os.path.join('locale',locale,'characterDescriptions.dic') + fileName = os.path.join(globalVars.appDir, 'locale', locale, 'characterDescriptions.dic') if not os.path.isfile(fileName): raise LookupError(fileName) f = codecs.open(fileName,"r","utf_8_sig",errors="replace") @@ -367,12 +367,14 @@ def _getSpeechSymbolsForLocale(locale): # Load the data before loading other symbols, # in order to allow translators to override them. try: - builtin.load(os.path.join("locale", locale, "cldr.dic"), - allowComplexSymbols=False) + builtin.load( + os.path.join(globalVars.appDir, "locale", locale, "cldr.dic"), + allowComplexSymbols=False + ) except IOError: log.debugWarning("No CLDR data for locale %s" % locale) try: - builtin.load(os.path.join("locale", locale, "symbols.dic")) + builtin.load(os.path.join(globalVars.appDir, "locale", locale, "symbols.dic")) except IOError: _noSymbolLocalesCache.add(locale) raise LookupError("No symbol information for locale %s" % locale) diff --git a/source/config/__init__.py b/source/config/__init__.py index 9b2ca5a594a..55c689897e5 100644 --- a/source/config/__init__.py +++ b/source/config/__init__.py @@ -83,7 +83,7 @@ def isInstalledCopy(): return False winreg.CloseKey(k) try: - return os.stat(instDir)==os.stat(os.getcwd()) + return os.stat(instDir) == os.stat(globalVars.appDir) except WindowsError: return False @@ -125,7 +125,7 @@ def getUserDefaultConfigPath(useInstalledPathIfExists=False): # Therefore add a suffix to the directory to make it specific to Windows Store application versions. installedUserConfigPath+='_appx' return installedUserConfigPath - return u'.\\userConfig\\' + return os.path.join(globalVars.appDir, 'userConfig') def getSystemConfigPath(): if isInstalledCopy(): @@ -227,7 +227,8 @@ def canStartOnSecureScreens(): # This function will be transformed into a flag in a future release. return isInstalledCopy() -SLAVE_FILENAME = u"nvda_slave.exe" + +SLAVE_FILENAME = os.path.join(globalVars.appDir, "nvda_slave.exe") #: The name of the registry key stored under HKEY_LOCAL_MACHINE where system wide NVDA settings are stored. #: Note that NVDA is a 32-bit application, so on X64 systems, this will evaluate to "SOFTWARE\WOW6432Node\nvda" @@ -252,7 +253,7 @@ def _setStartOnLogonScreen(enable): winreg.SetValueEx(k, u"startOnLogonScreen", None, winreg.REG_DWORD, int(enable)) def setSystemConfigToCurrentConfig(): - fromPath=os.path.abspath(globalVars.appArgs.configPath) + fromPath = globalVars.appArgs.configPath if ctypes.windll.shell32.IsUserAnAdmin(): _setSystemConfig(fromPath) else: diff --git a/source/core.py b/source/core.py index 17c8c38e675..14efe569747 100644 --- a/source/core.py +++ b/source/core.py @@ -224,7 +224,7 @@ def main(): globalVars.appArgs.configPath=config.getUserDefaultConfigPath(useInstalledPathIfExists=globalVars.appArgs.launcher) #Initialize the config path (make sure it exists) config.initConfigPath() - log.info("Config dir: %s"%os.path.abspath(globalVars.appArgs.configPath)) + log.info(f"Config dir: {globalVars.appArgs.configPath}") log.debug("loading config") import config config.initialize() @@ -232,7 +232,7 @@ def main(): log.info("Developer Scratchpad mode enabled") if not globalVars.appArgs.minimal and config.conf["general"]["playStartAndExitSounds"]: try: - nvwave.playWaveFile("waves\\start.wav") + nvwave.playWaveFile(os.path.join(globalVars.appDir, "waves", "start.wav")) except: pass logHandler.setLogLevelFromConfig() @@ -298,7 +298,10 @@ def onEndSession(evt): speech.cancelSpeech() if not globalVars.appArgs.minimal and config.conf["general"]["playStartAndExitSounds"]: try: - nvwave.playWaveFile("waves\\exit.wav",asynchronous=False) + nvwave.playWaveFile( + os.path.join(globalVars.appDir, "waves", "exit.wav"), + asynchronous=False + ) except: pass log.info("Windows session ending") @@ -410,7 +413,7 @@ def handlePowerStatusChange(self): if not wxLang and '_' in lang: wxLang=locale.FindLanguageInfo(lang.split('_')[0]) if hasattr(sys,'frozen'): - locale.AddCatalogLookupPathPrefix(os.path.join(os.getcwd(),"locale")) + locale.AddCatalogLookupPathPrefix(os.path.join(globalVars.appDir, "locale")) # #8064: Wx might know the language, but may not actually contain a translation database for that language. # If we try to initialize this language, wx will show a warning dialog. # #9089: some languages (such as Aragonese) do not have language info, causing language getter to fail. @@ -591,7 +594,10 @@ def run(self): if not globalVars.appArgs.minimal and config.conf["general"]["playStartAndExitSounds"]: try: - nvwave.playWaveFile("waves\\exit.wav",asynchronous=False) + nvwave.playWaveFile( + os.path.join(globalVars.appDir, "waves", "exit.wav"), + asynchronous=False + ) except: pass # #5189: Destroy the message window as late as possible diff --git a/source/fonts/__init__.py b/source/fonts/__init__.py index f6b565aec0d..ef01b44cf8f 100644 --- a/source/fonts/__init__.py +++ b/source/fonts/__init__.py @@ -1,10 +1,10 @@ -# brailleViewer.py # A part of NonVisual Desktop Access (NVDA) # Copyright (C) 2019 NV Access Limited # This file is covered by the GNU General Public License. # See the file COPYING for more details. from typing import List +import globalVars from logHandler import log import os from ctypes import WinDLL @@ -13,7 +13,7 @@ Loads custom fonts for use in NVDA. """ -fontsDir = os.path.abspath("fonts") +fontsDir = os.path.join(globalVars.appDir, "fonts") def _isSupportedFontPath(f: str) -> bool: diff --git a/source/gui/__init__.py b/source/gui/__init__.py index fd0d4f7b3c3..5b223aac95c 100644 --- a/source/gui/__init__.py +++ b/source/gui/__init__.py @@ -44,7 +44,7 @@ updateCheck = None ### Constants -NVDA_PATH = os.getcwd() +NVDA_PATH = globalVars.appDir ICON_PATH=os.path.join(NVDA_PATH, "images", "nvda.ico") DONATE_URL = "http://www.nvaccess.org/donate/" @@ -57,7 +57,7 @@ def getDocFilePath(fileName, localized=True): if hasattr(sys, "frozen"): getDocFilePath.rootPath = os.path.join(NVDA_PATH, "documentation") else: - getDocFilePath.rootPath = os.path.abspath(os.path.join("..", "user_docs")) + getDocFilePath.rootPath = os.path.join(NVDA_PATH, "..", "user_docs") if localized: lang = languageHandler.getLanguage() diff --git a/source/gui/installerGui.py b/source/gui/installerGui.py index 3faeae27424..fc836c18220 100644 --- a/source/gui/installerGui.py +++ b/source/gui/installerGui.py @@ -307,7 +307,7 @@ def __init__(self, parent): directoryEntryControl = groupHelper.addItem(gui.guiHelper.PathSelectionHelper(self, browseText, dirDialogTitle)) self.portableDirectoryEdit = directoryEntryControl.pathControl if globalVars.appArgs.portablePath: - self.portableDirectoryEdit.Value = os.path.abspath(globalVars.appArgs.portablePath) + self.portableDirectoryEdit.Value = globalVars.appArgs.portablePath # Translators: The label of a checkbox option in the Create Portable NVDA dialog. copyConfText = _("Copy current &user configuration") @@ -343,6 +343,18 @@ def onCreatePortable(self, evt): _("Error"), wx.OK | wx.ICON_ERROR) return + if not os.path.isabs(self.portableDirectoryEdit.Value): + gui.messageBox( + # Translators: The message displayed when the user has not specified an absolute destination directory + # in the Create Portable NVDA dialog. + _("Please specify an absolute path (including drive letter) in which to create the portable copy."), + # Translators: The message title displayed + # when the user has not specified an absolute destination directory + # in the Create Portable NVDA dialog. + _("Error"), + wx.OK | wx.ICON_ERROR + ) + return drv=os.path.splitdrive(self.portableDirectoryEdit.Value)[0] if drv and not os.path.isdir(drv): # Translators: The message displayed when the user specifies an invalid destination drive @@ -395,7 +407,7 @@ def doCreatePortable(portableDirectory,copyUserConfig=False,silent=False,startAf shellapi.ShellExecute( None, None, - os.path.join(os.path.abspath(portableDirectory),'nvda.exe'), + os.path.join(portableDirectory, 'nvda.exe'), None, None, winUser.SW_SHOWNORMAL diff --git a/source/inputCore.py b/source/inputCore.py index 1afaa1969f4..146b6189736 100644 --- a/source/inputCore.py +++ b/source/inputCore.py @@ -565,10 +565,10 @@ def loadLocaleGestureMap(self): self.localeGestureMap.clear() lang = languageHandler.getLanguage() try: - self.localeGestureMap.load(os.path.join("locale", lang, "gestures.ini")) + self.localeGestureMap.load(os.path.join(globalVars.appDir, "locale", lang, "gestures.ini")) except IOError: try: - self.localeGestureMap.load(os.path.join("locale", lang.split('_')[0], "gestures.ini")) + self.localeGestureMap.load(os.path.join(globalVars.appDir, "locale", lang.split('_')[0], "gestures.ini")) except IOError: log.debugWarning("No locale gesture map for language %s" % lang) diff --git a/source/installer.py b/source/installer.py index b450cd1b7f6..f1ca65e4277 100644 --- a/source/installer.py +++ b/source/installer.py @@ -121,7 +121,7 @@ def getDocFilePath(fileName,installDir): return tryPath def copyProgramFiles(destPath): - sourcePath=os.getcwd() + sourcePath = globalVars.appDir detectUserConfig=True detectNVDAExe=True for curSourceDir,subDirs,files in os.walk(sourcePath): @@ -140,7 +140,7 @@ def copyProgramFiles(destPath): tryCopyFile(sourceFilePath,destFilePath) def copyUserConfig(destPath): - sourcePath=os.path.abspath(globalVars.appArgs.configPath) + sourcePath = globalVars.appArgs.configPath for curSourceDir,subDirs,files in os.walk(sourcePath): curDestDir=os.path.join(destPath,os.path.relpath(curSourceDir,sourcePath)) if not os.path.isdir(curDestDir): @@ -598,7 +598,7 @@ def removeOldLoggedFiles(installPath): tryRemoveFile(filePath,rebootOK=True) def createPortableCopy(destPath,shouldCopyUserConfig=True): - destPath=os.path.abspath(destPath) + assert os.path.isabs(destPath), f"Destination path {destPath} is not absolute" #Remove all the main executables always for f in ("nvda.exe","nvda_noUIAccess.exe","nvda_UIAccess.exe"): f=os.path.join(destPath,f) diff --git a/source/logHandler.py b/source/logHandler.py index aab62ce11bd..6fa34b52a6f 100755 --- a/source/logHandler.py +++ b/source/logHandler.py @@ -249,7 +249,7 @@ class RemoteHandler(logging.Handler): def __init__(self): #Load nvdaHelperRemote.dll but with an altered search path so it can pick up other dlls in lib - path=os.path.abspath(os.path.join(u"lib",buildVersion.version,u"nvdaHelperRemote.dll")) + path = os.path.join(globalVars.appDir, "lib", buildVersion.version, "nvdaHelperRemote.dll") h=ctypes.windll.kernel32.LoadLibraryExW(path,0,LOAD_WITH_ALTERED_SEARCH_PATH) if not h: raise OSError("Could not load %s"%path) @@ -276,7 +276,7 @@ def handle(self,record): elif record.levelno>=logging.ERROR and shouldPlayErrorSound: import nvwave try: - nvwave.playWaveFile("waves\\error.wav") + nvwave.playWaveFile(os.path.join(globalVars.appDir, "waves", "error.wav")) except: pass return super().handle(record) @@ -333,7 +333,7 @@ def _getDefaultLogFilePath(): import tempfile return os.path.join(tempfile.gettempdir(), "nvda.log") else: - return ".\\nvda.log" + return os.path.join(globalVars.appDir, "nvda.log") def _excepthook(*exc_info): log.exception(exc_info=exc_info, codepath="unhandled exception") diff --git a/source/nvda.pyw b/source/nvda.pyw index 8d810104e0f..7e8c9bac7bf 100755 --- a/source/nvda.pyw +++ b/source/nvda.pyw @@ -9,23 +9,32 @@ import sys import os +import globalVars + if getattr(sys, "frozen", None): # We are running as an executable. # Append the path of the executable to sys so we can import modules from the dist dir. sys.path.append(sys.prefix) - os.chdir(sys.prefix) + appDir = sys.prefix else: import sourceEnv #We should always change directory to the location of this module (nvda.pyw), don't rely on sys.path[0] - os.chdir(os.path.normpath(os.path.dirname(__file__))) + appDir = os.path.normpath(os.path.dirname(__file__)) +appDir = os.path.abspath(appDir) +os.chdir(appDir) +globalVars.appDir = appDir import ctypes import locale import gettext try: - gettext.translation('nvda',localedir='locale',languages=[locale.getdefaultlocale()[0]]).install(True) + gettext.translation( + 'nvda', + localedir=os.path.join(globalVars.appDir, 'locale'), + languages=[locale.getdefaultlocale()[0]] + ).install(True) except: gettext.install('nvda') @@ -112,6 +121,18 @@ parser.add_argument('--enable-start-on-logon',metavar="True|False",type=stringTo # If this option is provided, NVDA will not replace an already running instance (#10179) parser.add_argument('--ease-of-access',action="store_true",dest='easeOfAccess',default=False,help="Started by Windows Ease of Access") (globalVars.appArgs,globalVars.appArgsExtra)=parser.parse_known_args() +# Make any app args path values absolute +# So as to not be affected by the current directory changing during process lifetime. +pathAppArgs = [ + "configPath", + "logFileName", + "portablePath", +] +for name in pathAppArgs: + origVal = getattr(globalVars.appArgs, name) + if isinstance(origVal, str): + newVal = os.path.abspath(origVal) + setattr(globalVars.appArgs, name, newVal) def terminateRunningNVDA(window): processID,threadID=winUser.getWindowThreadProcessID(window) diff --git a/source/nvda_slave.pyw b/source/nvda_slave.pyw index b011e16050b..81412162954 100755 --- a/source/nvda_slave.pyw +++ b/source/nvda_slave.pyw @@ -8,6 +8,12 @@ Performs miscellaneous tasks which need to be performed in a separate process. """ +import sys +import os +import globalVars +import winKernel + + # Initialise comtypes.client.gen_dir and the comtypes.gen search path # and Append our comInterfaces directory to the comtypes.gen search path. import comtypes @@ -16,23 +22,34 @@ import comtypes.gen import comInterfaces comtypes.gen.__path__.append(comInterfaces.__path__[0]) + +if hasattr(sys, "frozen"): + # Error messages (which are only for debugging) should not cause the py2exe log message box to appear. + sys.stderr = sys.stdout + globalVars.appDir = sys.prefix +else: + globalVars.appDir = os.path.abspath(os.path.dirname(__file__)) + +# #2391: some functions may still require the current directory to be set to NVDA's app dir +os.chdir(globalVars.appDir) + + import gettext import locale #Localization settings try: - gettext.translation('nvda',localedir='locale',languages=[locale.getdefaultlocale()[0]]).install() + gettext.translation( + 'nvda', + localedir=os.path.join(globalVars.appDir, 'locale'), + languages=[locale.getdefaultlocale()[0]] + ).install() except: gettext.install('nvda') -import sys -import os + import versionInfo import logHandler -if hasattr(sys, "frozen"): - # Error messages (which are only for debugging) should not cause the py2exe log message box to appear. - sys.stderr = sys.stdout - #Many functions expect the current directory to be where slave is located (#2391) - os.chdir(sys.prefix) + def main(): import installer @@ -76,7 +93,14 @@ def main(): raise ValueError("Addon path was not provided.") #Load nvdaHelperRemote.dll but with an altered search path so it can pick up other dlls in lib import ctypes - h=ctypes.windll.kernel32.LoadLibraryExW(os.path.abspath(os.path.join(u"lib",versionInfo.version,u"nvdaHelperRemote.dll")),0,0x8) + h = ctypes.windll.kernel32.LoadLibraryExW( + os.path.join(globalVars.appDir, "lib", versionInfo.version, "nvdaHelperRemote.dll"), + 0, + # Using an altered search path is necessary here + # As NVDAHelperRemote needs to locate dependent dlls in the same directory + # such as minhook.dll. + winKernel.LOAD_WITH_ALTERED_SEARCH_PATH + ) remoteLib=ctypes.WinDLL("nvdaHelperRemote",handle=h) ret = remoteLib.nvdaControllerInternal_installAddonPackageFromPath(addonPath) if ret != 0: diff --git a/source/speechDictHandler/__init__.py b/source/speechDictHandler/__init__.py index e4ba411f5ee..9b2e979173d 100644 --- a/source/speechDictHandler/__init__.py +++ b/source/speechDictHandler/__init__.py @@ -123,7 +123,7 @@ def initialize(): for type in dictTypes: dictionaries[type]=SpeechDict() dictionaries["default"].load(os.path.join(speechDictsPath, "default.dic")) - dictionaries["builtin"].load("builtin.dic") + dictionaries["builtin"].load(os.path.join(globalVars.appDir, "builtin.dic")) def loadVoiceDict(synth): """Loads appropriate dictionary for the given synthesizer. diff --git a/source/synthDrivers/_espeak.py b/source/synthDrivers/_espeak.py index 0fc98f615c4..c2a5ee197df 100755 --- a/source/synthDrivers/_espeak.py +++ b/source/synthDrivers/_espeak.py @@ -9,6 +9,7 @@ import nvwave import threading import queue +from ctypes import cdll from ctypes import * import config import globalVars @@ -321,7 +322,7 @@ def initialize(indexCallback=None): the number of the index or C{None} when speech stops. """ global espeakDLL, bgThread, bgQueue, player, onIndexReached - espeakDLL=cdll.LoadLibrary(r"synthDrivers\espeak.dll") + espeakDLL = cdll.LoadLibrary(os.path.join(globalVars.appDir, "synthDrivers", "espeak.dll")) espeakDLL.espeak_Info.restype=c_char_p espeakDLL.espeak_Synth.errcheck=espeak_errcheck espeakDLL.espeak_SetVoiceByName.errcheck=espeak_errcheck @@ -331,7 +332,7 @@ def initialize(indexCallback=None): espeakDLL.espeak_ListVoices.restype=POINTER(POINTER(espeak_VOICE)) espeakDLL.espeak_GetCurrentVoice.restype=POINTER(espeak_VOICE) espeakDLL.espeak_SetVoiceByName.argtypes=(c_char_p,) - eSpeakPath=os.path.abspath("synthDrivers") + eSpeakPath = os.path.join(globalVars.appDir, "synthDrivers") sampleRate = espeakDLL.espeak_Initialize( AUDIO_OUTPUT_SYNCHRONOUS, 300, os.fsencode(eSpeakPath), @@ -371,7 +372,7 @@ def info(): return espeakDLL.espeak_Info() def getVariantDict(): - dir='synthDrivers\\espeak-ng-data\\voices\\!v' + dir = os.path.join(globalVars.appDir, "synthDrivers", "espeak-ng-data", "voices", "!v") # Translators: name of the default espeak varient. variantDict={"none": pgettext("espeakVarient", "none")} for fileName in os.listdir(dir): diff --git a/source/systemUtils.py b/source/systemUtils.py index 672885f7ccf..ae73144c638 100644 --- a/source/systemUtils.py +++ b/source/systemUtils.py @@ -54,7 +54,7 @@ def execElevated(path, params=None, wait=False, handleAlreadyElevated=False): import subprocess if params is not None: params = subprocess.list2cmdline(params) - sei = shellapi.SHELLEXECUTEINFO(lpFile=os.path.abspath(path), lpParameters=params, nShow=winUser.SW_HIDE) + sei = shellapi.SHELLEXECUTEINFO(lpFile=path, lpParameters=params, nShow=winUser.SW_HIDE) # IsUserAnAdmin is apparently deprecated so may not work above Windows 8 if not handleAlreadyElevated or not ctypes.windll.shell32.IsUserAnAdmin(): sei.lpVerb = "runas" diff --git a/source/ui.py b/source/ui.py index af6b7a80d99..48661e1231e 100644 --- a/source/ui.py +++ b/source/ui.py @@ -18,6 +18,7 @@ import gui import speech import braille +import globalVars from typing import Optional @@ -45,7 +46,7 @@ def browseableMessage(message,title=None,isHtml=False): @param isHtml: Whether the message is html @type isHtml: boolean """ - htmlFileName = os.path.realpath( u'message.html' ) + htmlFileName = os.path.join(globalVars.appDir, 'message.html') if not os.path.isfile(htmlFileName ): raise LookupError(htmlFileName ) moniker = POINTER(IUnknown)() diff --git a/source/updateCheck.py b/source/updateCheck.py index d66a993546a..cb688ca172b 100644 --- a/source/updateCheck.py +++ b/source/updateCheck.py @@ -204,11 +204,11 @@ def _executeUpdate(destPath): if config.isInstalledCopy(): executeParams = u"--install -m" else: - portablePath = os.getcwd() + portablePath = globalVars.appDir if os.access(portablePath, os.W_OK): executeParams = u'--create-portable --portable-path "{portablePath}" --config-path "{configPath}" -m'.format( portablePath=portablePath, - configPath=os.path.abspath(globalVars.appArgs.configPath) + configPath=globalVars.appArgs.configPath ) else: executeParams = u"--launcher" diff --git a/source/visionEnhancementProviders/screenCurtain.py b/source/visionEnhancementProviders/screenCurtain.py index ab77249ac68..e60a9cc3941 100644 --- a/source/visionEnhancementProviders/screenCurtain.py +++ b/source/visionEnhancementProviders/screenCurtain.py @@ -7,6 +7,7 @@ This implementation only works on Windows 8 and above. """ +import os import vision from vision import providerBase import winVersion @@ -19,6 +20,7 @@ from logHandler import log from typing import Optional, Type import nvwave +import globalVars class MAGCOLOREFFECT(Structure): @@ -325,7 +327,7 @@ def __init__(self): raise e if self.getSettings().playToggleSounds: try: - nvwave.playWaveFile(r"waves\screenCurtainOn.wav") + nvwave.playWaveFile(os.path.join(globalVars.appDir, "waves", "screenCurtainOn.wav")) except Exception: log.exception() @@ -338,7 +340,7 @@ def terminate(self): Magnification.MagUninitialize() if self.getSettings().playToggleSounds: try: - nvwave.playWaveFile(r"waves\screenCurtainOff.wav") + nvwave.playWaveFile(os.path.join(globalVars.appDir, "waves", "screenCurtainOff.wav")) except Exception: log.exception() diff --git a/source/watchdog.py b/source/watchdog.py index 6a8f1ff9588..7269ec9a953 100644 --- a/source/watchdog.py +++ b/source/watchdog.py @@ -187,7 +187,7 @@ def _crashHandler(exceptionInfo): ctypes.pythonapi.PyThreadState_SetAsyncExc(threadId, None) # Write a minidump. - dumpPath = os.path.abspath(os.path.join(globalVars.appArgs.logFileName, "..", "nvda_crash.dmp")) + dumpPath = os.path.join(globalVars.appArgs.logFileName, "..", "nvda_crash.dmp") try: # Though we aren't using pythonic functions to write to the dump file, # open it in binary mode as opening it in text mode (the default) doesn't make sense. diff --git a/source/winKernel.py b/source/winKernel.py index 5dfda1880af..8645d295103 100644 --- a/source/winKernel.py +++ b/source/winKernel.py @@ -48,6 +48,9 @@ WAIT_FAILED = 0xffffffff # Image file machine constants IMAGE_FILE_MACHINE_UNKNOWN = 0 +# LoadLibraryEx constants +LOAD_WITH_ALTERED_SEARCH_PATH = 0x8 + def GetStdHandle(handleID): h=kernel32.GetStdHandle(handleID) diff --git a/tests/unit/__init__.py b/tests/unit/__init__.py index ab935110978..4e4d26c05b4 100644 --- a/tests/unit/__init__.py +++ b/tests/unit/__init__.py @@ -35,8 +35,13 @@ # as this module is imported to expand the system path. import sourceEnv # noqa: F401 -# Set options normally taken from the command line. import globalVars + + +# Tell NvDA where its application directory is +globalVars.appDir = SOURCE_DIR + +# Set options normally taken from the command line. class AppArgs: # The path from which to load a configuration file. # Ideally, this would be an in-memory, default configuration. diff --git a/user_docs/en/changes.t2t b/user_docs/en/changes.t2t index fa0141223bc..26ea1c721b5 100644 --- a/user_docs/en/changes.t2t +++ b/user_docs/en/changes.t2t @@ -29,10 +29,12 @@ What's New in NVDA - When "attempt to cancel expired focus events" is enabled, the title of the tab is now announced again when switching tabs in Firefox. (#11397) - NVDA no longer fails to announce a list item after typing a character in a list when speaking with the SAPI5 Ivona voices. (#11651) - It is again possible to use browse mode when reading emails in Windows 10 Mail 16005.13110 and later. (#11439) +- When using the SAPI5 Ivona voices from harposoftware.com, NvDA is now able to save configuration, switch synthesizers, and no longer will stay silent after restarting. (#11650) == Changes for Developers == - System tests can now send keys using spy.emulateKeyPress, which takes a key identifier that conforms to NVDA's own key names, and by default also blocks until the action is executed. (#11581) +- NVDA no longer requires the current directory to be the NVDA application directory in order to function. (#6491) = 2020.3 = From 03b4eb9e9f95634719cdf1671ba658fb1c121b64 Mon Sep 17 00:00:00 2001 From: Bill Dengler Date: Wed, 30 Sep 2020 00:10:38 -0400 Subject: [PATCH 14/53] Add comment. --- source/NVDAObjects/behaviors.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/source/NVDAObjects/behaviors.py b/source/NVDAObjects/behaviors.py index f4497ac3bf9..6d657519daa 100755 --- a/source/NVDAObjects/behaviors.py +++ b/source/NVDAObjects/behaviors.py @@ -323,7 +323,7 @@ def _initializeDMP(self): ) def _terminateDMP(self): - self._dmp.stdin.write(struct.pack("=II", 0, 0)) + self._dmp.stdin.write(struct.pack("=II", 0, 0)) # Sentinal value self._dmp = None def _monitor(self): From 5f18189d2072017fa8d81fa198a7bef0c4a35ddc Mon Sep 17 00:00:00 2001 From: Bill Dengler Date: Wed, 30 Sep 2020 01:00:49 -0400 Subject: [PATCH 15/53] Update build script pythonpath. --- source/setup.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/source/setup.py b/source/setup.py index bdde8599322..bde971148a8 100755 --- a/source/setup.py +++ b/source/setup.py @@ -20,7 +20,8 @@ from py2exe.dllfinder import DllFinder import wx import importlib.machinery - +import sys +sys.path.append(os.path.join("..", "include", "nvda_dmp")) RT_MANIFEST = 24 manifest_template = """\ From 834e6985bbe8872b9edb2f590a3756ec946d6dd4 Mon Sep 17 00:00:00 2001 From: Bill Dengler Date: Wed, 30 Sep 2020 01:13:43 -0400 Subject: [PATCH 16/53] Move import --- source/setup.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/source/setup.py b/source/setup.py index bde971148a8..1d7b3a53f50 100755 --- a/source/setup.py +++ b/source/setup.py @@ -6,6 +6,7 @@ #See the file COPYING for more details. import os +import sys import copy import gettext gettext.install("nvda") @@ -20,7 +21,7 @@ from py2exe.dllfinder import DllFinder import wx import importlib.machinery -import sys +# Explicitly put the nvda_dmp dir on the build path so the DMP library is included sys.path.append(os.path.join("..", "include", "nvda_dmp")) RT_MANIFEST = 24 manifest_template = """\ From 74c418f94a697058ee9a18258d0550ffd0805b44 Mon Sep 17 00:00:00 2001 From: Bill Dengler Date: Fri, 2 Oct 2020 16:08:44 -0400 Subject: [PATCH 17/53] Address review actions. --- source/NVDAObjects/behaviors.py | 139 ++---------------------------- source/core.py | 2 + source/diffHandler.py | 146 ++++++++++++++++++++++++++++++++ 3 files changed, 153 insertions(+), 134 deletions(-) create mode 100644 source/diffHandler.py diff --git a/source/NVDAObjects/behaviors.py b/source/NVDAObjects/behaviors.py index 6d657519daa..286699fb9b2 100755 --- a/source/NVDAObjects/behaviors.py +++ b/source/NVDAObjects/behaviors.py @@ -9,11 +9,8 @@ """ import os -import sys import time import threading -import struct -import subprocess import tones import queueHandler import eventHandler @@ -32,6 +29,7 @@ import nvwave import globalVars from typing import List +import diffHandler class ProgressBar(NVDAObject): @@ -235,7 +233,6 @@ def initOverlayClass(self): self._event = threading.Event() self._monitorThread = None self._keepMonitoring = False - self._dmp = None def startMonitoring(self): """Start monitoring for new text. @@ -271,15 +268,12 @@ def event_textChange(self): """ self._event.set() - def _get_shouldUseDMP(self): - return self._supportsDMP and config.conf["terminals"]["useDMPWhenSupported"] - def _get_devInfo(self): info = super().devInfo - if self.shouldUseDMP: - info.append("diffing algorithm: character-based (Diff Match Patch)") + if diffHandler._should_use_DMP(self._supportsDMP): + info.append("preferred diffing algorithm: character-based (Diff Match Patch)") else: - info.append("diffing algorithm: line-based (difflib)") + info.append("preferred diffing algorithm: line-based (difflib)") return info def _getText(self) -> str: @@ -307,32 +301,7 @@ def _reportNewText(self, line): """ speech.speakText(line) - def _initializeDMP(self): - if hasattr(sys, "frozen"): - dmp_path = (os.path.join(globalVars.appDir, "nvda_dmp.exe"),) - else: - dmp_path = (sys.executable, os.path.join( - globalVars.appDir, "..", "include", "nvda_dmp", "nvda_dmp.py" - )) - self._dmp = subprocess.Popen( - dmp_path, - creationflags=subprocess.CREATE_NO_WINDOW, - bufsize=0, - stdin=subprocess.PIPE, - stdout=subprocess.PIPE - ) - - def _terminateDMP(self): - self._dmp.stdin.write(struct.pack("=II", 0, 0)) # Sentinal value - self._dmp = None - def _monitor(self): - if self.shouldUseDMP: - try: - self._initializeDMP() - except Exception: - log.exception("Error initializing DMP, falling back to difflib") - self._supportsDmp = False try: oldText = self._getText() except: @@ -366,106 +335,8 @@ def _monitor(self): except: log.exception("Error getting or calculating new text") - if self.shouldUseDMP: - try: - self._terminateDMP() - except Exception: - log.exception("Error stopping DMP") - - def _calculateNewText_dmp(self, newText: str, oldText: str) -> List[str]: - try: - if not newText and not oldText: - # Return an empty list here to avoid exiting - # nvda_dmp uses two zero-length texts as a sentinal value - return [] - old = oldText.encode("utf-8") - new = newText.encode("utf-8") - tl = struct.pack("=II", len(old), len(new)) - self._dmp.stdin.write(tl) - self._dmp.stdin.write(old) - self._dmp.stdin.write(new) - buf = b"" - sizeb = b"" - SIZELEN = 4 - while len(sizeb) < SIZELEN: - sizeb = self._dmp.stdout.read(SIZELEN - len(sizeb)) - if sizeb is None: - sizeb = b"" - (size,) = struct.unpack("=I", sizeb) - while len(buf) < size: - buf += self._dmp.stdout.read(size - len(buf)) - return [ - line - for line in buf.decode("utf-8").splitlines() - if line and not line.isspace() - ] - except Exception: - log.exception("Exception in DMP, falling back to difflib") - self._supportsDMP = False - return self._calculateNewText_difflib(newText, oldText) - - def _calculateNewText_difflib(self, newLines: List[str], oldLines: List[str]) -> List[str]: - outLines = [] - - prevLine = None - from difflib import ndiff - - for line in ndiff(oldLines, newLines): - if line[0] == "?": - # We're never interested in these. - continue - if line[0] != "+": - # We're only interested in new lines. - prevLine = line - continue - text = line[2:] - if not text or text.isspace(): - prevLine = line - continue - - if prevLine and prevLine[0] == "-" and len(prevLine) > 2: - # It's possible that only a few characters have changed in this line. - # If so, we want to speak just the changed section, rather than the entire line. - prevText = prevLine[2:] - textLen = len(text) - prevTextLen = len(prevText) - # Find the first character that differs between the two lines. - for pos in range(min(textLen, prevTextLen)): - if text[pos] != prevText[pos]: - start = pos - break - else: - # We haven't found a differing character so far and we've hit the end of one of the lines. - # This means that the differing text starts here. - start = pos + 1 - # Find the end of the differing text. - if textLen != prevTextLen: - # The lines are different lengths, so assume the rest of the line changed. - end = textLen - else: - for pos in range(textLen - 1, start - 1, -1): - if text[pos] != prevText[pos]: - end = pos + 1 - break - - if end - start < 15: - # Less than 15 characters have changed, so only speak the changed chunk. - text = text[start:end] - - if text and not text.isspace(): - outLines.append(text) - prevLine = line - - return outLines - def _calculateNewText(self, newText: str, oldText: str) -> List[str]: - return ( - self._calculateNewText_dmp(newText, oldText) - if self.shouldUseDMP - else self._calculateNewText_difflib( - newText.splitlines(), oldText.splitlines() - ) - ) + return diffHandler.diff(newText, oldText, supports_dmp=self._supportsDMP) class Terminal(LiveText, EditableText): diff --git a/source/core.py b/source/core.py index 14efe569747..9e0f4c003d3 100644 --- a/source/core.py +++ b/source/core.py @@ -591,6 +591,8 @@ def run(self): _terminate(speech) _terminate(addonHandler) _terminate(garbageHandler) + import diffHandler + diffHandler.terminate() if not globalVars.appArgs.minimal and config.conf["general"]["playStartAndExitSounds"]: try: diff --git a/source/diffHandler.py b/source/diffHandler.py new file mode 100644 index 00000000000..3969d79bed9 --- /dev/null +++ b/source/diffHandler.py @@ -0,0 +1,146 @@ +# 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) 2020 Bill Dengler + +import globalVars +import os +import struct +import subprocess +import sys +from difflib import ndiff +from logHandler import log +from typing import List + +_dmpProc = None + + +def _dmp(newText: str, oldText: str) -> List[str]: + global _dmpProc + try: + initialize() + if not newText and not oldText: + # Return an empty list here to avoid exiting + # nvda_dmp uses two zero-length texts as a sentinal value + return [] + old = oldText.encode("utf-8") + new = newText.encode("utf-8") + # Sizes are packed as 32-bit ints in native byte order. + # Since nvda and nvda_dmp are running under the same interpreter, this is okay. + tl = struct.pack("=II", len(old), len(new)) + _dmpProc.stdin.write(tl) + _dmpProc.stdin.write(old) + _dmpProc.stdin.write(new) + buf = b"" + sizeb = b"" + SIZELEN = 4 + while len(sizeb) < SIZELEN: + try: + sizeb += _dmpProc.stdout.read(SIZELEN - len(sizeb)) + except TypeError: + pass + (size,) = struct.unpack("=I", sizeb) + while len(buf) < size: + buf += _dmpProc.stdout.read(size - len(buf)) + return [ + line + for line in buf.decode("utf-8").splitlines() + if line and not line.isspace() + ] + except Exception: + log.exception("Exception in DMP, falling back to difflib") + return _difflib(newText, oldText) + + +def _difflib(newText: str, oldText: str) -> List[str]: + newLines = newText.splitlines() + oldLines = oldText.splitlines() + outLines = [] + + prevLine = None + + for line in ndiff(oldLines, newLines): + if line[0] == "?": + # We're never interested in these. + continue + if line[0] != "+": + # We're only interested in new lines. + prevLine = line + continue + text = line[2:] + if not text or text.isspace(): + prevLine = line + continue + + if prevLine and prevLine[0] == "-" and len(prevLine) > 2: + # It's possible that only a few characters have changed in this line. + # If so, we want to speak just the changed section, rather than the entire line. + prevText = prevLine[2:] + textLen = len(text) + prevTextLen = len(prevText) + # Find the first character that differs between the two lines. + for pos in range(min(textLen, prevTextLen)): + if text[pos] != prevText[pos]: + start = pos + break + else: + # We haven't found a differing character so far and we've hit the end of one of the lines. + # This means that the differing text starts here. + start = pos + 1 + # Find the end of the differing text. + if textLen != prevTextLen: + # The lines are different lengths, so assume the rest of the line changed. + end = textLen + else: + for pos in range(textLen - 1, start - 1, -1): + if text[pos] != prevText[pos]: + end = pos + 1 + break + + if end - start < 15: + # Less than 15 characters have changed, so only speak the changed chunk. + text = text[start:end] + + if text and not text.isspace(): + outLines.append(text) + prevLine = line + + return outLines + + +def _should_use_DMP(supports_dmp: bool = True): + import config + return supports_dmp and config.conf["terminals"]["useDMPWhenSupported"] + + +def initialize(): + global _dmpProc + if not _dmpProc: + log.debug("Starting diff-match-patch proxy") + if hasattr(sys, "frozen"): + dmp_path = (os.path.join(globalVars.appDir, "nvda_dmp.exe"),) + else: + dmp_path = (sys.executable, os.path.join( + globalVars.appDir, "..", "include", "nvda_dmp", "nvda_dmp.py" + )) + _dmpProc = subprocess.Popen( + dmp_path, + creationflags=subprocess.CREATE_NO_WINDOW, + bufsize=0, + stdin=subprocess.PIPE, + stdout=subprocess.PIPE + ) + + +def terminate(): + global _dmpProc + try: + if _dmpProc: + log.debug("Terminating diff-match-patch proxy") + _dmpProc.stdin.write(struct.pack("=II", 0, 0)) # Sentinal value + except Exception: + pass + + +def diff(newText: str, oldText: str, supports_dmp: bool = True): + return _dmp(newText, oldText) if _should_use_DMP(supports_dmp=supports_dmp) else _difflib(newText, oldText) From 114ab85dd9a92b85f832a99f6c6f3f36a227f379 Mon Sep 17 00:00:00 2001 From: Bill Dengler Date: Fri, 2 Oct 2020 16:27:35 -0400 Subject: [PATCH 18/53] Don't restrict DMP setting to 1607+. Although it doesn't work on non-enhanced legacy consoles, there are other LiveText objects where DMP would theoretically be supported. --- source/gui/settingsDialogs.py | 1 - 1 file changed, 1 deletion(-) diff --git a/source/gui/settingsDialogs.py b/source/gui/settingsDialogs.py index 8ca3e3e9ce1..6bd778e0173 100644 --- a/source/gui/settingsDialogs.py +++ b/source/gui/settingsDialogs.py @@ -2533,7 +2533,6 @@ def __init__(self, parent): self.useDMPWhenSupportedCheckBox = terminalsGroup.addItem(wx.CheckBox(self, label=label)) self.useDMPWhenSupportedCheckBox.SetValue(config.conf["terminals"]["useDMPWhenSupported"]) self.useDMPWhenSupportedCheckBox.defaultValue = self._getDefaultValue(["terminals", "useDMPWhenSupported"]) - self.useDMPWhenSupportedCheckBox.Enable(winVersion.isWin10(1607)) # Translators: This is the label for a group of advanced options in the # Advanced settings panel From e58a0d332f7c3be7492616d7ac035bec1ca684bd Mon Sep 17 00:00:00 2001 From: Bill Dengler Date: Fri, 2 Oct 2020 16:32:11 -0400 Subject: [PATCH 19/53] Update user guide to clarify availability. --- user_docs/en/userGuide.t2t | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/user_docs/en/userGuide.t2t b/user_docs/en/userGuide.t2t index 3bd9c3cc76c..1017eba4c41 100644 --- a/user_docs/en/userGuide.t2t +++ b/user_docs/en/userGuide.t2t @@ -1843,7 +1843,8 @@ In untrusted environments, you may temporarily disable [speak typed characters # This option enables an alternative method for detecting output changes in terminals. It may improve performance when large volumes of text are written to the console and allow more accurate reporting of changes made in the middle of lines. However, it may be incompatible with some applications. -This feature is available and enabled by default on Windows 10 versions 1607 and later. +This feature is available and enabled by default in Windows Console on Windows 10 versions 1607 and later. +Additionally, it may be available in other terminals on earlier Windows releases. ==== Attempt to cancel speech for expired focus events ====[CancelExpiredFocusSpeech] This option enables behaviour which attempts to cancel speech for expired focus events. From 4140003ff1e61e1fd75d2434d43405896a10b828 Mon Sep 17 00:00:00 2001 From: Bill Dengler Date: Wed, 7 Oct 2020 20:10:24 -0400 Subject: [PATCH 20/53] Move to a class-based approach. --- source/NVDAObjects/behaviors.py | 21 +- source/NVDAObjects/window/winConsole.py | 18 +- source/core.py | 10 +- source/diffHandler.py | 269 +++++++++++++----------- 4 files changed, 176 insertions(+), 142 deletions(-) diff --git a/source/NVDAObjects/behaviors.py b/source/NVDAObjects/behaviors.py index 286699fb9b2..3cb1ab2fff4 100755 --- a/source/NVDAObjects/behaviors.py +++ b/source/NVDAObjects/behaviors.py @@ -221,9 +221,6 @@ class LiveText(NVDAObject): """ #: The time to wait before fetching text after a change event. STABILIZE_DELAY = 0 - #: Whether this object supports Diff-Match-Patch character diffing. - #: Set to False to use line diffing. - _supportsDMP = True # If the text is live, this is definitely content. presentationType = NVDAObject.presType_content @@ -268,12 +265,12 @@ def event_textChange(self): """ self._event.set() + def _get_diffAlgo(self): + return diffHandler.dmp if config.conf["terminals"]["useDMPWhenSupported"] else diffHandler.difflib + def _get_devInfo(self): info = super().devInfo - if diffHandler._should_use_DMP(self._supportsDMP): - info.append("preferred diffing algorithm: character-based (Diff Match Patch)") - else: - info.append("preferred diffing algorithm: line-based (difflib)") + info.append(f"diffing algorithm: {self.diffAlgo}") return info def _getText(self) -> str: @@ -285,7 +282,13 @@ def _getText(self) -> str: if hasattr(self, "_getTextLines"): log.warning("LiveText._getTextLines is deprecated, please override _getText instead.") return '\n'.join(self._getTextLines()) - return self.makeTextInfo(textInfos.POSITION_ALL).text + ti = self.makeTextInfo(textInfos.POSITION_ALL) + if self.diffAlgo.unit == textInfos.UNIT_CHARACTER: + return ti.text + elif self.diffAlgo.unit == textInfos.UNIT_LINE: + return "\n".join(ti.getTextInChunks(textInfos.UNIT_LINE)) + else: + raise NotImplementedError(f"Unknown unit {self.diffAlgo.unit}") def _reportNewLines(self, lines): """ @@ -336,7 +339,7 @@ def _monitor(self): log.exception("Error getting or calculating new text") def _calculateNewText(self, newText: str, oldText: str) -> List[str]: - return diffHandler.diff(newText, oldText, supports_dmp=self._supportsDMP) + return self.diffAlgo.diff(newText, oldText) class Terminal(LiveText, EditableText): diff --git a/source/NVDAObjects/window/winConsole.py b/source/NVDAObjects/window/winConsole.py index c2ffb03d285..1c61bee9805 100644 --- a/source/NVDAObjects/window/winConsole.py +++ b/source/NVDAObjects/window/winConsole.py @@ -20,14 +20,22 @@ class WinConsole(Terminal, EditableTextWithoutAutoSelectDetection, Window): STABILIZE_DELAY = 0.03 def initOverlayClass(self): + # Legacy consoles take quite a while to send textChange events. + # This significantly impacts typing performance, so don't queue chars. if isinstance(self, KeyboardHandlerBasedTypedCharSupport): - # Legacy consoles take quite a while to send textChange events. - # This significantly impacts typing performance, so don't queue chars. self._supportsTextChange = False + + def _get_diffAlgo(self): + # Non-enhanced legacy consoles use caret proximity to detect + # typed/deleted text. + # Single-character changes are not reported as + # they are confused for typed characters. + # Force difflib to keep meaningful edit reporting in these consoles. + if not isinstance(self, KeyboardHandlerBasedTypedCharSupport): + from diffHandler import difflib + return difflib else: - # Use line diffing to report changes in the middle of lines - # in non-enhanced legacy consoles. - self._supportsDMP = False + return super().diffAlgo def _get_windowThreadID(self): # #10113: Windows forces the thread of console windows to match the thread of the first attached process. diff --git a/source/core.py b/source/core.py index 9e0f4c003d3..ebd7ce59b7d 100644 --- a/source/core.py +++ b/source/core.py @@ -591,8 +591,14 @@ def run(self): _terminate(speech) _terminate(addonHandler) _terminate(garbageHandler) - import diffHandler - diffHandler.terminate() + # DMP is only started if needed. + # Terminate manually (and let it write to the log if necessary) + # as core._terminate always writes an entry. + try: + import diffHandler + diffHandler.dmp._terminate() + except Exception: + pass if not globalVars.appArgs.minimal and config.conf["general"]["playStartAndExitSounds"]: try: diff --git a/source/diffHandler.py b/source/diffHandler.py index 3969d79bed9..404e95db6c1 100644 --- a/source/diffHandler.py +++ b/source/diffHandler.py @@ -8,139 +8,156 @@ import struct import subprocess import sys +from abc import abstractmethod +from baseObject import AutoPropertyObject from difflib import ndiff from logHandler import log +from textInfos import UNIT_CHARACTER, UNIT_LINE from typing import List -_dmpProc = None - - -def _dmp(newText: str, oldText: str) -> List[str]: - global _dmpProc - try: - initialize() - if not newText and not oldText: - # Return an empty list here to avoid exiting - # nvda_dmp uses two zero-length texts as a sentinal value - return [] - old = oldText.encode("utf-8") - new = newText.encode("utf-8") - # Sizes are packed as 32-bit ints in native byte order. - # Since nvda and nvda_dmp are running under the same interpreter, this is okay. - tl = struct.pack("=II", len(old), len(new)) - _dmpProc.stdin.write(tl) - _dmpProc.stdin.write(old) - _dmpProc.stdin.write(new) - buf = b"" - sizeb = b"" - SIZELEN = 4 - while len(sizeb) < SIZELEN: - try: - sizeb += _dmpProc.stdout.read(SIZELEN - len(sizeb)) - except TypeError: - pass - (size,) = struct.unpack("=I", sizeb) - while len(buf) < size: - buf += _dmpProc.stdout.read(size - len(buf)) - return [ - line - for line in buf.decode("utf-8").splitlines() - if line and not line.isspace() - ] - except Exception: - log.exception("Exception in DMP, falling back to difflib") - return _difflib(newText, oldText) - - -def _difflib(newText: str, oldText: str) -> List[str]: - newLines = newText.splitlines() - oldLines = oldText.splitlines() - outLines = [] - - prevLine = None - - for line in ndiff(oldLines, newLines): - if line[0] == "?": - # We're never interested in these. - continue - if line[0] != "+": - # We're only interested in new lines. - prevLine = line - continue - text = line[2:] - if not text or text.isspace(): - prevLine = line - continue - - if prevLine and prevLine[0] == "-" and len(prevLine) > 2: - # It's possible that only a few characters have changed in this line. - # If so, we want to speak just the changed section, rather than the entire line. - prevText = prevLine[2:] - textLen = len(text) - prevTextLen = len(prevText) - # Find the first character that differs between the two lines. - for pos in range(min(textLen, prevTextLen)): - if text[pos] != prevText[pos]: - start = pos - break - else: - # We haven't found a differing character so far and we've hit the end of one of the lines. - # This means that the differing text starts here. - start = pos + 1 - # Find the end of the differing text. - if textLen != prevTextLen: - # The lines are different lengths, so assume the rest of the line changed. - end = textLen + +class DiffAlgo(AutoPropertyObject): + @abstractmethod + def diff(self, newText: str, oldText: str) -> List[str]: + raise NotImplementedError + + _abstract_unit = True + + def _get_unit(self): + raise NotImplementedError + + def _get_name(self): + return "diffing algorithm" + + def __repr__(self): + return f"{self.unit}-based ({self.name})" + + +class DiffMatchPatch(DiffAlgo): + name = "diff match patch" + unit = UNIT_CHARACTER + + def __init__(self, *args, **kwargs): + self._proc = None + return super().__init__(*args, **kwargs) + + def _initialize(self): + if not self._proc: + log.debug("Starting diff-match-patch proxy") + if hasattr(sys, "frozen"): + dmp_path = (os.path.join(globalVars.appDir, "nvda_dmp.exe"),) else: - for pos in range(textLen - 1, start - 1, -1): + dmp_path = (sys.executable, os.path.join( + globalVars.appDir, "..", "include", "nvda_dmp", "nvda_dmp.py" + )) + self._proc = subprocess.Popen( + dmp_path, + creationflags=subprocess.CREATE_NO_WINDOW, + bufsize=0, + stdin=subprocess.PIPE, + stdout=subprocess.PIPE + ) + + def diff(self, newText: str, oldText: str) -> List[str]: + try: + self._initialize() + if not newText and not oldText: + # Return an empty list here to avoid exiting + # nvda_dmp uses two zero-length texts as a sentinal value + return [] + old = oldText.encode("utf-8") + new = newText.encode("utf-8") + # Sizes are packed as 32-bit ints in native byte order. + # Since nvda and nvda_dmp are running on the same platform/version, this is okay. + tl = struct.pack("=II", len(old), len(new)) + self._proc.stdin.write(tl) + self._proc.stdin.write(old) + self._proc.stdin.write(new) + buf = b"" + sizeb = b"" + SIZELEN = 4 + while len(sizeb) < SIZELEN: + try: + sizeb += self._proc.stdout.read(SIZELEN - len(sizeb)) + except TypeError: + pass + (size,) = struct.unpack("=I", sizeb) + while len(buf) < size: + buf += self._proc.stdout.read(size - len(buf)) + return [ + line + for line in buf.decode("utf-8").splitlines() + if line and not line.isspace() + ] + except Exception: + log.exception("Exception in DMP, falling back to difflib") + return Difflib().diff(newText, oldText) + + def _terminate(self): + if self._proc: + log.debug("Terminating diff-match-patch proxy") + self._proc.stdin.write(struct.pack("=II", 0, 0)) # Sentinal value + + +class Difflib(DiffAlgo): + name = "difflib" + unit = UNIT_LINE + + def diff(self, newText: str, oldText: str) -> List[str]: + newLines = newText.splitlines() + oldLines = oldText.splitlines() + outLines = [] + + prevLine = None + + for line in ndiff(oldLines, newLines): + if line[0] == "?": + # We're never interested in these. + continue + if line[0] != "+": + # We're only interested in new lines. + prevLine = line + continue + text = line[2:] + if not text or text.isspace(): + prevLine = line + continue + + if prevLine and prevLine[0] == "-" and len(prevLine) > 2: + # It's possible that only a few characters have changed in this line. + # If so, we want to speak just the changed section, rather than the entire line. + prevText = prevLine[2:] + textLen = len(text) + prevTextLen = len(prevText) + # Find the first character that differs between the two lines. + for pos in range(min(textLen, prevTextLen)): if text[pos] != prevText[pos]: - end = pos + 1 + start = pos break + else: + # We haven't found a differing character so far and we've hit the end of one of the lines. + # This means that the differing text starts here. + start = pos + 1 + # Find the end of the differing text. + if textLen != prevTextLen: + # The lines are different lengths, so assume the rest of the line changed. + end = textLen + else: + for pos in range(textLen - 1, start - 1, -1): + if text[pos] != prevText[pos]: + end = pos + 1 + break + + if end - start < 15: + # Less than 15 characters have changed, so only speak the changed chunk. + text = text[start:end] + + if text and not text.isspace(): + outLines.append(text) + prevLine = line - if end - start < 15: - # Less than 15 characters have changed, so only speak the changed chunk. - text = text[start:end] - - if text and not text.isspace(): - outLines.append(text) - prevLine = line - - return outLines - - -def _should_use_DMP(supports_dmp: bool = True): - import config - return supports_dmp and config.conf["terminals"]["useDMPWhenSupported"] - - -def initialize(): - global _dmpProc - if not _dmpProc: - log.debug("Starting diff-match-patch proxy") - if hasattr(sys, "frozen"): - dmp_path = (os.path.join(globalVars.appDir, "nvda_dmp.exe"),) - else: - dmp_path = (sys.executable, os.path.join( - globalVars.appDir, "..", "include", "nvda_dmp", "nvda_dmp.py" - )) - _dmpProc = subprocess.Popen( - dmp_path, - creationflags=subprocess.CREATE_NO_WINDOW, - bufsize=0, - stdin=subprocess.PIPE, - stdout=subprocess.PIPE - ) - - -def terminate(): - global _dmpProc - try: - if _dmpProc: - log.debug("Terminating diff-match-patch proxy") - _dmpProc.stdin.write(struct.pack("=II", 0, 0)) # Sentinal value - except Exception: - pass + return outLines -def diff(newText: str, oldText: str, supports_dmp: bool = True): - return _dmp(newText, oldText) if _should_use_DMP(supports_dmp=supports_dmp) else _difflib(newText, oldText) +difflib = Difflib() +dmp = DiffMatchPatch() From 46eccead87dc20c495fb8ded3ad1cd3862c3940e Mon Sep 17 00:00:00 2001 From: Bill Dengler Date: Wed, 7 Oct 2020 21:25:50 -0400 Subject: [PATCH 21/53] Change the DMP setting to a three-way option. --- source/NVDAObjects/behaviors.py | 6 +++- source/config/configSpec.py | 2 +- source/gui/settingsDialogs.py | 49 +++++++++++++++++++++++++++------ user_docs/en/userGuide.t2t | 13 +++++++-- 4 files changed, 56 insertions(+), 14 deletions(-) diff --git a/source/NVDAObjects/behaviors.py b/source/NVDAObjects/behaviors.py index 3cb1ab2fff4..233035e2c0e 100755 --- a/source/NVDAObjects/behaviors.py +++ b/source/NVDAObjects/behaviors.py @@ -266,7 +266,11 @@ def event_textChange(self): self._event.set() def _get_diffAlgo(self): - return diffHandler.dmp if config.conf["terminals"]["useDMPWhenSupported"] else diffHandler.difflib + return ( + diffHandler.dmp + if config.conf["terminals"]["diffAlgo"] == "dmp" + else diffHandler.difflib + ) def _get_devInfo(self): info = super().devInfo diff --git a/source/config/configSpec.py b/source/config/configSpec.py index c4f94eda5df..061ccaae6f1 100644 --- a/source/config/configSpec.py +++ b/source/config/configSpec.py @@ -219,7 +219,7 @@ [terminals] speakPasswords = boolean(default=false) keyboardSupportInLegacy = boolean(default=True) - useDMPWhenSupported = boolean(default=True) + diffAlgo = option("auto", "dmp", "difflib", default="auto") [update] autoCheck = boolean(default=true) diff --git a/source/gui/settingsDialogs.py b/source/gui/settingsDialogs.py index 6bd778e0173..3ef131a475e 100644 --- a/source/gui/settingsDialogs.py +++ b/source/gui/settingsDialogs.py @@ -2527,12 +2527,40 @@ def __init__(self, parent): self.keyboardSupportInLegacyCheckBox.defaultValue = self._getDefaultValue(["terminals", "keyboardSupportInLegacy"]) self.keyboardSupportInLegacyCheckBox.Enable(winVersion.isWin10(1607)) - # Translators: This is the label for a checkbox in the - # Advanced settings panel. - label = _("Detect changes by c&haracter when supported") - self.useDMPWhenSupportedCheckBox = terminalsGroup.addItem(wx.CheckBox(self, label=label)) - self.useDMPWhenSupportedCheckBox.SetValue(config.conf["terminals"]["useDMPWhenSupported"]) - self.useDMPWhenSupportedCheckBox.defaultValue = self._getDefaultValue(["terminals", "useDMPWhenSupported"]) + # Translators: This is the label for a combo box for selecting a + # method of detecting changed content in terminals in the advanced + # settings panel. + # Choices are automatic, character, and line. + diffAlgoComboText = _("C&hange detection preference:") + diffAlgoChoices = [ + # Translators: A choice in a combo box in the advanced settings + # panel to have NVDA determine the method of detecting changed + # content in terminals automatically + _("automatic"), + # Translators: A choice in a combo box in the advanced settings + # panel to have NVDA detect changes in terminals + # by character when supported. + _("character"), + # Translators: A choice in a combo box in the advanced settings + # panel to have NVDA detect changes in terminals + # by line. + _("line") + ] + #: The possible diffAlgo config values, in the order they appear + #: in the combo box. + self.diffAlgoVals = ( + "auto", + "dmp", + "difflib" + ) + self.diffAlgoCombo = terminalsGroup.addLabeledControl(diffAlgoComboText, wx.Choice, choices=diffAlgoChoices) + curChoice = self.diffAlgoVals.index( + config.conf['terminals']['diffAlgo'] + ) + self.diffAlgoCombo.SetSelection(curChoice) + self.diffAlgoCombo.defaultValue = self.diffAlgoVals.index( + self._getDefaultValue(["terminals", "diffAlgo"]) + ) # Translators: This is the label for a group of advanced options in the # Advanced settings panel @@ -2651,7 +2679,7 @@ def haveConfigDefaultsBeenRestored(self): and self.winConsoleSpeakPasswordsCheckBox.IsChecked() == self.winConsoleSpeakPasswordsCheckBox.defaultValue and self.cancelExpiredFocusSpeechCombo.GetSelection() == self.cancelExpiredFocusSpeechCombo.defaultValue and self.keyboardSupportInLegacyCheckBox.IsChecked() == self.keyboardSupportInLegacyCheckBox.defaultValue - and self.useDMPWhenSupportedCheckBox.IsChecked() == self.useDMPWhenSupportedCheckBox.defaultValue + and self.diffAlgoCombo.GetSelection() == self.diffAlgoCombo.defaultValue and self.caretMoveTimeoutSpinControl.GetValue() == self.caretMoveTimeoutSpinControl.defaultValue and set(self.logCategoriesList.CheckedItems) == set(self.logCategoriesList.defaultCheckedItems) and True # reduce noise in diff when the list is extended. @@ -2665,7 +2693,7 @@ def restoreToDefaults(self): self.winConsoleSpeakPasswordsCheckBox.SetValue(self.winConsoleSpeakPasswordsCheckBox.defaultValue) self.cancelExpiredFocusSpeechCombo.SetSelection(self.cancelExpiredFocusSpeechCombo.defaultValue) self.keyboardSupportInLegacyCheckBox.SetValue(self.keyboardSupportInLegacyCheckBox.defaultValue) - self.useDMPWhenSupportedCheckBox.SetValue(self.useDMPWhenSupportedCheckBox.defaultValue) + self.diffAlgoCombo.SetSelection(self.diffAlgoCombo.defaultValue == 'auto') self.caretMoveTimeoutSpinControl.SetValue(self.caretMoveTimeoutSpinControl.defaultValue) self.logCategoriesList.CheckedItems = self.logCategoriesList.defaultCheckedItems self._defaultsRestored = True @@ -2682,7 +2710,10 @@ def onSave(self): config.conf["terminals"]["speakPasswords"] = self.winConsoleSpeakPasswordsCheckBox.IsChecked() config.conf["featureFlag"]["cancelExpiredFocusSpeech"] = self.cancelExpiredFocusSpeechCombo.GetSelection() config.conf["terminals"]["keyboardSupportInLegacy"]=self.keyboardSupportInLegacyCheckBox.IsChecked() - config.conf["terminals"]["useDMPWhenSupported"] = self.useDMPWhenSupportedCheckBox.IsChecked() + diffAlgoChoice = self.diffAlgoCombo.GetSelection() + config.conf['terminals']['diffAlgo'] = ( + self.diffAlgoVals[diffAlgoChoice] + ) config.conf["editableText"]["caretMoveTimeoutMs"]=self.caretMoveTimeoutSpinControl.GetValue() for index,key in enumerate(self.logCategories): config.conf['debugLog'][key]=self.logCategoriesList.IsChecked(index) diff --git a/user_docs/en/userGuide.t2t b/user_docs/en/userGuide.t2t index 1017eba4c41..329b057958b 100644 --- a/user_docs/en/userGuide.t2t +++ b/user_docs/en/userGuide.t2t @@ -1839,12 +1839,19 @@ This feature is available and enabled by default on Windows 10 versions 1607 and Warning: with this option enabled, typed characters that do not appear onscreen, such as passwords, will not be suppressed. In untrusted environments, you may temporarily disable [speak typed characters #KeyboardSettingsSpeakTypedCharacters] and [speak typed words #KeyboardSettingsSpeakTypedWords] when entering passwords. -==== Detect changes by character when supported ====[AdvancedSettingsUseDMPWhenSupported] -This option enables an alternative method for detecting output changes in terminals. +==== Change detection preference ====[AdvancedSettingsDiffAlgo] +This setting controls how NVDA determines the new text to speak in terminals. +The change detection preference combo box has three options: +- automatic: as of NVDA 2020.4, this option behaves the same as "line". +In a future release, it may be changed to "character" pending positive user testing. +- character: This option causes NVDA to calculate changes to terminal text by character. It may improve performance when large volumes of text are written to the console and allow more accurate reporting of changes made in the middle of lines. However, it may be incompatible with some applications. -This feature is available and enabled by default in Windows Console on Windows 10 versions 1607 and later. +This feature is supported in Windows Console on Windows 10 versions 1607 and later. Additionally, it may be available in other terminals on earlier Windows releases. +- line: this option causes NVDA to calculate changes to terminal text by line. +It is identical to NVDA's behaviour in versions 2020.3 and earlier. +- ==== Attempt to cancel speech for expired focus events ====[CancelExpiredFocusSpeech] This option enables behaviour which attempts to cancel speech for expired focus events. From 0b3c59fada97243f050c8b4fd529eac74e79bf16 Mon Sep 17 00:00:00 2001 From: Bill Dengler Date: Thu, 8 Oct 2020 01:08:51 -0400 Subject: [PATCH 22/53] Add logging, update comment. --- source/core.py | 2 +- source/diffHandler.py | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/source/core.py b/source/core.py index ebd7ce59b7d..c2a453eec31 100644 --- a/source/core.py +++ b/source/core.py @@ -598,7 +598,7 @@ def run(self): import diffHandler diffHandler.dmp._terminate() except Exception: - pass + log.debug("Exception while terminating DMP", exc_info=True) if not globalVars.appArgs.minimal and config.conf["general"]["playStartAndExitSounds"]: try: diff --git a/source/diffHandler.py b/source/diffHandler.py index 404e95db6c1..4f0b947869e 100644 --- a/source/diffHandler.py +++ b/source/diffHandler.py @@ -68,7 +68,8 @@ def diff(self, newText: str, oldText: str) -> List[str]: old = oldText.encode("utf-8") new = newText.encode("utf-8") # Sizes are packed as 32-bit ints in native byte order. - # Since nvda and nvda_dmp are running on the same platform/version, this is okay. + # Since nvda and nvda_dmp are running on the same Python + # platform/version, this is okay. tl = struct.pack("=II", len(old), len(new)) self._proc.stdin.write(tl) self._proc.stdin.write(old) From ec879e6bdc5fccb0395d760cc932555bd18aed20 Mon Sep 17 00:00:00 2001 From: Bill Dengler Date: Thu, 8 Oct 2020 02:25:30 -0400 Subject: [PATCH 23/53] Fix one last comment. --- source/NVDAObjects/UIA/winConsoleUIA.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/source/NVDAObjects/UIA/winConsoleUIA.py b/source/NVDAObjects/UIA/winConsoleUIA.py index d6cf2700eb8..2b0de200e09 100644 --- a/source/NVDAObjects/UIA/winConsoleUIA.py +++ b/source/NVDAObjects/UIA/winConsoleUIA.py @@ -48,10 +48,8 @@ def _getBoundingRange(self, obj, position): if position == textInfos.POSITION_FIRST: collapseToEnd = False elif position == textInfos.POSITION_LAST: - # 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. + # The exclusive end hangs off the end of the visible ranges. + # Move back one character to remain within bounds. _rangeObj.MoveEndpointByUnit( UIAHandler.TextPatternRangeEndpoint_End, UIAHandler.NVDAUnitsToUIAUnits['character'], From 6fd1fbe8b6e4baf9e05ffd00118b6adc8aa46ff7 Mon Sep 17 00:00:00 2001 From: Bill Dengler Date: Thu, 8 Oct 2020 12:41:27 -0400 Subject: [PATCH 24/53] Address review actions. --- source/gui/settingsDialogs.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/source/gui/settingsDialogs.py b/source/gui/settingsDialogs.py index 3ef131a475e..1860e67447f 100644 --- a/source/gui/settingsDialogs.py +++ b/source/gui/settingsDialogs.py @@ -2536,15 +2536,15 @@ def __init__(self, parent): # Translators: A choice in a combo box in the advanced settings # panel to have NVDA determine the method of detecting changed # content in terminals automatically - _("automatic"), + pgettext("change detection preference", "automatic (line)"), # Translators: A choice in a combo box in the advanced settings # panel to have NVDA detect changes in terminals # by character when supported. - _("character"), + pgettext("change detection preference", "character"), # Translators: A choice in a combo box in the advanced settings # panel to have NVDA detect changes in terminals # by line. - _("line") + pgettext("change detection preference", "line") ] #: The possible diffAlgo config values, in the order they appear #: in the combo box. From 9b6761dc075fdacf0606071af7bb370d14487377 Mon Sep 17 00:00:00 2001 From: Bill Dengler Date: Thu, 8 Oct 2020 13:22:45 -0400 Subject: [PATCH 25/53] Review actions. --- source/NVDAObjects/behaviors.py | 7 +-- source/diffHandler.py | 100 +++++++++++++++----------------- 2 files changed, 48 insertions(+), 59 deletions(-) diff --git a/source/NVDAObjects/behaviors.py b/source/NVDAObjects/behaviors.py index 233035e2c0e..c34884e3957 100755 --- a/source/NVDAObjects/behaviors.py +++ b/source/NVDAObjects/behaviors.py @@ -287,12 +287,7 @@ def _getText(self) -> str: log.warning("LiveText._getTextLines is deprecated, please override _getText instead.") return '\n'.join(self._getTextLines()) ti = self.makeTextInfo(textInfos.POSITION_ALL) - if self.diffAlgo.unit == textInfos.UNIT_CHARACTER: - return ti.text - elif self.diffAlgo.unit == textInfos.UNIT_LINE: - return "\n".join(ti.getTextInChunks(textInfos.UNIT_LINE)) - else: - raise NotImplementedError(f"Unknown unit {self.diffAlgo.unit}") + return self.diffAlgo._getText(ti) def _reportNewLines(self, lines): """ diff --git a/source/diffHandler.py b/source/diffHandler.py index 4f0b947869e..00dda4f7d56 100644 --- a/source/diffHandler.py +++ b/source/diffHandler.py @@ -12,7 +12,8 @@ from baseObject import AutoPropertyObject from difflib import ndiff from logHandler import log -from textInfos import UNIT_CHARACTER, UNIT_LINE +from textInfos import TextInfo, UNIT_LINE +from threading import Lock from typing import List @@ -21,25 +22,14 @@ class DiffAlgo(AutoPropertyObject): def diff(self, newText: str, oldText: str) -> List[str]: raise NotImplementedError - _abstract_unit = True - - def _get_unit(self): + @abstractmethod + def _getText(self, ti: TextInfo) -> str: raise NotImplementedError - def _get_name(self): - return "diffing algorithm" - - def __repr__(self): - return f"{self.unit}-based ({self.name})" - class DiffMatchPatch(DiffAlgo): - name = "diff match patch" - unit = UNIT_CHARACTER - - def __init__(self, *args, **kwargs): - self._proc = None - return super().__init__(*args, **kwargs) + _proc = None + _lock = Lock() def _initialize(self): if not self._proc: @@ -58,41 +48,45 @@ def _initialize(self): stdout=subprocess.PIPE ) + def _getText(self, ti: TextInfo) -> str: + return ti.text + def diff(self, newText: str, oldText: str) -> List[str]: - try: - self._initialize() - if not newText and not oldText: - # Return an empty list here to avoid exiting - # nvda_dmp uses two zero-length texts as a sentinal value - return [] - old = oldText.encode("utf-8") - new = newText.encode("utf-8") - # Sizes are packed as 32-bit ints in native byte order. - # Since nvda and nvda_dmp are running on the same Python - # platform/version, this is okay. - tl = struct.pack("=II", len(old), len(new)) - self._proc.stdin.write(tl) - self._proc.stdin.write(old) - self._proc.stdin.write(new) - buf = b"" - sizeb = b"" - SIZELEN = 4 - while len(sizeb) < SIZELEN: - try: - sizeb += self._proc.stdout.read(SIZELEN - len(sizeb)) - except TypeError: - pass - (size,) = struct.unpack("=I", sizeb) - while len(buf) < size: - buf += self._proc.stdout.read(size - len(buf)) - return [ - line - for line in buf.decode("utf-8").splitlines() - if line and not line.isspace() - ] - except Exception: - log.exception("Exception in DMP, falling back to difflib") - return Difflib().diff(newText, oldText) + with self._lock: + try: + self._initialize() + if not newText and not oldText: + # Return an empty list here to avoid exiting + # nvda_dmp uses two zero-length texts as a sentinal value + return [] + old = oldText.encode("utf-8") + new = newText.encode("utf-8") + # Sizes are packed as 32-bit ints in native byte order. + # Since nvda and nvda_dmp are running on the same Python + # platform/version, this is okay. + tl = struct.pack("=II", len(old), len(new)) + self._proc.stdin.write(tl) + self._proc.stdin.write(old) + self._proc.stdin.write(new) + buf = b"" + sizeb = b"" + SIZELEN = 4 + while len(sizeb) < SIZELEN: + try: + sizeb += self._proc.stdout.read(SIZELEN - len(sizeb)) + except TypeError: + pass + (size,) = struct.unpack("=I", sizeb) + while len(buf) < size: + buf += self._proc.stdout.read(size - len(buf)) + return [ + line + for line in buf.decode("utf-8").splitlines() + if line and not line.isspace() + ] + except Exception: + log.exception("Exception in DMP, falling back to difflib") + return Difflib().diff(newText, oldText) def _terminate(self): if self._proc: @@ -101,9 +95,6 @@ def _terminate(self): class Difflib(DiffAlgo): - name = "difflib" - unit = UNIT_LINE - def diff(self, newText: str, oldText: str) -> List[str]: newLines = newText.splitlines() oldLines = oldText.splitlines() @@ -159,6 +150,9 @@ def diff(self, newText: str, oldText: str) -> List[str]: return outLines + def _getText(self, ti: TextInfo) -> str: + return "\n".join(ti.getTextInChunks(UNIT_LINE)) + difflib = Difflib() dmp = DiffMatchPatch() From 561ef04e50fce61e19990feca8106635ea0b4c7d Mon Sep 17 00:00:00 2001 From: Bill Dengler Date: Thu, 8 Oct 2020 13:30:07 -0400 Subject: [PATCH 26/53] Add docstrings. --- source/diffHandler.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/source/diffHandler.py b/source/diffHandler.py index 00dda4f7d56..4ff5e2829d0 100644 --- a/source/diffHandler.py +++ b/source/diffHandler.py @@ -28,6 +28,10 @@ def _getText(self, ti: TextInfo) -> str: class DiffMatchPatch(DiffAlgo): + ( + "A character-based diffing approach, using the Google Diff Match Patch " + "library in a proxy process (to work around a licence conflict)." + ) _proc = None _lock = Lock() @@ -95,6 +99,11 @@ def _terminate(self): class Difflib(DiffAlgo): + ( + "A line-based diffing approach in pure Python, using the Python " + "standard library." + ) + def diff(self, newText: str, oldText: str) -> List[str]: newLines = newText.splitlines() oldLines = oldText.splitlines() From efba5d18129dd109707b2249b16309f523a8db70 Mon Sep 17 00:00:00 2001 From: Bill Dengler Date: Thu, 8 Oct 2020 14:57:26 -0400 Subject: [PATCH 27/53] Change UI to show actual diff algorithms. --- source/gui/settingsDialogs.py | 14 +++++++------- user_docs/en/userGuide.t2t | 12 ++++++------ 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/source/gui/settingsDialogs.py b/source/gui/settingsDialogs.py index 1860e67447f..69b827475ec 100644 --- a/source/gui/settingsDialogs.py +++ b/source/gui/settingsDialogs.py @@ -2530,21 +2530,21 @@ def __init__(self, parent): # Translators: This is the label for a combo box for selecting a # method of detecting changed content in terminals in the advanced # settings panel. - # Choices are automatic, character, and line. - diffAlgoComboText = _("C&hange detection preference:") + # Choices are automatic, prefer Diff Match Patch, and prefer Difflib. + diffAlgoComboText = _("D&iff algorithm:") diffAlgoChoices = [ # Translators: A choice in a combo box in the advanced settings # panel to have NVDA determine the method of detecting changed # content in terminals automatically - pgettext("change detection preference", "automatic (line)"), + _("Automatic (Difflib)"), # Translators: A choice in a combo box in the advanced settings # panel to have NVDA detect changes in terminals - # by character when supported. - pgettext("change detection preference", "character"), + # by character when supported, using the diff match patch algorithm. + _("Prefer Diff Match Patch"), # Translators: A choice in a combo box in the advanced settings # panel to have NVDA detect changes in terminals - # by line. - pgettext("change detection preference", "line") + # by line, using the difflib algorithm. + _("Prefer Difflib") ] #: The possible diffAlgo config values, in the order they appear #: in the combo box. diff --git a/user_docs/en/userGuide.t2t b/user_docs/en/userGuide.t2t index 329b057958b..b36e9d4cd8f 100644 --- a/user_docs/en/userGuide.t2t +++ b/user_docs/en/userGuide.t2t @@ -1839,17 +1839,17 @@ This feature is available and enabled by default on Windows 10 versions 1607 and Warning: with this option enabled, typed characters that do not appear onscreen, such as passwords, will not be suppressed. In untrusted environments, you may temporarily disable [speak typed characters #KeyboardSettingsSpeakTypedCharacters] and [speak typed words #KeyboardSettingsSpeakTypedWords] when entering passwords. -==== Change detection preference ====[AdvancedSettingsDiffAlgo] +==== diff algorithm ====[AdvancedSettingsDiffAlgo] This setting controls how NVDA determines the new text to speak in terminals. -The change detection preference combo box has three options: -- automatic: as of NVDA 2020.4, this option behaves the same as "line". -In a future release, it may be changed to "character" pending positive user testing. -- character: This option causes NVDA to calculate changes to terminal text by character. +The diff algorithm combo box has three options: +- Automatic: as of NVDA 2020.4, this option causes NVDA to prefer the Difflib algorithm. +In a future release, it may be changed to Diff Match Patch pending positive user testing. +- Prefer Diff Match Patch: This option causes NVDA to calculate changes to terminal text by character. It may improve performance when large volumes of text are written to the console and allow more accurate reporting of changes made in the middle of lines. However, it may be incompatible with some applications. This feature is supported in Windows Console on Windows 10 versions 1607 and later. Additionally, it may be available in other terminals on earlier Windows releases. -- line: this option causes NVDA to calculate changes to terminal text by line. +- Prefer Difflib: this option causes NVDA to calculate changes to terminal text by line. It is identical to NVDA's behaviour in versions 2020.3 and earlier. - From 139d3450589f18888033a0aec0626d8269468def Mon Sep 17 00:00:00 2001 From: Bill Dengler Date: Fri, 9 Oct 2020 10:19:55 -0400 Subject: [PATCH 28/53] Review actions. --- source/diffHandler.py | 24 ++++++++++++++---------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/source/diffHandler.py b/source/diffHandler.py index 4ff5e2829d0..ee5590b8d3e 100644 --- a/source/diffHandler.py +++ b/source/diffHandler.py @@ -32,11 +32,15 @@ class DiffMatchPatch(DiffAlgo): "A character-based diffing approach, using the Google Diff Match Patch " "library in a proxy process (to work around a licence conflict)." ) + #: A subprocess.Popen object for the nvda_dmp process. _proc = None + #: A lock to control access to the nvda_dmp process. + #: Control access to avoid synchronization problems if multiple threads + #: attempt to use nvda_dmp at the same time. _lock = Lock() def _initialize(self): - if not self._proc: + if not DiffMatchPatch._proc: log.debug("Starting diff-match-patch proxy") if hasattr(sys, "frozen"): dmp_path = (os.path.join(globalVars.appDir, "nvda_dmp.exe"),) @@ -44,7 +48,7 @@ def _initialize(self): dmp_path = (sys.executable, os.path.join( globalVars.appDir, "..", "include", "nvda_dmp", "nvda_dmp.py" )) - self._proc = subprocess.Popen( + DiffMatchPatch._proc = subprocess.Popen( dmp_path, creationflags=subprocess.CREATE_NO_WINDOW, bufsize=0, @@ -56,7 +60,7 @@ def _getText(self, ti: TextInfo) -> str: return ti.text def diff(self, newText: str, oldText: str) -> List[str]: - with self._lock: + with DiffMatchPatch._lock: try: self._initialize() if not newText and not oldText: @@ -69,20 +73,20 @@ def diff(self, newText: str, oldText: str) -> List[str]: # Since nvda and nvda_dmp are running on the same Python # platform/version, this is okay. tl = struct.pack("=II", len(old), len(new)) - self._proc.stdin.write(tl) - self._proc.stdin.write(old) - self._proc.stdin.write(new) + DiffMatchPatch._proc.stdin.write(tl) + DiffMatchPatch._proc.stdin.write(old) + DiffMatchPatch._proc.stdin.write(new) buf = b"" sizeb = b"" SIZELEN = 4 while len(sizeb) < SIZELEN: try: - sizeb += self._proc.stdout.read(SIZELEN - len(sizeb)) + sizeb += DiffMatchPatch._proc.stdout.read(SIZELEN - len(sizeb)) except TypeError: pass (size,) = struct.unpack("=I", sizeb) while len(buf) < size: - buf += self._proc.stdout.read(size - len(buf)) + buf += DiffMatchPatch._proc.stdout.read(size - len(buf)) return [ line for line in buf.decode("utf-8").splitlines() @@ -93,9 +97,9 @@ def diff(self, newText: str, oldText: str) -> List[str]: return Difflib().diff(newText, oldText) def _terminate(self): - if self._proc: + if DiffMatchPatch._proc: log.debug("Terminating diff-match-patch proxy") - self._proc.stdin.write(struct.pack("=II", 0, 0)) # Sentinal value + DiffMatchPatch._proc.stdin.write(struct.pack("=II", 0, 0)) # Sentinal value class Difflib(DiffAlgo): From 250841e596ccb1f2035e3203cc483544b5db0352 Mon Sep 17 00:00:00 2001 From: Bill Dengler Date: Fri, 9 Oct 2020 10:47:24 -0400 Subject: [PATCH 29/53] Update source/diffHandler.py Co-authored-by: Reef Turner --- source/diffHandler.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/source/diffHandler.py b/source/diffHandler.py index ee5590b8d3e..788b539c5a5 100644 --- a/source/diffHandler.py +++ b/source/diffHandler.py @@ -28,10 +28,9 @@ def _getText(self, ti: TextInfo) -> str: class DiffMatchPatch(DiffAlgo): - ( - "A character-based diffing approach, using the Google Diff Match Patch " - "library in a proxy process (to work around a licence conflict)." - ) + """A character-based diffing approach, using the Google Diff Match Patch + library in a proxy process (to work around a licence conflict). + """ #: A subprocess.Popen object for the nvda_dmp process. _proc = None #: A lock to control access to the nvda_dmp process. From abfb69f914a53aec351c19b05429656abcb01335 Mon Sep 17 00:00:00 2001 From: Bill Dengler Date: Fri, 9 Oct 2020 11:12:58 -0400 Subject: [PATCH 30/53] Remove prefer. --- source/gui/settingsDialogs.py | 6 +++--- user_docs/en/userGuide.t2t | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/source/gui/settingsDialogs.py b/source/gui/settingsDialogs.py index 69b827475ec..815a94c2d11 100644 --- a/source/gui/settingsDialogs.py +++ b/source/gui/settingsDialogs.py @@ -2530,7 +2530,7 @@ def __init__(self, parent): # Translators: This is the label for a combo box for selecting a # method of detecting changed content in terminals in the advanced # settings panel. - # Choices are automatic, prefer Diff Match Patch, and prefer Difflib. + # Choices are automatic, Diff Match Patch, and Difflib. diffAlgoComboText = _("D&iff algorithm:") diffAlgoChoices = [ # Translators: A choice in a combo box in the advanced settings @@ -2540,11 +2540,11 @@ def __init__(self, parent): # Translators: A choice in a combo box in the advanced settings # panel to have NVDA detect changes in terminals # by character when supported, using the diff match patch algorithm. - _("Prefer Diff Match Patch"), + _("Diff Match Patch"), # Translators: A choice in a combo box in the advanced settings # panel to have NVDA detect changes in terminals # by line, using the difflib algorithm. - _("Prefer Difflib") + _("Difflib") ] #: The possible diffAlgo config values, in the order they appear #: in the combo box. diff --git a/user_docs/en/userGuide.t2t b/user_docs/en/userGuide.t2t index b36e9d4cd8f..5398fd7baa1 100644 --- a/user_docs/en/userGuide.t2t +++ b/user_docs/en/userGuide.t2t @@ -1842,14 +1842,14 @@ In untrusted environments, you may temporarily disable [speak typed characters # ==== diff algorithm ====[AdvancedSettingsDiffAlgo] This setting controls how NVDA determines the new text to speak in terminals. The diff algorithm combo box has three options: -- Automatic: as of NVDA 2020.4, this option causes NVDA to prefer the Difflib algorithm. +- Automatic: as of NVDA 2020.4, this option is equivalent to Difflib. In a future release, it may be changed to Diff Match Patch pending positive user testing. -- Prefer Diff Match Patch: This option causes NVDA to calculate changes to terminal text by character. +- Diff Match Patch: This option causes NVDA to calculate changes to terminal text by character. It may improve performance when large volumes of text are written to the console and allow more accurate reporting of changes made in the middle of lines. However, it may be incompatible with some applications. This feature is supported in Windows Console on Windows 10 versions 1607 and later. Additionally, it may be available in other terminals on earlier Windows releases. -- Prefer Difflib: this option causes NVDA to calculate changes to terminal text by line. +- Difflib: this option causes NVDA to calculate changes to terminal text by line. It is identical to NVDA's behaviour in versions 2020.3 and earlier. - From 63aae028c62d4ff98d338cab561b0700cdef4a71 Mon Sep 17 00:00:00 2001 From: Bill Dengler Date: Mon, 12 Oct 2020 01:49:55 -0400 Subject: [PATCH 31/53] Fix accelerator. --- source/gui/settingsDialogs.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/source/gui/settingsDialogs.py b/source/gui/settingsDialogs.py index 815a94c2d11..72fa23e0074 100644 --- a/source/gui/settingsDialogs.py +++ b/source/gui/settingsDialogs.py @@ -2531,7 +2531,7 @@ def __init__(self, parent): # method of detecting changed content in terminals in the advanced # settings panel. # Choices are automatic, Diff Match Patch, and Difflib. - diffAlgoComboText = _("D&iff algorithm:") + diffAlgoComboText = _("&Diff algorithm:") diffAlgoChoices = [ # Translators: A choice in a combo box in the advanced settings # panel to have NVDA determine the method of detecting changed From eb32b264d8127b6f0e421aa31d844f8382ffddc9 Mon Sep 17 00:00:00 2001 From: Bill Dengler Date: Mon, 12 Oct 2020 11:52:45 -0400 Subject: [PATCH 32/53] Centralize config check. --- source/NVDAObjects/behaviors.py | 6 +----- source/diffHandler.py | 10 ++++++++++ source/gui/settingsDialogs.py | 6 +++--- 3 files changed, 14 insertions(+), 8 deletions(-) diff --git a/source/NVDAObjects/behaviors.py b/source/NVDAObjects/behaviors.py index c34884e3957..ea77389f5af 100755 --- a/source/NVDAObjects/behaviors.py +++ b/source/NVDAObjects/behaviors.py @@ -266,11 +266,7 @@ def event_textChange(self): self._event.set() def _get_diffAlgo(self): - return ( - diffHandler.dmp - if config.conf["terminals"]["diffAlgo"] == "dmp" - else diffHandler.difflib - ) + return diffHandler.prefer_dmp() def _get_devInfo(self): info = super().devInfo diff --git a/source/diffHandler.py b/source/diffHandler.py index 788b539c5a5..b387aefdd6f 100644 --- a/source/diffHandler.py +++ b/source/diffHandler.py @@ -3,6 +3,7 @@ # See the file COPYING for more details. # Copyright (C) 2020 Bill Dengler +import config import globalVars import os import struct @@ -168,3 +169,12 @@ def _getText(self, ti: TextInfo) -> str: difflib = Difflib() dmp = DiffMatchPatch() + + +def prefer_dmp(): + "Checks the config, returning dmp if preferred by the user, difflib otherwise." + return ( + dmp + if config.conf["terminals"]["diffAlgo"] == "dmp" + else difflib + ) diff --git a/source/gui/settingsDialogs.py b/source/gui/settingsDialogs.py index ee857b45ec7..ed8f1640c10 100644 --- a/source/gui/settingsDialogs.py +++ b/source/gui/settingsDialogs.py @@ -2530,7 +2530,7 @@ def __init__(self, parent): # Translators: This is the label for a combo box for selecting a # method of detecting changed content in terminals in the advanced # settings panel. - # Choices are automatic, Diff Match Patch, and Difflib. + # Choices are automatic, prefer Diff Match Patch, and force Difflib. diffAlgoComboText = _("&Diff algorithm:") diffAlgoChoices = [ # Translators: A choice in a combo box in the advanced settings @@ -2540,11 +2540,11 @@ def __init__(self, parent): # Translators: A choice in a combo box in the advanced settings # panel to have NVDA detect changes in terminals # by character when supported, using the diff match patch algorithm. - _("Diff Match Patch"), + _("prefer Diff Match Patch"), # Translators: A choice in a combo box in the advanced settings # panel to have NVDA detect changes in terminals # by line, using the difflib algorithm. - _("Difflib") + _("force Difflib") ] #: The possible diffAlgo config values, in the order they appear #: in the combo box. From 8438518d2f37142a9075982a32d1daefe1f07582 Mon Sep 17 00:00:00 2001 From: Bill Dengler Date: Mon, 12 Oct 2020 11:56:49 -0400 Subject: [PATCH 33/53] Update user guide. --- user_docs/en/userGuide.t2t | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/user_docs/en/userGuide.t2t b/user_docs/en/userGuide.t2t index d7f37db3474..e81cfeed87b 100644 --- a/user_docs/en/userGuide.t2t +++ b/user_docs/en/userGuide.t2t @@ -1845,12 +1845,12 @@ This setting controls how NVDA determines the new text to speak in terminals. The diff algorithm combo box has three options: - Automatic: as of NVDA 2020.4, this option is equivalent to Difflib. In a future release, it may be changed to Diff Match Patch pending positive user testing. -- Diff Match Patch: This option causes NVDA to calculate changes to terminal text by character. +- prefer Diff Match Patch: This option causes NVDA to calculate changes to terminal text by character. It may improve performance when large volumes of text are written to the console and allow more accurate reporting of changes made in the middle of lines. However, it may be incompatible with some applications. This feature is supported in Windows Console on Windows 10 versions 1607 and later. Additionally, it may be available in other terminals on earlier Windows releases. -- Difflib: this option causes NVDA to calculate changes to terminal text by line. +- force Difflib: this option causes NVDA to calculate changes to terminal text by line. It is identical to NVDA's behaviour in versions 2020.3 and earlier. - From 3fa0b393b05f10caf66a2bde8e3ebe0498ed38c9 Mon Sep 17 00:00:00 2001 From: Bill Dengler Date: Mon, 12 Oct 2020 20:33:38 -0400 Subject: [PATCH 34/53] Re-centralize _get_diffAlgo on the LiveText object, but add a comprehensive docstring to clarify expectations for downstream users. I think the code is easier to reason about this way. --- source/NVDAObjects/behaviors.py | 17 ++++++++++++++++- source/diffHandler.py | 10 ---------- 2 files changed, 16 insertions(+), 11 deletions(-) diff --git a/source/NVDAObjects/behaviors.py b/source/NVDAObjects/behaviors.py index ea77389f5af..5e8284d23a9 100755 --- a/source/NVDAObjects/behaviors.py +++ b/source/NVDAObjects/behaviors.py @@ -266,7 +266,22 @@ def event_textChange(self): self._event.set() def _get_diffAlgo(self): - return diffHandler.prefer_dmp() + """ + This property controls which diff algorithm is used. The default + implementation returns either diffHandler.dmp or diffHandler.difflib + based on user preference. Subclasses can override this property to + choose a diffAlgo object (overriding user preference) + if one is incompatible with a particular application. + As of NVDA 2020.4, diffHandler.dmp is experimental. Therefore, + subclasses should either use the base implementation to check the + user config, or return diffHandler.difflib + to forcibly use Difflib. + """ + return ( + diffHandler.dmp + if config.conf["terminals"]["diffAlgo"] == "dmp" + else diffHandler.difflib + ) def _get_devInfo(self): info = super().devInfo diff --git a/source/diffHandler.py b/source/diffHandler.py index b387aefdd6f..788b539c5a5 100644 --- a/source/diffHandler.py +++ b/source/diffHandler.py @@ -3,7 +3,6 @@ # See the file COPYING for more details. # Copyright (C) 2020 Bill Dengler -import config import globalVars import os import struct @@ -169,12 +168,3 @@ def _getText(self, ti: TextInfo) -> str: difflib = Difflib() dmp = DiffMatchPatch() - - -def prefer_dmp(): - "Checks the config, returning dmp if preferred by the user, difflib otherwise." - return ( - dmp - if config.conf["terminals"]["diffAlgo"] == "dmp" - else difflib - ) From f119840ba948de82486ff12fe5e5682ae7ec0671 Mon Sep 17 00:00:00 2001 From: Bill Dengler Date: Tue, 13 Oct 2020 08:39:24 -0400 Subject: [PATCH 35/53] Update nvda_dmp. --- include/nvda_dmp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/include/nvda_dmp b/include/nvda_dmp index 456fabb5bbe..30836c4e393 160000 --- a/include/nvda_dmp +++ b/include/nvda_dmp @@ -1 +1 @@ -Subproject commit 456fabb5bbe22e7058ec05ef0ed99e2d6ecf77a5 +Subproject commit 30836c4e3935f9448535b6f56f9ebf4199ae4a9e From a4a6ec5eb039b609f7c58e508747f0f595b8fc59 Mon Sep 17 00:00:00 2001 From: Bill Dengler Date: Tue, 13 Oct 2020 09:20:48 -0400 Subject: [PATCH 36/53] Cleanup. --- source/diffHandler.py | 9 ++++----- source/gui/settingsDialogs.py | 2 +- source/setup.py | 4 ++-- 3 files changed, 7 insertions(+), 8 deletions(-) diff --git a/source/diffHandler.py b/source/diffHandler.py index 788b539c5a5..c32622f45d2 100644 --- a/source/diffHandler.py +++ b/source/diffHandler.py @@ -39,6 +39,7 @@ class DiffMatchPatch(DiffAlgo): _lock = Lock() def _initialize(self): + "Start the nvda_dmp process if it is not already running." if not DiffMatchPatch._proc: log.debug("Starting diff-match-patch proxy") if hasattr(sys, "frozen"): @@ -98,14 +99,12 @@ def diff(self, newText: str, oldText: str) -> List[str]: def _terminate(self): if DiffMatchPatch._proc: log.debug("Terminating diff-match-patch proxy") - DiffMatchPatch._proc.stdin.write(struct.pack("=II", 0, 0)) # Sentinal value + # nvda_dmp exits when it receives two zero-length texts. + DiffMatchPatch._proc.stdin.write(struct.pack("=II", 0, 0)) class Difflib(DiffAlgo): - ( - "A line-based diffing approach in pure Python, using the Python " - "standard library." - ) + "A line-based diffing approach in pure Python, using the Python standard library." def diff(self, newText: str, oldText: str) -> List[str]: newLines = newText.splitlines() diff --git a/source/gui/settingsDialogs.py b/source/gui/settingsDialogs.py index ed8f1640c10..83457adfde4 100644 --- a/source/gui/settingsDialogs.py +++ b/source/gui/settingsDialogs.py @@ -2535,7 +2535,7 @@ def __init__(self, parent): diffAlgoChoices = [ # Translators: A choice in a combo box in the advanced settings # panel to have NVDA determine the method of detecting changed - # content in terminals automatically + # content in terminals automatically. _("Automatic (Difflib)"), # Translators: A choice in a combo box in the advanced settings # panel to have NVDA detect changes in terminals diff --git a/source/setup.py b/source/setup.py index 1d7b3a53f50..ab47a241e26 100755 --- a/source/setup.py +++ b/source/setup.py @@ -201,8 +201,8 @@ def getRecursiveDataFiles(dest,source,excludes=()): "description": "NVDA Diff-match-patch proxy", "product_name": name, "product_version": version, - "copyright": copyright, - "company_name": publisher, + "copyright": f"{copyright}, Bill Dengler", + "company_name": f"Bill Dengler, {publisher}", }, ], options = {"py2exe": { From ecf2967404d6773315e06b54813d9971fe5d3991 Mon Sep 17 00:00:00 2001 From: Bill Dengler Date: Tue, 13 Oct 2020 15:08:26 -0400 Subject: [PATCH 37/53] s/prefer/allow --- source/gui/settingsDialogs.py | 4 ++-- user_docs/en/userGuide.t2t | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/source/gui/settingsDialogs.py b/source/gui/settingsDialogs.py index 83457adfde4..c1d72b03005 100644 --- a/source/gui/settingsDialogs.py +++ b/source/gui/settingsDialogs.py @@ -2530,7 +2530,7 @@ def __init__(self, parent): # Translators: This is the label for a combo box for selecting a # method of detecting changed content in terminals in the advanced # settings panel. - # Choices are automatic, prefer Diff Match Patch, and force Difflib. + # Choices are automatic, allow Diff Match Patch, and force Difflib. diffAlgoComboText = _("&Diff algorithm:") diffAlgoChoices = [ # Translators: A choice in a combo box in the advanced settings @@ -2540,7 +2540,7 @@ def __init__(self, parent): # Translators: A choice in a combo box in the advanced settings # panel to have NVDA detect changes in terminals # by character when supported, using the diff match patch algorithm. - _("prefer Diff Match Patch"), + _("allow Diff Match Patch"), # Translators: A choice in a combo box in the advanced settings # panel to have NVDA detect changes in terminals # by line, using the difflib algorithm. diff --git a/user_docs/en/userGuide.t2t b/user_docs/en/userGuide.t2t index e81cfeed87b..dbd43b61f1e 100644 --- a/user_docs/en/userGuide.t2t +++ b/user_docs/en/userGuide.t2t @@ -1845,7 +1845,7 @@ This setting controls how NVDA determines the new text to speak in terminals. The diff algorithm combo box has three options: - Automatic: as of NVDA 2020.4, this option is equivalent to Difflib. In a future release, it may be changed to Diff Match Patch pending positive user testing. -- prefer Diff Match Patch: This option causes NVDA to calculate changes to terminal text by character. +- allow Diff Match Patch: This option causes NVDA to calculate changes to terminal text by character. It may improve performance when large volumes of text are written to the console and allow more accurate reporting of changes made in the middle of lines. However, it may be incompatible with some applications. This feature is supported in Windows Console on Windows 10 versions 1607 and later. From 4e1acebccdb27716dcb9f8ce334b19e9472a3f3e Mon Sep 17 00:00:00 2001 From: Bill Dengler Date: Tue, 13 Oct 2020 16:19:55 -0400 Subject: [PATCH 38/53] Move config check. --- source/NVDAObjects/behaviors.py | 17 +---------------- source/diffHandler.py | 20 ++++++++++++++++++++ 2 files changed, 21 insertions(+), 16 deletions(-) diff --git a/source/NVDAObjects/behaviors.py b/source/NVDAObjects/behaviors.py index 5e8284d23a9..c504665fd1e 100755 --- a/source/NVDAObjects/behaviors.py +++ b/source/NVDAObjects/behaviors.py @@ -266,22 +266,7 @@ def event_textChange(self): self._event.set() def _get_diffAlgo(self): - """ - This property controls which diff algorithm is used. The default - implementation returns either diffHandler.dmp or diffHandler.difflib - based on user preference. Subclasses can override this property to - choose a diffAlgo object (overriding user preference) - if one is incompatible with a particular application. - As of NVDA 2020.4, diffHandler.dmp is experimental. Therefore, - subclasses should either use the base implementation to check the - user config, or return diffHandler.difflib - to forcibly use Difflib. - """ - return ( - diffHandler.dmp - if config.conf["terminals"]["diffAlgo"] == "dmp" - else diffHandler.difflib - ) + return diffHandler.get_dmp_algo() def _get_devInfo(self): info = super().devInfo diff --git a/source/diffHandler.py b/source/diffHandler.py index c32622f45d2..e0f15084205 100644 --- a/source/diffHandler.py +++ b/source/diffHandler.py @@ -3,6 +3,7 @@ # See the file COPYING for more details. # Copyright (C) 2020 Bill Dengler +import config import globalVars import os import struct @@ -165,5 +166,24 @@ def _getText(self, ti: TextInfo) -> str: return "\n".join(ti.getTextInChunks(UNIT_LINE)) +def get_dmp_algo(): + """ + This property controls which diff algorithm is used. The default + implementation returns either diffHandler.dmp or diffHandler.difflib + based on user preference. Subclasses can override this property to + choose a diffAlgo object (overriding user preference) + if one is incompatible with a particular application. + As of NVDA 2020.4, diffHandler.dmp is experimental. Therefore, + subclasses should either use the base implementation to check the + user config, or return diffHandler.difflib + to forcibly use Difflib. + """ + return ( + dmp + if config.conf["terminals"]["diffAlgo"] == "dmp" + else difflib + ) + + difflib = Difflib() dmp = DiffMatchPatch() From 25e2810094a08707de6db535e5938d64191e55ec Mon Sep 17 00:00:00 2001 From: Bill Dengler Date: Wed, 14 Oct 2020 12:54:45 -0400 Subject: [PATCH 39/53] Review actions. --- source/NVDAObjects/window/winConsole.py | 4 ++-- source/core.py | 2 +- source/diffHandler.py | 22 +++++++++++++--------- 3 files changed, 16 insertions(+), 12 deletions(-) diff --git a/source/NVDAObjects/window/winConsole.py b/source/NVDAObjects/window/winConsole.py index 1c61bee9805..8d8d7771ad5 100644 --- a/source/NVDAObjects/window/winConsole.py +++ b/source/NVDAObjects/window/winConsole.py @@ -32,8 +32,8 @@ def _get_diffAlgo(self): # they are confused for typed characters. # Force difflib to keep meaningful edit reporting in these consoles. if not isinstance(self, KeyboardHandlerBasedTypedCharSupport): - from diffHandler import difflib - return difflib + from diffHandler import get_difflib_algo + return get_difflib_algo() else: return super().diffAlgo diff --git a/source/core.py b/source/core.py index c2a453eec31..dd8d5e45b9d 100644 --- a/source/core.py +++ b/source/core.py @@ -596,7 +596,7 @@ def run(self): # as core._terminate always writes an entry. try: import diffHandler - diffHandler.dmp._terminate() + diffHandler._dmp._terminate() except Exception: log.debug("Exception while terminating DMP", exc_info=True) diff --git a/source/diffHandler.py b/source/diffHandler.py index e0f15084205..45574eb0e37 100644 --- a/source/diffHandler.py +++ b/source/diffHandler.py @@ -168,22 +168,26 @@ def _getText(self, ti: TextInfo) -> str: def get_dmp_algo(): """ - This property controls which diff algorithm is used. The default - implementation returns either diffHandler.dmp or diffHandler.difflib - based on user preference. Subclasses can override this property to + This function returns a Diff Match Patch object if allowed by the user. + LiveText objects can override the diffAlgo property to choose a diffAlgo object (overriding user preference) if one is incompatible with a particular application. - As of NVDA 2020.4, diffHandler.dmp is experimental. Therefore, + As of NVDA 2020.4, DMP is experimental. Therefore, subclasses should either use the base implementation to check the - user config, or return diffHandler.difflib + user config, or return diffHandler.get_difflib_algo() to forcibly use Difflib. """ return ( - dmp + _dmp if config.conf["terminals"]["diffAlgo"] == "dmp" - else difflib + else _difflib ) -difflib = Difflib() -dmp = DiffMatchPatch() +def get_difflib_algo(): + "Returns an instance of the difflib diffAlgo." + return _difflib + + +_difflib = Difflib() +_dmp = DiffMatchPatch() From f0a014167ea70457d25dd38518f697020addf0dd Mon Sep 17 00:00:00 2001 From: Bill Dengler Date: Mon, 19 Oct 2020 04:00:35 -0400 Subject: [PATCH 40/53] Review actions. --- include/nvda_dmp | 2 +- source/NVDAObjects/behaviors.py | 10 ++++++++++ source/diffHandler.py | 10 +++------- 3 files changed, 14 insertions(+), 8 deletions(-) diff --git a/include/nvda_dmp b/include/nvda_dmp index 30836c4e393..02379d68ea0 160000 --- a/include/nvda_dmp +++ b/include/nvda_dmp @@ -1 +1 @@ -Subproject commit 30836c4e3935f9448535b6f56f9ebf4199ae4a9e +Subproject commit 02379d68ea01ca269ff4c9b94e4312ad8b9f031c diff --git a/source/NVDAObjects/behaviors.py b/source/NVDAObjects/behaviors.py index c504665fd1e..0e24732698d 100755 --- a/source/NVDAObjects/behaviors.py +++ b/source/NVDAObjects/behaviors.py @@ -266,6 +266,16 @@ def event_textChange(self): self._event.set() def _get_diffAlgo(self): + """ + This property controls which diffing algorithms are supported by + this object. Most subclasses should simply use the base + implementation, which returns either DMP (character-based diffing) + or Difflib (line-based diffing) depending on user preference. + However, if DMP causes problems in particular cases, this property + should be overridden to return diffHandler.get_difflib_algo() for + those cases to forcibly use Difflib, overriding a user's preference + for DMP. + """ return diffHandler.get_dmp_algo() def _get_devInfo(self): diff --git a/source/diffHandler.py b/source/diffHandler.py index 45574eb0e37..97a8ecfc3dd 100644 --- a/source/diffHandler.py +++ b/source/diffHandler.py @@ -169,13 +169,9 @@ def _getText(self, ti: TextInfo) -> str: def get_dmp_algo(): """ This function returns a Diff Match Patch object if allowed by the user. - LiveText objects can override the diffAlgo property to - choose a diffAlgo object (overriding user preference) - if one is incompatible with a particular application. - As of NVDA 2020.4, DMP is experimental. Therefore, - subclasses should either use the base implementation to check the - user config, or return diffHandler.get_difflib_algo() - to forcibly use Difflib. + As of NVDA 2020.4, DMP is experimental and must be enabled by a user + setting. If not explicitly enabled by the user, this function returns + a Difflib instance instead. """ return ( _dmp From 9b53399c96e0ec7a6c2e8dfb3fd6e4522fb524e8 Mon Sep 17 00:00:00 2001 From: Bill Dengler Date: Mon, 19 Oct 2020 04:06:51 -0400 Subject: [PATCH 41/53] Sync. --- include/nvda_dmp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/include/nvda_dmp b/include/nvda_dmp index 02379d68ea0..b2ccf200866 160000 --- a/include/nvda_dmp +++ b/include/nvda_dmp @@ -1 +1 @@ -Subproject commit 02379d68ea01ca269ff4c9b94e4312ad8b9f031c +Subproject commit b2ccf2008669e1acb0a7181c268ce6a1311d4d7e From a89a4fe882a64805a42e7cc8e2978e80da71aa09 Mon Sep 17 00:00:00 2001 From: Bill Dengler Date: Tue, 20 Oct 2020 22:52:40 -0400 Subject: [PATCH 42/53] Update user_docs/en/userGuide.t2t Co-authored-by: Reef Turner --- user_docs/en/userGuide.t2t | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/user_docs/en/userGuide.t2t b/user_docs/en/userGuide.t2t index 648377a42a6..8b91835c813 100644 --- a/user_docs/en/userGuide.t2t +++ b/user_docs/en/userGuide.t2t @@ -1840,7 +1840,7 @@ This feature is available and enabled by default on Windows 10 versions 1607 and Warning: with this option enabled, typed characters that do not appear onscreen, such as passwords, will not be suppressed. In untrusted environments, you may temporarily disable [speak typed characters #KeyboardSettingsSpeakTypedCharacters] and [speak typed words #KeyboardSettingsSpeakTypedWords] when entering passwords. -==== diff algorithm ====[AdvancedSettingsDiffAlgo] +==== Diff algorithm ====[AdvancedSettingsDiffAlgo] This setting controls how NVDA determines the new text to speak in terminals. The diff algorithm combo box has three options: - Automatic: as of NVDA 2020.4, this option is equivalent to Difflib. From 279cbd438882aa753bc878db71fa120787839c2d Mon Sep 17 00:00:00 2001 From: Bill Dengler Date: Tue, 20 Oct 2020 23:22:21 -0400 Subject: [PATCH 43/53] Remove unneeded type checks in legacy console. --- source/NVDAObjects/IAccessible/winConsole.py | 45 +++++++++++++++----- source/NVDAObjects/window/winConsole.py | 20 +-------- 2 files changed, 35 insertions(+), 30 deletions(-) diff --git a/source/NVDAObjects/IAccessible/winConsole.py b/source/NVDAObjects/IAccessible/winConsole.py index 9347169bc5d..d2c9343ce3e 100644 --- a/source/NVDAObjects/IAccessible/winConsole.py +++ b/source/NVDAObjects/IAccessible/winConsole.py @@ -1,22 +1,45 @@ -#NVDAObjects/IAccessible/WinConsole.py -#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) 2007-2019 NV Access Limited, Bill Dengler +# 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) 2007-2020 NV Access Limited, Bill Dengler import config +from NVDAObjects.behaviors import KeyboardHandlerBasedTypedCharSupport from winVersion import isWin10 from . import IAccessible from ..window import winConsole -class WinConsole(winConsole.WinConsole, IAccessible): - "The legacy console implementation for situations where UIA isn't supported." - pass + +class EnhancedLegacyWinConsole(KeyboardHandlerBasedTypedCharSupport, winConsole.WinConsole, IAccessible): + """ + A hybrid approach to console access, using legacy APIs to read output + and KeyboardHandlerBasedTypedCharSupport for input. + """ + #: Legacy consoles take quite a while to send textChange events. + #: This significantly impacts typing performance, so don't queue chars. + _supportsTextChange = False + + +class LegacyWinConsole(winConsole.WinConsole, IAccessible): + """ + NVDA's original console support, used by default on Windows versions + before 1607. + """ + + def _get_diffAlgo(self): + # Non-enhanced legacy consoles use caret proximity to detect + # typed/deleted text. + # Single-character changes are not reported as + # they are confused for typed characters. + # Force difflib to keep meaningful edit reporting in these consoles. + from diffHandler import get_difflib_algo + return get_difflib_algo() + def findExtraOverlayClasses(obj, clsList): if isWin10(1607) and config.conf['terminals']['keyboardSupportInLegacy']: - from NVDAObjects.behaviors import KeyboardHandlerBasedTypedCharSupport - clsList.append(KeyboardHandlerBasedTypedCharSupport) - clsList.append(WinConsole) + clsList.append(EnhancedLegacyWinConsole) + else: + clsList.append(LegacyWinConsole) diff --git a/source/NVDAObjects/window/winConsole.py b/source/NVDAObjects/window/winConsole.py index 8d8d7771ad5..01ecec4e75e 100644 --- a/source/NVDAObjects/window/winConsole.py +++ b/source/NVDAObjects/window/winConsole.py @@ -13,30 +13,12 @@ class WinConsole(Terminal, EditableTextWithoutAutoSelectDetection, Window): """ - NVDA's legacy Windows Console support. + Base class for NVDA's legacy Windows Console support. This is used in situations where UIA isn't available. Please consider using NVDAObjects.UIA.winConsoleUIA instead. """ STABILIZE_DELAY = 0.03 - def initOverlayClass(self): - # Legacy consoles take quite a while to send textChange events. - # This significantly impacts typing performance, so don't queue chars. - if isinstance(self, KeyboardHandlerBasedTypedCharSupport): - self._supportsTextChange = False - - def _get_diffAlgo(self): - # Non-enhanced legacy consoles use caret proximity to detect - # typed/deleted text. - # Single-character changes are not reported as - # they are confused for typed characters. - # Force difflib to keep meaningful edit reporting in these consoles. - if not isinstance(self, KeyboardHandlerBasedTypedCharSupport): - from diffHandler import get_difflib_algo - return get_difflib_algo() - else: - return super().diffAlgo - def _get_windowThreadID(self): # #10113: Windows forces the thread of console windows to match the thread of the first attached process. # However, To correctly handle speaking of typed characters, From bfc85e76add785a2a6fdfa970959ce8cc8f5d1c4 Mon Sep 17 00:00:00 2001 From: Bill Dengler Date: Tue, 20 Oct 2020 23:31:35 -0400 Subject: [PATCH 44/53] Fix comments. --- source/NVDAObjects/behaviors.py | 13 ++++++------- source/diffHandler.py | 6 +++--- 2 files changed, 9 insertions(+), 10 deletions(-) diff --git a/source/NVDAObjects/behaviors.py b/source/NVDAObjects/behaviors.py index 0e24732698d..cf0eba50a0f 100755 --- a/source/NVDAObjects/behaviors.py +++ b/source/NVDAObjects/behaviors.py @@ -267,14 +267,13 @@ def event_textChange(self): def _get_diffAlgo(self): """ - This property controls which diffing algorithms are supported by + This property controls which diffing algorithm should be used by this object. Most subclasses should simply use the base - implementation, which returns either DMP (character-based diffing) - or Difflib (line-based diffing) depending on user preference. - However, if DMP causes problems in particular cases, this property - should be overridden to return diffHandler.get_difflib_algo() for - those cases to forcibly use Difflib, overriding a user's preference - for DMP. + implementation, which returns DMP (character-based diffing). + + @Note: DMP is experimental, and can be disallowed via user + preference. In this case, the prior stable implementation, Difflib + (line-based diffing), will be used. """ return diffHandler.get_dmp_algo() diff --git a/source/diffHandler.py b/source/diffHandler.py index 97a8ecfc3dd..9cd02253d99 100644 --- a/source/diffHandler.py +++ b/source/diffHandler.py @@ -169,9 +169,9 @@ def _getText(self, ti: TextInfo) -> str: def get_dmp_algo(): """ This function returns a Diff Match Patch object if allowed by the user. - As of NVDA 2020.4, DMP is experimental and must be enabled by a user - setting. If not explicitly enabled by the user, this function returns - a Difflib instance instead. + DMP is experimental and can be explicitly enabled/disabled by a user + setting to opt in or out of the experiment. If config does not allow + DMP, this function returns a Difflib instance instead. """ return ( _dmp From afb4a76463310dcf549dbec44e5329a72033c0de Mon Sep 17 00:00:00 2001 From: Bill Dengler Date: Thu, 29 Oct 2020 13:59:11 -0400 Subject: [PATCH 45/53] Review actions. --- source/core.py | 2 +- source/diffHandler.py | 3 ++- source/setup.py | 8 +++++++- 3 files changed, 10 insertions(+), 3 deletions(-) diff --git a/source/core.py b/source/core.py index dd8d5e45b9d..63f5128ed2a 100644 --- a/source/core.py +++ b/source/core.py @@ -598,7 +598,7 @@ def run(self): import diffHandler diffHandler._dmp._terminate() except Exception: - log.debug("Exception while terminating DMP", exc_info=True) + log.exception("Exception while terminating DMP") if not globalVars.appArgs.minimal and config.conf["general"]["playStartAndExitSounds"]: try: diff --git a/source/diffHandler.py b/source/diffHandler.py index 9cd02253d99..476669a023c 100644 --- a/source/diffHandler.py +++ b/source/diffHandler.py @@ -101,7 +101,8 @@ def _terminate(self): if DiffMatchPatch._proc: log.debug("Terminating diff-match-patch proxy") # nvda_dmp exits when it receives two zero-length texts. - DiffMatchPatch._proc.stdin.write(struct.pack("=II", 0, 0)) + with DiffMatchPatch._lock: + DiffMatchPatch._proc.stdin.write(struct.pack("=II", 0, 0)) class Difflib(DiffAlgo): diff --git a/source/setup.py b/source/setup.py index ab47a241e26..9db1366581a 100755 --- a/source/setup.py +++ b/source/setup.py @@ -15,7 +15,13 @@ from glob import glob import fnmatch # versionInfo names must be imported after Gettext -from versionInfo import formatBuildVersionString, name, version, publisher # noqa: E402 +# Suppress E402 (module level import not at top of file) +from versionInfo import ( + formatBuildVersionString, + name, + version, + publisher +) # noqa: E402 from versionInfo import * from py2exe import distutils_buildexe from py2exe.dllfinder import DllFinder From 71da66a4c970d18f29b3856738acbd1468330cba Mon Sep 17 00:00:00 2001 From: Bill Dengler Date: Fri, 30 Oct 2020 18:15:35 -0400 Subject: [PATCH 46/53] Review actions. --- source/diffHandler.py | 49 ++++++++++++++++++++++--------------------- 1 file changed, 25 insertions(+), 24 deletions(-) diff --git a/source/diffHandler.py b/source/diffHandler.py index 476669a023c..5a1cf4cee0d 100644 --- a/source/diffHandler.py +++ b/source/diffHandler.py @@ -42,32 +42,33 @@ class DiffMatchPatch(DiffAlgo): def _initialize(self): "Start the nvda_dmp process if it is not already running." if not DiffMatchPatch._proc: - log.debug("Starting diff-match-patch proxy") - if hasattr(sys, "frozen"): - dmp_path = (os.path.join(globalVars.appDir, "nvda_dmp.exe"),) - else: - dmp_path = (sys.executable, os.path.join( - globalVars.appDir, "..", "include", "nvda_dmp", "nvda_dmp.py" - )) - DiffMatchPatch._proc = subprocess.Popen( - dmp_path, - creationflags=subprocess.CREATE_NO_WINDOW, - bufsize=0, - stdin=subprocess.PIPE, - stdout=subprocess.PIPE - ) + with DiffMatchPatch._lock: + log.debug("Starting diff-match-patch proxy") + if hasattr(sys, "frozen"): + dmp_path = (os.path.join(globalVars.appDir, "nvda_dmp.exe"),) + else: + dmp_path = (sys.executable, os.path.join( + globalVars.appDir, "..", "include", "nvda_dmp", "nvda_dmp.py" + )) + DiffMatchPatch._proc = subprocess.Popen( + dmp_path, + creationflags=subprocess.CREATE_NO_WINDOW, + bufsize=0, + stdin=subprocess.PIPE, + stdout=subprocess.PIPE + ) def _getText(self, ti: TextInfo) -> str: return ti.text def diff(self, newText: str, oldText: str) -> List[str]: - with DiffMatchPatch._lock: - try: - self._initialize() - if not newText and not oldText: - # Return an empty list here to avoid exiting - # nvda_dmp uses two zero-length texts as a sentinal value - return [] + try: + if not newText and not oldText: + # Return an empty list here to avoid exiting + # nvda_dmp uses two zero-length texts as a sentinal value + return [] + self._initialize() + with DiffMatchPatch._lock: old = oldText.encode("utf-8") new = newText.encode("utf-8") # Sizes are packed as 32-bit ints in native byte order. @@ -93,9 +94,9 @@ def diff(self, newText: str, oldText: str) -> List[str]: for line in buf.decode("utf-8").splitlines() if line and not line.isspace() ] - except Exception: - log.exception("Exception in DMP, falling back to difflib") - return Difflib().diff(newText, oldText) + except Exception: + log.exception("Exception in DMP, falling back to difflib") + return Difflib().diff(newText, oldText) def _terminate(self): if DiffMatchPatch._proc: From 21741915fea3a7d82c361593623651df79ba42c9 Mon Sep 17 00:00:00 2001 From: Bill Dengler Date: Thu, 5 Nov 2020 02:37:17 -0500 Subject: [PATCH 47/53] Review actions. --- source/diffHandler.py | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/source/diffHandler.py b/source/diffHandler.py index 5a1cf4cee0d..8894d51b533 100644 --- a/source/diffHandler.py +++ b/source/diffHandler.py @@ -41,8 +41,8 @@ class DiffMatchPatch(DiffAlgo): def _initialize(self): "Start the nvda_dmp process if it is not already running." - if not DiffMatchPatch._proc: - with DiffMatchPatch._lock: + with DiffMatchPatch._lock: + if not DiffMatchPatch._proc: log.debug("Starting diff-match-patch proxy") if hasattr(sys, "frozen"): dmp_path = (os.path.join(globalVars.appDir, "nvda_dmp.exe"),) @@ -99,11 +99,12 @@ def diff(self, newText: str, oldText: str) -> List[str]: return Difflib().diff(newText, oldText) def _terminate(self): - if DiffMatchPatch._proc: - log.debug("Terminating diff-match-patch proxy") - # nvda_dmp exits when it receives two zero-length texts. - with DiffMatchPatch._lock: + with DiffMatchPatch._lock: + if DiffMatchPatch._proc: + log.debug("Terminating diff-match-patch proxy") + # nvda_dmp exits when it receives two zero-length texts. DiffMatchPatch._proc.stdin.write(struct.pack("=II", 0, 0)) + DiffMatchPatch._proc = None class Difflib(DiffAlgo): From 1eecdfb8056a8428ad9495d9eab910fbf739617c Mon Sep 17 00:00:00 2001 From: Bill Dengler Date: Tue, 17 Nov 2020 02:11:11 -0500 Subject: [PATCH 48/53] Wait for NVDA_dmp to exit, and throw an exception if it doesn't within five seconds. --- source/diffHandler.py | 1 + 1 file changed, 1 insertion(+) diff --git a/source/diffHandler.py b/source/diffHandler.py index 8894d51b533..d441842c2c7 100644 --- a/source/diffHandler.py +++ b/source/diffHandler.py @@ -104,6 +104,7 @@ def _terminate(self): log.debug("Terminating diff-match-patch proxy") # nvda_dmp exits when it receives two zero-length texts. DiffMatchPatch._proc.stdin.write(struct.pack("=II", 0, 0)) + DiffMatchPatch._proc.wait(timeout=5) DiffMatchPatch._proc = None From 578ab7d534800e954b7073d685d0f8d7edb721e0 Mon Sep 17 00:00:00 2001 From: Bill Dengler Date: Tue, 17 Nov 2020 02:18:26 -0500 Subject: [PATCH 49/53] Don't set _proc to None on termination, so that _initialize cannot be called twice. --- source/diffHandler.py | 1 - 1 file changed, 1 deletion(-) diff --git a/source/diffHandler.py b/source/diffHandler.py index d441842c2c7..e8c7e3bbe8b 100644 --- a/source/diffHandler.py +++ b/source/diffHandler.py @@ -105,7 +105,6 @@ def _terminate(self): # nvda_dmp exits when it receives two zero-length texts. DiffMatchPatch._proc.stdin.write(struct.pack("=II", 0, 0)) DiffMatchPatch._proc.wait(timeout=5) - DiffMatchPatch._proc = None class Difflib(DiffAlgo): From 47b0c13e9c7e512b96f13282cf13db8ab149f330 Mon Sep 17 00:00:00 2001 From: Bill Dengler Date: Mon, 30 Nov 2020 03:02:22 -0500 Subject: [PATCH 50/53] Review action. --- source/diffHandler.py | 36 ++++++++++++++++++------------------ 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/source/diffHandler.py b/source/diffHandler.py index e8c7e3bbe8b..5d37065c054 100644 --- a/source/diffHandler.py +++ b/source/diffHandler.py @@ -40,23 +40,23 @@ class DiffMatchPatch(DiffAlgo): _lock = Lock() def _initialize(self): - "Start the nvda_dmp process if it is not already running." - with DiffMatchPatch._lock: - if not DiffMatchPatch._proc: - log.debug("Starting diff-match-patch proxy") - if hasattr(sys, "frozen"): - dmp_path = (os.path.join(globalVars.appDir, "nvda_dmp.exe"),) - else: - dmp_path = (sys.executable, os.path.join( - globalVars.appDir, "..", "include", "nvda_dmp", "nvda_dmp.py" - )) - DiffMatchPatch._proc = subprocess.Popen( - dmp_path, - creationflags=subprocess.CREATE_NO_WINDOW, - bufsize=0, - stdin=subprocess.PIPE, - stdout=subprocess.PIPE - ) + """Start the nvda_dmp process if it is not already running. + @note: This should be run from within the context of an acquired lock.""" + if not DiffMatchPatch._proc: + log.debug("Starting diff-match-patch proxy") + if hasattr(sys, "frozen"): + dmp_path = (os.path.join(globalVars.appDir, "nvda_dmp.exe"),) + else: + dmp_path = (sys.executable, os.path.join( + globalVars.appDir, "..", "include", "nvda_dmp", "nvda_dmp.py" + )) + DiffMatchPatch._proc = subprocess.Popen( + dmp_path, + creationflags=subprocess.CREATE_NO_WINDOW, + bufsize=0, + stdin=subprocess.PIPE, + stdout=subprocess.PIPE + ) def _getText(self, ti: TextInfo) -> str: return ti.text @@ -67,8 +67,8 @@ def diff(self, newText: str, oldText: str) -> List[str]: # Return an empty list here to avoid exiting # nvda_dmp uses two zero-length texts as a sentinal value return [] - self._initialize() with DiffMatchPatch._lock: + self._initialize() old = oldText.encode("utf-8") new = newText.encode("utf-8") # Sizes are packed as 32-bit ints in native byte order. From b4f65fe98c73bd1b97c31a58e47f793d983a6de8 Mon Sep 17 00:00:00 2001 From: Bill Dengler Date: Tue, 8 Dec 2020 00:02:22 -0500 Subject: [PATCH 51/53] Remove _getTextLines entirely, as this PR has been retargetted for 2021.1. --- source/NVDAObjects/behaviors.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/source/NVDAObjects/behaviors.py b/source/NVDAObjects/behaviors.py index cf0eba50a0f..6713ef9c296 100755 --- a/source/NVDAObjects/behaviors.py +++ b/source/NVDAObjects/behaviors.py @@ -288,9 +288,6 @@ def _getText(self) -> str: The base implementation uses the L{TextInfo}. However, subclasses should override this if there is a better way to retrieve the text. """ - if hasattr(self, "_getTextLines"): - log.warning("LiveText._getTextLines is deprecated, please override _getText instead.") - return '\n'.join(self._getTextLines()) ti = self.makeTextInfo(textInfos.POSITION_ALL) return self.diffAlgo._getText(ti) From 7a8d579cdc625b8cf17e2be84c9415062fbcea2a Mon Sep 17 00:00:00 2001 From: Bill Dengler Date: Sat, 19 Dec 2020 08:42:50 -0500 Subject: [PATCH 52/53] Update user guide (s/2020.4/2021.1) as PR was held back. --- user_docs/en/userGuide.t2t | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/user_docs/en/userGuide.t2t b/user_docs/en/userGuide.t2t index 18b08c5c4e2..8ec6f64f7e7 100644 --- a/user_docs/en/userGuide.t2t +++ b/user_docs/en/userGuide.t2t @@ -1846,7 +1846,7 @@ In untrusted environments, you may temporarily disable [speak typed characters # ==== Diff algorithm ====[AdvancedSettingsDiffAlgo] This setting controls how NVDA determines the new text to speak in terminals. The diff algorithm combo box has three options: -- Automatic: as of NVDA 2020.4, this option is equivalent to Difflib. +- Automatic: as of NVDA 2021.1, this option is equivalent to Difflib. In a future release, it may be changed to Diff Match Patch pending positive user testing. - allow Diff Match Patch: This option causes NVDA to calculate changes to terminal text by character. It may improve performance when large volumes of text are written to the console and allow more accurate reporting of changes made in the middle of lines. From 78e4012b237c2d082f12b87dca0a5bd0199d6de7 Mon Sep 17 00:00:00 2001 From: Reef Turner Date: Thu, 31 Dec 2020 17:01:56 +0800 Subject: [PATCH 53/53] update changes file for PR #11639 --- user_docs/en/changes.t2t | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/user_docs/en/changes.t2t b/user_docs/en/changes.t2t index 25f92ff365b..1ce7ab07062 100644 --- a/user_docs/en/changes.t2t +++ b/user_docs/en/changes.t2t @@ -12,10 +12,16 @@ What's New in NVDA == Bug Fixes == +- In terminal programs on Windows 10 version 1607 and later, when inserting or deleting characters in the middle of a line, the characters to the right of the caret are no longer read out. (#3200) + - This experimental fix must be manually enabled in NVDA's advanced settings panel by changing the diff algorithm to Diff Match Patch. == Changes for Developers == - Note: this is a Add-on API compatibility breaking release. Add-ons will need to be re-tested and have their manifest updated. +- `LiveText._getTextLines` has been removed. (#11639) + - Instead, override `_getText` which returns a string of all text in the object. +- `LiveText` objects can now calculate diffs by character. (#11639) + - To alter the diff behaviour for some object, override the `diffAlgo` property (see the docstring for details). = 2020.4 =