diff --git a/nvdaHelper/local/UIAUtils.cpp b/nvdaHelper/local/UIAUtils.cpp index f01c86847ba..9a207b32741 100644 --- a/nvdaHelper/local/UIAUtils.cpp +++ b/nvdaHelper/local/UIAUtils.cpp @@ -18,3 +18,13 @@ PROPERTYID registerUIAProperty(GUID* guid, LPCWSTR programmaticName, UIAutomatio registrar->Release(); return propertyId; } + +int registerUIAAnnotationType(GUID* guid) { + if(!guid) { + LOG_DEBUGWARNING(L"NULL GUID given"); + return 0; + } + winrt::Windows::UI::UIAutomation::Core::CoreAutomationRegistrar registrar {}; + auto res = registrar.RegisterAnnotationType(*guid); + return res.LocalId; +} diff --git a/nvdaHelper/local/UIAUtils.h b/nvdaHelper/local/UIAUtils.h index b0229e806f7..13807960af1 100644 --- a/nvdaHelper/local/UIAUtils.h +++ b/nvdaHelper/local/UIAUtils.h @@ -1,9 +1,15 @@ #ifndef NVDAHELPERLOCAL_UIAUTILS_H #define NVDAHELPERLOCAL_UIAUTILS_H +// The following header included to allow winrt::guid to be converted to GUID +#include + +#include #include PROPERTYID registerUIAProperty(GUID* guid, LPCWSTR programmaticName, UIAutomationType propertyType); +int registerUIAAnnotationType(GUID* guid); + #endif diff --git a/nvdaHelper/local/nvdaHelperLocal.def b/nvdaHelper/local/nvdaHelperLocal.def index 8735a7e61e3..7c700b58506 100644 --- a/nvdaHelper/local/nvdaHelperLocal.def +++ b/nvdaHelper/local/nvdaHelperLocal.def @@ -55,6 +55,7 @@ EXPORTS calculateCharacterOffsets findWindowWithClassInThread registerUIAProperty + registerUIAAnnotationType dllImportTableHooks_hookSingle dllImportTableHooks_unhookSingle audioDucking_shouldDelay diff --git a/source/NVDAObjects/UIA/__init__.py b/source/NVDAObjects/UIA/__init__.py index 28fd25676b7..6ae7ceee419 100644 --- a/source/NVDAObjects/UIA/__init__.py +++ b/source/NVDAObjects/UIA/__init__.py @@ -17,6 +17,7 @@ import languageHandler import UIAHandler import _UIACustomProps +import _UIACustomAnnotations import globalVars import eventHandler import controlTypes @@ -818,6 +819,7 @@ def updateSelection(self): class UIA(Window): _UIACustomProps = _UIACustomProps.CustomPropertiesCommon.get() + _UIACustomAnnotationTypes = _UIACustomAnnotations.CustomAnnotationTypesCommon.get() shouldAllowDuplicateUIAFocusEvent = False diff --git a/source/NVDAObjects/UIA/excel.py b/source/NVDAObjects/UIA/excel.py index e4c0d85a296..3b89e40b75b 100644 --- a/source/NVDAObjects/UIA/excel.py +++ b/source/NVDAObjects/UIA/excel.py @@ -4,6 +4,8 @@ # Copyright (C) 2018-2021 NV Access Limited, Leonard de Ruijter from typing import Optional, Tuple +from comtypes import COMError +import winVersion import UIAHandler import _UIAHandler import _UIAConstants @@ -16,6 +18,9 @@ from _UIACustomProps import ( CustomPropertyInfo, ) +from _UIACustomAnnotations import ( + CustomAnnotationTypeInfo, +) from comtypes import GUID from scriptHandler import script import ui @@ -90,10 +95,35 @@ def __init__(self): ) +class ExcelCustomAnnotationTypes: + """ UIA 'custom annotation types' specific to Excel. + Once registered, all subsequent registrations will return the same ID value. + This class should be used as a singleton via ExcelCustomAnnotationTypes.get() + to prevent unnecessary work by repeatedly interacting with UIA. + """ + #: Singleton instance + _instance: "Optional[ExcelCustomAnnotationTypes]" = None + + @classmethod + def get(cls) -> "ExcelCustomAnnotationTypes": + """Get the singleton instance or initialise it. + """ + if cls._instance is None: + cls._instance = cls() + return cls._instance + + def __init__(self): + self.note = CustomAnnotationTypeInfo( + guid=GUID("{4E863D9A-F502-4A67-808F-9E711702D05E}"), + ) + + class ExcelObject(UIA): """Common base class for all Excel UIA objects """ _UIAExcelCustomProps = ExcelCustomProperties.get() + _UIAExcelCustomAnnotationTypes = ExcelCustomAnnotationTypes.get() + class ExcelCell(ExcelObject): @@ -367,6 +397,15 @@ def _get_states(self): states.add(controlTypes.State.HASFORMULA) if self._getUIACacheablePropertyValue(self._UIAExcelCustomProps.hasDataValidationDropdown.id): states.add(controlTypes.State.HASPOPUP) + if winVersion.getWinVer() >= winVersion.WIN11: + try: + annotationTypes = self._getUIACacheablePropertyValue(UIAHandler.UIA_AnnotationTypesPropertyId) + except COMError: + # annotationTypes cannot be fetched on older Operating Systems such as Windows 7. + annotationTypes = None + if annotationTypes: + if self._UIAExcelCustomAnnotationTypes.note.id in annotationTypes: + states.add(controlTypes.State.HASNOTE) return states def _get_cellCoordsText(self): @@ -421,6 +460,17 @@ def _get_cellCoordsText(self): description=_("Reports the note or comment thread on the current cell"), gesture="kb:NVDA+alt+c") def script_reportComment(self, gesture): + if winVersion.getWinVer() >= winVersion.WIN11: + noteElement = self.UIAAnnotationObjects.get(self._UIAExcelCustomAnnotationTypes.note.id) + if noteElement: + name = noteElement.CurrentName + desc = noteElement.GetCurrentPropertyValue(UIAHandler.UIA_FullDescriptionPropertyId) + # Translators: a note on a cell in Microsoft excel. + text = _("{name}: {desc}").format(name=name, desc=desc) + ui.message(text) + else: + # Translators: message when a cell in Excel contains no note + ui.message(_("No note on this cell")) commentsElement = self.UIAAnnotationObjects.get(UIAHandler.AnnotationType_Comment) if commentsElement: comment = commentsElement.GetCurrentPropertyValue(UIAHandler.UIA_FullDescriptionPropertyId) @@ -428,21 +478,21 @@ def script_reportComment(self, gesture): numReplies = commentsElement.GetCurrentPropertyValue(self._UIAExcelCustomProps.commentReplyCount.id) if numReplies == 0: # Translators: a comment on a cell in Microsoft excel. - text = _("{comment} by {author}").format( + text = _("Comment thread: {comment} by {author}").format( comment=comment, author=author ) else: # Translators: a comment on a cell in Microsoft excel. - text = _("{comment} by {author} with {numReplies} replies").format( + text = _("Comment thread: {comment} by {author} with {numReplies} replies").format( comment=comment, author=author, numReplies=numReplies ) ui.message(text) else: - # Translators: A message in Excel when there is no note - ui.message(_("No note or comment thread on this cell")) + # Translators: A message in Excel when there is no comment thread + ui.message(_("No comment thread on this cell")) class ExcelWorksheet(ExcelObject): diff --git a/source/_UIACustomAnnotations.py b/source/_UIACustomAnnotations.py new file mode 100644 index 00000000000..7187441b6b0 --- /dev/null +++ b/source/_UIACustomAnnotations.py @@ -0,0 +1,80 @@ +# A part of NonVisual Desktop Access (NVDA) +# Copyright (C) 2021 NV Access Limited +# This file is covered by the GNU General Public License. +# See the file COPYING for more details. + +from dataclasses import ( + dataclass, + field, +) +from typing import Optional + +from comtypes import ( + GUID, + byref, +) +import winVersion + + +""" +This module provides helpers and a common format to define UIA custom annotation types. +The common custom annotation types are defined here. +Custom annotation types specific to an application should be defined within a NVDAObjects/UIA +submodule specific to that application, E.G. 'NVDAObjects/UIA/excel.py' + +UIA originally had hard coded 'static' ID's for annotation types. +For an example see 'AnnotationType_SpellingError' in +`source/comInterfaces/_944DE083_8FB8_45CF_BCB7_C477ACB2F897_0_1_0.py` +imported via `UIAutomationClient.py`. +When a new annotation type was added the UIA spec had to be updated. +Now a mechanism is in place to allow applications to register "custom annotation types". +This relies on both the UIA server application and the UIA client application sharing a known +GUID for the annotation type. +""" + + +@dataclass +class CustomAnnotationTypeInfo: + """Holds information about a CustomAnnotationType + This makes it easy to define custom annotation types to be loaded. + """ + guid: GUID + id: int = field(init=False) + + def __post_init__(self) -> None: + """ The id field must be initialised at runtime. + A GUID uniquely identifies a custom annotation, but the UIA system relies on integer IDs. + Any application (clients or providers) can register a custom annotation type, subsequent applications + will get the same id for a given GUID. + Registering custom annotations is only supported on Windows 11 and above. + For any lesser version, id will be 0. + + """ + if winVersion.getWinVer() >= winVersion.WIN11: + import NVDAHelper + self.id = NVDAHelper.localLib.registerUIAAnnotationType( + byref(self.guid), + ) + else: + self.id = 0 + + +class CustomAnnotationTypesCommon: + """UIA 'custom annotation types' common to all applications. + Once registered, all subsequent registrations will return the same ID value. + This class should be used as a singleton via CustomAnnotationTypesCommon.get() + to prevent unnecessary work by repeatedly interacting with UIA. + """ + #: Singleton instance + _instance: "Optional[CustomAnnotationTypesCommon]" = None + + @classmethod + def get(cls) -> "CustomAnnotationTypesCommon": + """Get the singleton instance or initialise it. + """ + if cls._instance is None: + cls._instance = cls() + return cls._instance + + def __init__(self): + pass diff --git a/source/controlTypes/state.py b/source/controlTypes/state.py index 29b792bbc1f..e32f8addbc1 100644 --- a/source/controlTypes/state.py +++ b/source/controlTypes/state.py @@ -71,6 +71,7 @@ def negativeDisplayString(self) -> str: OVERFLOWING = 0x10000000000 UNLOCKED = 0x20000000000 HAS_ARIA_DETAILS = 0x40000000000 + HASNOTE = 0x80000000000 STATES_SORTED = frozenset([State.SORTED, State.SORTED_ASCENDING, State.SORTED_DESCENDING]) @@ -160,6 +161,8 @@ def negativeDisplayString(self) -> str: # Translators: a state that denotes that the object is unlocked (such as an unlocked cell in a protected # Excel spreadsheet). State.UNLOCKED: _("unlocked"), + # Translators: a state that denotes the existance of a note. + State.HASNOTE: _("has note"), }