Repository: napari/napari

Issue:
Error when update Shapes.data when the layer has properties
## 🐛 Bug

As reported by image.sc user johschmidt42 in [this thread](https://forum.image.sc/t/replace-shapes-in-the-shapes-layer-with-new-shapes/48633/4),  updating the layer data via the `Shapes.data` setter on a `Shapes` layer that has properties/text throws an error. This bug shows a couple of things we need to fix:

- we need to make sure that properties are properly trimmed/extended in the `Shapes.data` setter. That is if the new data has the same number of shapes, the properties can remain unchanged. If the new data has more shapes, we need to extend properties by the number of new shapes. Finally, if the new data has fewer shapes, we need to trim the properties to match. Currently, we are just using the `Shapes.add()` method without adjusting the properties.
- there is a bug in [`TextManager.add()`](https://github.com/napari/napari/blob/ab1cb018634d82b345dc6e8175f7058419250184/napari/layers/utils/text.py#L240-L255) that doesn't properly extend the properties, so when the current properties are passed and more than one new text element is specified, we hit an indexing error.


## To Reproduce

From image.sc user johschmidt42 in [this thread](https://forum.image.sc/t/replace-shapes-in-the-shapes-layer-with-new-shapes/48633/4):

<details>
  <summary>Example script</summary>
  
  ```python
from skimage import data
import napari
import numpy as np


def enable_gui_qt():
    from IPython import get_ipython

    ipython = get_ipython()
    ipython.magic('gui qt')


enable_gui_qt()

viewer = napari.view_image(data.astronaut(), rgb=True)
old_shapes = [np.array([[217.18092301, 14.32894795],
                        [531.98354928, 14.32894795],
                        [531.98354928, 277.02631195],
                        [217.18092301, 277.02631195]]),
              np.array([[81.49013583, 199.95394483],
                        [287.74013235, 199.95394483],
                        [287.74013235, 396.43420467],
                        [81.49013583, 396.43420467]])]

old_scores = list(np.random.rand(len(old_shapes)))
old_labels = [f'old_label_{i}' for i in range(len(old_shapes))]

properties = {
    'labels': old_labels,
    'scores': old_scores
}

text_parameters = {
    'text': '{labels} {scores:.2f}',
    'size': 12,
    'color': 'white',
    'anchor': 'upper_left',
    'translation': [-1, 0],
}

shapes_layer = viewer.add_shapes(data=old_shapes,
                                 face_color='transparent',
                                 edge_color='red',
                                 edge_width=4,
                                 name='some_name',
                                 properties=properties,
                                 text=text_parameters)

new_shapes = [np.array([[220.18092301, 14.32894795],
                        [531.98354928, 14.32894795],
                        [531.98354928, 277.02631195],
                        [220.18092301, 277.02631195]]),
              np.array([[90.49013583, 199.95394483],
                        [287.74013235, 199.95394483],
                        [287.74013235, 396.43420467],
                        [90.49013583, 396.43420467]]),
              np.array([[266.0296064, 300.61183407],
                        [490.77960153, 300.61183407],
                        [490.77960153, 510.63156727],
                        [266.0296064, 510.63156727]])]

shapes_layer.data = new_shapes
  ```
</details>

Gives the following traceback:
```python
Traceback (most recent call last):
  File "/home/ky/code/napari/examples/bad_shapes.py", line 54, in <module>
    shapes_layer.data = new_shapes
  File "/home/ky/code/napari/napari/layers/shapes/shapes.py", line 539, in data
    self.add(data, shape_type=shape_type)
  File "/home/ky/code/napari/napari/layers/shapes/shapes.py", line 1501, in add
    self.text.add(self.current_properties, n_new_shapes)
  File "/home/ky/code/napari/napari/layers/utils/text.py", line 252, in add
    self._text_format_string, n_text=n_text, properties=properties
  File "/home/ky/code/napari/napari/layers/utils/_text_utils.py", line 161, in format_text_properties
    properties=properties,
  File "/home/ky/code/napari/napari/layers/utils/_text_utils.py", line 211, in _format_text_f_string
    prop_value = properties[format_key[0]][i]
IndexError: index 1 is out of bounds for axis 0 with size 1

Process finished with exit code 1
```

## Expected behavior

The layer data should update and the properties/text should update to remain valid.

## Environment
<details>
  <summary>environment details</summary>

```
napari: 0.3.9.dev285+g0d5fa95b.d20210131
Platform: Linux-5.8.0-41-generic-x86_64-with-debian-bullseye-sid
System: Ubuntu 20.04.1 LTS
Python: 3.7.7 (default, May 7 2020, 21:25:33) [GCC 7.3.0]
Qt: 5.14.2
PyQt5: 5.14.2
NumPy: 1.19.1
SciPy: 1.5.2
Dask: 2.24.0
VisPy: 0.6.4

OpenGL:
- GL version: 4.6.0 NVIDIA 450.102.04
- MAX_TEXTURE_SIZE: 32768

Screens:
- screen 1: resolution 2560x1440, scale 1.0
- screen 2: resolution 1440x2560, scale 1.0

Plugins:
- svg: 0.1.4
```

</details>



Discussion:
This issue has been mentioned on **Image.sc Forum**. There might be relevant details there:

https://forum.image.sc/t/replace-shapes-in-the-shapes-layer-with-new-shapes/48633/5


Resolving patch:
diff --git a/napari/layers/shapes/shapes.py b/napari/layers/shapes/shapes.py
--- a/napari/layers/shapes/shapes.py
+++ b/napari/layers/shapes/shapes.py
@@ -534,9 +534,6 @@ def data(self, data, shape_type='rectangle'):
         self._data_view = ShapeList()
         self.add(data, shape_type=shape_type)
 
-        n_new_shapes = number_of_shapes(data)
-        self.text.add(self.current_properties, n_new_shapes)
-
         self._update_dims()
         self.events.data(value=self.data)
         self._set_editable()
@@ -1487,14 +1484,30 @@ def add(
             z_index = z_index or 0
 
         if n_new_shapes > 0:
-            for k in self.properties:
-                new_property = np.repeat(
-                    self.current_properties[k], n_new_shapes, axis=0
-                )
-                self.properties[k] = np.concatenate(
-                    (self.properties[k], new_property), axis=0
-                )
-            self.text.add(self.current_properties, n_new_shapes)
+            if len(self.properties) > 0:
+                first_prop_key = next(iter(self.properties))
+                n_prop_values = len(self.properties[first_prop_key])
+            else:
+                n_prop_values = 0
+            total_shapes = n_new_shapes + self.nshapes
+            if total_shapes > n_prop_values:
+                n_props_to_add = total_shapes - n_prop_values
+                for k in self.properties:
+                    new_property = np.repeat(
+                        self.current_properties[k], n_props_to_add, axis=0
+                    )
+                    self.properties[k] = np.concatenate(
+                        (self.properties[k], new_property), axis=0
+                    )
+                self.text.add(self.current_properties, n_props_to_add)
+            if total_shapes < n_prop_values:
+                for k in self.properties:
+                    self.properties[k] = self.properties[k][:total_shapes]
+                n_props_to_remove = n_prop_values - total_shapes
+                indices_to_remove = np.arange(n_prop_values)[
+                    -n_props_to_remove:
+                ]
+                self.text.remove(indices_to_remove)
 
             self._add_shapes(
                 data,
diff --git a/napari/layers/utils/_text_utils.py b/napari/layers/utils/_text_utils.py
--- a/napari/layers/utils/_text_utils.py
+++ b/napari/layers/utils/_text_utils.py
@@ -150,7 +150,7 @@ def format_text_properties(text: str, n_text: int, properties: dict = {}):
 
     # If the text value is a property key, the text is the property values
     if text in properties:
-        formatted_text = np.array([str(v) for v in properties[text]])
+        formatted_text = np.resize([str(v) for v in properties[text]], n_text)
         text_mode = TextMode.PROPERTY
     elif ('{' in text) and ('}' in text):
         format_keys = _get_format_keys(text, properties)


Test patch:
diff --git a/napari/layers/shapes/_tests/test_shapes.py b/napari/layers/shapes/_tests/test_shapes.py
--- a/napari/layers/shapes/_tests/test_shapes.py
+++ b/napari/layers/shapes/_tests/test_shapes.py
@@ -121,6 +121,32 @@ def test_adding_properties(attribute):
         layer.properties = properties_2
 
 
+def test_data_setter_with_properties():
+    """Test layer data on a layer with properties via the data setter"""
+    shape = (10, 4, 2)
+    np.random.seed(0)
+    data = 20 * np.random.random(shape)
+    properties = {'shape_type': _make_cycled_properties(['A', 'B'], shape[0])}
+    layer = Shapes(data, properties=properties)
+
+    # test setting to data with fewer shapes
+    n_new_shapes = 4
+    new_data = 20 * np.random.random((n_new_shapes, 4, 2))
+    layer.data = new_data
+    assert len(layer.properties['shape_type']) == n_new_shapes
+
+    # test setting to data with more shapes
+    n_new_shapes_2 = 6
+    new_data_2 = 20 * np.random.random((n_new_shapes_2, 4, 2))
+    layer.data = new_data_2
+    assert len(layer.properties['shape_type']) == n_new_shapes_2
+
+    # test setting to data with same shapes
+    new_data_3 = 20 * np.random.random((n_new_shapes_2, 4, 2))
+    layer.data = new_data_3
+    assert len(layer.properties['shape_type']) == n_new_shapes_2
+
+
 def test_properties_dataframe():
     """Test if properties can be provided as a DataFrame"""
     shape = (10, 4, 2)
@@ -227,6 +253,32 @@ def test_refresh_text():
     np.testing.assert_equal(layer.text.values, new_properties['shape_type'])
 
 
+@pytest.mark.parametrize("properties", [properties_array, properties_list])
+def test_data_setter_with_text(properties):
+    """Test layer data on a layer with text via the data setter"""
+    shape = (10, 4, 2)
+    np.random.seed(0)
+    data = 20 * np.random.random(shape)
+    layer = Shapes(data, properties=copy(properties), text='shape_type')
+
+    # test setting to data with fewer shapes
+    n_new_shapes = 4
+    new_data = 20 * np.random.random((n_new_shapes, 4, 2))
+    layer.data = new_data
+    assert len(layer.text.values) == n_new_shapes
+
+    # test setting to data with more shapes
+    n_new_shapes_2 = 6
+    new_data_2 = 20 * np.random.random((n_new_shapes_2, 4, 2))
+    layer.data = new_data_2
+    assert len(layer.text.values) == n_new_shapes_2
+
+    # test setting to data with same shapes
+    new_data_3 = 20 * np.random.random((n_new_shapes_2, 4, 2))
+    layer.data = new_data_3
+    assert len(layer.text.values) == n_new_shapes_2
+
+
 def test_rectangles():
     """Test instantiating Shapes layer with a random 2D rectangles."""
     # Test a single four corner rectangle