Repository: napari/napari

Issue:
Creating properties *after* creating a points layer results in an inconsistent state
## 🐛 Bug

When properties are added *after* a points layer has been created (rather than at creation time), the `.current_properties` attribute is not properly initialized, so switching the face color mode causes a KeyError in that dictionary.

## To Reproduce

```python
import napari
import numpy as np

viewer = napari.Viewer()
points = viewer.add_points(np.random.random((5, 2) * 512)
points.properties = {
    'cat': np.random.randint(0, 5, size=5),
    'cont': np.random.random(5),
}
points.face_color_mode = 'cycle'
```

which causes:

```pytb
---------------------------------------------------------------------------
KeyError                                  Traceback (most recent call last)
<ipython-input-8-0ffc0dfa8ad5> in <module>
----> 1 pts.face_color_mode = 'cycle'

~/projects/napari/napari/layers/points/points.py in face_color_mode(self, face_color_mode)
    880     @face_color_mode.setter
    881     def face_color_mode(self, face_color_mode):
--> 882         self._set_color_mode(face_color_mode, 'face')
    883 
    884     def _set_color_mode(

~/projects/napari/napari/layers/points/points.py in _set_color_mode(self, color_mode, attribute)
    913                         'values': self.properties[new_color_property],
    914                         'current_value': np.squeeze(
--> 915                             self.current_properties[new_color_property]
    916                         ),
    917                     }

KeyError: 'cat'
```

## Expected behavior

The above lines:

```python
points = viewer.add_points(np.random.random((5, 2) * 512)
points.properties = {
    'cat': np.random.randint(0, 5, size=5),
    'cont': np.random.random(5),
}
```

should be equivalent to:

```python
points = viewer.add_points(
    np.random.random((5, 2) * 512)
    properties = {
        'cat': np.random.randint(0, 5, size=5),
        'cont': np.random.random(5),
    },
)
```

In the latter case, no error is raised when setting `pts.face_color_mode = 'cycle'`. (Though there are other reasons this doesn't work well currently, but that is a different issue.)




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
@@ -107,7 +107,7 @@ class Points(Layer):
     affine : n-D array or napari.utils.transforms.Affine
         (N+1, N+1) affine transformation matrix in homogeneous coordinates.
         The first (N, N) entries correspond to a linear transform and
-        the final column is a lenght N translation vector and a 1 or a napari
+        the final column is a length N translation vector and a 1 or a napari
         AffineTransform object. If provided then translate, scale, rotate, and
         shear values are ignored.
     opacity : float
@@ -395,12 +395,17 @@ def __init__(
 
         self.size = size
 
-        # set the current_properties
-        if len(data) > 0:
+        self._update_current_properties()
+
+        # Trigger generation of view slice and thumbnail
+        self._update_dims()
+
+    def _update_current_properties(self):
+        if len(self._data) > 0:
             self.current_properties = {
                 k: np.asarray([v[-1]]) for k, v in self.properties.items()
             }
-        elif len(data) == 0 and self.properties:
+        elif len(self._data) == 0 and self.properties:
             self.current_properties = {
                 k: np.asarray([v[0]])
                 for k, v in self._property_choices.items()
@@ -408,9 +413,6 @@ def __init__(
         else:
             self.current_properties = {}
 
-        # Trigger generation of view slice and thumbnail
-        self._update_dims()
-
     @property
     def data(self) -> np.ndarray:
         """(N, D) array: coordinates for N points in D dimensions."""
@@ -530,6 +532,10 @@ def properties(
         self._properties, self._property_choices = self._prepare_properties(
             properties, self._property_choices
         )
+        # Updating current_properties can modify properties, so block to avoid
+        # infinite recursion when explicitly setting the properties.
+        with self.block_update_properties():
+            self._update_current_properties()
         self._update_color_manager(
             self._face,
             self._properties,
@@ -957,7 +963,7 @@ def _set_color_mode(
             it should be one of: 'direct', 'cycle', or 'colormap'
         attribute : str in {'edge', 'face'}
             The name of the attribute to set the color of.
-            Should be 'edge' for edge_colo_moder or 'face' for face_color_mode.
+            Should be 'edge' for edge_color_mode or 'face' for face_color_mode.
         """
         color_mode = ColorMode(color_mode)
         color_manager = getattr(self, f'_{attribute}')


Test patch:
diff --git a/napari/layers/points/_tests/test_points.py b/napari/layers/points/_tests/test_points.py
--- a/napari/layers/points/_tests/test_points.py
+++ b/napari/layers/points/_tests/test_points.py
@@ -9,6 +9,7 @@
 from napari._tests.utils import check_layer_world_data_extent
 from napari.layers import Points
 from napari.layers.points._points_utils import points_to_squares
+from napari.layers.utils.color_manager import ColorProperties
 from napari.utils.colormaps.standardize_color import transform_color
 
 
@@ -1593,3 +1594,31 @@ def test_prepare_properties():
     properties, choices = layer._prepare_properties({"aa": [1, 2, 1]})
     assert np.array_equal(properties["aa"], [1, 2, 1])
     assert np.array_equal(choices["aa"], [1, 2])
+
+
+def test_set_face_color_mode_after_set_properties():
+    # See GitHub issue for more details:
+    # https://github.com/napari/napari/issues/2755
+    np.random.seed(0)
+    num_points = 3
+    points = Points(np.random.random((num_points, 2)))
+
+    points.properties = {
+        'cat': np.random.randint(low=0, high=num_points, size=num_points),
+        'cont': np.random.random(num_points),
+    }
+
+    # Initially the color_mode is DIRECT, which means that the face ColorManager
+    # has no color_properties, so the first property is used with a warning.
+    with pytest.warns(UserWarning):
+        points.face_color_mode = 'cycle'
+
+    first_property_key, first_property_values = next(
+        iter(points.properties.items())
+    )
+    expected_properties = ColorProperties(
+        name=first_property_key,
+        values=first_property_values,
+        current_value=first_property_values[-1],
+    )
+    assert points._face.color_properties == expected_properties