Repository: napari/napari

Issue:
Show Key Bindings menu option is not working in Napari 0.4.3
## 🐛 Bug

The Show Key Bindings menu option is not working.

## To Reproduce

Steps to reproduce the behavior:

1. Click on the Help menubar option
2. Then, click on Show Key Bindings

<!-- If you have a code sample, error messages, stack traces, please provide it here as well -->

## Expected behavior

I would expect to see a list of all the available Key Bindings

## Environment

napari: 0.4.3
Platform: Linux-4.15.0-55-generic-x86_64-with-glibc2.10
Python: 3.8.3 (default, Jul 2 2020, 16:21:59) [GCC 7.3.0]
Qt: 5.14.2
PyQt5: 5.14.2
NumPy: 1.19.4
SciPy: 1.5.4
Dask: 2021.01.0
VisPy: 0.6.6

OpenGL:
- GL version: 4.6.0 NVIDIA 460.27.04
- MAX_TEXTURE_SIZE: 32768

Screens:
- screen #1: resolution 1920x1080, scale 1.0

Plugins:
- napari_plugin_engine: 0.1.9
- svg: 0.1.4

 - Any other relevant information:

#### Error message:

/opt/Installers/python3.8/site-packages/napari/_qt/qt_viewer.py", line 576, in show_key_bindings_dialog
    dialog = QtAboutKeyBindings(self.viewer, parent=self)
  File "/opt/Installers/python3.8/site-packages/napari/_qt/dialogs/qt_about_key_bindings.py", line 67, in __init__
    theme = get_theme(self.qt_viewer.viewer.theme)
AttributeError: 'QtAboutKeyBindings' object has no attribute 'qt_viewer'



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

https://forum.image.sc/t/is-it-possible-to-open-the-napari-viewer-with-the-layer-controls-and-layer-list-gui-elements-hidden/47755/1

I can reproduce this, sorry about that. We should add a test when we fix!
I was about to write up an issue on this, here is what I figured out:

The reference to the `qt_viewer` attribute looks to have been added in a recent change #2031.

If I change the following lines:
https://github.com/napari/napari/blob/ddd6715208eb02774d01c49444d45764b42b5e25/napari/_qt/dialogs/qt_about_key_bindings.py#L67
https://github.com/napari/napari/blob/ddd6715208eb02774d01c49444d45764b42b5e25/napari/_qt/dialogs/qt_about_key_bindings.py#L128
 To access the `viewer` object directly through the reference in the `QtAboutKeyBindings` object:
 ```python
         theme = get_theme(self.viewer.theme)
```
I then get a new traceback a little farther down in the code:
```bash 
ERROR:root:Unhandled exception:
Traceback (most recent call last):
  File "/Users/nate/Documents/GitHub/napari/napari/_qt/qt_viewer.py", line 576, in show_key_bindings_dialog
    dialog = QtAboutKeyBindings(self.viewer, parent=self)
  File "/Users/nate/Documents/GitHub/napari/napari/_qt/dialogs/qt_about_key_bindings.py", line 100, in __init__
    self.update_active_layer()
  File "/Users/nate/Documents/GitHub/napari/napari/_qt/dialogs/qt_about_key_bindings.py", line 131, in update_active_layer
    text = get_key_bindings_summary(self.viewer.active_keymap, col=col)
AttributeError: 'Viewer' object has no attribute 'active_keymap'
 ```
With a little bit of digging, it looks like the `active_keymap` property can be accessed through:
 ```python
         text = get_key_bindings_summary(
            self.viewer.window.qt_viewer._key_map_handler.active_keymap,
            col=col
        )
 ```
After making these changes, the dialog then opens as expected. I'm a little reluctant to suggest these changes as a "fix", as I'm not sure everything else that relates to this, but hopefully this helps. 

> After making these changes, the dialog then opens as expected. I'm a little reluctant to suggest these changes as a "fix", as I'm not sure everything else that relates to this, but hopefully this helps.

Great digging! The first fix looks great and is what we want. The second one doesn't seem ideal as we have to go into private objects. One option might be to pass and attached the `key_map_handler` to the `QtAboutKeyBindings` when it is constructed inside the `QtViewer`, that seems a little more clean and robust than going `viewer.window.qt_viewer._key_map_handler`, and it seems reasonable the `QtAboutKeyBindings` should know directly about the  `key_map_handler`.

If you @nhthayer were able to submit these fixes as a PR, ideally with tests that would be amazing. I can help with the tests and help with the passing of `key_map_handler` if you need!!



Resolving patch:
diff --git a/napari/_qt/dialogs/qt_about_key_bindings.py b/napari/_qt/dialogs/qt_about_key_bindings.py
--- a/napari/_qt/dialogs/qt_about_key_bindings.py
+++ b/napari/_qt/dialogs/qt_about_key_bindings.py
@@ -23,6 +23,9 @@ class QtAboutKeyBindings(QDialog):
     viewer : napari.components.ViewerModel
         Napari viewer containing the rendered scene, layers, and controls.
 
+    key_map_handler : napari.utils.key_bindings.KeyMapHandler
+        Handler for key mapping and calling functionality.
+
     Attributes
     ----------
     key_bindings_strs : collections.OrderedDict
@@ -47,7 +50,7 @@ class QtAboutKeyBindings(QDialog):
 
     ALL_ACTIVE_KEYBINDINGS = 'All active key bindings'
 
-    def __init__(self, viewer, parent=None):
+    def __init__(self, viewer, key_map_handler, parent=None):
         super().__init__(parent=parent)
 
         self.viewer = viewer
@@ -64,7 +67,8 @@ def __init__(self, viewer, parent=None):
         # Can switch to a normal dict when our minimum Python is 3.7
         self.key_bindings_strs = OrderedDict()
         self.key_bindings_strs[self.ALL_ACTIVE_KEYBINDINGS] = ''
-        theme = get_theme(self.qt_viewer.viewer.theme)
+        self.key_map_handler = key_map_handler
+        theme = get_theme(self.viewer.theme)
         col = theme['secondary']
         layers = [
             napari.layers.Image,
@@ -95,7 +99,6 @@ def __init__(self, viewer, parent=None):
         self.layout.addLayout(layer_type_layout)
         self.layout.addWidget(self.textEditBox, 1)
 
-        self.viewer.events.active_layer.connect(self.update_active_layer)
         self.viewer.events.theme.connect(self.update_active_layer)
         self.update_active_layer()
 
@@ -125,11 +128,12 @@ def update_active_layer(self, event=None):
         event : napari.utils.event.Event, optional
             The napari event that triggered this method, by default None.
         """
-        theme = get_theme(self.qt_viewer.viewer.theme)
+        theme = get_theme(self.viewer.theme)
         col = theme['secondary']
         # Add class and instance viewer key bindings
-        text = get_key_bindings_summary(self.viewer.active_keymap, col=col)
-
+        text = get_key_bindings_summary(
+            self.key_map_handler.active_keymap, col=col
+        )
         # Update layer speficic key bindings if all active are displayed
         self.key_bindings_strs[self.ALL_ACTIVE_KEYBINDINGS] = text
         if self.layerTypeComboBox.currentText() == self.ALL_ACTIVE_KEYBINDINGS:
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
@@ -106,6 +106,7 @@ def __init__(self, viewer, welcome=False):
         self.viewerButtons = QtViewerButtons(self.viewer)
         self._key_map_handler = KeymapHandler()
         self._key_map_handler.keymap_providers = [self.viewer]
+        self._key_bindings_dialog = None
         self._active_layer = None
         self._console = None
 
@@ -329,6 +330,10 @@ def _on_active_layer_change(self, event=None):
 
         self._active_layer = active_layer
 
+        # If a QtAboutKeyBindings exists, update its text.
+        if self._key_bindings_dialog is not None:
+            self._key_bindings_dialog.update_active_layer()
+
     def _on_add_layer_change(self, event):
         """When a layer is added, set its parent and order.
 
@@ -573,8 +578,15 @@ def toggle_console_visibility(self, event=None):
         )
 
     def show_key_bindings_dialog(self, event=None):
-        dialog = QtAboutKeyBindings(self.viewer, parent=self)
-        dialog.show()
+        if self._key_bindings_dialog is None:
+            self._key_bindings_dialog = QtAboutKeyBindings(
+                self.viewer, self._key_map_handler, parent=self
+            )
+        # make sure the dialog is shown
+        self._key_bindings_dialog.show()
+        # make sure the the dialog gets focus
+        self._key_bindings_dialog.raise_()  # for macOS
+        self._key_bindings_dialog.activateWindow()  # for Windows
 
     def _map_canvas2world(self, position):
         """Map position from canvas pixels into world coordinates.


Test patch:
diff --git a/napari/_qt/dialogs/_tests/test_qt_about_key_bindings.py b/napari/_qt/dialogs/_tests/test_qt_about_key_bindings.py
new file mode 100644
--- /dev/null
+++ b/napari/_qt/dialogs/_tests/test_qt_about_key_bindings.py
@@ -0,0 +1,65 @@
+import numpy as np
+
+from napari._qt.dialogs.qt_about_key_bindings import QtAboutKeyBindings
+
+
+def test_about_key_bindings_dialog(make_test_viewer):
+    """Test creating QtAboutKeyBindings dialog window and its methods."""
+    viewer = make_test_viewer()
+    view = viewer.window.qt_viewer
+    key_bindings_dialog = QtAboutKeyBindings(viewer, view._key_map_handler)
+    assert key_bindings_dialog.viewer == viewer
+    assert key_bindings_dialog.key_map_handler == view._key_map_handler
+
+    # check ability to update the active layer, should've ran during init too
+    key_bindings_dialog.update_active_layer()
+
+    # check ability to change layer type selected in dropdown
+    for layer in key_bindings_dialog.key_bindings_strs.keys():
+        key_bindings_dialog.change_layer_type(layer)
+        # Could test to make sure text has been updated, but `toHtml` does not
+        # return the same str that is provided to `setHtml`, so this could get
+        # messy...
+
+
+def test_show_key_bindings_dialog(make_test_viewer, monkeypatch):
+    """Test creating dialog with method of qt_viewer."""
+    viewer = make_test_viewer()
+    view = viewer.window.qt_viewer
+    # check that dialog does not exist yet
+    assert not view.findChild(QtAboutKeyBindings)
+    # turn off showing the dialog for test
+    monkeypatch.setattr(QtAboutKeyBindings, 'show', lambda *a: None)
+    # create the dialog and make sure that it now exists
+    view.show_key_bindings_dialog()
+    assert isinstance(view.findChild(QtAboutKeyBindings), QtAboutKeyBindings)
+
+
+def test_updating_with_layer_change(make_test_viewer, monkeypatch):
+    """Test that the dialog text updates when the active layer is changed."""
+    viewer = make_test_viewer()
+    view = viewer.window.qt_viewer
+    # turn off showing the dialog for test
+    monkeypatch.setattr(QtAboutKeyBindings, 'show', lambda *a: None)
+    view.show_key_bindings_dialog()
+    dialog = view.findChild(QtAboutKeyBindings)
+
+    # add an image layer
+    viewer.add_image(np.random.random((5, 5, 10, 15)))
+    # capture dialog text after active_layer events
+    active_img_layer_text = dialog.textEditBox.toHtml()
+    dialog.update_active_layer()  # force an to update to dialog
+    # check that the text didn't update without a change in the active layer
+    assert dialog.textEditBox.toHtml() == active_img_layer_text
+
+    # add a shape layer (different keybindings)
+    viewer.add_shapes(None, shape_type='polygon')
+    # check that the new layer is the active_layer
+    assert viewer.active_layer == viewer.layers[1]
+    # capture dialog text after active_layer events
+    active_shape_layer_text = dialog.textEditBox.toHtml()
+    # check that the text has changed for the new key bindings
+    assert active_shape_layer_text != active_img_layer_text
+    dialog.update_active_layer()  # force an update to dialog
+    # check that the text didn't update without a change in the active layer
+    assert dialog.textEditBox.toHtml() == active_shape_layer_text