Repository: nvaccess/nvda

Issue:
Consider dropping gui.accPropServer based GUI annotations in favour of wx.Accessible
### Is your feature request related to a problem? Please describe.
To annotate dialogs (e.g. adding accessible descriptions, changing roles, checked/unchecked state of checkable list items), we use the gui.accPropServer module for a custom COM server implementation using comtypes. It took us several iterations to get this right and honestly, it feels slightly hacky to me.

### Describe the solution you'd like
WX is now able to do this for us. The process is pretty straight forward:

1. Subclass wx.Accessible
2. Override the methods you need, e.g. GetDescription, GetRole
3. Instanciate the accessible
4. Set the accessible on the control using SetAccessible

### Describe alternatives you've considered
Leave as is.



Discussion:
CC @feerrenrut you also devoted some time to the gui.accPropServer module. I really feel like we need to consider the wx way since it results in less and cleaner code and means a switch to a process that is much better documented and elegant
It would be worth trying it out. Removing code is certainly nice to do.

Resolving patch:
diff --git a/source/gui/accPropServer.py b/source/gui/accPropServer.py
deleted file mode 100644
--- a/source/gui/accPropServer.py
+++ /dev/null
@@ -1,178 +0,0 @@
-#accPropServer.py
-#A part of NonVisual Desktop Access (NVDA)
-#Copyright (C) 2017-2018 NV Access Limited, Derek Riemer, Babbage B.V.
-#This file is covered by the GNU General Public License.
-#See the file COPYING for more details.
-
-"""Implementation of IAccProcServer, so that customization of a wx control can be done very fast."""
-from ctypes.wintypes import BOOL
-from typing import Optional, Tuple, Any, Union, Callable
-
-from logHandler import log
-from comtypes.automation import S_OK, VARIANT, POINTER, c_int, c_double, _oleaut32
-from comtypes import COMObject, GUID
-from comInterfaces.Accessibility import IAccPropServer, ANNO_CONTAINER, ANNO_THIS
-from abc import ABCMeta, abstractmethod
-import weakref
-import winUser
-import wx
-
-_VariantInit: Callable[[POINTER(VARIANT),], None] = _oleaut32.VariantInit
-_VariantInit.argtypes = (POINTER(VARIANT),)
-
-AcceptedGetPropTypes = Union[
-		bool,
-		int, c_int,
-		float, c_double,
-		str,
-		VARIANT,
-		# And others: L{comtpyes.automation.tagVariant._set_value}
-	]
-
-class IAccPropServer_Impl(COMObject, metaclass=ABCMeta):
-	"""Base class for implementing a COM interface for a hwnd based AccPropServer\
-	to annotate a WX control.
-	The AccPropServer registers itself using the window handle of the WX control.
-	When the WX control is destroyed, the instance is automatically unregistered.
-	This should eventually be dropped in favor of WX' own annotation support,
-	blocked by wxWidgets/Phoenix#1129.
-	Please override the L{_GetPropValue} method, not L{GetPropValue}.
-	L{GetPropValue} wraps L{_getPropValue} to catch and log exceptions (Which for some reason NVDA's logger misses when they occur in GetPropValue).
-	You must also provide the L{properties} property.
-	"""
-
-	_com_interfaces_ = [
-		IAccPropServer
-	]
-
-	# Constants used with `IAccPropServer::GetPropValue` method see
-	# https://msdn.microsoft.com/en-us/library/windows/desktop/dd318495(v=vs.85).aspx
-	HAS_PROP = 1  # TRUE - Constant for `BOOL* pfHasProp` out param of `IAccPropServer::GetPropValue` method
-	DOES_NOT_HAVE_PROP = 0  # FALSE - Constant for `BOOL* pfHasProp` out param of `IAccPropServer::GetPropValue` method
-
-	# An array with the GUIDs of the properties that an AccPropServer should override
-	properties_GUIDPTR = []
-	properties = []
-
-	def __init__(self, control, annotateProperties, annotateChildren=False):
-		"""Initialize the instance of AccPropServer. 
-		@param control: the WX control instance, so you can look up things in the _getPropValue method.
-			It's available on self.control.
-		@Type control: Subclass of wx.Window
-		@param annotateProperties The properties that should be annotated, see oleacc.py for constants.
-		@type annotateProperties List of oleacc constants. Internally these are converted to GUID pointers for the server.
-		@param annotateChildren: whether the WX control is a container which children should be annotated.
-		@type annotateChildren: bool
-		"""
-		self.properties = annotateProperties
-		self.properties_GUIDPTR = convertToGUIDPointerList(annotateProperties)
-		self.control = weakref.ref(control)
-		self.hwnd = control.GetHandle()
-		super(IAccPropServer_Impl, self).__init__()
-		# Import late to avoid circular import
-		from IAccessibleHandler import accPropServices
-		accPropServices.SetHwndPropServer(
-			hwnd=self.hwnd,
-			idObject=winUser.OBJID_CLIENT,
-			idChild=0,
-			paProps=self.properties_GUIDPTR,
-			cProps=len(self.properties_GUIDPTR),
-			pServer=self,
-			AnnoScope=ANNO_CONTAINER if annotateChildren else ANNO_THIS
-		)
-		# clean up of accPropServices needs to happen when the control is destroyed. We can't rely on 
-		# pythons `__del__` method to be called, and the wx framework does not call Destroy on child controls,
-		# automatically. Instead we can bind to the "window destroy" event for the control to do necessary 
-		# cleanup (wxWidgets/Phoenix/#630). Not performing this cleanup results in a reference to the parent
-		# window, keeping it from being deleted correctly. This can cause a freeze on exit of NVDA.
-		control.Bind(wx.EVT_WINDOW_DESTROY, self._onDestroyControl, source=control)
-
-	@abstractmethod
-	def _getPropValue(
-			self,
-			pIDString: str,
-			dwIDStringLen: int,
-			idProp: GUID
-	) -> Optional[Tuple[BOOL, AcceptedGetPropTypes]]:
-		""" Use this method to implement GetPropValue.
-		It is wrapped by the callback GetPropValue to handle exceptions, and ensure valid return types.
-		For instructions on implementing accPropServers, see https://msdn.microsoft.com/en-us/library/windows/desktop/dd373681(v=vs.85).aspx .
-		For instructions specifically about this method, see https://msdn.microsoft.com/en-us/library/windows/desktop/dd318495(v=vs.85).aspx .
-		@param pIDString: Contains a string that identifies the property being requested.
-			If a single callback object is registered for annotating multiple accessible elements,
-			the identity string can be used to determine which element the request refers to.
-			If the accessible element is HWND-based,
-			IAccessibleHandler.accPropServices.DecomposeHwndIdentityString can be used
-			to extract the HWND/idObject/idChild from the identity string.
-			Note that, while one IAccPropServer implementation can annotate
-			multiple accessible elements, it is still bound to one wx.Control.
-		@param dwIDStringLen: Specifies the length of the identity string specified by the pIDString parameter.
-		@param idProp: Specifies a GUID indicating the desired property. One of the values from oleacc.PROPID_*
-		@return Use L{self._hasProp} to return correct values or return None if unable to supply the property.
-		"""
-		raise NotImplementedError
-
-	def _hasProp(
-			self,
-			value: AcceptedGetPropTypes
-	) -> Optional[Tuple[BOOL, AcceptedGetPropTypes]]:
-		"""Constructs a tuple for the `IAccPropServer::GetPropValue` method, two elements:
-			1. `VARIANT pvarValue`
-			2. `BOOL pfHasProp` (either self.HAS_PROP or self.DOES_NOT_HAVE_PROP)"""
-		return value, self.HAS_PROP
-
-	def GetPropValue(
-			self, this, # unused "this" used to indicate to comTypes we want a low level implementation
-			pIDString: str,
-			dwIDStringLen: int,
-			idProp: GUID,
-			pvarValue: POINTER(VARIANT),
-			pfGotProp: POINTER(BOOL)
-	) -> int:
-		""" Exposed method to get a prop value.
-		see L{_getPropValue} for more details of args.
-		Uses a low-level approach, because comtypes tries to clear the VARIANT even though it is an out param.
-		When the pfHasProp part is FALSE / self.DOES_NOT_HAVE_PROP, then the pvarValue.vt part must be VT_EMPTY.
-		"""
-		# ensure exceptions don't leave this function. They will get get swallowed by the caller.
-		# instead catch and log exceptions.
-		try:
-			# Preset values for "no prop value", in case we return early.
-			pfGotProp.contents.value = self.DOES_NOT_HAVE_PROP
-			_VariantInit(pvarValue)
-
-			ret = self._getPropValue(pIDString, dwIDStringLen, idProp)
-			if ret is None:
-				# We don't have the prop value, return early.
-				return S_OK
-			elif len(ret) != 2:
-				# We don't have the prop value, internal error.
-				raise RuntimeError("_getPropValue implementation must return None or two element tuple")
-			elif ret[1] != self.HAS_PROP:
-				# We don't have the prop value, return early.
-				return S_OK
-
-			# we do have the prop value
-			pfGotProp.contents.value = self.HAS_PROP
-			pvarValue.contents.value = ret[0]
-		except Exception as e: # catch and log all exceptions so they are not swallowed by caller.
-			log.exception()
-		return S_OK
-
-	def _onDestroyControl(self, evt):
-		evt.Skip()  # Allow other handlers to process this event.
-		self._cleanup()
-
-	def _cleanup(self):
-		# Import late to avoid circular import
-		from IAccessibleHandler import accPropServices
-		accPropServices.ClearHwndProps(
-			hwnd=self.hwnd,
-			idObject=winUser.OBJID_CLIENT,
-			idChild=0,
-			paProps=self.properties_GUIDPTR,
-			cProps=len(self.properties_GUIDPTR)
-		)
-
-def convertToGUIDPointerList(propList):
-	return (GUID * len(propList))(*propList)
diff --git a/source/gui/nvdaControls.py b/source/gui/nvdaControls.py
--- a/source/gui/nvdaControls.py
+++ b/source/gui/nvdaControls.py
@@ -9,7 +9,6 @@
 import wx
 from comtypes import GUID
 from wx.lib.mixins import listctrl as listmix
-from . import accPropServer
 from .dpiScalingHelper import DpiScalingHelperMixin
 from . import guiHelper
 import oleacc
@@ -86,85 +85,35 @@ def OnSetFocus(self, evt):
 		self.SetSelection(0, numChars)
 		evt.Skip()
 
-class AccPropertyOverride(accPropServer.IAccPropServer_Impl):
 
-	def __init__(self, control, propertyAnnotations):
-		"""
-		A simple class for overriding specific values on a control
-		:type propertyAnnotations: dict
-		"""
-		super(AccPropertyOverride, self).__init__(
-			control,
-			annotateProperties=list(propertyAnnotations.keys()),
-			annotateChildren=False
-		)
-		self.propertyAnnotations = propertyAnnotations
+class ListCtrlAccessible(wx.Accessible):
+	"""WX Accessible implementation for checkable lists which aren't fully accessible."""
 
-	def _getPropValue(self, pIDString, dwIDStringLen, idProp):
-		control = self.control()  # self.control held as a weak ref, ensure it stays alive for the duration of this method
-		if control is None or not self.propertyAnnotations:
-			return None
+	def GetRole(self, childId):
+		if childId == winUser.CHILDID_SELF:
+			return super().GetRole(childId)
+		return (wx.ACC_OK, wx.ROLE_SYSTEM_CHECKBUTTON)
 
-		try:
-			val = self.propertyAnnotations[idProp]
-			if callable(val):
-				val = val()
-			return self._hasProp(val)
-		except KeyError:
-			pass
-
-		return None
-
-	def _cleanup(self):
-		# could contain references (via lambda) of our owner, set it to None to avoid a circular reference which
-		# would block destruction.
-		self.propertyAnnotations = None
-		super(AccPropertyOverride, self)._cleanup()
-
-class ListCtrlAccPropServer(accPropServer.IAccPropServer_Impl):
-	"""AccPropServer for wx checkable lists which aren't fully accessible."""
-
-	def __init__(self, control):
-		super(ListCtrlAccPropServer, self).__init__(
-			control,
-			annotateProperties=[
-				oleacc.PROPID_ACC_ROLE,  # supposed to be checkbox, rather than list item
-				oleacc.PROPID_ACC_STATE  # should include the checkable state and checked state if the item is checked.
-			],
-			annotateChildren=True
-		)
+	def GetState(self, childId):
+		if childId == winUser.CHILDID_SELF:
+			return super().GetState(childId)
+		states = wx.ACC_STATE_SYSTEM_SELECTABLE | wx.ACC_STATE_SYSTEM_FOCUSABLE
+		if self.Window.IsChecked(childId - 1):
+			states |= wx.ACC_STATE_SYSTEM_CHECKED
+		if self.Window.IsSelected(childId - 1):
+			# wx doesn't seem to  have a method to check whether a list item is focused.
+			# Therefore, assume that a selected item is focused,which is the case in single select list boxes.
+			states |= wx.ACC_STATE_SYSTEM_SELECTED | wx.ACC_STATE_SYSTEM_FOCUSED
+		return (wx.ACC_OK, states)
 
-	def _getPropValue(self, pIDString: str, dwIDStringLen: int, idProp: GUID) -> Optional[Tuple[BOOL, Any]]:
-		control = self.control()  # self.control held as a weak ref, ensure it stays alive for the duration of this method
-		if control is None:
-			return None
-
-		# Import late to prevent circular import.
-		from IAccessibleHandler import accPropServices
-		handle, objid, childid = accPropServices.DecomposeHwndIdentityString(pIDString, dwIDStringLen)
-		if childid == winUser.CHILDID_SELF:
-			return None
-
-		if idProp == oleacc.PROPID_ACC_ROLE:
-			return self._hasProp(oleacc.ROLE_SYSTEM_CHECKBUTTON)
-
-		if idProp == oleacc.PROPID_ACC_STATE:
-			states = oleacc.STATE_SYSTEM_SELECTABLE|oleacc.STATE_SYSTEM_FOCUSABLE
-			if control.IsChecked(childid-1):
-				states |= oleacc.STATE_SYSTEM_CHECKED
-			if control.IsSelected(childid-1):
-				# wx doesn't seem to  have a method to check whether a list item is focused.
-				# Therefore, assume that a selected item is focused,which is the case in single select list boxes.
-				states |= oleacc.STATE_SYSTEM_SELECTED | oleacc.STATE_SYSTEM_FOCUSED
-			return self._hasProp(states)
 
 class CustomCheckListBox(wx.CheckListBox):
 	"""Custom checkable list to fix a11y bugs in the standard wx checkable list box."""
 
 	def __init__(self, *args, **kwargs):
 		super(CustomCheckListBox, self).__init__(*args, **kwargs)
-		# Register object with COM to fix accessibility bugs in wx.
-		self.server = ListCtrlAccPropServer(self)
+		# Register a custom wx.Accessible implementation to fix accessibility incompleties
+		self.SetAccessible(ListCtrlAccessible(self))
 		# Register ourself with ourself's selected event, so that we can notify winEvent of the state change.
 		self.Bind(wx.EVT_CHECKLISTBOX, self.notifyIAccessible)
 
@@ -189,8 +138,8 @@ def __init__(self, parent, id=wx.ID_ANY, autoSizeColumn="LAST", pos=wx.DefaultPo
 	):
 		AutoWidthColumnListCtrl.__init__(self, parent, id=id, pos=pos, size=size, style=style, autoSizeColumn=autoSizeColumn)
 		listmix.CheckListCtrlMixin.__init__(self, check_image, uncheck_image, imgsz)
-		# Register object with COM to fix accessibility bugs in wx.
-		self.server = ListCtrlAccPropServer(self)
+		# Register a custom wx.Accessible implementation to fix accessibility incompleties
+		self.SetAccessible(ListCtrlAccessible(self))
 		# Register our hook to check/uncheck items with space.
 		# Use wx.EVT_CHAR_HOOK, because EVT_LIST_KEY_DOWN isn't triggered for space.
 		self.Bind(wx.EVT_CHAR_HOOK, self.onCharHook)
diff --git a/source/gui/settingsDialogs.py b/source/gui/settingsDialogs.py
--- a/source/gui/settingsDialogs.py
+++ b/source/gui/settingsDialogs.py
@@ -357,6 +357,22 @@ def _sendLayoutUpdatedEvent(self):
 		event.SetEventObject(self)
 		self.GetEventHandler().ProcessEvent(event)
 
+
+class SettingsPanelAccessible(wx.Accessible):
+	"""
+	WX Accessible implementation to set the role of a settings panel to property page,
+	as well as to set the accessible description based on the panel's description.
+	"""
+
+	Window: SettingsPanel
+
+	def GetRole(self, childId):
+		return (wx.ACC_OK, wx.ROLE_SYSTEM_PROPERTYPAGE)
+
+	def GetDescription(self, childId):
+		return (wx.ACC_OK, self.Window.panelDescription)
+
+
 class MultiCategorySettingsDialog(SettingsDialog):
 	"""A settings dialog with multiple settings categories.
 	A multi category settings dialog consists of a list view with settings categories on the left side, 
@@ -524,14 +540,7 @@ def _getCategoryPanel(self, catId):
 					).format(cls, panel.Size[0])
 				)
 			panel.SetLabel(panel.title)
-			import oleacc
-			panel.server = nvdaControls.AccPropertyOverride(
-				panel,
-				propertyAnnotations={
-					oleacc.PROPID_ACC_ROLE: oleacc.ROLE_SYSTEM_PROPERTYPAGE,  # change the role from pane to property page
-					oleacc.PROPID_ACC_DESCRIPTION: panel.panelDescription,  # set a description
-				}
-			)
+			panel.SetAccessible(SettingsPanelAccessible(panel))
 		return panel
 
 	def postInit(self):


Test patch: