Repository: nvaccess/nvda

Issue:
Table navigation doesn't respect colspan
### Steps to reproduce:
1. Download [colspan.html.txt](https://github.com/nvaccess/nvda/files/1070568/colspan.html.txt) and remove the .txt extension
2. Open this in a browser (Firefox, Chrome, IE and Edge tested)
3. Go to the table. It has two rows, the lower left cell spanning two columns (b1 and b2)
4. Go to cell b1 (row 1 column 2), press control+alt+down arrow. You will land on the a2 and b2 cell
5. Press ctrl+alt+up arrow

### Expected behavior:
NVDA returns to row 1 column 2. Expectation based on Excel behavior

### Actual behavior:
NVDA lands on row 1 column 1, so it doesn't get back to the original position

### System configuration:
NVDA version: next-14106,059e7f02
NVDA Installed
Windows version: Windows 10 Version 1703 (OS Build 15063.296)


Discussion:
Totally agree that this is the ideal behaviour. The question is how to implement this and how to handle the edge cases.

Table navigation uses the cursor position as the point of origin when navigating. This is necessary because otherwise, if you navigated elsewhere in the table (or even to another table) by some other means (e.g. the cursor keys, quick navigation or search), the user expects to navigate from that new position, not the cell at which they last used table navigation. This creates a problem, though, because as far as the cursor is concerned, the position of the spanned cell is exactly the same regardless of how you get there.

To fix this, table navigation needs to keep some additional state, but only act on it if the user hasn't moved the cursor to another table/cell by some other means. Here's where the edge cases come in. Should we preserve this state if the user moves the cursor by some other means but still stays within the cell? Or should we only preserve the state if the user is only using table navigation commands? I tend to think the latter is acceptable. Examples:

- If the user presses control+alt+down arrow, then right arrow, then control+alt+up arrow, should we preserve the origin column?
- What if the user is in b1, then skips over c1 to a2-b2 by searching? Should control+alt+upArrow move to b1 in that case? 

Handling these cases is going to be hard to implement and probably overkill.

I can think of two ways to maintain the state:

1. Cache the last table navigation origin and the direction of movement. If the cursor moves at all other than table navigation, throw it away. When table navigation is used again, if the direction is the reverse of the cached direction, just return the user to the cached origin.
2. Cache the last table navigation origin table, row and column. When table navigation is used again, if the cached table, row and column fall within the cell at the cursor (accounting for span), use those instead of the starting coordinates of the cell at the cursor. I think I prefer this approach.
I agree that your second approach is probably the most cleanest one. I assume you'd cache those things on the tree interceptor?

May be we should also take the reporting of things into account here. E.g. If we are at the a2 and b2 cell and coordinates reporting is on, should NVDA report that this cell spans both column 1 and column 2? I think it should whenever possible. I know this is a completely different issue though
Still reproducible in NVDA 2019.1.1 and different browsers (tested in FF 66.0.3 and Crhome 73).
Just to indicate that this issue is still reproducible with 2020.3 and that Jaws does not exhibit this issue.


Resolving patch:
diff --git a/source/documentBase.py b/source/documentBase.py
--- a/source/documentBase.py
+++ b/source/documentBase.py
@@ -1,14 +1,44 @@
-#A part of NonVisual Desktop Access (NVDA)
-#Copyright (C) 2017 NV Access Limited
-#This file is covered by the GNU General Public License.
-#See the file COPYING for more details.
+# A part of NonVisual Desktop Access (NVDA)
+# Copyright (C) 2017-2022 NV Access Limited
+# This file is covered by the GNU General Public License.
+# See the file COPYING for more details.
 
+from dataclasses import dataclass
+from enum import Enum
+from typing import Any, Optional, Tuple, Union
 from baseObject import AutoPropertyObject, ScriptableObject
 import config
 import textInfos
 import controlTypes
 
 
+_TableID = Union[int, Tuple, Any]
+"""
+A variety of types can be used for a tableID.
+Known to be a tuple for UIA, an integer for virtual buffers.
+"""
+
+
+class _Axis(str, Enum):
+	ROW = "row"
+	COLUMN = "column"
+
+
+class _Movement(str, Enum):
+	NEXT = "next"
+	PREVIOUS = "previous"
+
+
+@dataclass
+class _TableSelection:
+	selection: textInfos.TextInfo
+	axis: _Axis
+	row: int
+	rowSpan: int
+	col: int
+	colSpan: int
+
+
 class TextContainerObject(AutoPropertyObject):
 	"""
 	An object that contains text which can be accessed via a call to a makeTextInfo method.
@@ -21,6 +51,8 @@ def _get_TextInfo(self):
 	def makeTextInfo(self, position) -> textInfos.TextInfo:
 		return self.TextInfo(self,position)
 
+	selection: textInfos.TextInfo
+
 	def _get_selection(self):
 		return self.makeTextInfo(textInfos.POSITION_SELECTION)
 
@@ -33,6 +65,8 @@ class DocumentWithTableNavigation(TextContainerObject,ScriptableObject):
 	The document could be an NVDAObject, or a BrowseModeDocument treeIntercepter for example.
 	"""
 
+	_lastTableSelection: Optional[_TableSelection] = None
+
 	def _getTableCellCoords(self, info):
 		"""
 		Fetches information about the deepest table cell at the given position.
@@ -87,28 +121,31 @@ def _getTableCellAt(self,tableID,startPos,row,column):
 		raise NotImplementedError
 
 	_missingTableCellSearchLimit=3 #: The number of missing  cells L{_getNearestTableCell} is allowed to skip over to locate the next available cell
-	def _getNearestTableCell(self, tableID, startPos, origRow, origCol, origRowSpan, origColSpan, movement, axis):
+
+	def _getNearestTableCell(
+			self,
+			tableID: _TableID,
+			startPos: textInfos.TextInfo,
+			origRow: int,
+			origCol: int,
+			origRowSpan: int,
+			origColSpan: int,
+			movement: _Movement,
+			axis: _Axis,
+	) -> textInfos.TextInfo:
 		"""
 		Locates the nearest table cell relative to another table cell in a given direction, given its coordinates.
 		For example, this is used to move to the cell in the next column, previous row, etc.
 		This method will skip over missing table cells (where L{_getTableCellAt} raises LookupError), up to the number of times set by _missingTableCellSearchLimit set on this instance.
 		@param tableID: the ID of the table
 		@param startPos: the position in the document to start searching from.
-		@type startPos: L{textInfos.TextInfo}
 		@param origRow: the row number of the starting cell
-		@type origRow: int
 		@param origCol: the column number  of the starting cell
-		@type origCol: int
 		@param origRowSpan: the row span of the row of the starting cell
-		@type origRowSpan: int
 		@param origColSpan: the column span of the column of the starting cell
-		@type origColSpan: int
 		@param movement: the direction ("next" or "previous")
-		@type movement: string
 		@param axis: the axis of movement ("row" or "column")
-		@type axis: string
 		@returns: the position of the nearest table cell
-		@rtype: L{textInfos.TextInfo}
 		"""
 		if not axis:
 			raise ValueError("Axis must be row or column")
@@ -138,7 +175,11 @@ def _getNearestTableCell(self, tableID, startPos, origRow, origCol, origRowSpan,
 				destCol+=1 if movement=="next" else -1
 		raise LookupError
 
-	def _tableMovementScriptHelper(self, movement="next", axis=None):
+	def _tableMovementScriptHelper(
+			self,
+			movement: _Movement = _Movement.NEXT,
+			axis: Optional[_Axis] = None
+	):
 		# documentBase is a core module and should not depend on these UI modules and so they are imported
 		# at run-time. (#12404)
 		from scriptHandler import isScriptWaiting
@@ -157,6 +198,22 @@ def _tableMovementScriptHelper(self, movement="next", axis=None):
 			ui.message(_("Not in a table cell"))
 			return
 
+		# The following lines check whether user has been issuing table navigation commands repeatedly.
+		# In this case, instead of using current column/row index, we used cached value
+		# to allow users being able to skip merged cells without affecting the initial column/row index.
+		# For more info see issue #11919 and #7278.
+		if (
+			self._lastTableSelection
+			and self.selection == self._lastTableSelection.selection
+			and self._lastTableSelection.axis == axis
+		):
+			if axis == _Axis.ROW:
+				origCol = self._lastTableSelection.col
+				origColSpan = self._lastTableSelection.colSpan
+			else:
+				origRow = self._lastTableSelection.row
+				origRowSpan = self._lastTableSelection.rowSpan
+
 		try:
 			info = self._getNearestTableCell(tableID, self.selection, origRow, origCol, origRowSpan, origColSpan, movement, axis)
 		except LookupError:
@@ -169,6 +226,14 @@ def _tableMovementScriptHelper(self, movement="next", axis=None):
 		speakTextInfo(info, formatConfig=formatConfig, reason=controlTypes.OutputReason.CARET)
 		info.collapse()
 		self.selection = info
+		self._lastTableSelection = _TableSelection(
+			selection=self.selection,
+			axis=axis,
+			row=origRow,
+			rowSpan=origRow,
+			col=origCol,
+			colSpan=origColSpan,
+		)
 
 	def script_nextRow(self, gesture):
 		self._tableMovementScriptHelper(axis="row", movement="next")


Test patch:
diff --git a/tests/system/robot/chromeTests.py b/tests/system/robot/chromeTests.py
--- a/tests/system/robot/chromeTests.py
+++ b/tests/system/robot/chromeTests.py
@@ -1654,6 +1654,52 @@ def test_focusTargetReporting():
 	)
 
 
+def test_tableNavigationWithMergedColumns():
+	"""When navigating through a merged cell,
+	NVDA should preserve the column/row position from the previous cell.
+	Refer to #7278, #11919.
+	"""
+	_chrome.prepareChrome("""
+	<p>This is text</p>
+	<table
+	 border=0 cellpadding=0 cellspacing=0 width=192
+	 style='border-collapse: collapse;table-layout:fixed;width:144pt'
+	>
+	<col width=64 span=3 style='width:48pt'>
+	<tr height=20 style='height:15.0pt'>
+		<td height=20 width=64 style='height:15.0pt;width:48pt'>a1</td>
+		<td width=64 style='width:48pt'>b1</td>
+		<td width=64 style='width:48pt'>c1</td>
+	</tr>
+	<tr height=20 style='height:15.0pt'>
+		<td colspan=2 height=20 style='height:15.0pt'>a2 and b2</td>
+		<td>c2</td>
+	</tr>
+	</table>
+	""")
+	# Navigate to end of text, this aligns the cursor with column 2
+	actualSpeech = _chrome.getSpeechAfterKey("downArrow")
+	_asserts.strings_match(actualSpeech, "This is text")
+
+	# Navigate to the table
+	actualSpeech = _chrome.getSpeechAfterKey("downArrow")
+	_asserts.strings_match(actualSpeech, "table  with 2 rows and 3 columns  row 1  column 1  a 1")
+
+	# Navigate to a cell in row 1, column 2
+	actualSpeech = _chrome.getSpeechAfterKey("downArrow")
+	_asserts.strings_match(actualSpeech, "column 2  b 1")
+
+	# Navigate to a merged cell below
+	actualSpeech = _chrome.getSpeechAfterKey("control+alt+downArrow")
+	_asserts.strings_match(actualSpeech, "row 2  column 1  through 2  a 2 and b 2")
+
+	# Return to row 1, column 2
+	# In #7278, #11919, NVDA would return to row 1, column 1
+	# This caused column position to be lost when navigating through merged cells
+	actualSpeech = _chrome.getSpeechAfterKey("control+alt+upArrow")
+	_asserts.strings_match(actualSpeech, "row 1  column 2  b 1")
+
+
 def test_focus_mode_on_focusable_read_only_lists():
 	"""
 	If a list is read-only, but is focusable, and a list element receives focus, switch to focus mode.
diff --git a/tests/system/robot/chromeTests.robot b/tests/system/robot/chromeTests.robot
--- a/tests/system/robot/chromeTests.robot
+++ b/tests/system/robot/chromeTests.robot
@@ -120,6 +120,9 @@ Quick Nav reports target first
 Focus reports target first
 	[Documentation]	Focus target should always be reported before ancestors. Ancestors should be reported from inner to outer.
 	test_focusTargetReporting
+Table navigation with merged columns
+	[Documentation]	When navigating through a merged cell, preserve the column/row position from the previous cell.
+	test_tableNavigationWithMergedColumns
 focus mode is turned on on focused read-only list item
 	[Documentation]	Focused list items with a focusable list container should cause focus mode to be turned on automatically.
 	test_focus_mode_on_focusable_read_only_lists