Repository: napari/napari (Python)

Issue:
Consider a `remove` method for Shapes and Points
## 🧰 Task

See discussion here, for Shapes, but Points work the same way.
https://napari.zulipchat.com/#narrow/channel/212875-general/topic/Embarrassing.3A.20how.20do.20you.20remove.20a.20shape.20or.20point.20via.20API.3F/with/435063537

To remove a Shape (or Point) programmatically you must first set it to be selected, e.g.:
`viewer.layers[0].selected_data = {0}`
and then remove selected:
`viewer.layers[0].remove_selected()`
On one hand, this is very analogous to the GUI way. On the other hand it seems needless to select and then remove, if we're doing it programmatically?

Juan noted that if a `remove` method is implemented, then `remove_selected()` can be re-implemented to just be `remove(self.selected_data)`



Discussion: Do we need to re-implement `remove_selected()` . Can't we do following ?

  ```python
    def remove(self, indices=None):
        """
        Remove shape(s) from the layer.

        Parameters
        ----------
        indices : int | list[int] | None
            - If `None`, removes the last visible shape.
            - If an `int`, removes the shape at that index.
            - If a list of ints, removes all shapes at the specified indices.
        """
        if indices is None:
            visible_indices = list(set(np.nonzero(self._data_view._displayed)[0]))
            if not visible_indices:
                show_warning("No shapes to remove.")
                return
            indices = visible_indices[-1]

        self.selected_data = set(indices) if isinstance(indices, Iterable)  else {indices}
        self.remove_selected()

    def pop(self):
        """Remove the last shape from the layer."""
        self.remove()
```
I think from a logical point of view, it's more natural to have `remove` be the "core" method and `remove_selected` use `remove` than the other way around, but of course you're right, it's just an implementation detail in the long run.
Perhaps @jni can further elaborate on his thinking.



Fix (patch):
diff --git a/src/napari/layers/points/points.py b/src/napari/layers/points/points.py
index 863c7689b62..35c9885e844 100644
--- a/src/napari/layers/points/points.py
+++ b/src/napari/layers/points/points.py
@@ -1996,52 +1996,78 @@ def add(self, coords):
         self.selected_data = set(np.arange(cur_points, len(self.data)))
         self.events.features()
 
-    def remove_selected(self) -> None:
-        """Removes selected points if any."""
-        index = list(self.selected_data)
-        index.sort()
-        if len(index):
+    def remove(self, indices: list[int]) -> None:
+        """Removes any points at the given indices.
+
+        Parameters
+        ----------
+        indices : List[int]
+            List of indices of points to remove from the layer.
+        """
+        indices = sorted(indices)
+        if len(indices):
             self.events.data(
                 value=self.data,
                 action=ActionType.REMOVING,
                 data_indices=tuple(
-                    self.selected_data,
+                    indices,
                 ),
                 vertex_indices=((),),
             )
-            self._shown = np.delete(self._shown, index, axis=0)
-            self._size = np.delete(self._size, index, axis=0)
-            self._symbol = np.delete(self._symbol, index, axis=0)
-            self._border_width = np.delete(self._border_width, index, axis=0)
+            self._shown = np.delete(self._shown, indices, axis=0)
+            self._size = np.delete(self._size, indices, axis=0)
+            self._symbol = np.delete(self._symbol, indices, axis=0)
+            self._border_width = np.delete(self._border_width, indices, axis=0)
             with self._border.events.blocker_all():
-                self._border._remove(indices_to_remove=index)
+                self._border._remove(indices_to_remove=indices)
             with self._face.events.blocker_all():
-                self._face._remove(indices_to_remove=index)
-            self._feature_table.remove(index)
-            self.text.remove(index)
-            if self._value in self.selected_data:
+                self._face._remove(indices_to_remove=indices)
+            self._feature_table.remove(indices)
+            self.text.remove(indices)
+            if self._value in indices:
                 self._value = None
             else:
                 if self._value is not None:
                     # update the index of self._value to account for the
                     # data being removed
-                    indices_removed = np.array(index) < self._value
+                    indices_removed = np.array(indices) < self._value
                     offset = np.sum(indices_removed)
                     self._value -= offset
                     self._value_stored -= offset
 
-            self._set_data(np.delete(self.data, index, axis=0))
+            self._set_data(np.delete(self.data, indices, axis=0))
+
+            if len(self.data) == 0 and self.selected_data:
+                self.selected_data.clear()
+            elif self.selected_data:
+                selected_not_removed = self.selected_data - set(indices)
+                if selected_not_removed:
+                    indices_array = np.array(indices)
+                    remaining_selected = np.fromiter(
+                        selected_not_removed,
+                        dtype=np.intp,
+                        count=len(selected_not_removed),
+                    )
+                    shifts = np.searchsorted(indices_array, remaining_selected)
+                    new_selected_indices = remaining_selected - shifts
+                    self.selected_data = set(new_selected_indices)
+                else:
+                    self.selected_data.clear()
+
             self.events.data(
                 value=self.data,
                 action=ActionType.REMOVED,
                 data_indices=tuple(
-                    self.selected_data,
+                    indices,
                 ),
                 vertex_indices=((),),
             )
-            self.selected_data = set()
             self.events.features()
 
+    def remove_selected(self) -> None:
+        """Remove all selected points."""
+        self.remove(list(self.selected_data))
+
     def _move(
         self,
         selection_indices: Sequence[int],
diff --git a/src/napari/layers/shapes/shapes.py b/src/napari/layers/shapes/shapes.py
index 59ce419b859..292ed33f38b 100644
--- a/src/napari/layers/shapes/shapes.py
+++ b/src/napari/layers/shapes/shapes.py
@@ -2762,43 +2762,69 @@ def _update_thumbnail(self, event=None):
 
             self.thumbnail = colormapped
 
-    def remove_selected(self) -> None:
-        """Remove any selected shapes."""
-        index = list(self.selected_data)
-        to_remove = sorted(index, reverse=True)
+    def remove(self, indices: list[int]) -> None:
+        """Remove any shapes at the given indices.
 
-        if len(index) > 0:
+        Parameters
+        ----------
+        indices : List[int]
+            List of indices of shapes to remove from the layer.
+        """
+        to_remove = sorted(indices, reverse=True)
+
+        if len(indices) > 0:
             self.events.data(
                 value=self.data,
                 action=ActionType.REMOVING,
                 data_indices=tuple(
-                    index,
+                    indices,
                 ),
                 vertex_indices=((),),
             )
+            # FIXME: this is really slow
             for ind in to_remove:
                 self._data_view.remove(ind)
 
-            self._feature_table.remove(index)
-            self.text.remove(index)
+            if len(self.data) == 0 and self.selected_data:
+                self.selected_data.clear()
+            elif self.selected_data:
+                selected_not_removed = self.selected_data - set(indices)
+                if selected_not_removed:
+                    indices_array = np.array(to_remove[::-1])
+                    remaining_selected = np.fromiter(
+                        selected_not_removed,
+                        dtype=np.intp,
+                        count=len(selected_not_removed),
+                    )
+                    shifts = np.searchsorted(indices_array, remaining_selected)
+                    new_selected_indices = remaining_selected - shifts
+                    self.selected_data = set(new_selected_indices)
+                else:
+                    self.selected_data.clear()
+
+            self._feature_table.remove(indices)
+            self.text.remove(indices)
             self._data_view._edge_color = np.delete(
-                self._data_view._edge_color, index, axis=0
+                self._data_view._edge_color, indices, axis=0
             )
             self._data_view._face_color = np.delete(
-                self._data_view._face_color, index, axis=0
+                self._data_view._face_color, indices, axis=0
             )
             self.events.data(
                 value=self.data,
                 action=ActionType.REMOVED,
                 data_indices=tuple(
-                    index,
+                    indices,
                 ),
                 vertex_indices=((),),
             )
             self.events.features()
-        self.selected_data.clear()
         self._finish_drawing()
 
+    def remove_selected(self) -> None:
+        """Remove any selected shapes."""
+        self.remove(list(self.selected_data))
+
     def _rotate_box(self, angle, center=(0, 0)):
         """Perform a rotation on the selected box.