Bug 456602 - [Cocoa] ArrayIndexOutOfBoundsException in Table._getItem

The problem is that item for cached `selectedRowIndex` can be deleted
between mouse down (where variable is set) and mouse up (where it's used).

I tried to solve the original problem (where macOS sends SWT.Selection
after `[NSEvent doubleClickInterval]` from mouse down) but didn't find a
way to do that. It seems that there are no settings that could disable
double click detection in `-[NSTableView mouseDown:]`. So the workaround
has to stay, it seems.

I also noticed that macOS cancels pending selection event in
`-[NSTableView noteNumberOfRowsChanged]`. SWT already calls that
whenever number of items changes (item is deleted or added). I was
thinking to reset `selectedRowIndex` in it, but then I wasn't sure
if it's the right thing to do if for example an item is added at the
end - surely this doesn't prevent from proceeding with handling click
on the old item? I the end, I decided to not overcomplicate things and
just fix the crash without affecting anything else.

Change-Id: I42cb381d408d105e4df6372a2d69967b9b274a9a
Signed-off-by: Alexandr Miloslavskiy <alexandr.miloslavskiy@syntevo.com>
Reviewed-on: https://git.eclipse.org/r/c/platform/eclipse.platform.swt/+/192115
Tested-by: Platform Bot <platform-bot@eclipse.org>
Tested-by: Lakshmi P Shanmugam <lshanmug@in.ibm.com>
Reviewed-by: Lakshmi P Shanmugam <lshanmug@in.ibm.com>
diff --git a/bundles/org.eclipse.swt/Eclipse SWT/cocoa/org/eclipse/swt/widgets/Table.java b/bundles/org.eclipse.swt/Eclipse SWT/cocoa/org/eclipse/swt/widgets/Table.java
index 965fb9d..c49f135 100644
--- a/bundles/org.eclipse.swt/Eclipse SWT/cocoa/org/eclipse/swt/widgets/Table.java
+++ b/bundles/org.eclipse.swt/Eclipse SWT/cocoa/org/eclipse/swt/widgets/Table.java
@@ -3636,39 +3636,66 @@
 	return sendMouseEvent(NSApplication.sharedApplication().currentEvent(), SWT.DragDetect, true);
 }
 
+void handleClickSelected() {
+	/*
+	 * When there are multiple selected items and one of them is clicked
+	 * without modifiers, macOS supports two cases:
+	 * 1) For single click, all other items are deselected
+	 * 2) For double-click, selection stays as is, allowing to create
+	 *    double-click event with multiple items.
+	 * In order to distinguish between the two, macOS delays (1) by
+	 * [NSEvent doubleClickInterval] in order to see if it's case (2).
+	 * This causes SWT.Selection to occur after SWT.MouseUp.
+	 *
+	 * Bug 289483: For consistent cross-platform behavior, we want
+	 * SWT.Selection to occur before SWT.MouseUp. The workaround is to
+	 * implement (1) in SWT code and ignore the delayed selection event.
+	 */
+
+	int clickedRow = selectedRowIndex;
+	selectedRowIndex = -1;
+
+	if (clickedRow == -1) return;
+	if (dragDetected) return;
+
+	// Deselect all items except the clicked one
+	NSTableView widget = (NSTableView)view;
+	NSIndexSet selectedRows = widget.selectedRowIndexes ();
+	int count = (int)selectedRows.count();
+	long [] indexBuffer = new long [count];
+	selectedRows.getIndexes(indexBuffer, count, 0);
+	for (int i = 0; i < count; i++) {
+		if (indexBuffer[i] == clickedRow) continue;
+		ignoreSelect = true;
+		widget.deselectRow (indexBuffer[i]);
+		ignoreSelect = false;
+	}
+
+	// Bug 456602: It's possible that item is removed between mouse
+	// down (where 'selectedRowIndex' was cached) and mouse up (current
+	// code). In such case, all other items are still deselected, because
+	// 1) without workaround, selection should have happened in mouse down,
+	//    where item still existed
+	// 2) clicking empty space deselects all items on macOS
+	// If item is deleted, then pending selection is canceled by macOS, so
+	// there's no need to ignore the next selection event.
+	if (clickedRow >= itemCount) return;
+
+	// Emulate SWT.Selection
+	Event event = new Event ();
+	event.item = _getItem(clickedRow);
+	sendSelectionEvent (SWT.Selection, event, false);
+
+	// Ignore real SWT.Selection that will arrive later
+	ignoreSelect = true;
+}
+
 @Override
 boolean sendMouseEvent(NSEvent nsEvent, int type, boolean send) {
 	if (type == SWT.DragDetect) {
 		dragDetected = true;
 	} else if (type == SWT.MouseUp) {
-		/*
-		 * This code path handles the case of an unmodified click on an already-selected row.
-		 * To keep the order of events correct, deselect the other selected items and send the
-		 * selection event before MouseUp is sent. Ignore the next selection event.
-		 */
-		if (selectedRowIndex != -1) {
-			if (dragDetected) {
-				selectedRowIndex = -1;
-			} else {
-				NSTableView widget = (NSTableView)view;
-				NSIndexSet selectedRows = widget.selectedRowIndexes ();
-				int count = (int)selectedRows.count();
-				long [] indexBuffer = new long [count];
-				selectedRows.getIndexes(indexBuffer, count, 0);
-				for (int i = 0; i < count; i++) {
-					if (indexBuffer[i] == selectedRowIndex) continue;
-					ignoreSelect = true;
-					widget.deselectRow (indexBuffer[i]);
-					ignoreSelect = false;
-				}
-
-				Event event = new Event ();
-				event.item = _getItem ((int)selectedRowIndex);
-				selectedRowIndex = -1;
-				sendSelectionEvent (SWT.Selection, event, false);
-				ignoreSelect = true;
-			}
-		}
+		handleClickSelected();
 		dragDetected = false;
 	}
 
diff --git a/bundles/org.eclipse.swt/Eclipse SWT/cocoa/org/eclipse/swt/widgets/Tree.java b/bundles/org.eclipse.swt/Eclipse SWT/cocoa/org/eclipse/swt/widgets/Tree.java
index 0485d22..1c2de57 100644
--- a/bundles/org.eclipse.swt/Eclipse SWT/cocoa/org/eclipse/swt/widgets/Tree.java
+++ b/bundles/org.eclipse.swt/Eclipse SWT/cocoa/org/eclipse/swt/widgets/Tree.java
@@ -2805,11 +2805,7 @@
 	if (type == SWT.DragDetect) {
 		dragDetected = true;
 	} else if (type == SWT.MouseUp) {
-		/*
-		 * This code path handles the case of an unmodified click on an already-selected row.
-		 * To keep the order of events correct, deselect the other selected items and send the
-		 * selection event before MouseUp is sent. Ignore the next selection event.
-		 */
+		// See code comment in Table.handleClickSelected()
 		if (selectedRowIndex != -1) {
 			if (dragDetected) {
 				selectedRowIndex = -1;
@@ -2828,6 +2824,8 @@
 
 				Event event = new Event ();
 				id itemID = widget.itemAtRow (selectedRowIndex);
+				// (itemID = null) means that item was removed after
+				// 'selectedRowIndex' was cached
 				if (itemID != null) {
 					Widget item = display.getWidget (itemID.id);
 					if (item != null && item instanceof TreeItem) {
diff --git a/tests/org.eclipse.swt.tests/ManualTests/org/eclipse/swt/tests/manual/Bug456602_macOS_AIOOBE_Table_selectedRowIndex.java b/tests/org.eclipse.swt.tests/ManualTests/org/eclipse/swt/tests/manual/Bug456602_macOS_AIOOBE_Table_selectedRowIndex.java
new file mode 100644
index 0000000..c161b26
--- /dev/null
+++ b/tests/org.eclipse.swt.tests/ManualTests/org/eclipse/swt/tests/manual/Bug456602_macOS_AIOOBE_Table_selectedRowIndex.java
@@ -0,0 +1,90 @@
+/*******************************************************************************
+ * Copyright (c) 2022 Syntevo and others.
+ *
+ * This program and the accompanying materials
+ * are made available under the terms of the Eclipse Public License 2.0
+ * which accompanies this distribution, and is available at
+ * https://www.eclipse.org/legal/epl-2.0/
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ *
+ * Contributors:
+ *     Syntevo - initial API and implementation
+ *******************************************************************************/
+
+package org.eclipse.swt.tests.manual;
+
+import org.eclipse.swt.SWT;
+import org.eclipse.swt.dnd.*;
+import org.eclipse.swt.layout.GridLayout;
+import org.eclipse.swt.widgets.*;
+
+import java.util.Arrays;
+
+public final class Bug456602_macOS_AIOOBE_Table_selectedRowIndex {
+	public static void main(String[] args) {
+		final Display display = new Display();
+
+		final Shell shell = new Shell(display);
+		shell.setLayout(new GridLayout(1, true));
+
+		Label hint = new Label(shell, 0);
+		hint.setText(
+			"Bug 456602\n" +
+			"----------\n" +
+			"1) Use macOS\n" +
+			"2) Click 'Delete items' button\n" +
+			"3) Press left mouse button on last table item and HOLD the button\n" +
+			"4) When item is deleted, release the button\n" +
+			"5) Bug 456602: SWT will have an internal AIOOBE\n" +
+			"6) Bug 456602: More exceptions are reported if you set focus to\n" +
+			"   Text beforehand and move the mouse after\n" +
+			"7) In real world, user would clicks faster, but crashes will\n" +
+			"   still happen sometimes\n" +
+			"\n" +
+			"Bug 355200 and Bug 289483\n" +
+			"-------------------------\n" +
+			"Just to ensure that old workarounds are still working fine:\n" +
+			"1) Click any Table item when multiple items are selected\n" +
+			"2) SWT.Selection shall report clicked item\n" +
+			"3) The order shall be: SWT.MouseDown, SWT.Selection, SWT.MouseUp"
+		);
+
+		Table table = new Table(shell, SWT.BORDER | SWT.MULTI);
+		table.addListener(SWT.MouseDown, e -> {
+			System.out.println(System.currentTimeMillis() + " SWT.MouseDown: " + Arrays.toString(table.getSelectionIndices()));
+		});
+		table.addListener(SWT.MouseUp, e -> {
+			System.out.println(System.currentTimeMillis() + " SWT.MouseUp:   " + Arrays.toString(table.getSelectionIndices()));
+		});
+		table.addListener(SWT.Selection, e -> {
+			System.out.println(System.currentTimeMillis() + " SWT.Selection: " + Arrays.toString(table.getSelectionIndices()));
+		});
+
+		for (int iItem = 0; iItem < 10; iItem++) {
+			new TableItem(table, 0).setText("Item #" + iItem);
+		}
+		table.selectAll();
+
+		Button button = new Button(shell, SWT.PUSH);
+		button.setText("Delete items after 2000ms");
+		button.addListener(SWT.Selection, e -> {
+			display.timerExec(2000, () -> {
+				table.setItemCount(table.getItemCount() / 2);
+			});
+		});
+
+		new Text(shell, 0).setText("I'm a text field");
+
+		shell.pack();
+		shell.open();
+
+		while (!shell.isDisposed()) {
+			if (!display.readAndDispatch()) {
+				display.sleep();
+			}
+		}
+
+		display.dispose();
+	}
+}