Repository: nvaccess/nvda

Issue:
LibreOffice Calc: report selected cell coordinates
This is a feature request that NVDA report selected cell  coordinates.
Steps to reproduce:
1. Open Calc.
2. Press shift+downArrow.
NVDA report: nothing
expected: a1 through a2 selected
3. press ctrl+home to move to top.
4. Press shift+space to select entire row
NVDA report: space
expected: a1 through amj1 selected



Discussion:
hi.
i confirm this issue.
does nvda support reporting of selection in microsoft excel?
if you found solution for this, please inform me.
thanks and God bless all.
@zahra21 yes in Excel this is supported. In Libre Calc this is still an issue. Right?
It seems that LibreOffice developer Tamás Zolnai committed a patch related to this issue (adding selection-changed event).

[bug 93825 – Missing accessibility events when selecting/deselecting cells in Calc](https://bugs.documentfoundation.org/show_bug.cgi?id=93825)

cc @leonardder: Could you please check, if this is helpful?

Thanks.


Is this in a release already?

Yes, developer Tamás Zolnai pointed out that it is available in 5.2.4. @leonardder
I tested with the most recent version, but without luck I'm afraid.



In this case, LibreOffice developer said "selection change event is added."
But, this patch does not fix screen reader NVDA "report selected cell coordinates".
What should we do (Let LibreOffice developer know the gap)?
Or how to use this **"selection change event** to implement feature "report selected cell coordinates?

















I actually have the feeling that, though there is now a selection change 
event, there is no focus event fired when selecting another cell. We 
might be able to fix this on our end, but I'll have to investigate this. 
I'd rather have LibreOffice doing this correctly, but for me personally, 
it is not that much of a high priority honestly.

Is anyone working on this? It's still an issue in libreoffice 7.0.2 and nvda 2020.3
A fix would be greatly appreciated.

Apart from the patch refered to in https://github.com/nvaccess/nvda/issues/6897#issuecomment-519355077, it looks like LibreOffice people aren't that accessibility minded unfortunately.
> it looks like LibreOffice people aren't that accessibility minded unfortunately.

Do we have examples of this? Were the correct bugs ever filed against libreoffice to make them aware of the issue? I'm not a developer so can't file the bugs myself as i wouldn't know what to point at or what the behind the seens tech speak would be.

A number of accessibility bugs filed, many [issues here with the `app/libreoffice` label](https://github.com/nvaccess/nvda/labels/app%2Flibreoffice) have links to bugs on the issue tracker for libreoffice. 

I have tried to reach out to a few developers who work on libreoffice, for the record NV Access is willing to work with libreoffice developers to help solve these issues. They can contact us via info@nvaccess.org to start a conversation.
There is an issue on LibreOfice's bug tracker for this, which goes back to 2016: https://bugs.documentfoundation.org/show_bug.cgi?id=100086 (with references to an older issue again).

Resolving patch:
diff --git a/source/NVDAObjects/IAccessible/__init__.py b/source/NVDAObjects/IAccessible/__init__.py
--- a/source/NVDAObjects/IAccessible/__init__.py
+++ b/source/NVDAObjects/IAccessible/__init__.py
@@ -1297,17 +1297,20 @@ def _get_selectionContainer(self):
 			return self.table
 		return super(IAccessible,self).selectionContainer
 
-	def _getSelectedItemsCount_accSelection(self,maxCount):
+	def _getSelectedItemsCount_accSelection(self, maxCount: int) -> int:
 		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)
 		if not enumObj:
 			raise NotImplementedError
+		# Some implementations of accSelection (e.g. in Symphony based products) don't return
+		# a fresh IEnumVARIANT. Reset it to ensure we enumerate from the start.
+		enumObj.Reset()
 		# Call the rawmethod for IEnumVARIANT::Next as COMTypes' overloaded version does not allow limiting the amount of items returned
 		numItemsFetched=ctypes.c_ulong()
 		itemsBuf=(VARIANT*(maxCount+1))()
@@ -1318,24 +1321,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
--- a/source/appModules/soffice.py
+++ b/source/appModules/soffice.py
@@ -1,82 +1,23 @@
-#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
+from IAccessibleHandler import IA2, splitIA2Attribs
 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 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):
 
@@ -94,7 +35,7 @@ def _getFormatFieldAndOffsets(self,offset,formatConfig,calculateOffsets=True):
 			except COMError:
 				pass
 		if attribsString:
-			formatField.update(IAccessibleHandler.splitIA2Attribs(attribsString))
+			formatField.update(splitIA2Attribs(attribsString))
 
 		try:
 			escapement = int(formatField["CharEscapement"])
@@ -151,7 +92,9 @@ 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(
+				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,93 @@ 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 SELECTABLE to ensure 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(IA2.IAccessible2)
+			firstAddress = firstAccessible.accName(0)
+			firstValue = firstAccessible.accValue(0) or ''
+			lastAccessible = selected[count - 1].QueryInterface(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(IA2.IAccessible2)
+			lastAddress = lastAccessible.accName(0)
+			# Translators: LibreOffice, report range of cell coordinates
+			return _("{firstAddress} through {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 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."""
@@ -245,29 +257,23 @@ def chooseNVDAObjectOverlayClasses(self, obj, clsList):
 		role=obj.role
 		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 role == controlTypes.Role.TABLECELL:
+				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:
+			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


Test patch: