From f41509c68e9fbed4ae8e08abbd97b32c48ebd8db Mon Sep 17 00:00:00 2001 From: Yukie Nobuharu Date: Mon, 17 Aug 2026 18:10:32 +0800 Subject: [PATCH 1/2] Fix autocomplete suggestions not being announced Notepad++'s Scintilla autocomplete popup doesn't expose its item text through MSAA, and its rendering isn't visible to NVDA's display-model screen scraping either - both were confirmed empty via diagnostic logging against a real Notepad++ 8.9.7 session. Read the suggestion text directly from Scintilla instead, via SCI_AUTOCGETCURRENTTEXT, using the same cross-process buffer technique NVDA core already uses to read this editor's document text. Also widens the ListBoxX ancestor match in chooseNVDAObjectOverlayClasses, which assumed a fixed 3-level parent chain that no longer matches the real (5-level) object tree, and adds native-listbox and display-model fallbacks for older/differently themed builds. --- addon/appModules/notepad++/__init__.py | 26 ++-- addon/appModules/notepad++/autocomplete.py | 143 ++++++++++++++++++++- addon/appModules/notepad++/editWindow.py | 72 +++++++++++ 3 files changed, 226 insertions(+), 15 deletions(-) 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..0ceeef2 100755 --- a/addon/appModules/notepad++/autocomplete.py +++ b/addon/appModules/notepad++/autocomplete.py @@ -5,15 +5,144 @@ #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 ctypes + import braille import config +from logHandler import log +import displayModel +import textInfos +import speech + +# Win32 listbox messages, used as a fallback below. Scintilla's autocomplete +# popup (ListBoxX) hosts a genuine native listbox control as a child window +# (the "ListBox" ancestor closest to the LISTITEM in the object tree). Even +# when that control is owner-drawn/custom-themed so nothing is exposed via +# MSAA, it still normally stores each item's real string internally, since +# it needs that string for incremental filtering as the user types. +# LB_GETTEXT/LB_GETCURSEL read that stored string directly. Windows marshals +# these specific messages across process boundaries automatically for +# standard listbox controls, so this works even though Notepad++ is a +# separate process. +LB_GETCURSEL = 0x0188 +LB_GETTEXT = 0x0189 +LB_GETTEXTLEN = 0x018A + + +def _getNativeListboxSelectedText(hwnd): + """Read the currently selected item's text directly from a native Win32 + listbox control via SendMessage, bypassing MSAA/UIA and screen rendering + entirely. Returns None if anything about this fails or looks wrong, + rather than raising, so callers can fall back to other approaches. + """ + user32 = ctypes.windll.user32 + idx = user32.SendMessageW(hwnd, LB_GETCURSEL, 0, 0) + if idx < 0: + return None + length = user32.SendMessageW(hwnd, LB_GETTEXTLEN, idx, 0) + if length <= 0: + return None + buf = ctypes.create_unicode_buffer(length + 1) + user32.SendMessageW(hwnd, LB_GETTEXT, idx, buf) + return buf.value or None + + +# On current Notepad++/Scintilla builds, the autocomplete popup (ListBoxX) +# does not expose its item text through MSAA (name/value/description are +# all empty), and its content is not visible to NVDA's display-model screen +# scraping either (it appears to be rendered with a themed/DirectWrite path +# that classic GDI-hook-based scraping can't see). Both were confirmed empty +# 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. The native-listbox and +# display-model reads below are kept as fallbacks for older or differently +# themed builds where the Scintilla query might not be available or might +# fail for some other reason. +# +# 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: + + # Fallback text source if nothing else works: read whatever is actually + # rendered on screen within this item's bounding rectangle. + TextInfo = displayModel.DisplayModelTextInfo + + 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 + + def _getNativeListboxText(self): + # Find the nearest ancestor that is the real "ListBox" child window + # (not the outer "ListBoxX" popup frame). + try: + ancestor = self.parent + for _ in range(3): + if ancestor is None: + return None + if ancestor.windowClassName == u'ListBox': + return _getNativeListboxSelectedText(ancestor.windowHandle) + ancestor = ancestor.parent + except Exception: + log.exception( + "notepad++ addon: native listbox text extraction failed" + ) + return None + + def _getDisplayModelText(self): + try: + info = self.makeTextInfo(textInfos.POSITION_ALL) + return (info.text or "").strip() + except Exception: + log.exception( + "notepad++ addon: displayModel text extraction failed" + ) + return "" -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), + self._getNativeListboxText, + self._getDisplayModelText, + ): + 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, From fc6e754eff7e59dea6fa82124a79d909846e5575 Mon Sep 17 00:00:00 2001 From: Yukie Nobuharu Date: Mon, 17 Aug 2026 19:12:13 +0800 Subject: [PATCH 2/2] Simplify: drop native-listbox and display-model fallbacks Both were confirmed to return no data on the actual popup during investigation. SCI_AUTOCGETCURRENTTEXT has been present in Scintilla long before any Notepad++ version anyone is likely running today, so the extra complexity wasn't justified for a fallback with no observed case. Kept the cheap MSAA read as the only fallback. --- addon/appModules/notepad++/autocomplete.py | 86 ++-------------------- 1 file changed, 7 insertions(+), 79 deletions(-) diff --git a/addon/appModules/notepad++/autocomplete.py b/addon/appModules/notepad++/autocomplete.py index 0ceeef2..19ecec6 100755 --- a/addon/appModules/notepad++/autocomplete.py +++ b/addon/appModules/notepad++/autocomplete.py @@ -5,62 +5,23 @@ #This file is covered by the GNU General Public License. #See the file COPYING for more details. -import ctypes - import braille import config from logHandler import log -import displayModel -import textInfos import speech -# Win32 listbox messages, used as a fallback below. Scintilla's autocomplete -# popup (ListBoxX) hosts a genuine native listbox control as a child window -# (the "ListBox" ancestor closest to the LISTITEM in the object tree). Even -# when that control is owner-drawn/custom-themed so nothing is exposed via -# MSAA, it still normally stores each item's real string internally, since -# it needs that string for incremental filtering as the user types. -# LB_GETTEXT/LB_GETCURSEL read that stored string directly. Windows marshals -# these specific messages across process boundaries automatically for -# standard listbox controls, so this works even though Notepad++ is a -# separate process. -LB_GETCURSEL = 0x0188 -LB_GETTEXT = 0x0189 -LB_GETTEXTLEN = 0x018A - - -def _getNativeListboxSelectedText(hwnd): - """Read the currently selected item's text directly from a native Win32 - listbox control via SendMessage, bypassing MSAA/UIA and screen rendering - entirely. Returns None if anything about this fails or looks wrong, - rather than raising, so callers can fall back to other approaches. - """ - user32 = ctypes.windll.user32 - idx = user32.SendMessageW(hwnd, LB_GETCURSEL, 0, 0) - if idx < 0: - return None - length = user32.SendMessageW(hwnd, LB_GETTEXTLEN, idx, 0) - if length <= 0: - return None - buf = ctypes.create_unicode_buffer(length + 1) - user32.SendMessageW(hwnd, LB_GETTEXT, idx, buf) - return buf.value or None - - # On current Notepad++/Scintilla builds, the autocomplete popup (ListBoxX) -# does not expose its item text through MSAA (name/value/description are -# all empty), and its content is not visible to NVDA's display-model screen -# scraping either (it appears to be rendered with a themed/DirectWrite path -# that classic GDI-hook-based scraping can't see). Both were confirmed empty -# via targeted diagnostic logging against a real Notepad++ 8.9.7 session. +# 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. The native-listbox and -# display-model reads below are kept as fallbacks for older or differently -# themed builds where the Scintilla query might not be available or might -# fail for some other reason. +# 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 @@ -69,10 +30,6 @@ def _getNativeListboxSelectedText(hwnd): # API for no benefit, so it's now a plain mixin. class AutocompleteList: - # Fallback text source if nothing else works: read whatever is actually - # rendered on screen within this item's bounding rectangle. - TextInfo = displayModel.DisplayModelTextInfo - def _getScintillaAutocompleteText(self): try: editRef = getattr(self.appModule, "_edit", None) @@ -86,33 +43,6 @@ def _getScintillaAutocompleteText(self): ) return None - def _getNativeListboxText(self): - # Find the nearest ancestor that is the real "ListBox" child window - # (not the outer "ListBoxX" popup frame). - try: - ancestor = self.parent - for _ in range(3): - if ancestor is None: - return None - if ancestor.windowClassName == u'ListBox': - return _getNativeListboxSelectedText(ancestor.windowHandle) - ancestor = ancestor.parent - except Exception: - log.exception( - "notepad++ addon: native listbox text extraction failed" - ) - return None - - def _getDisplayModelText(self): - try: - info = self.makeTextInfo(textInfos.POSITION_ALL) - return (info.text or "").strip() - except Exception: - log.exception( - "notepad++ addon: displayModel text extraction failed" - ) - return "" - def _getSuggestionText(self): """Try each available way of reading the currently highlighted suggestion's text, in order of reliability, and return the first @@ -121,8 +51,6 @@ def _getSuggestionText(self): for getText in ( self._getScintillaAutocompleteText, lambda: self.name or getattr(self, "value", None) or getattr(self, "description", None), - self._getNativeListboxText, - self._getDisplayModelText, ): text = getText() if text: