Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 18 additions & 8 deletions addon/appModules/notepad++/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
import gui
import addonHandler
import eventHandler
from logHandler import log
from controlTypes import Role
import speech
import nvwave
Expand All @@ -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
Expand Down
71 changes: 64 additions & 7 deletions addon/appModules/notepad++/autocomplete.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
72 changes: 72 additions & 0 deletions addon/appModules/notepad++/editWindow.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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."""
Expand All @@ -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,
Expand Down