diff --git a/addon/appModules/notepad++/__init__.py b/addon/appModules/notepad++/__init__.py index 4290045..e9c133f 100755 --- a/addon/appModules/notepad++/__init__.py +++ b/addon/appModules/notepad++/__init__.py @@ -14,6 +14,7 @@ import gui import addonHandler import eventHandler +from logHandler import log from controlTypes import Role import speech import nvwave @@ -32,15 +33,24 @@ def chooseNVDAObjectOverlayClasses(self,obj,clsList): if obj.windowClassName == u'Scintilla' and obj.windowControlID == 0: clsList.insert(0, editWindow.EditWindow) return - try: - if (obj.role == Role.LISTITEM and - obj.parent.windowClassName == u'ListBox' and - obj.parent.parent.parent.windowClassName == u'ListBoxX'): - clsList.insert(0, autocomplete.AutocompleteList) - return + try: + if obj.role == Role.LISTITEM: + # Walk up a few ancestors looking for the ListBoxX autocomplete + # popup, rather than assuming a fixed parent/grandparent depth. + # On current Notepad++/Scintilla builds the real chain is + # ListBox -> ListBox -> ListBoxX -> ListBoxX -> #32769, five + # levels deep, not the three levels originally assumed here - + # confirmed via diagnostic logging against a live session. + ancestor = obj.parent + for _ in range(6): + if ancestor is None: + break + if ancestor.windowClassName == u'ListBoxX': + clsList.insert(0, autocomplete.AutocompleteList) + return + ancestor = ancestor.parent except AttributeError: - pass - + log.exception("notepad++ addon: AttributeError while walking ancestor chain") if ( (obj.windowControlID == 1682 and obj.role == Role.EDITABLETEXT) or diff --git a/addon/appModules/notepad++/autocomplete.py b/addon/appModules/notepad++/autocomplete.py index 5144f0c..19ecec6 100755 --- a/addon/appModules/notepad++/autocomplete.py +++ b/addon/appModules/notepad++/autocomplete.py @@ -5,15 +5,72 @@ #This file is covered by the GNU General Public License. #See the file COPYING for more details. -from NVDAObjects.IAccessible import IAccessible -import speech import braille import config +from logHandler import log +import speech + +# On current Notepad++/Scintilla builds, the autocomplete popup (ListBoxX) +# does not expose its item text through MSAA - name/value/description are +# all empty. Confirmed via targeted diagnostic logging against a real +# Notepad++ 8.9.7 session. +# +# The reliable fix is to bypass the popup UI entirely and ask Scintilla +# itself for its own internal autocomplete state, via +# EditWindow.getAutocompleteCurrentText() (see editWindow.py), which uses +# the documented SCI_AUTOCGETCURRENTTEXT message - present in Scintilla +# since well before any Notepad++ version anyone is likely running today. +# The plain MSAA read below is kept as a cheap fallback in case that ever +# isn't available; it costs nothing to check. +# +# This class used to subclass NVDAObjects.IAccessible.IAccessible directly. +# That's unnecessary (NVDA's overlay-class mechanism combines this mixin +# with whichever underlying API class - IAccessible or UIA - the real +# object actually uses) and ties the class to one specific accessibility +# API for no benefit, so it's now a plain mixin. +class AutocompleteList: + + def _getScintillaAutocompleteText(self): + try: + editRef = getattr(self.appModule, "_edit", None) + edit = editRef() if editRef else None + if edit is None: + return None + return edit.getAutocompleteCurrentText() + except Exception: + log.exception( + "notepad++ addon: could not reach edit window for Scintilla query" + ) + return None -class AutocompleteList(IAccessible): + def _getSuggestionText(self): + """Try each available way of reading the currently highlighted + suggestion's text, in order of reliability, and return the first + one that produces something. Returns None if none of them do. + """ + for getText in ( + self._getScintillaAutocompleteText, + lambda: self.name or getattr(self, "value", None) or getattr(self, "description", None), + ): + text = getText() + if text: + return text + return None def event_selection(self): - speech.cancelSpeech() - speech.speakText(self.name) - if config.conf["notepadPp"]["brailleAutocompleteSuggestions"]: - braille.handler.message(u'⣏ %s ⣹' % self.name) + try: + text = self._getSuggestionText() + log.debug( + u"notepad++ addon: autocomplete selection changed, text=%r" % text + ) + speech.cancelSpeech() + if text: + speech.speakText(text) + if config.conf["notepadPp"]["brailleAutocompleteSuggestions"] and text: + braille.handler.message(u'⣏ %s ⣹' % text) + except Exception: + # Log instead of silently swallowing, so future failures here + # are visible in the NVDA log rather than invisible. + log.exception( + "notepad++ addon: failed to announce autocomplete suggestion" + ) diff --git a/addon/appModules/notepad++/editWindow.py b/addon/appModules/notepad++/editWindow.py index a95bfe6..120b70f 100755 --- a/addon/appModules/notepad++/editWindow.py +++ b/addon/appModules/notepad++/editWindow.py @@ -5,8 +5,13 @@ #See the file COPYING for more details. import weakref +import ctypes +import locale import addonHandler import config +import watchdog +import winKernel +from logHandler import log try: from nvdaBuiltin.appModules.notepadPlusPlus import NppEdit as EditWindowBaseCls except ImportError: @@ -25,6 +30,21 @@ addonHandler.initTranslation() +# Scintilla message constants (from Scintilla.iface / Scintilla.h; these are +# stable across Scintilla versions - Notepad++ 8.9.7 uses Scintilla 5.x). +SCI_AUTOCACTIVE = 2102 +# "get int AutoCGetCurrentText=2610(, stringresult text)" - a "stringresult" +# message: call once with lParam=0 to get the required buffer length +# (excluding the NUL terminator), then again with a big-enough buffer to +# fill it. This asks Scintilla's own internal autocomplete state directly +# for the currently-highlighted suggestion's text, bypassing the popup UI +# entirely - no dependency on MSAA, on how the popup is rendered, or on +# native listbox selection state, all of which were confirmed empty for +# this control via diagnostic logging. +SCI_AUTOCGETCURRENTTEXT = 2610 +SCI_GETCODEPAGE = 2137 +SC_CP_UTF8 = 65001 + def _hasActiveInputComposition(obj): """Return True when NVDA has current IME composition text for this object.""" @@ -36,6 +56,58 @@ def _hasActiveInputComposition(obj): class EditWindow(EditWindowBaseCls, EditableTextWithSuggestions): """An edit window that implements all of the scripts on the edit field for Notepad++""" + def getAutocompleteCurrentText(self): + """Ask Scintilla directly for the text of the currently selected + autocomplete suggestion, via SCI_AUTOCGETCURRENTTEXT. Returns None if + autocomplete isn't active, or if anything about the cross-process + read fails, rather than raising - callers should treat None as + "couldn't determine the text" and fall back to other approaches. + """ + try: + hwnd = self.windowHandle + processHandle = self.processHandle + active = watchdog.cancellableSendMessage(hwnd, SCI_AUTOCACTIVE, 0, 0) + if not active: + return None + # First call: lParam=0 asks Scintilla for the required length + # (not including the NUL terminator), without writing anything. + length = watchdog.cancellableSendMessage(hwnd, SCI_AUTOCGETCURRENTTEXT, 0, 0) + if length <= 0: + return None + bufLen = length + 1 + # Scintilla will write into this buffer via lParam, but since + # Notepad++ runs in a different process, the pointer we pass + # must be valid in *its* address space, not ours - so we + # allocate memory in the remote process, pass that address as + # lParam, then copy the written bytes back into our own buffer + # via ReadProcessMemory. This is the same technique NVDA core + # uses to read this editor's own document text. + remoteBuf = winKernel.virtualAllocEx( + processHandle, None, bufLen, winKernel.MEM_COMMIT, winKernel.PAGE_READWRITE + ) + try: + watchdog.cancellableSendMessage(hwnd, SCI_AUTOCGETCURRENTTEXT, 0, remoteBuf) + localBuf = ctypes.create_string_buffer(bufLen) + winKernel.readProcessMemory(processHandle, remoteBuf, localBuf, bufLen, None) + finally: + winKernel.virtualFreeEx(processHandle, remoteBuf, 0, winKernel.MEM_RELEASE) + # Match NVDA core's own ScintillaTextInfo._get_encoding: don't + # assume UTF-8, ask the document what codepage it's actually + # using (autocomplete suggestions can contain non-ASCII + # identifiers in non-Unicode files too). + codepage = watchdog.cancellableSendMessage(hwnd, SCI_GETCODEPAGE, 0, 0) + if codepage == SC_CP_UTF8: + encoding = "utf-8" + else: + encoding = locale.getlocale()[1] or "utf-8" + text = localBuf.value.decode(encoding, errors="replace") + return text or None + except Exception: + log.exception( + "notepad++ addon: SCI_AUTOCGETCURRENTTEXT read failed" + ) + return None + def event_loseFocus(self): #Hack: finding the edit field from the foreground window is unreliable, so cache it here. # The object tree is all sorts of fubar,