Repository: napari/napari

Issue:
Minor improvement: Guide the user on how to open images
## 🚀 Feature
Improve the user experience for new users when there's an empty canvas. Provide instructions on how to open images or even a quick message for how to get started.

## Motivation

It's not totally clear that I can drag images to the canvas to open them. A clear message would help the user to get started quicker.

## Pitch

I recommend having a message on the canvas when I first open napari with an empty canvas that says something like “Drag image(s) here to open or use the menu: File > Open image(s)”
![empty_canvas_message](https://user-images.githubusercontent.com/57509887/79029991-c4a2f900-7b4b-11ea-94bd-e74341754996.png)





Discussion:
Yes this would be really nice, probably best to just create a special http://vispy.org/visuals.html#vispy.visuals.TextVisual with that message and then we detect that no layers are present in the layers list show it and then hide it otherwise. This could be triggered from our model file here https://github.com/napari/napari/blob/ff22470b8ecb6c54a25925b126be21467157de20/napari/components/viewer_model.py#L454
The text visual would get added here https://github.com/napari/napari/blob/ff22470b8ecb6c54a25925b126be21467157de20/napari/_qt/qt_viewer.py#L142 probably after being created as inside it's own dedicated `_vispy/vispy_canvas_text.py` file

[EDIT] See discussion below that there is no need to use a vispy text visual in the canvas here, instead we can just use a Qt widget. Though the point at which that display gets triggered from the model file remains the same.
that would certainly work, but it's not immediately obvious to me that we want to do this with GL, or that it actually needs to be a vispy visual that gets added to the scene canvas (even though it would be shown in the same general area).  I'm not sure whether GL will render text (with all the anti-aliasing considerations that platforms handle) as "naturally" as a Qt widget?
I'll also just put a screenshot here of the vscode info placeholder, which I've always liked:

<img width="607" alt="Screen Shot 2020-04-12 at 12 23 07 PM" src="https://user-images.githubusercontent.com/1609449/79074095-6fd0c100-7cb8-11ea-9c33-34f90d9ea110.png">
> but it's not immediately obvious to me that we want to do this with GL, or that it actually needs to be a vispy visual that gets added to the scene canvas (even though it would be shown in the same general area).

Oh right - we could just "do this with Qt" instead by showing a different widget instead of the canvas right and not using vispy? That would be much much simpler - I like what you're showing there from vscode!!
yeah, that's what I was thinking.  it could even be a transparent widget that sits on top of the canvas area that disappears when `len(layers) > 0`... so we wouldn't need to change anything about the canvas.
What do you think about something like this?
![napari_blank_canvas_mockup](https://user-images.githubusercontent.com/57509887/79295889-bd068b80-7e8e-11ea-9399-0108537627c5.png)

Beautiful @justinelarsen - looks like time for your first napari PR :-)
I started working on this but hit a snag. 

I created a new QtCanvas class which is instantiated within the QtViewer to contain both the existing VisPy canvas and a new widget for an empty canvas. The idea is to show only one at a time based on if there are layers or not. I created a diagram that I thought would help to explain. 

As I started implementing though, I realized that there are a lot of methods within QtViewer that use/set canvas properties or call on canvas methods. Also, there are a lot of mouse event methods in QtViewer that would need to be moved with the SceneCanvas as those methods get connected to the canvas.

This seems like a lot of work and potential to create bugs for something that I feel shouldn't be so complicated so I wanted to stop and get others' opinions. I could just leave everything in QtViewer and not create a new class for QtCanvas and then just add the new widgets to QtViewer. I'm not sure if this is bulking the QtViewer class too much though. Thoughts?

![napari emptyCanvas Implementation](https://user-images.githubusercontent.com/57509887/80761862-3984a580-8af0-11ea-9115-ea6e79c0589d.jpg)

Hmm, I would try making QtCanvas not a QStackWidget, but just a QWidget (potentially subclassing, or at least containing) the SceneCanvas. This will then make the swapping out much easier. As you said many of the mouse events/ methods would have to move, but it could actually consolidate a lot of our vispy code - see this proposal from a while back https://github.com/napari/napari/issues/855

QtViewer would still need QStackWidget, but this could maybe just be created directly there and there would just be minimal logic inside the QtViewer to decide which widget is being shown. If it becomes too much, you could also add another new class that subclassed the QStackWidget and was very light weight and add that to QtViewer.

Resolving patch:
diff --git a/napari/_qt/qt_viewer.py b/napari/_qt/qt_viewer.py
--- a/napari/_qt/qt_viewer.py
+++ b/napari/_qt/qt_viewer.py
@@ -40,6 +40,7 @@
     VispyAxesVisual,
     VispyCamera,
     VispyScaleBarVisual,
+    VispyWelcomeVisual,
     create_vispy_visual,
 )
 
@@ -60,6 +61,9 @@ class QtViewer(QSplitter):
     ----------
     viewer : napari.components.ViewerModel
         Napari viewer containing the rendered scene, layers, and controls.
+    welcome : bool
+        Flag to show a welcome message when no layers are present in the
+        canvas.
 
     Attributes
     ----------
@@ -95,7 +99,7 @@ class QtViewer(QSplitter):
 
     raw_stylesheet = get_stylesheet()
 
-    def __init__(self, viewer):
+    def __init__(self, viewer, welcome=True):
         # Avoid circular import.
         from .layer_controls import QtLayerControlsContainer
 
@@ -207,6 +211,17 @@ def __init__(self, viewer):
         )
         self.canvas.events.resize.connect(self.scale_bar._on_position_change)
 
+        self._show_welcome = welcome
+        if self._show_welcome:
+            self.welcome = VispyWelcomeVisual(
+                self.viewer, parent=self.view, order=-100
+            )
+            self.viewer.events.layers_change.connect(
+                self.welcome._on_visible_change
+            )
+            self.viewer.events.palette.connect(self.welcome._on_palette_change)
+            self.canvas.events.resize.connect(self.welcome._on_canvas_change)
+
         main_widget = QWidget()
         main_layout = QVBoxLayout()
         main_layout.setContentsMargins(10, 22, 10, 2)
diff --git a/napari/_vispy/__init__.py b/napari/_vispy/__init__.py
--- a/napari/_vispy/__init__.py
+++ b/napari/_vispy/__init__.py
@@ -3,3 +3,4 @@
 from .vispy_axes_visual import VispyAxesVisual
 from .vispy_camera import VispyCamera
 from .vispy_scale_bar_visual import VispyScaleBarVisual
+from .vispy_welcome_visual import VispyWelcomeVisual
diff --git a/napari/_vispy/vispy_welcome_visual.py b/napari/_vispy/vispy_welcome_visual.py
new file mode 100644
--- /dev/null
+++ b/napari/_vispy/vispy_welcome_visual.py
@@ -0,0 +1,126 @@
+from os.path import dirname, join
+
+import numpy as np
+import scipy.ndimage as ndi
+from imageio import imread
+from vispy.scene.visuals import Text
+from vispy.visuals.transforms import STTransform
+
+from ..utils.misc import str_to_rgb
+from ..utils.theme import darken, lighten
+from .image import Image as ImageNode
+
+
+class VispyWelcomeVisual:
+    """Welcome to napari visual.
+    """
+
+    def __init__(self, viewer, parent=None, order=0):
+
+        self._viewer = viewer
+
+        # Load logo and make grayscale
+        logopath = join(dirname(__file__), '..', 'resources', 'logo.png')
+        logo = imread(logopath)
+        self._logo_raw = logo
+        self._logo_border = np.all(logo[..., :3] == [38, 40, 61], axis=2)
+        self._logo = np.zeros(logo.shape)
+
+        self.node = ImageNode(parent=parent)
+        self.node.order = order
+
+        self.node.cmap = 'gray'
+        self.node.transform = STTransform()
+
+        self.text_node = Text(pos=[0, 0], parent=parent, bold=True)
+        self.text_node.order = order
+        self.text_node.transform = STTransform()
+        self.text_node.anchors = ('center', 'center')
+        self.text_node.text = (
+            'drag and drop file(s) here\n'
+            'use the File Open menu\n'
+            'call a viewer.add_* method'
+        )
+        self.text_node.color = np.divide(
+            str_to_rgb(darken(self._viewer.palette['foreground'], 30)), 255
+        )
+
+        self._on_palette_change(None)
+        self._on_visible_change(None)
+        self._on_canvas_change(None)
+
+    def _on_palette_change(self, event):
+        """Change colors of the logo and text."""
+        if (
+            np.mean(str_to_rgb(self._viewer.palette['background'])[:3])
+            < 255 / 2
+        ):
+            foreground_color = np.divide(
+                str_to_rgb(darken(self._viewer.palette['foreground'], 50)), 255
+            )
+            background_color = np.divide(
+                str_to_rgb(darken(self._viewer.palette['background'], 70)), 255
+            )
+            text_color = np.divide(
+                str_to_rgb(darken(self._viewer.palette['foreground'], 50)), 255
+            )
+        else:
+            foreground_color = np.divide(
+                str_to_rgb(lighten(self._viewer.palette['foreground'], 30)),
+                255,
+            )
+            background_color = np.divide(
+                str_to_rgb(lighten(self._viewer.palette['background'], 30)),
+                255,
+            )
+            text_color = np.divide(
+                str_to_rgb(darken(self._viewer.palette['background'], 40)), 255
+            )
+
+        new_logo = np.zeros(self._logo_raw.shape)
+        new_logo[self._logo_border, :3] = foreground_color
+        new_logo[np.invert(self._logo_border), :3] = background_color
+        new_logo[..., -1] = self._logo_raw[..., -1] * 0.7
+
+        # Do a convolution to smooth any pixelation
+        kernel = np.array([[0, 0.5, 0], [0.5, 1, 0.5], [0, 0.5, 0]])
+        kernel = np.expand_dims(kernel / np.sum(kernel), axis=2)
+        new_logo = ndi.convolve(new_logo, kernel)
+
+        self._logo = new_logo
+        self.node.set_data(self._logo)
+        self.text_node.color = list(text_color) + [0.7]
+
+    def _on_visible_change(self, event):
+        """Change visibiliy of axes."""
+        visible = len(self._viewer.layers) == 0
+        self.node.visible = visible
+        self.text_node.visible = visible
+
+    def _on_canvas_change(self, event):
+        """Change visibiliy of axes."""
+        if self.node.canvas is not None:
+            center = np.divide(self.node.canvas.size, 2)
+        else:
+            center = np.array([256, 256])
+
+        # Calculate some good default positions for the logo and text
+        center_logo = [
+            center[0] - center[1] / 2.4,
+            2 / 3 * center[1] - center[1] / 3,
+        ]
+        self.node.transform.translate = [center_logo[0], center_logo[1], 0, 0]
+        self.node.transform.scale = [
+            center[1] / 1.2 / self._logo.shape[0],
+            center[1] / 1.2 / self._logo.shape[0],
+            0,
+            0,
+        ]
+
+        self.text_node.font_size = center[1] / 24
+        self.text_node.transform.translate = [
+            center[0],
+            1.45 * center[1],
+            0,
+            0,
+        ]


Test patch:
diff --git a/napari/_tests/test_with_screenshot.py b/napari/_tests/test_with_screenshot.py
--- a/napari/_tests/test_with_screenshot.py
+++ b/napari/_tests/test_with_screenshot.py
@@ -407,6 +407,32 @@ def test_labels_painting(make_test_viewer):
     assert screenshot[:, :, :2].max() > 0
 
 
+@pytest.mark.skipif(
+    sys.platform.startswith('win') or not os.getenv("CI"),
+    reason='Screenshot tests are not supported on napari windows CI.',
+)
+def test_welcome(make_test_viewer):
+    """Test that something appears when axes become visible."""
+    viewer = make_test_viewer(show=True)
+
+    # Check something is visible
+    screenshot = viewer.screenshot(canvas_only=True)
+    assert len(viewer.layers) == 0
+    assert screenshot[..., :-1].max() > 0
+
+    # Check adding zeros image makes it go away
+    viewer.add_image(np.zeros((1, 1)))
+    screenshot = viewer.screenshot(canvas_only=True)
+    assert len(viewer.layers) == 1
+    assert screenshot[..., :-1].max() == 0
+
+    # Remove layer and check something is visible again
+    viewer.layers.pop(0)
+    screenshot = viewer.screenshot(canvas_only=True)
+    assert len(viewer.layers) == 0
+    assert screenshot[..., :-1].max() > 0
+
+
 @pytest.mark.skipif(
     sys.platform.startswith('win') or not os.getenv("CI"),
     reason='Screenshot tests are not supported on napari windows CI.',
@@ -416,21 +442,20 @@ def test_axes_visible(make_test_viewer):
     viewer = make_test_viewer(show=True)
 
     # Check axes are not visible
-    screenshot = viewer.screenshot(canvas_only=True)
+    launch_screenshot = viewer.screenshot(canvas_only=True)
     assert not viewer.axes.visible
-    assert screenshot[..., :-1].max() == 0
 
     # Make axes visible and check something is seen
     viewer.axes.visible = True
-    screenshot = viewer.screenshot(canvas_only=True)
+    on_screenshot = viewer.screenshot(canvas_only=True)
     assert viewer.axes.visible
-    assert screenshot[..., :-1].max() > 0
+    assert abs(on_screenshot - launch_screenshot).max() > 0
 
     # Make axes not visible and check they are gone
     viewer.axes.visible = False
-    screenshot = viewer.screenshot(canvas_only=True)
+    off_screenshot = viewer.screenshot(canvas_only=True)
     assert not viewer.axes.visible
-    assert screenshot[..., :-1].max() == 0
+    np.testing.assert_almost_equal(launch_screenshot, off_screenshot)
 
 
 @pytest.mark.skipif(
@@ -442,18 +467,17 @@ def test_scale_bar_visible(make_test_viewer):
     viewer = make_test_viewer(show=True)
 
     # Check scale bar is not visible
-    screenshot = viewer.screenshot(canvas_only=True)
+    launch_screenshot = viewer.screenshot(canvas_only=True)
     assert not viewer.scale_bar.visible
-    assert screenshot[..., :-1].max() == 0
 
     # Make scale bar visible and check something is seen
     viewer.scale_bar.visible = True
-    screenshot = viewer.screenshot(canvas_only=True)
+    on_screenshot = viewer.screenshot(canvas_only=True)
     assert viewer.scale_bar.visible
-    assert screenshot[..., :-1].max() > 0
+    assert abs(on_screenshot - launch_screenshot).max() > 0
 
     # Make scale bar not visible and check it is gone
     viewer.scale_bar.visible = False
-    screenshot = viewer.screenshot(canvas_only=True)
+    off_screenshot = viewer.screenshot(canvas_only=True)
     assert not viewer.scale_bar.visible
-    assert screenshot[..., :-1].max() == 0
+    np.testing.assert_almost_equal(launch_screenshot, off_screenshot)