From b9f7ee1c2a8cbb9415545dc2562901881e27f11f Mon Sep 17 00:00:00 2001 From: Leonard de Ruijter Date: Mon, 13 Sep 2021 18:20:33 +0200 Subject: [PATCH] Improve selection and merged cell announcement in LibreOffice Calc --- source/NVDAObjects/IAccessible/__init__.py | 25 ++- source/appModules/soffice.py | 183 +++++++++++---------- 2 files changed, 111 insertions(+), 97 deletions(-) diff --git a/source/NVDAObjects/IAccessible/__init__.py b/source/NVDAObjects/IAccessible/__init__.py index 92b70715afd..a0462ef58f4 100644 --- a/source/NVDAObjects/IAccessible/__init__.py +++ b/source/NVDAObjects/IAccessible/__init__.py @@ -1289,12 +1289,12 @@ def _get_selectionContainer(self): return self.table return super(IAccessible,self).selectionContainer - def _getSelectedItemsCount_accSelection(self,maxCount): + def _getSelectedItemsCount_accSelection(self, maxCount): sel=self.IAccessibleObject.accSelection if not sel: raise NotImplementedError # accSelection can return a child ID of a simple element, for instance in QT tree tables. - # Therefore treet this as a single selection. + # Therefore treat this as a single selection. if isinstance(sel,int) and sel>0: return 1 enumObj=sel.QueryInterface(IEnumVARIANT) @@ -1310,24 +1310,33 @@ def _getSelectedItemsCount_accSelection(self,maxCount): raise COMError(res,None,None) return numItemsFetched.value if numItemsFetched.value <= maxCount else sys.maxsize - def getSelectedItemsCount(self,maxCount): - # To fetch the number of selected items, we first try MSAA's accSelection, but if that fails in any way, we fall back to using IAccessibleTable2's nSelectedCells, if we are on an IAccessible2 table. + def getSelectedItemsCount(self, maxCount=2): + # To fetch the number of selected items, we first try MSAA's accSelection, + # but if that fails in any way, we fall back to using IAccessibleTable2's nSelectedCells, + # if we are on an IAccessible2 table, or IAccessibleTable's nSelectedChildren, + # if we are on an IAccessible table. # Currently Chrome does not implement accSelection, thus for Google Sheets we must use nSelectedCells when on a table. + # For older symphony based products, we use nSelectedChildren. try: return self._getSelectedItemsCount_accSelection(maxCount) except (COMError,NotImplementedError) as e: log.debug("Cannot fetch selected items count using accSelection, %s"%e) pass - if hasattr(self,'IAccessibleTable2Object'): + if hasattr(self, 'IAccessibleTable2Object'): try: return self.IAccessibleTable2Object.nSelectedCells except COMError as e: - log.debug("Error calling IAccessibleTable2::nSelectedCells, %s"%e) + log.debug(f"Error calling IAccessibleTable2::nSelectedCells, {e}") + pass + elif hasattr(self, 'IAccessibleTableObject'): + try: + return self.IAccessibleTableObject.nSelectedChildren + except COMError as e: + log.debug(f"Error calling IAccessibleTable::nSelectedCells, {e}") pass else: log.debug("No means of getting a selection count from this IAccessible") - return super(IAccessible,self).getSelectedItemsCount(maxCount) - + return super().getSelectedItemsCount(maxCount) def _get_table(self): if not isinstance(self.IAccessibleObject, IA2.IAccessible2): diff --git a/source/appModules/soffice.py b/source/appModules/soffice.py index 88d06a08950..c87217cfca7 100755 --- a/source/appModules/soffice.py +++ b/source/appModules/soffice.py @@ -1,82 +1,25 @@ -#appModules/soffice.py -#A part of NonVisual Desktop Access (NVDA) -#This file is covered by the GNU General Public License. -#See the file COPYING for more details. -#Copyright (C) 2006-2019 NV Access Limited, Bill Dengler +# A part of NonVisual Desktop Access (NVDA) +# This file is covered by the GNU General Public License. +# See the file COPYING for more details. +# Copyright (C) 2006-2021 NV Access Limited, Bill Dengler, Leonard de Ruijter from comtypes import COMError -from comInterfaces import IAccessible2Lib as IA2 import IAccessibleHandler import appModuleHandler import controlTypes import textInfos import colors from compoundDocuments import CompoundDocument -from NVDAObjects.JAB import JAB, JABTextInfo from NVDAObjects.IAccessible import IAccessible, IA2TextTextInfo from NVDAObjects.behaviors import EditableText from logHandler import log +import speech +import ui +import time +import api +import braille +import vision -def gridCoordStringToNumbers(coordString): - if not coordString or len(coordString)<2 or ' ' in coordString or coordString[0].isdigit() or not coordString[-1].isdigit(): - raise ValueError("bad coord string: %r"%coordString) - rowNum=0 - colNum=0 - coordStringRowStartIndex=None - for index,ch in enumerate(reversed(coordString)): - if not ch.isdigit(): - coordStringRowStartIndex=len(coordString)-index - break - rowNum=int(coordString[coordStringRowStartIndex:]) - for index,ch in enumerate(reversed(coordString[0:coordStringRowStartIndex])): - colNum+=((ord(ch.upper())-ord('A')+1)*(26**index)) - return rowNum,colNum - -class JAB_OOTable(JAB): - - def _get_rowCount(self): - return 0 - - def _get_columnCount(self): - return 0 - -class JAB_OOTableCell(JAB): - - role=controlTypes.Role.TABLECELL - - def _get_name(self): - name=super(JAB_OOTableCell,self).name - if name and name.startswith('Cell') and name[-2].isdigit(): - return None - return name - - def _get_cellCoordsText(self): - name=super(JAB_OOTableCell,self).name - if name and name.startswith('Cell') and name[-2].isdigit(): - return name[5:-1] - - def _get_value(self): - value=super(JAB_OOTableCell,self).value - if not value and issubclass(self.TextInfo,JABTextInfo): - value=self.makeTextInfo(textInfos.POSITION_ALL).text - return value - - def _get_states(self): - states=super(JAB_OOTableCell,self).states - states.discard(controlTypes.State.EDITABLE) - return states - - def _get_rowNumber(self): - try: - return gridCoordStringToNumbers(self.cellCoordsText)[0] - except ValueError: - return 0 - - def _get_columnNumber(self): - try: - return gridCoordStringToNumbers(self.cellCoordsText)[1] - except ValueError: - return 0 class SymphonyTextInfo(IA2TextTextInfo): @@ -151,7 +94,7 @@ def _getFormatFieldAndOffsets(self,offset,formatConfig,calculateOffsets=True): # optimisation: Assume a hyperlink occupies a full attribute run. try: - if obj.IAccessibleTextObject.QueryInterface(IA2.IAccessibleHypertext).hyperlinkIndex(offset) != -1: + if obj.IAccessibleTextObject.QueryInterface(IAccessibleHandler.IA2.IAccessibleHypertext).hyperlinkIndex(offset) != -1: formatField["link"] = True except COMError: pass @@ -194,6 +137,7 @@ def _getStoryLength(self): # HACK: Account for the character faked in _getLineOffsets() so that move() will work. return max(super(SymphonyTextInfo, self)._getStoryLength(), 1) + class SymphonyText(IAccessible, EditableText): TextInfo = SymphonyTextInfo @@ -203,6 +147,7 @@ def _get_positionInfo(self): return {"level": int(level)} return super(SymphonyText, self).positionInfo + class SymphonyTableCell(IAccessible): """Silences particular states, and redundant column/row numbers""" @@ -213,26 +158,92 @@ def _get_cellCoordsText(self): name=None + def _get_hasSelection(self): + return ( + self.selectionContainer + and 1 < self.selectionContainer.getSelectedItemsCount() + ) + def _get_states(self): states=super(SymphonyTableCell,self).states states.discard(controlTypes.State.MULTILINE) states.discard(controlTypes.State.EDITABLE) - if controlTypes.State.SELECTED not in states and {controlTypes.State.FOCUSED, controlTypes.State.SELECTABLE}.issubset(states): + if controlTypes.State.SELECTED not in states and controlTypes.State.FOCUSED in states: # #8988: Cells in Libre Office do not have the selected state when a single cell is selected (i.e. has focus). # Since #8898, the negative selected state is announced for table cells with the selectable state. - states.add(controlTypes.State.SELECTED) + if self.hasSelection: + # The selected state is never added to a focused object, even though it is selected. + # We assume our focus is in the selection. + states.add(controlTypes.State.SELECTED) + else: + # Remove the selectable state, since that ensures the negative selected state isn't spoken for focused cells. + states.discard(controlTypes.State.SELECTABLE) if self.IA2Attributes.get('Formula'): # #860: Recent versions of Calc expose has formula state via IAccessible 2. states.add(controlTypes.State.HASFORMULA) return states + +class SymphonyIATableCell(SymphonyTableCell): + """An overlay class for cells implementing IAccessibleTableCell""" + + def event_selectionAdd(self): + curFocus = api.getFocusObject() + if self.table and self.table == curFocus.table: + curFocus.announceSelectionChange() + + def event_selectionRemove(self): + self.event_selectionAdd() + + def announceSelectionChange(self): + if self is api.getFocusObject(): + speech.speakObjectProperties(self, states=True, cellCoordsText=True, reason=controlTypes.OutputReason.CHANGE) + braille.handler.handleUpdate(self) + vision.handler.handleUpdate(self, property="states") + + def _get_cellCoordsText(self): + if self.hasSelection and controlTypes.State.FOCUSED in self.states: + selected, count = self.table.IAccessibleTable2Object.selectedCells + firstAccessible = selected[0].QueryInterface(IAccessibleHandler.IA2.IAccessible2) + firstAddress = firstAccessible.accName(0) + firstValue = firstAccessible.accValue(0) or '' + lastAccessible = selected[count - 1].QueryInterface(IAccessibleHandler.IA2.IAccessible2) + lastAddress = lastAccessible.accName(0) + lastValue = lastAccessible.accValue(0) or '' + # Translators: LibreOffice, report selected range of cell coordinates with their values + return _("{firstAddress} {firstValue} through {lastAddress} {lastValue}").format( + firstAddress=firstAddress, + firstValue=firstValue, + lastAddress=lastAddress, + lastValue=lastValue + ) + elif self.rowSpan > 1 or self.columnSpan > 1: + lastSelected = ( + (self.rowNumber - 1) + (self.rowSpan - 1), + (self.columnNumber - 1) + (self.columnSpan - 1) + ) + lastCellUnknown = self.table.IAccessibleTable2Object.cellAt(*lastSelected) + lastAccessible = lastCellUnknown.QueryInterface(IAccessibleHandler.IA2.IAccessible2) + lastAddress = lastAccessible.accName(0) + # Translators: LibreOffice, report range of cell coordinates + return _("{firstAddress} throuhg {lastAddress}").format( + firstAddress=self._get_name(), + lastAddress=lastAddress + ) + return super().cellCoordsText + + class SymphonyTable(IAccessible): - def getSelectedItemsCount(self,maxCount=2): - # #8988: Neither accSelection nor IAccessibleTable2 is implemented on the LibreOffice tables. - # Returning 1 will suppress redundant selected announcements, - # while having the drawback of never announcing selected for selected cells. - return 1 + def _getSelectedItemsCount_accSelection(self, maxCount): + # accSelection is broken in LibreOffice. + raise NotImplementedError + + def event_selectionWithIn(self): + curFocus = api.getFocusObject() + if self == curFocus.table: + curFocus.announceSelectionChange() + class SymphonyParagraph(SymphonyText): """Removes redundant information that can be retreaved in other ways.""" @@ -246,28 +257,22 @@ def chooseNVDAObjectOverlayClasses(self, obj, clsList): windowClassName=obj.windowClassName if isinstance(obj, IAccessible) and windowClassName in ("SALTMPSUBFRAME", "SALSUBFRAME", "SALFRAME"): if role==controlTypes.Role.TABLECELL: - clsList.insert(0, SymphonyTableCell) - elif role==controlTypes.Role.TABLE: + if obj._IATableCell: + clsList.insert(0, SymphonyIATableCell) + else: + clsList.insert(0, SymphonyTableCell) + elif role==controlTypes.Role.TABLE and ( + hasattr(obj, "IAccessibleTable2Object") + or hasattr(obj, "IAccessibleTableObject") + ): clsList.insert(0, SymphonyTable) elif hasattr(obj, "IAccessibleTextObject"): clsList.insert(0, SymphonyText) if role==controlTypes.Role.PARAGRAPH: clsList.insert(0, SymphonyParagraph) - if isinstance(obj, JAB) and windowClassName == "SALFRAME": - if role in (controlTypes.Role.PANEL,controlTypes.Role.LABEL): - parent=obj.parent - if parent and parent.role==controlTypes.Role.TABLE: - clsList.insert(0,JAB_OOTableCell) - elif role==controlTypes.Role.TABLE: - clsList.insert(0,JAB_OOTable) def event_NVDAObject_init(self, obj): windowClass = obj.windowClassName - if isinstance(obj, JAB) and windowClass == "SALFRAME": - # OpenOffice.org has some strange role mappings due to its use of JAB. - if obj.role == controlTypes.Role.CANVAS: - obj.role = controlTypes.Role.DOCUMENT - if windowClass in ("SALTMPSUBFRAME", "SALFRAME") and obj.role in (controlTypes.Role.DOCUMENT,controlTypes.Role.TEXTFRAME) and obj.description: # This is a word processor document. obj.description = None