Repository: napari/napari

Issue:
Uncovered lines in points properties code
In this code report:

https://codecov.io/gh/napari/napari/pull/1069/diff

these lines are not covered:

https://github.com/napari/napari/blob/6ea95a9fa2f9150a4dbb5ec1286b8ff2020c3957/napari/layers/points/points.py#L565-L575

and

https://github.com/napari/napari/blob/6ea95a9fa2f9150a4dbb5ec1286b8ff2020c3957/napari/layers/points/points.py#L606-L608

(The "ifs" are executed but never evaluate to True in tests.) It's possible that the second one in particular will *never* be hit, since we automatically convert input to arrays. So the code could possibly be removed.


Discussion:
Thanks for making the issue @jni! I will add these soon.

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
@@ -336,7 +336,7 @@ def __init__(
         self._clipboard = {}
 
         with self.block_update_properties():
-            self.edge_color_property = ''
+            self._edge_color_property = ''
             self.edge_color = edge_color
             if edge_color_cycle is None:
                 edge_color_cycle = deepcopy(DEFAULT_COLOR_CYCLE)
@@ -369,55 +369,8 @@ def __init__(
                 k: np.asarray([v[0]])
                 for k, v in self._property_choices.items()
             }
-            if self._edge_color_mode == ColorMode.DIRECT:
-                self._current_edge_color = transform_color_with_defaults(
-                    num_entries=1,
-                    colors=edge_color,
-                    elem_name="edge_color",
-                    default="white",
-                )
-            elif self._edge_color_mode == ColorMode.CYCLE:
-                curr_edge_color = transform_color(next(self.edge_color_cycle))
-                prop_value = self._property_choices[self._edge_color_property][
-                    0
-                ]
-                self.edge_color_cycle_map[prop_value] = curr_edge_color
-                self._current_edge_color = curr_edge_color
-            elif self._edge_color_mode == ColorMode.COLORMAP:
-                prop_value = self._property_choices[self._edge_color_property][
-                    0
-                ]
-                curr_edge_color, _ = map_property(
-                    prop=prop_value,
-                    colormap=self.edge_colormap[1],
-                    contrast_limits=self._edge_contrast_limits,
-                )
-                self._current_edge_color = curr_edge_color
-
-            if self._face_color_mode == ColorMode.DIRECT:
-                self._current_face_color = transform_color_with_defaults(
-                    num_entries=1,
-                    colors=face_color,
-                    elem_name="face_color",
-                    default="white",
-                )
-            elif self._face_color_mode == ColorMode.CYCLE:
-                curr_face_color = transform_color(next(self.face_color_cycle))
-                prop_value = self._property_choices[self._face_color_property][
-                    0
-                ]
-                self.face_color_cycle_map[prop_value] = curr_face_color
-                self._current_face_color = curr_face_color
-            elif self._face_color_mode == ColorMode.COLORMAP:
-                prop_value = self._property_choices[self._face_color_property][
-                    0
-                ]
-                curr_face_color, _ = map_property(
-                    prop=prop_value,
-                    colormap=self.face_colormap[1],
-                    contrast_limits=self._face_contrast_limits,
-                )
-                self._current_face_color = curr_face_color
+            self._initialize_current_color_for_empty_layer(edge_color, 'edge')
+            self._initialize_current_color_for_empty_layer(face_color, 'face')
         else:
             self._current_edge_color = self.edge_color[-1]
             self._current_face_color = self.face_color[-1]
@@ -426,6 +379,51 @@ def __init__(
         # Trigger generation of view slice and thumbnail
         self._update_dims()
 
+    def _initialize_current_color_for_empty_layer(
+        self, color: ColorType, attribute: str
+    ):
+        """Initialize current_{edge,face}_color when starting with empty layer.
+
+        Parameters:
+        -----------
+        color : (N, 4) array or str
+            The value for setting edge or face_color
+        attribute : str in {'edge', 'face'}
+            The name of the attribute to set the color of.
+            Should be 'edge' for edge_color or 'face' for face_color.
+        """
+        color_mode = getattr(self, f'_{attribute}_color_mode')
+        if color_mode == ColorMode.DIRECT:
+            curr_color = transform_color_with_defaults(
+                num_entries=1,
+                colors=color,
+                elem_name=f'{attribute}_color',
+                default="white",
+            )
+
+        elif color_mode == ColorMode.CYCLE:
+            color_cycle = getattr(self, f'{attribute}_color_cycle')
+            curr_color = transform_color(next(color_cycle))
+
+            # add the new color cycle mapping
+            color_property = getattr(self, f'_{attribute}_color_property')
+            prop_value = self._property_choices[color_property][0]
+            color_cycle_map = getattr(self, f'{attribute}_color_cycle_map')
+            color_cycle_map[prop_value] = curr_color
+            setattr(self, f'{attribute}_color_cycle_map', color_cycle_map)
+
+        elif color_mode == ColorMode.COLORMAP:
+            color_property = getattr(self, f'_{attribute}_color_property')
+            prop_value = self._property_choices[color_property][0]
+            colormap = getattr(self, f'{attribute}_colormap')
+            contrast_limits = getattr(self, f'_{attribute}_contrast_limits')
+            curr_color, _ = map_property(
+                prop=prop_value,
+                colormap=colormap[1],
+                contrast_limits=contrast_limits,
+            )
+        setattr(self, f'_current_{attribute}_color', curr_color)
+
     @property
     def data(self) -> np.ndarray:
         """(N, D) array: coordinates for N points in D dimensions."""
@@ -473,78 +471,10 @@ def data(self, data: np.ndarray):
                     )
 
                 # add new edge colors
-                if self._edge_color_mode == ColorMode.DIRECT:
-                    new_edge_colors = np.tile(
-                        self._current_edge_color, (adding, 1)
-                    )
-                elif self._edge_color_mode == ColorMode.CYCLE:
-                    edge_color_property = self.current_properties[
-                        self._edge_color_property
-                    ][0]
-
-                    # check if the new edge color property is in the cycle map
-                    # and add it if it is not
-                    edge_color_cycle_keys = [*self.edge_color_cycle_map]
-                    if edge_color_property not in edge_color_cycle_keys:
-                        self.edge_color_cycle_map[edge_color_property] = next(
-                            self.edge_color_cycle
-                        )
-
-                    new_edge_colors = np.tile(
-                        self.edge_color_cycle_map[edge_color_property],
-                        (adding, 1),
-                    )
-                elif self._edge_color_mode == ColorMode.COLORMAP:
-                    edge_color_property_value = self.current_properties[
-                        self._edge_color_property
-                    ][0]
-
-                    ec, _ = map_property(
-                        prop=edge_color_property_value,
-                        colormap=self.edge_colormap[1],
-                        contrast_limits=self._edge_contrast_limits,
-                    )
-                    new_edge_colors = np.tile(ec, (adding, 1))
-                self._edge_color = np.vstack(
-                    (self.edge_color, new_edge_colors)
-                )
+                self._add_point_color(adding, 'edge')
 
                 # add new face colors
-                if self._face_color_mode == ColorMode.DIRECT:
-                    new_face_colors = np.tile(
-                        self._current_face_color, (adding, 1)
-                    )
-                elif self._face_color_mode == ColorMode.CYCLE:
-                    face_color_property = self.current_properties[
-                        self._face_color_property
-                    ][0]
-
-                    # check if the new edge color property is in the cycle map
-                    # and add it if it is not
-                    face_color_cycle_keys = [*self.face_color_cycle_map]
-                    if face_color_property not in face_color_cycle_keys:
-                        self.face_color_cycle_map[face_color_property] = next(
-                            self.face_color_cycle
-                        )
-
-                    new_face_colors = np.tile(
-                        self.face_color_cycle_map[face_color_property],
-                        (adding, 1),
-                    )
-                elif self._face_color_mode == ColorMode.COLORMAP:
-                    face_color_property_value = self.current_properties[
-                        self._face_color_property
-                    ][0]
-
-                    fc, _ = map_property(
-                        prop=face_color_property_value,
-                        colormap=self.face_colormap[1],
-                        contrast_limits=self._face_contrast_limits,
-                    )
-                    new_face_colors = np.tile(fc, (adding, 1))
-                self._face_color = np.vstack(
-                    (self.face_color, new_face_colors)
-                )
+                self._add_point_color(adding, 'face')
 
                 self.size = np.concatenate((self._size, size), axis=0)
                 self.selected_data = set(np.arange(cur_npoints, len(data)))
@@ -552,6 +482,53 @@ def data(self, data: np.ndarray):
         self._update_dims()
         self.events.data()
 
+    def _add_point_color(self, adding: int, attribute: str):
+        """Add the edge or face colors for new points.
+
+        Parameters:
+        ----------
+        adding : int
+            the number of points that were added
+            (and thus the number of color entries to add)
+        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.
+        """
+        color_mode = getattr(self, f'_{attribute}_color_mode')
+        if color_mode == ColorMode.DIRECT:
+            current_face_color = getattr(self, f'_current_{attribute}_color')
+            new_colors = np.tile(current_face_color, (adding, 1))
+        elif color_mode == ColorMode.CYCLE:
+            property_name = getattr(self, f'_{attribute}_color_property')
+            color_property_value = self.current_properties[property_name][0]
+
+            # check if the new color property is in the cycle map
+            # and add it if it is not
+            color_cycle_map = getattr(self, f'{attribute}_color_cycle_map')
+            color_cycle_keys = [*color_cycle_map]
+            if color_property_value not in color_cycle_keys:
+                color_cycle = getattr(self, f'{attribute}_color_cycle')
+                color_cycle_map[color_property_value] = next(color_cycle)
+                setattr(self, f'{attribute}_color_cycle_map', color_cycle_map)
+
+            new_colors = np.tile(
+                color_cycle_map[color_property_value], (adding, 1),
+            )
+        elif color_mode == ColorMode.COLORMAP:
+            property_name = getattr(self, f'_{attribute}_color_property')
+            color_property_value = self.current_properties[property_name][0]
+            colormap = getattr(self, f'{attribute}_colormap')
+            contrast_limits = getattr(self, f'_{attribute}_contrast_limits')
+
+            fc, _ = map_property(
+                prop=color_property_value,
+                colormap=colormap[1],
+                contrast_limits=contrast_limits,
+            )
+            new_colors = np.tile(fc, (adding, 1))
+        colors = getattr(self, f'{attribute}_color')
+        setattr(self, f'_{attribute}_color', np.vstack((colors, new_colors)))
+
     @property
     def properties(self):
         """dict {str: np.ndarray (N,)}, DataFrame: Annotations for each point"""
@@ -566,13 +543,17 @@ def properties(self, properties: Dict[str, np.ndarray]):
             self._face_color_property not in self._properties
         ):
             self._face_color_property = ''
-            warnings.warn('property used for face_color dropped')
+            warnings.warn(
+                'property used for face_color dropped', RuntimeWarning
+            )
 
         if self._edge_color_property and (
             self._edge_color_property not in self._properties
         ):
             self._edge_color_property = ''
-            warnings.warn('property used for edge_color dropped')
+            warnings.warn(
+                'property used for edge_color dropped', RuntimeWarning
+            )
 
     @property
     def current_properties(self):
@@ -708,30 +689,7 @@ def edge_color(self):
 
     @edge_color.setter
     def edge_color(self, edge_color):
-        # if the provided face color is a string, first check if it is a key in the properties.
-        # otherwise, assume it is the name of a color
-        if self._is_color_mapped(edge_color):
-            if guess_continuous(self.properties[edge_color]):
-                self._edge_color_mode = ColorMode.COLORMAP
-            else:
-                self._edge_color_mode = ColorMode.CYCLE
-            self._edge_color_property = edge_color
-            self.refresh_colors()
-
-        else:
-            transformed_color = transform_color_with_defaults(
-                num_entries=len(self.data),
-                colors=edge_color,
-                elem_name="edge_color",
-                default="white",
-            )
-            self._edge_color = normalize_and_broadcast_colors(
-                len(self.data), transformed_color
-            )
-            self.edge_color_mode = ColorMode.DIRECT
-            self._edge_color_property = ''
-
-            self.events.edge_color()
+        self._set_color(edge_color, 'edge')
 
     @property
     def edge_color_cycle(self):
@@ -821,33 +779,7 @@ def edge_color_mode(self):
 
     @edge_color_mode.setter
     def edge_color_mode(self, edge_color_mode: Union[str, ColorMode]):
-        edge_color_mode = ColorMode(edge_color_mode)
-
-        if edge_color_mode == ColorMode.DIRECT:
-            self._edge_color_mode = edge_color_mode
-        elif edge_color_mode in (ColorMode.CYCLE, ColorMode.COLORMAP):
-            if self._edge_color_property == '':
-                if self.properties:
-                    self._edge_color_property = next(iter(self.properties))
-                    warnings.warn(
-                        'Edge color was not set, setting to: %s'
-                        % self._face_color_property
-                    )
-                else:
-                    raise ValueError(
-                        'There must be a valid Points.properties to use ColorMode.Cycle'
-                    )
-            # ColorMode.COLORMAP can only be applied to numeric properties
-            if (edge_color_mode == ColorMode.COLORMAP) and not issubclass(
-                self.properties[self._edge_color_property].dtype.type,
-                np.number,
-            ):
-                raise TypeError(
-                    'selected property must be numeric to use ColorMode.COLORMAP'
-                )
-
-            self._edge_color_mode = edge_color_mode
-            self.refresh_colors()
+        self._set_color_mode(edge_color_mode, 'edge')
 
     @property
     def face_color(self):
@@ -856,29 +788,7 @@ def face_color(self):
 
     @face_color.setter
     def face_color(self, face_color):
-        # if the provided face color is a string, first check if it is a key in the properties.
-        # otherwise, assume it is the name of a color
-        if self._is_color_mapped(face_color):
-            if guess_continuous(self.properties[face_color]):
-                self._face_color_mode = ColorMode.COLORMAP
-            else:
-                self._face_color_mode = ColorMode.CYCLE
-            self._face_color_property = face_color
-            self.refresh_colors()
-
-        else:
-            transformed_color = transform_color_with_defaults(
-                num_entries=len(self.data),
-                colors=face_color,
-                elem_name="face_color",
-                default="white",
-            )
-            self._face_color = normalize_and_broadcast_colors(
-                len(self.data), transformed_color
-            )
-            self.face_color_mode = ColorMode.DIRECT
-
-            self.events.face_color()
+        self._set_color(face_color, 'face')
 
     @property
     def face_color_cycle(self):
@@ -964,35 +874,92 @@ def face_color_mode(self):
 
     @face_color_mode.setter
     def face_color_mode(self, face_color_mode):
-        face_color_mode = ColorMode(face_color_mode)
+        self._set_color_mode(face_color_mode, 'face')
+
+    def _set_color_mode(
+        self, color_mode: Union[ColorMode, str], attribute: str
+    ):
+        """ Set the face_color_mode or edge_color_mode property
+
+        Parameters
+        ----------
+        color_mode : str, ColorMode
+            The value for setting edge or face_color_mode. If color_mode is a string,
+            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.
+        """
+        color_mode = ColorMode(color_mode)
 
-        if face_color_mode == ColorMode.DIRECT:
-            self._face_color_mode = face_color_mode
-        elif face_color_mode in (ColorMode.CYCLE, ColorMode.COLORMAP):
-            if self._face_color_property == '':
+        if color_mode == ColorMode.DIRECT:
+            setattr(self, f'_{attribute}_color_mode', color_mode)
+        elif color_mode in (ColorMode.CYCLE, ColorMode.COLORMAP):
+            color_property = getattr(self, f'_{attribute}_color_property')
+            if color_property == '':
                 if self.properties:
-                    self._face_color_property = next(iter(self.properties))
+                    new_color_property = next(iter(self.properties))
+                    setattr(
+                        self,
+                        f'_{attribute}_color_property',
+                        new_color_property,
+                    )
                     warnings.warn(
-                        'Face color was not set, setting to: %s'
-                        % self._face_color_property
+                        '_{attribute}_color_property was not set, setting to: {new_color_property}'
                     )
                 else:
                     raise ValueError(
-                        'There must be a valid Points.properties to use %s'
-                        % face_color_mode
+                        'There must be a valid Points.properties to use {color_mode}'
                     )
 
             # ColorMode.COLORMAP can only be applied to numeric properties
-            if (face_color_mode == ColorMode.COLORMAP) and not issubclass(
-                self.properties[self._face_color_property].dtype.type,
-                np.number,
+            color_property = getattr(self, f'_{attribute}_color_property')
+            if (color_mode == ColorMode.COLORMAP) and not issubclass(
+                self.properties[color_property].dtype.type, np.number,
             ):
                 raise TypeError(
                     'selected property must be numeric to use ColorMode.COLORMAP'
                 )
-            self._face_color_mode = face_color_mode
+            setattr(self, f'_{attribute}_color_mode', color_mode)
+            self.refresh_colors()
+
+    def _set_color(self, color: ColorType, attribute: str):
+        """ Set the face_color or edge_color property
+
+        Parameters
+        ----------
+        color : (N, 4) array or str
+            The value for setting edge or face_color
+        attribute : str in {'edge', 'face'}
+            The name of the attribute to set the color of.
+            Should be 'edge' for edge_color or 'face' for face_color.
+        """
+        # if the provided color is a string, first check if it is a key in the properties.
+        # otherwise, assume it is the name of a color
+        if self._is_color_mapped(color):
+            if guess_continuous(self.properties[color]):
+                setattr(self, f'_{attribute}_color_mode', ColorMode.COLORMAP)
+            else:
+                setattr(self, f'_{attribute}_color_mode', ColorMode.CYCLE)
+            setattr(self, f'_{attribute}_color_property', color)
             self.refresh_colors()
 
+        else:
+            transformed_color = transform_color_with_defaults(
+                num_entries=len(self.data),
+                colors=color,
+                elem_name="face_color",
+                default="white",
+            )
+            colors = normalize_and_broadcast_colors(
+                len(self.data), transformed_color
+            )
+            setattr(self, f'_{attribute}_color', colors)
+            setattr(self, f'_{attribute}_color_mode', ColorMode.DIRECT)
+
+            color_event = getattr(self.events, f'{attribute}_color')
+            color_event()
+
     def refresh_colors(self, update_color_mapping: bool = False):
         """Calculate and update face and edge colors if using a cycle or color map
 
@@ -1007,131 +974,102 @@ def refresh_colors(self, update_color_mapping: bool = False):
             the color cycle map or colormap), set update_color_mapping=False.
             Default value is False.
         """
+
+        self._refresh_color('face', update_color_mapping)
+        self._refresh_color('edge', update_color_mapping)
+
+    def _refresh_color(self, attribute, update_color_mapping: bool = False):
+        """Calculate and update face or edge colors if using a cycle or color map
+
+        Parameters
+        ----------
+        attribute : str  in {'edge', 'face'}
+            The name of the attribute to set the color of.
+            Should be 'edge' for edge_color or 'face' for face_color.
+        update_color_mapping : bool
+            If set to True, the function will recalculate the color cycle map
+            or colormap (whichever is being used). If set to False, the function
+            will use the current color cycle map or color map. For example, if you
+            are adding/modifying points and want them to be colored with the same
+            mapping as the other points (i.e., the new points shouldn't affect
+            the color cycle map or colormap), set update_color_mapping=False.
+            Default value is False.
+        """
         if self._update_properties:
-            if self._face_color_mode == ColorMode.CYCLE:
-                face_color_properties = self.properties[
-                    self._face_color_property
-                ]
+            color_mode = getattr(self, f'_{attribute}_color_mode')
+            if color_mode == ColorMode.CYCLE:
+                color_property = getattr(self, f'_{attribute}_color_property')
+                color_properties = self.properties[color_property]
                 if update_color_mapping:
-                    self.face_color_cycle_map = {
+                    color_cycle = getattr(self, f'{attribute}_color_cycle')
+                    color_cycle_map = {
                         k: c
                         for k, c in zip(
-                            np.unique(face_color_properties),
-                            self.face_color_cycle,
+                            np.unique(color_properties), color_cycle,
                         )
                     }
+                    setattr(
+                        self, f'{attribute}_color_cycle_map', color_cycle_map
+                    )
 
                 else:
                     # add properties if they are not in the colormap
                     # and update_color_mapping==False
-                    face_color_cycle_keys = [*self.face_color_cycle_map]
-                    props_in_map = np.in1d(
-                        face_color_properties, face_color_cycle_keys
+                    color_cycle_map = getattr(
+                        self, f'{attribute}_color_cycle_map'
                     )
+                    color_cycle_keys = [*color_cycle_map]
+                    props_in_map = np.in1d(color_properties, color_cycle_keys)
                     if not np.all(props_in_map):
                         props_to_add = np.unique(
-                            face_color_properties[np.logical_not(props_in_map)]
+                            color_properties[np.logical_not(props_in_map)]
                         )
+                        color_cycle = getattr(self, f'{attribute}_color_cycle')
                         for prop in props_to_add:
-                            self.face_color_cycle_map[prop] = next(
-                                self.face_color_cycle
-                            )
-                face_colors = np.array(
-                    [
-                        self.face_color_cycle_map[x]
-                        for x in face_color_properties
-                    ]
-                )
-                if len(face_colors) == 0:
-                    face_colors = np.empty((0, 4))
-                self._face_color = face_colors
-
-                self.events.face_color()
-            elif self._face_color_mode == ColorMode.COLORMAP:
-                face_color_properties = self.properties[
-                    self._face_color_property
-                ]
-                if len(face_color_properties) > 0:
-                    if (
-                        update_color_mapping
-                        or self.face_contrast_limits is None
-                    ):
-                        face_colors, contrast_limits = map_property(
-                            prop=face_color_properties,
-                            colormap=self.face_colormap[1],
-                        )
-                        self.face_contrast_limits = contrast_limits
-                    else:
-                        face_colors, _ = map_property(
-                            prop=face_color_properties,
-                            colormap=self.face_colormap[1],
-                            contrast_limits=self.face_contrast_limits,
-                        )
-                else:
-                    face_colors = np.empty((0, 4))
-                self._face_color = face_colors
-
-            if self._edge_color_mode == ColorMode.CYCLE:
-                edge_color_properties = self.properties[
-                    self._edge_color_property
-                ]
-                if update_color_mapping:
-                    self.edge_color_cycle_map = {
-                        k: c
-                        for k, c in zip(
-                            np.unique(edge_color_properties),
-                            self.edge_color_cycle,
+                            color_cycle_map[prop] = next(color_cycle)
+                        setattr(
+                            self,
+                            f'{attribute}_color_cycle_map',
+                            color_cycle_map,
                         )
-                    }
-                else:
-                    # add properties if they are not in the colormap
-                    # and update_color_mapping==False
-                    edge_color_cycle_keys = [*self.edge_color_cycle_map]
-                    props_in_map = np.in1d(
-                        edge_color_properties, edge_color_cycle_keys
+                colors = np.array(
+                    [color_cycle_map[x] for x in color_properties]
+                )
+                if len(colors) == 0:
+                    colors = np.empty((0, 4))
+                setattr(self, f'_{attribute}_color', colors)
+
+            elif color_mode == ColorMode.COLORMAP:
+                color_property = getattr(self, f'_{attribute}_color_property')
+                color_properties = self.properties[color_property]
+                if len(color_properties) > 0:
+                    contrast_limits = getattr(
+                        self, f'{attribute}_contrast_limits'
                     )
-                    if not np.all(props_in_map):
-                        props_to_add = np.unique(
-                            edge_color_properties[np.logical_not(props_in_map)]
+                    colormap = getattr(self, f'{attribute}_colormap')
+                    if update_color_mapping or contrast_limits is None:
+
+                        colors, contrast_limits = map_property(
+                            prop=color_properties, colormap=colormap[1],
                         )
-                        for prop in props_to_add:
-                            self.edge_color_cycle_map[prop] = next(
-                                self.edge_color_cycle
-                            )
-                edge_colors = np.array(
-                    [
-                        self.edge_color_cycle_map[x]
-                        for x in edge_color_properties
-                    ]
-                )
-                if len(edge_colors) == 0:
-                    edge_colors = np.empty((0, 4))
-                self._edge_color = edge_colors
-            elif self._edge_color_mode == ColorMode.COLORMAP:
-                edge_color_properties = self.properties[
-                    self._edge_color_property
-                ]
-                if len(edge_color_properties) > 0:
-                    if (
-                        update_color_mapping
-                        or self.edge_contrast_limits is None
-                    ):
-                        edge_colors, contrast_limits = map_property(
-                            prop=edge_color_properties,
-                            colormap=self.edge_colormap[1],
+                        setattr(
+                            self,
+                            f'{attribute}_contrast_limits',
+                            contrast_limits,
                         )
-                        self.edge_contrast_limits = contrast_limits
                     else:
-                        edge_colors, _ = map_property(
-                            prop=edge_color_properties,
-                            colormap=self.edge_colormap[1],
-                            contrast_limits=self.edge_contrast_limits,
+
+                        colors, _ = map_property(
+                            prop=color_properties,
+                            colormap=colormap[1],
+                            contrast_limits=contrast_limits,
                         )
                 else:
-                    edge_colors = np.empty((0, 4))
-                self._edge_color = edge_colors
-            self.events.face_color()
-            self.events.edge_color()
+                    colors = np.empty((0, 4))
+                setattr(self, f'_{attribute}_color', colors)
+
+            color_event = getattr(self.events, f'{attribute}_color')
+            color_event()
 
     def _is_color_mapped(self, color):
         """ determines if the new color argument is for directly setting or cycle/colormap"""


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
@@ -1,4 +1,5 @@
 from copy import copy
+from itertools import cycle, islice
 from xml.etree.ElementTree import Element
 
 import numpy as np
@@ -10,6 +11,25 @@
 from napari.utils.colormaps.standardize_color import transform_color
 
 
+def _make_cycled_properties(values, length):
+    """Helper function to make property values
+
+    Parameters:
+    -----------
+    values :
+        The values to be cycled.
+    length : int
+        The length of the resulting property array
+
+    Returns:
+    --------
+    cycled_properties : np.ndarray
+        The property array comprising the cycled values.
+    """
+    cycled_properties = np.array(list(islice(cycle(values), 0, length)))
+    return cycled_properties
+
+
 def test_empty_points():
     pts = Points()
     assert pts.data.shape == (0, 2)
@@ -445,12 +465,46 @@ def test_properties():
     assert np.all(layer.properties['point_type'] == paste_annotations)
 
 
+@pytest.mark.parametrize("attribute", ['edge', 'face'])
+def test_adding_properties(attribute):
+    """Test adding properties to an existing layer"""
+    shape = (10, 2)
+    np.random.seed(0)
+    data = 20 * np.random.random(shape)
+    layer = Points(data)
+
+    # add properties
+    properties = {'point_type': _make_cycled_properties(['A', 'B'], shape[0])}
+    layer.properties = properties
+    np.testing.assert_equal(layer.properties, properties)
+
+    # add properties as a dataframe
+    properties_df = pd.DataFrame(properties)
+    layer.properties = properties_df
+    np.testing.assert_equal(layer.properties, properties)
+
+    # add properties as a dictionary with list values
+    properties_list = {
+        'point_type': list(_make_cycled_properties(['A', 'B'], shape[0]))
+    }
+    layer.properties = properties_list
+    assert isinstance(layer.properties['point_type'], np.ndarray)
+
+    # removing a property that was the _edge_color_property should give a warning
+    setattr(layer, f'_{attribute}_color_property', 'vector_type')
+    properties_2 = {
+        'not_vector_type': _make_cycled_properties(['A', 'B'], shape[0])
+    }
+    with pytest.warns(RuntimeWarning):
+        layer.properties = properties_2
+
+
 def test_properties_dataframe():
     """test if properties can be provided as a DataFrame"""
     shape = (10, 2)
     np.random.seed(0)
     data = 20 * np.random.random(shape)
-    properties = {'point_type': np.array(['A', 'B'] * int(shape[0] / 2))}
+    properties = {'point_type': _make_cycled_properties(['A', 'B'], shape[0])}
     properties_df = pd.DataFrame(properties)
     properties_df = properties_df.astype(properties['point_type'].dtype)
     layer = Points(data, properties=properties_df)
@@ -462,7 +516,9 @@ def test_properties_list():
     shape = (10, 2)
     np.random.seed(0)
     data = 20 * np.random.random(shape)
-    properties = {'point_type': ['A', 'B'] * int(shape[0] / 2)}
+    properties = {
+        'point_type': list(_make_cycled_properties(['A', 'B'], shape[0]))
+    }
     layer = Points(data, properties=properties)
     np.testing.assert_equal(layer.properties, properties)
 
@@ -474,7 +530,7 @@ def test_adding_annotations():
     shape = (10, 2)
     np.random.seed(0)
     data = 20 * np.random.random(shape)
-    properties = {'point_type': np.array(['A', 'B'] * int((shape[0] / 2)))}
+    properties = {'point_type': _make_cycled_properties(['A', 'B'], shape[0])}
     layer = Points(data)
     assert layer.properties == {}
 
@@ -484,7 +540,7 @@ def test_adding_annotations():
 
     # change properties
     new_annotations = {
-        'other_type': np.array(['C', 'D'] * int((shape[0] / 2)))
+        'other_type': _make_cycled_properties(['C', 'D'], shape[0])
     }
     layer.properties = copy(new_annotations)
     assert layer.properties == new_annotations
@@ -495,7 +551,7 @@ def test_add_points_with_properties():
     shape = (10, 2)
     np.random.seed(0)
     data = 20 * np.random.random(shape)
-    properties = {'point_type': np.array(['A', 'B'] * int((shape[0] / 2)))}
+    properties = {'point_type': _make_cycled_properties(['A', 'B'], shape[0])}
     layer = Points(data, properties=copy(properties))
 
     coord = [18, 18]
@@ -509,7 +565,9 @@ def test_add_points_with_properties_as_list():
     shape = (10, 2)
     np.random.seed(0)
     data = 20 * np.random.random(shape)
-    properties = {'point_type': ['A', 'B'] * int((shape[0] / 2))}
+    properties = {
+        'point_type': list(_make_cycled_properties(['A', 'B'], shape[0]))
+    }
     layer = Points(data, properties=copy(properties))
 
     coord = [18, 18]
@@ -523,7 +581,7 @@ def test_updating_points_properties():
     shape = (10, 2)
     np.random.seed(0)
     data = 20 * np.random.random(shape)
-    properties = {'point_type': np.array(['A', 'B'] * int((shape[0] / 2)))}
+    properties = {'point_type': _make_cycled_properties(['A', 'B'], shape[0])}
     layer = Points(data, properties=copy(properties))
 
     layer.mode = 'select'
@@ -550,7 +608,7 @@ def test_is_color_mapped():
     shape = (10, 2)
     np.random.seed(0)
     data = 20 * np.random.random(shape)
-    annotations = {'point_type': np.array(['A', 'B'] * int((shape[0] / 2)))}
+    annotations = {'point_type': _make_cycled_properties(['A', 'B'], shape[0])}
     layer = Points(data, properties=annotations)
 
     # giving the name of an annotation should return True
@@ -608,6 +666,107 @@ def test_n_dimensional():
     assert layer.n_dimensional is True
 
 
+@pytest.mark.parametrize("attribute", ['edge', 'face'])
+def test_switch_color_mode(attribute):
+    """Test switching between color modes"""
+    shape = (10, 2)
+    np.random.seed(0)
+    data = 20 * np.random.random(shape)
+    # create a continuous property with a known value in the last element
+    continuous_prop = np.random.random((shape[0],))
+    continuous_prop[-1] = 1
+    properties = {
+        'point_truthiness': continuous_prop,
+        'point_type': _make_cycled_properties(['A', 'B'], shape[0]),
+    }
+    initial_color = [1, 0, 0, 1]
+    color_cycle = ['red', 'blue']
+    color_kwarg = f'{attribute}_color'
+    colormap_kwarg = f'{attribute}_colormap'
+    color_cycle_kwarg = f'{attribute}_color_cycle'
+    args = {
+        color_kwarg: initial_color,
+        colormap_kwarg: 'gray',
+        color_cycle_kwarg: color_cycle,
+    }
+    layer = Points(data, properties=properties, **args,)
+
+    layer_color_mode = getattr(layer, f'{attribute}_color_mode')
+    layer_color = getattr(layer, f'{attribute}_color')
+    assert layer_color_mode == 'direct'
+    np.testing.assert_allclose(
+        layer_color, np.repeat([initial_color], shape[0], axis=0)
+    )
+
+    # there should not be an edge_color_property
+    color_property = getattr(layer, f'_{attribute}_color_property')
+    assert color_property == ''
+
+    # transitioning to colormap should raise a warning
+    # because there isn't an edge color property yet and
+    # the first property in points.properties is being automatically selected
+    with pytest.warns(UserWarning):
+        setattr(layer, f'{attribute}_color_mode', 'colormap')
+    color_property = getattr(layer, f'_{attribute}_color_property')
+    assert color_property == next(iter(properties))
+    layer_color = getattr(layer, f'{attribute}_color')
+    np.testing.assert_allclose(layer_color[-1], [1, 1, 1, 1])
+
+    # switch to color cycle
+    setattr(layer, f'{attribute}_color_mode', 'cycle')
+    setattr(layer, f'{attribute}_color', 'point_type')
+    color = getattr(layer, f'{attribute}_color')
+    layer_color = transform_color(color_cycle * int((shape[0] / 2)))
+    np.testing.assert_allclose(color, layer_color)
+
+    # switch back to direct, edge_colors shouldn't change
+    setattr(layer, f'{attribute}_color_mode', 'direct')
+    new_edge_color = getattr(layer, f'{attribute}_color')
+    np.testing.assert_allclose(new_edge_color, color)
+
+
+@pytest.mark.parametrize("attribute", ['edge', 'face'])
+def test_colormap_without_properties(attribute):
+    """Setting the colormode to colormap should raise an exception"""
+    shape = (10, 2)
+    np.random.seed(0)
+    data = 20 * np.random.random(shape)
+    layer = Points(data)
+
+    with pytest.raises(ValueError):
+        setattr(layer, f'{attribute}_color_mode', 'colormap')
+
+
+@pytest.mark.parametrize("attribute", ['edge', 'face'])
+def test_colormap_with_categorical_properties(attribute):
+    """Setting the colormode to colormap should raise an exception"""
+    shape = (10, 2)
+    np.random.seed(0)
+    data = 20 * np.random.random(shape)
+    properties = {'point_type': _make_cycled_properties(['A', 'B'], shape[0])}
+    layer = Points(data, properties=properties)
+
+    with pytest.raises(TypeError):
+        setattr(layer, f'{attribute}_color_mode', 'colormap')
+
+
+@pytest.mark.parametrize("attribute", ['edge', 'face'])
+def test_add_colormap(attribute):
+    """Test  directly adding a vispy Colormap object"""
+    shape = (10, 2)
+    np.random.seed(0)
+    data = 20 * np.random.random(shape)
+    annotations = {'point_type': _make_cycled_properties([0, 1.5], shape[0])}
+    color_kwarg = f'{attribute}_color'
+    colormap_kwarg = f'{attribute}_colormap'
+    args = {color_kwarg: 'point_type', colormap_kwarg: 'viridis'}
+    layer = Points(data, properties=annotations, **args,)
+
+    setattr(layer, f'{attribute}_colormap', get_colormap('gray'))
+    layer_colormap = getattr(layer, f'{attribute}_colormap')
+    assert layer_colormap[0] == 'unknown_colormap'
+
+
 def test_edge_color_direct():
     """Test setting edge color."""
     shape = (10, 2)
@@ -683,7 +842,7 @@ def test_edge_color_cycle():
     shape = (10, 2)
     np.random.seed(0)
     data = 20 * np.random.random(shape)
-    annotations = {'point_type': np.array(['A', 'B'] * int((shape[0] / 2)))}
+    annotations = {'point_type': _make_cycled_properties(['A', 'B'], shape[0])}
     color_cycle = ['red', 'blue']
     layer = Points(
         data,
@@ -727,6 +886,9 @@ def test_edge_color_cycle():
         ),
     )
 
+    # refresh colors
+    layer.refresh_colors(update_color_mapping=True)
+
 
 def test_add_edge_color_cycle_to_empty_layer():
     """ Test adding a point to an empty layer when edge color is a color cycle
@@ -773,7 +935,7 @@ def test_adding_value_edge_color_cycle():
     shape = (10, 2)
     np.random.seed(0)
     data = 20 * np.random.random(shape)
-    annotations = {'point_type': np.array(['A', 'B'] * int((shape[0] / 2)))}
+    annotations = {'point_type': _make_cycled_properties(['A', 'B'], shape[0])}
     color_cycle = ['red', 'blue']
     layer = Points(
         data,
@@ -797,7 +959,7 @@ def test_edge_color_colormap():
     shape = (10, 2)
     np.random.seed(0)
     data = 20 * np.random.random(shape)
-    annotations = {'point_type': np.array([0, 1.5] * int((shape[0] / 2)))}
+    annotations = {'point_type': _make_cycled_properties([0, 1.5], shape[0])}
     layer = Points(
         data,
         properties=annotations,
@@ -924,7 +1086,7 @@ def test_face_color_cycle():
     shape = (10, 2)
     np.random.seed(0)
     data = 20 * np.random.random(shape)
-    annotations = {'point_type': np.array(['A', 'B'] * int((shape[0] / 2)))}
+    annotations = {'point_type': _make_cycled_properties(['A', 'B'], shape[0])}
     color_cycle = ['red', 'blue']
     layer = Points(
         data,
@@ -968,6 +1130,9 @@ def test_face_color_cycle():
         ),
     )
 
+    # refresh colors
+    layer.refresh_colors(update_color_mapping=True)
+
 
 def test_add_face_color_cycle_to_empty_layer():
     """ Test adding a point to an empty layer when face color is a color cycle
@@ -1013,7 +1178,7 @@ def test_adding_value_face_color_cycle():
     shape = (10, 2)
     np.random.seed(0)
     data = 20 * np.random.random(shape)
-    annotations = {'point_type': np.array(['A', 'B'] * int((shape[0] / 2)))}
+    annotations = {'point_type': _make_cycled_properties(['A', 'B'], shape[0])}
     color_cycle = ['red', 'blue']
     layer = Points(
         data,
@@ -1037,7 +1202,7 @@ def test_face_color_colormap():
     shape = (10, 2)
     np.random.seed(0)
     data = 20 * np.random.random(shape)
-    annotations = {'point_type': np.array([0, 1.5] * int((shape[0] / 2)))}
+    annotations = {'point_type': _make_cycled_properties([0, 1.5], shape[0])}
     layer = Points(
         data,
         properties=annotations,