Repository: napari/napari

Issue:
Text is not shown for initially empty point layers
## 🐛 Bug

When working with initially empty point layers, the text labels of points added by the user are not displayed.

## To Reproduce

```py
import napari
import numpy as np

from PIL import Image

# fix DecompressionBombErrors
Image.MAX_IMAGE_PIXELS = None


def on_click(layer, event):
    if layer.mode == 'add':
        # assign the next ID to the current point
        layer.current_properties['id'][0] = max(layer.properties['id'], default=-1) + 1
    elif layer.mode == 'select':
        # print properties (for debugging purposes only)
        print(layer.properties)


with napari.gui_qt():
    viewer = napari.Viewer()
    viewer.open('/path/to/file.png')
    # Note: the necessity to specify **potential** property values here is limiting!
    points_layer = viewer.add_points(properties={'id': np.arange(1000)}, text='id')
    points_layer.mouse_drag_callbacks.append(on_click)
```

## Expected behavior

The IDs are displayed next to the points added by the user

## Environment

napari: 0.4.2
Platform: Linux-5.4.0-60-generic-x86_64-with-glibc2.31
Python: 3.9.1 (default, Dec 11 2020, 14:32:07) [GCC 7.3.0]
Qt: 5.15.2
PyQt5: 5.15.2
NumPy: 1.19.5
SciPy: 1.6.0
Dask: 2020.12.0
VisPy: 0.6.6

GL version: 4.6.0 NVIDIA 460.32.03
MAX_TEXTURE_SIZE: 16384

Plugins:
- napari_plugin_engine: 0.1.9
- svg: 0.1.4

## Additional context

Use case: selecting control points in two images for image registration


Discussion:
My current workaround is adding the following to the example code above:
```py
    @points_layer.events.current_properties.connect
    def text_workaround(event):
        points_layer.text = 'id'
```

However, this workaround does not (directly) allow for using dictionary definitions of `text`, see https://github.com/napari/napari/issues/1833
Hello @jwindhager ! Thank you for making this issue! I agree that this is not the ideal behavior. One should be able to create an empty `Points` layer with the text property already specified and then when a point is added, the corresponding text element gets added. I think this shouldn't be too difficult to add, but I have a few things in the queue right now.

A bit of context about requiring potential properties to be specified when creating an empty layer with properties: We did it this way because we wanted it so if the user created an empty layer with properties and then through the GUI they added a point, that new point would be assigned property values. However, I can see how this would be annoying. We are happy to hear any suggestions you may have about improving the behavior!
Thank you for the insight and for handling this, @kevinyamauchi !

> A bit of context about requiring potential properties to be specified when creating an empty layer with properties: We did it this way because we wanted it so if the user created an empty layer with properties and then through the GUI they added a point, that new point would be assigned property values. However, I can see how this would be annoying. We are happy to hear any suggestions you may have about improving the behavior!

I'd suggest to change the signature to `properties: Union[None, list[str], dict[str, Optional[list]]] = None`:
- `list[str]` for simply specifying a list of property names
- `dict[str, Optional[list]]` for specifying property names together with potential values
This issue has been mentioned on **Image.sc Forum**. There might be relevant details there:

https://forum.image.sc/t/bounding-box-annotations-with-text/46607/3


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
@@ -1488,7 +1488,7 @@ def _paste_data(self):
                 range(totpoints, totpoints + len(self._clipboard['data']))
             )
 
-            if self._clipboard['text'] is not None:
+            if len(self._clipboard['text']) > 0:
                 self.text._values = np.concatenate(
                     (self.text.values, self._clipboard['text']), axis=0
                 )
@@ -1510,8 +1510,8 @@ def _copy_data(self):
                 'indices': self._slice_indices,
             }
 
-            if self.text.values is None:
-                self._clipboard['text'] = None
+            if len(self.text.values) == 0:
+                self._clipboard['text'] = np.empty(0)
 
             else:
                 self._clipboard['text'] = deepcopy(self.text.values[index])
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
@@ -2285,8 +2285,8 @@ def _copy_data(self):
                 },
                 'indices': self._slice_indices,
             }
-            if self.text.values is None:
-                self._clipboard['text'] = None
+            if len(self.text.values) == 0:
+                self._clipboard['text'] = np.empty(0)
             else:
                 self._clipboard['text'] = deepcopy(self.text.values[index])
         else:
@@ -2322,7 +2322,7 @@ def _paste_data(self):
                     shape, face_color=face_color, edge_color=edge_color
                 )
 
-            if self._clipboard['text'] is not None:
+            if len(self._clipboard['text']) > 0:
                 self.text._values = np.concatenate(
                     (self.text.values, self._clipboard['text']), axis=0
                 )
diff --git a/napari/layers/utils/text.py b/napari/layers/utils/text.py
--- a/napari/layers/utils/text.py
+++ b/napari/layers/utils/text.py
@@ -107,10 +107,21 @@ def values(self):
     def _set_text(
         self, text: Union[None, str], n_text: int, properties: dict = {}
     ):
-        if len(properties) == 0 or n_text == 0 or text is None:
+        if text is None:
+            text = np.empty(0)
+        if n_text == 0 and len(text) != 0:
+            # initialize text but don't add text elements
+            formatted_text, text_mode = format_text_properties(
+                text, n_text, properties
+            )
+            self._text_format_string = text
+            self._mode = text_mode
+            self._values = formatted_text
+        elif len(properties) == 0 or len(text) == 0:
+            # set text mode to NONE if no props/text are provided
             self._mode = TextMode.NONE
             self._text_format_string = ''
-            self._values = None
+            self._values = np.empty(0)
         else:
             formatted_text, text_mode = format_text_properties(
                 text, n_text, properties
@@ -288,7 +299,7 @@ def compute_text_coords(
         anchor_y : str
             THe vispy text anchor for the y axis
         """
-        if self._mode in [TextMode.FORMATTED, TextMode.PROPERTY]:
+        if len(self.values) > 0:
             anchor_coords, anchor_x, anchor_y = get_text_anchors(
                 view_data, ndisplay, self._anchor
             )


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
@@ -119,6 +119,37 @@ def test_empty_layer_with_edge_colormap():
     np.testing.assert_allclose(layer._edge.current_color, edge_color)
 
 
+def test_empty_layer_with_text_properties():
+    """Test initializing an empty layer with text defined"""
+    default_properties = {'point_type': np.array([1.5], dtype=float)}
+    text_kwargs = {'text': 'point_type', 'color': 'red'}
+    layer = Points(
+        properties=default_properties,
+        text=text_kwargs,
+    )
+    np.testing.assert_equal(layer.text.values, np.empty(0))
+    np.testing.assert_allclose(layer.text.color, [1, 0, 0, 1])
+
+    # add a point and check that the appropriate text value was added
+    layer.add([1, 1])
+    np.testing.assert_equal(layer.text.values, ['1.5'])
+    np.testing.assert_allclose(layer.text.color, [1, 0, 0, 1])
+
+
+def test_empty_layer_with_text_formatted():
+    """Test initializing an empty layer with text defined"""
+    default_properties = {'point_type': np.array([1.5], dtype=float)}
+    layer = Points(
+        properties=default_properties,
+        text='point_type: {point_type:.2f}',
+    )
+    np.testing.assert_equal(layer.text.values, np.empty(0))
+
+    # add a point and check that the appropriate text value was added
+    layer.add([1, 1])
+    np.testing.assert_equal(layer.text.values, ['point_type: 1.50'])
+
+
 def test_random_points():
     """Test instantiating Points layer with random 2D data."""
     shape = (10, 2)
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
@@ -159,6 +159,39 @@ def test_properties_dataframe():
     np.testing.assert_equal(layer.properties, properties)
 
 
+def test_empty_layer_with_text_properties():
+    """Test initializing an empty layer with text defined"""
+    default_properties = {'shape_type': np.array([1.5], dtype=float)}
+    text_kwargs = {'text': 'shape_type', 'color': 'red'}
+    layer = Shapes(
+        properties=default_properties,
+        text=text_kwargs,
+    )
+    assert layer.text.mode == 'property'
+    np.testing.assert_equal(layer.text.values, np.empty(0))
+    np.testing.assert_allclose(layer.text.color, [1, 0, 0, 1])
+
+    # add a shape and check that the appropriate text value was added
+    layer.add(np.random.random((1, 4, 2)))
+    np.testing.assert_equal(layer.text.values, ['1.5'])
+    np.testing.assert_allclose(layer.text.color, [1, 0, 0, 1])
+
+
+def test_empty_layer_with_text_formatted():
+    """Test initializing an empty layer with text defined"""
+    default_properties = {'shape_type': np.array([1.5], dtype=float)}
+    layer = Shapes(
+        properties=default_properties,
+        text='shape_type: {shape_type:.2f}',
+    )
+    assert layer.text.mode == 'formatted'
+    np.testing.assert_equal(layer.text.values, np.empty(0))
+
+    # add a shape and check that the appropriate text value was added
+    layer.add(np.random.random((1, 4, 2)))
+    np.testing.assert_equal(layer.text.values, ['shape_type: 1.50'])
+
+
 @pytest.mark.parametrize("properties", [properties_array, properties_list])
 def test_text_from_property_value(properties):
     """Test setting text from a property value"""
diff --git a/napari/layers/utils/_tests/test_text.py b/napari/layers/utils/_tests/test_text.py
--- a/napari/layers/utils/_tests/test_text.py
+++ b/napari/layers/utils/_tests/test_text.py
@@ -4,6 +4,39 @@
 from napari.layers.utils.text import TextManager
 
 
+def test_empty_text_manager_property():
+    """Test creating an empty text manager in property mode.
+    This is for creating an empty layer with text initialized.
+    """
+    properties = {'confidence': np.empty(0, dtype=np.float)}
+    text_manager = TextManager(
+        text='confidence', n_text=0, properties=properties
+    )
+    assert text_manager.mode == 'property'
+    np.testing.assert_equal(text_manager.values, np.empty(0))
+
+    # add a text element
+    new_properties = {'confidence': np.array([0.5])}
+    text_manager.add(new_properties, 1)
+    np.testing.assert_equal(text_manager.values, ['0.5'])
+
+
+def test_empty_text_manager_format():
+    """Test creating an empty text manager in formatted mode.
+    This is for creating an empty layer with text initialized.
+    """
+    properties = {'confidence': np.empty(0, dtype=np.float)}
+    text = 'confidence: {confidence:.2f}'
+    text_manager = TextManager(text=text, n_text=0, properties=properties)
+    assert text_manager.mode == 'formatted'
+    np.testing.assert_equal(text_manager.values, np.empty(0))
+
+    # add a text element
+    new_properties = {'confidence': np.array([0.5])}
+    text_manager.add(new_properties, 1)
+    np.testing.assert_equal(text_manager.values, ['confidence: 0.50'])
+
+
 def test_text_manager_property():
     n_text = 3
     text = 'class'