Repository: napari/napari

Issue:
Removing a point with text derived from a property errors
## 🐛 Bug

When a points layer has text derived from properties (i.e. when it's in mode `PROPERTY` or `FORMATTED`), trying to remove that point errors (see the traceback at the end).

The reason for this is fairly simple: the call to [`TextManager.remove` in `Points.remove_selected`](https://github.com/napari/napari/blob/9af23fb0ddd8b9804edc84d534095a00b697c41c/napari/layers/points/points.py#L1424) does not block the event emitted by modifying `TextManager.values`, so the vispy points layer attempts to refresh before the data itself has been updated and there's an OOB error on the text values.

What's weird is that I test this behavior a lot, so I find it surprising that I just noticed this. But it's likely that I've only been testing this behavior on a branch where it's fixed by my changes.

## To Reproduce

Steps to reproduce the behavior:

1. Run the example code below.
2. Select one of the properties (e.g. the one on the right).
3. Click the delete button.

```python
import numpy as np
import napari

points = np.array([[100, 100], [200, 300], [333, 111]])
properties = {
    'confidence': np.array([1, 0.5, 0]),
    'good_point': np.array([True, False, False]),
}
text = {
    'text': 'Confidence is {confidence:.2f}',
    'size': 20,
    'color': 'green',
    'translation': np.array([-30, 0]),
}
viewer = napari.view_points(
    data=points,
    properties=properties,
    text=text,
    size=20,
)

napari.run()
```

## Expected behavior

The point should be deleted without an error.

## Environment

napari: 0.4.12.dev61+g1287e618.d20211015
Platform: macOS-10.16-x86_64-i386-64bit
System: MacOS 11.6
Python: 3.9.6 (default, Jul 30 2021, 09:31:09) [Clang 10.0.0 ]
Qt: 5.15.2
PyQt5: 5.15.4
NumPy: 1.21.1
SciPy: 1.7.1
Dask: 2021.07.2
VisPy: 0.7.3

OpenGL:
- GL version: 2.1 INTEL-16.5.2
- MAX_TEXTURE_SIZE: 16384

Screens:
- screen 1: resolution 1920x1080, scale 1.0

Plugins:
- animation: 0.0.2
- console: 0.0.4
- scikit-image: 0.4.12.dev61+g1287e618.d20211015
- svg: 0.1.5

## Additional context

Here's the traceback:

```
IndexError                                Traceback (most recent call last)
~/projects/napari/napari/utils/action_manager.py in <lambda>()
     51     def callable(self, context):
     52         if not hasattr(self, '_command_with_context'):
---> 53             self._command_with_context = lambda: call_with_context(
        global self._command_with_context = undefined
        global call_with_context = <function call_with_context at 0x7ff544cc39d0>
        global self.command = undefined
        global context = undefined
     54                 self.command, context
     55             )

~/projects/napari/napari/utils/action_manager.py in call_with_context(function=<function delete_selected_points>, context=<napari.utils.action_manager.Context object>)
     40     ]
     41     ctx = {k: v for k, v in context.items() if k in context_keys}
---> 42     return function(**ctx)
        function = <function delete_selected_points at 0x7ff5474b0550>
        ctx = {'layer': <Points layer 'points' at 0x7ff54cd00370>}
     43 
     44 

~/projects/napari/napari/layers/points/_points_key_bindings.py in delete_selected_points(layer=<Points layer 'points'>)
     66 def delete_selected_points(layer):
     67     """Delete all selected points."""
---> 68     layer.remove_selected()
        layer.remove_selected = <bound method Points.remove_selected of <Points layer 'points' at 0x7ff54cd00370>>

~/projects/napari/napari/layers/points/points.py in remove_selected(self=<Points layer 'points'>)
   1422                     self.properties[k], index, axis=0
   1423                 )
-> 1424             self.text.remove(index)
        self.text.remove = <bound method TextManager.remove of TextManager(values=<class 'numpy.ndarray'> (2,) <U18, visible=True, size=20, color=<class 'numpy.ndarray'> (4,) float64, blending=<Blending.TRANSLUCENT: 'translucent'>, anchor=<Anchor.CENTER: 'center'>, translation=<class 'numpy.ndarray'> (2,) float64, rotation=0.0)>
        index = [1]
   1425             if self._value in self.selected_data:
   1426                 self._value = None

~/projects/napari/napari/layers/utils/text_manager.py in remove(self=TextManager(values=<class 'numpy.ndarray'> (2,) ...lass 'numpy.ndarray'> (2,) float64, rotation=0.0), indices_to_remove=[1])
    137             selected_indices = list(indices_to_remove)
    138             if len(selected_indices) > 0:
--> 139                 self.values = np.delete(self.values, selected_indices, axis=0)
        self.values = <class 'numpy.ndarray'> (2,) <U18
        global np.delete = <function delete at 0x7ff54340f0d0>
        selected_indices = [1]
        global axis = undefined
    140 
    141     def compute_text_coords(

~/projects/napari/napari/utils/events/evented_model.py in __setattr__(self=TextManager(values=<class 'numpy.ndarray'> (2,) ...lass 'numpy.ndarray'> (2,) float64, rotation=0.0), name='values', value=<class 'numpy.ndarray'> (2,) <U18)
    149         are_equal = self.__eq_operators__.get(name, operator.eq)
    150         if not are_equal(after, before):
--> 151             getattr(self.events, name)(value=after)  # emit event
        global getattr = undefined
        self.events = <napari.utils.events.event.EmitterGroup object at 0x7ff54d2d35b0>
        name = 'values'
        value = <class 'numpy.ndarray'> (2,) <U18
        after = <class 'numpy.ndarray'> (2,) <U18
    152 
    153     # expose the private EmitterGroup publically

~/projects/napari/napari/utils/events/event.py in __call__(self=<napari.utils.events.event.EventEmitter object>, *args=(), **kwargs={'value': <class 'numpy.ndarray'> (2,) <U18})
    597                     continue
    598 
--> 599                 self._invoke_callback(cb, event)
        self._invoke_callback = <bound method EventEmitter._invoke_callback of <napari.utils.events.event.EventEmitter object at 0x7ff54d2d3310>>
        cb = <bound method VispyPointsLayer._on_text_change of <napari._vispy.layers.points.VispyPointsLayer object at 0x7ff54d2b2a90>>
        event = <Event blocked=False handled=False native=None source=None sources=[] type='values'>
    600                 if event.blocked:
    601                     break

~/projects/napari/napari/utils/events/event.py in _invoke_callback(self=<napari.utils.events.event.EventEmitter object>, cb=<bound method VispyPointsLayer._on_text_change o...ri._vispy.layers.points.VispyPointsLayer object>>, event=<Event blocked=False handled=False native=None source=None sources=[] type='values'>)
    629                 self.disconnect(cb)
    630                 return
--> 631             _handle_exception(
        global _handle_exception = <function _handle_exception at 0x7ff546361670>
        self.ignore_callback_errors = False
        self.print_callback_errors = 'reminders'
        self = <napari.utils.events.event.EventEmitter object at 0x7ff54d2d3310>
        global cb_event = undefined
        cb = <bound method VispyPointsLayer._on_text_change of <napari._vispy.layers.points.VispyPointsLayer object at 0x7ff54d2b2a90>>
        event = <Event blocked=False handled=False native=None source=None sources=[] type='values'>
    632                 self.ignore_callback_errors,
    633                 self.print_callback_errors,

~/projects/napari/napari/utils/events/event.py in _invoke_callback(self=<napari.utils.events.event.EventEmitter object>, cb=<bound method VispyPointsLayer._on_text_change o...ri._vispy.layers.points.VispyPointsLayer object>>, event=<Event blocked=False handled=False native=None source=None sources=[] type='values'>)
    618     def _invoke_callback(self, cb: Callback, event: Event):
    619         try:
--> 620             cb(event)
        cb = <bound method VispyPointsLayer._on_text_change of <napari._vispy.layers.points.VispyPointsLayer object at 0x7ff54d2b2a90>>
        event = <Event blocked=False handled=False native=None source=None sources=[] type='values'>
    621         except Exception as e:
    622             # dead Qt object with living python pointer. not importing Qt

~/projects/napari/napari/_vispy/layers/points.py in _on_text_change(self=<napari._vispy.layers.points.VispyPointsLayer object>, update_node=<Event blocked=False handled=False native=None source=None sources=[] type='values'>)
    138             if len(text_coords) == 0:
    139                 text_coords = np.zeros((1, ndisplay))
--> 140             text = self.layer._view_text
        text = undefined
        self.layer._view_text = undefined
    141         text_node = self._get_text_node()
    142         update_text(

~/projects/napari/napari/layers/points/points.py in _view_text(self=<Points layer 'points'>)
   1135             Array of text strings for the N text elements in view
   1136         """
-> 1137         return self.text.view_text(self._indices_view)
        self.text.view_text = <bound method TextManager.view_text of TextManager(values=<class 'numpy.ndarray'> (2,) <U18, visible=True, size=20, color=<class 'numpy.ndarray'> (4,) float64, blending=<Blending.TRANSLUCENT: 'translucent'>, anchor=<Anchor.CENTER: 'center'>, translation=<class 'numpy.ndarray'> (2,) float64, rotation=0.0)>
        self._indices_view = <class 'numpy.ndarray'> (3,) int64
   1138 
   1139     @property

~/projects/napari/napari/layers/utils/text_manager.py in view_text(self=TextManager(values=<class 'numpy.ndarray'> (2,) ...lass 'numpy.ndarray'> (2,) float64, rotation=0.0), indices_view=<class 'numpy.ndarray'> (3,) int64)
    188             TextMode.PROPERTY,
    189         ]:
--> 190             return self.values[indices_view]
        self.values = <class 'numpy.ndarray'> (2,) <U18
        indices_view = <class 'numpy.ndarray'> (3,) int64
    191         # if no points in this slice send dummy data
    192         return np.array([''])

IndexError: index 2 is out of bounds for axis 0 with size 2
```



Discussion:

Resolving patch:
diff --git a/napari/layers/points/points.py b/napari/layers/points/points.py
--- a/napari/layers/points/points.py
+++ b/napari/layers/points/points.py
@@ -1421,7 +1421,8 @@ def remove_selected(self):
                 self.properties[k] = np.delete(
                     self.properties[k], index, axis=0
                 )
-            self.text.remove(index)
+            with self.text.events.blocker_all():
+                self.text.remove(index)
             if self._value in self.selected_data:
                 self._value = None
             self.selected_data = set()


Test patch:
diff --git a/napari/_vispy/_tests/test_vispy_points_layer.py b/napari/_vispy/_tests/test_vispy_points_layer.py
--- a/napari/_vispy/_tests/test_vispy_points_layer.py
+++ b/napari/_vispy/_tests/test_vispy_points_layer.py
@@ -13,6 +13,21 @@ def test_VispyPointsLayer(opacity):
     assert visual.node.opacity == opacity
 
 
+def test_remove_selected_with_derived_text():
+    """See https://github.com/napari/napari/issues/3504"""
+    points = np.random.rand(3, 2)
+    properties = {'class': np.array(['A', 'B', 'C'])}
+    layer = Points(points, text='class', properties=properties)
+    vispy_layer = VispyPointsLayer(layer)
+    text_node = vispy_layer._get_text_node()
+    np.testing.assert_array_equal(text_node.text, ['A', 'B', 'C'])
+
+    layer.selected_data = {1}
+    layer.remove_selected()
+
+    np.testing.assert_array_equal(text_node.text, ['A', 'C'])
+
+
 def test_change_text_updates_node_string():
     points = np.random.rand(3, 2)
     properties = {
diff --git a/napari/_vispy/_tests/test_vispy_shapes_layer.py b/napari/_vispy/_tests/test_vispy_shapes_layer.py
--- a/napari/_vispy/_tests/test_vispy_shapes_layer.py
+++ b/napari/_vispy/_tests/test_vispy_shapes_layer.py
@@ -4,6 +4,21 @@
 from napari.layers import Shapes
 
 
+def test_remove_selected_with_derived_text():
+    """See https://github.com/napari/napari/issues/3504"""
+    shapes = np.random.rand(3, 4, 2)
+    properties = {'class': np.array(['A', 'B', 'C'])}
+    layer = Shapes(shapes, properties=properties, text='class')
+    vispy_layer = VispyShapesLayer(layer)
+    text_node = vispy_layer._get_text_node()
+    np.testing.assert_array_equal(text_node.text, ['A', 'B', 'C'])
+
+    layer.selected_data = {1}
+    layer.remove_selected()
+
+    np.testing.assert_array_equal(text_node.text, ['A', 'C'])
+
+
 def test_change_text_updates_node_string():
     shapes = np.random.rand(3, 4, 2)
     properties = {