Repository: napari/napari

Issue:
Unable to set shortcuts/keybindings on Windows
## 🐛 Bug

<!-- A clear and concise description of what the bug is. -->
It is not possible to set shortcuts/keybindings on Windows when using modifier keys (`Ctrl`, `Shift`, etc) plus some other key (for example `Ctrl + T`)

## To Reproduce

Steps to reproduce the behavior:

1. Go to `Preferences > Shortcuts`
2. Try to set an alternative keybinding for an action. For example, set for the `Toggle current viewer theme` something like `Ctrl + T`
3. Instead of setting the new keybinding or showing the conflict message, a warning is shown and a `Ctrl+unknown` text is left.

<!-- If you have a code sample, error messages, stack traces, please provide it here as well -->
* A preview of the behavior:

![mod_shortcuts](https://github.com/napari/napari/assets/16781833/98a9b9b2-8824-48a4-b9c9-6eef29495ba8)

* The warning message being shown:

```python-traceback
E:\Acer\Documentos\Quansight\Napari\napari\napari\utils\action_manager.py:324: UserWarning: `Ctrl-+T` does not seem to be a valid shortcut Key.!
```

## Expected behavior

<!-- A clear and concise description of what you expected to happen. -->
Set the new keybinding combination or show a warning if it conflicts with an already set keybinding

## Environment

 - Please copy and paste the information at napari info option in help menubar here:

<details>

```
napari: 0.5.0a2.dev365+g08a22b05
Platform: Windows-10-10.0.19045-SP0
Python: 3.10.9 | packaged by conda-forge | (main, Jan 11 2023, 15:15:40) [MSC v.1916 64 bit (AMD64)]
Qt: 5.15.2
PyQt5: 5.15.9
NumPy: 1.25.2
SciPy: 1.9.3
Dask: 2022.10.2
VisPy: 0.13.0
magicgui: 0.7.3
superqt: unknown
in-n-out: 0.1.5
app-model: 0.2.2
npe2: 0.7.2

OpenGL:
- GL version: 4.5.0 - Build 25.20.100.6444
- MAX_TEXTURE_SIZE: 16384

Screens:
- screen 1: resolution 1366x768, scale 1.0
- screen 2: resolution 1920x1080, scale 1.0
- screen 3: resolution 1600x900, scale 1.0

Settings path:
- C:\Users\dalth\AppData\Local\napari\napari-dev_4454fdbf170edefe23827a4bf512389a6806af69\settings.yaml
```

</details>

## Additional context

<!-- Add any other context about the problem here. -->
I was checking #6204 on Windows when I noticed this. Checking the code related with the shortcuts seems like this is caused due to a replace done at:

https://github.com/napari/napari/blob/08a22b053f87ca5010fe43b983390c199921f83e/napari/utils/interactions.py#L335

Maybe such replace (adding a `-`) shouldn't be done on Windows? Removing the `+ '-'` seems like helps:

![mod_shortcuts_no_minus](https://github.com/napari/napari/assets/16781833/dd77f87e-66f5-47fc-8eb9-8beb588e3a8c)



Discussion:

Resolving patch:
diff --git a/napari/_qt/widgets/qt_keyboard_settings.py b/napari/_qt/widgets/qt_keyboard_settings.py
--- a/napari/_qt/widgets/qt_keyboard_settings.py
+++ b/napari/_qt/widgets/qt_keyboard_settings.py
@@ -405,7 +405,6 @@ def _set_keybinding(self, row, col):
                 action_manager._shortcuts.get(current_action, [])
             )
             for mod in {"Shift", "Ctrl", "Alt", "Cmd", "Super", 'Meta'}:
-                # we want to prevent multiple modifiers but still allow single modifiers.
                 if new_shortcut.endswith('-' + mod):
                     self._show_bind_shortcut_error(
                         current_action,
@@ -656,6 +655,8 @@ def keyPressEvent(self, event) -> None:
             Qt.Key.Key_Enter,
         }:
             # Do not allow user to set these keys as shortcut.
+            # Use them as a save trigger for modifier only shortcuts.
+            self.clearFocus()
             return
 
         # Translate key value to key string.
diff --git a/napari/utils/interactions.py b/napari/utils/interactions.py
--- a/napari/utils/interactions.py
+++ b/napari/utils/interactions.py
@@ -239,10 +239,10 @@ def hello_world(layer, event):
 }
 
 
-joinchar = '+'
+JOINCHAR = '+'
 if sys.platform.startswith('darwin'):
     KEY_SYMBOLS.update({'Ctrl': '⌃', 'Alt': '⌥', 'Meta': '⌘'})
-    joinchar = ''
+    JOINCHAR = ''
 elif sys.platform.startswith('linux'):
     KEY_SYMBOLS.update({'Meta': 'Super'})
 
@@ -323,14 +323,13 @@ def parse_platform(text: str) -> str:
         # as you can't get two non-modifier keys,  or alone.
         if text == '+':
             return text
-        if joinchar == "+":
-            text.replace('++', '+Plus')
-            text.replace('+', '')
-            text.replace('Plus', '+')
+        if JOINCHAR == "+":
+            text = text.replace('++', '+Plus')
+            text = text.replace('+', '')
+            text = text.replace('Plus', '+')
         for k, v in KEY_SYMBOLS.items():
             if text.endswith(v):
                 text = text.replace(v, k)
-                assert v not in text
             else:
                 text = text.replace(v, k + '-')
 
@@ -360,7 +359,7 @@ def platform(self) -> str:
             Shortcut formatted to be displayed on current paltform.
         """
         return ' '.join(
-            joinchar.join(
+            JOINCHAR.join(
                 KEY_SYMBOLS.get(x, x)
                 for x in ([*_kb2mods(part), str(part.key)])
             )


Test patch:
diff --git a/napari/_qt/widgets/_tests/test_shortcut_editor_widget.py b/napari/_qt/widgets/_tests/test_shortcut_editor_widget.py
--- a/napari/_qt/widgets/_tests/test_shortcut_editor_widget.py
+++ b/napari/_qt/widgets/_tests/test_shortcut_editor_widget.py
@@ -1,15 +1,31 @@
+import sys
 from unittest.mock import patch
 
 import pytest
+from qtpy.QtCore import QPoint, Qt
+from qtpy.QtWidgets import QApplication, QMessageBox
 
 from napari._qt.widgets.qt_keyboard_settings import ShortcutEditor, WarnPopup
+from napari.settings import get_settings
 from napari.utils.action_manager import action_manager
+from napari.utils.interactions import KEY_SYMBOLS
 
 
 @pytest.fixture
 def shortcut_editor_widget(qtbot):
+    # Always reset shortcuts (settings and action manager)
+    get_settings().shortcuts.reset()
+    for (
+        action,
+        shortcuts,
+    ) in get_settings().shortcuts.shortcuts.items():
+        action_manager.unbind_shortcut(action)
+        for shortcut in shortcuts:
+            action_manager.bind_shortcut(action, shortcut)
+
     def _shortcut_editor_widget(**kwargs):
         widget = ShortcutEditor(**kwargs)
+        widget._reset_shortcuts()
         widget.show()
         qtbot.addWidget(widget)
 
@@ -45,3 +61,114 @@ def test_mark_conflicts(shortcut_editor_widget, qtbot):
     assert widget._mark_conflicts("Y", 1)
     # "Y" is arbitrary chosen and on conflict with existing shortcut should be changed
     qtbot.add_widget(widget._warn_dialog)
+
+
+def test_restore_defaults(shortcut_editor_widget):
+    widget = shortcut_editor_widget()
+    shortcut = widget._table.item(0, widget._shortcut_col).text()
+    assert shortcut == KEY_SYMBOLS["Ctrl"]
+    widget._table.item(0, widget._shortcut_col).setText("R")
+    shortcut = widget._table.item(0, widget._shortcut_col).text()
+    assert shortcut == "R"
+    with patch(
+        "napari._qt.widgets.qt_keyboard_settings.QMessageBox.question"
+    ) as mock:
+        mock.return_value = QMessageBox.RestoreDefaults
+        widget._restore_button.click()
+        assert mock.called
+    shortcut = widget._table.item(0, widget._shortcut_col).text()
+    assert shortcut == KEY_SYMBOLS["Ctrl"]
+
+
+@pytest.mark.parametrize(
+    "key, modifier, key_symbols",
+    [
+        (
+            "U",
+            Qt.KeyboardModifier.MetaModifier
+            if sys.platform == "darwin"
+            else Qt.KeyboardModifier.ControlModifier,
+            [KEY_SYMBOLS["Ctrl"], "U"],
+        ),
+        (
+            "Y",
+            Qt.KeyboardModifier.MetaModifier
+            | Qt.KeyboardModifier.ShiftModifier
+            if sys.platform == "darwin"
+            else Qt.KeyboardModifier.ControlModifier
+            | Qt.KeyboardModifier.ShiftModifier,
+            [KEY_SYMBOLS["Ctrl"], KEY_SYMBOLS["Shift"], "Y"],
+        ),
+    ],
+)
+def test_keybinding_with_modifiers(
+    shortcut_editor_widget, qtbot, recwarn, key, modifier, key_symbols
+):
+    widget = shortcut_editor_widget()
+    shortcut = widget._table.item(0, widget._shortcut_col).text()
+    assert shortcut == KEY_SYMBOLS["Ctrl"]
+
+    x = widget._table.columnViewportPosition(widget._shortcut_col)
+    y = widget._table.rowViewportPosition(0)
+    item_pos = QPoint(x, y)
+    qtbot.mouseClick(
+        widget._table.viewport(), Qt.MouseButton.LeftButton, pos=item_pos
+    )
+    qtbot.mouseDClick(
+        widget._table.viewport(), Qt.MouseButton.LeftButton, pos=item_pos
+    )
+    qtbot.waitUntil(lambda: QApplication.focusWidget() is not None)
+    qtbot.keyClicks(QApplication.focusWidget(), key, modifier=modifier)
+    assert len([warn for warn in recwarn if warn.category is UserWarning]) == 0
+
+    shortcut = widget._table.item(0, widget._shortcut_col).text()
+    for key_symbol in key_symbols:
+        assert key_symbol in shortcut
+
+
+@pytest.mark.parametrize(
+    "modifiers, key_symbols, valid",
+    [
+        (
+            Qt.KeyboardModifier.ShiftModifier,
+            [KEY_SYMBOLS["Shift"]],
+            True,
+        ),
+        (
+            Qt.KeyboardModifier.AltModifier
+            | Qt.KeyboardModifier.ShiftModifier,
+            [KEY_SYMBOLS["Ctrl"]],
+            False,
+        ),
+    ],
+)
+def test_keybinding_with_only_modifiers(
+    shortcut_editor_widget, qtbot, recwarn, modifiers, key_symbols, valid
+):
+    widget = shortcut_editor_widget()
+    shortcut = widget._table.item(0, widget._shortcut_col).text()
+    assert shortcut == KEY_SYMBOLS["Ctrl"]
+
+    x = widget._table.columnViewportPosition(widget._shortcut_col)
+    y = widget._table.rowViewportPosition(0)
+    item_pos = QPoint(x, y)
+    qtbot.mouseClick(
+        widget._table.viewport(), Qt.MouseButton.LeftButton, pos=item_pos
+    )
+    qtbot.mouseDClick(
+        widget._table.viewport(), Qt.MouseButton.LeftButton, pos=item_pos
+    )
+    qtbot.waitUntil(lambda: QApplication.focusWidget() is not None)
+    with patch.object(WarnPopup, "exec_") as mock:
+        qtbot.keyClick(
+            QApplication.focusWidget(), Qt.Key_Enter, modifier=modifiers
+        )
+        if valid:
+            assert not mock.called
+        else:
+            assert mock.called
+    assert len([warn for warn in recwarn if warn.category is UserWarning]) == 0
+
+    shortcut = widget._table.item(0, widget._shortcut_col).text()
+    for key_symbol in key_symbols:
+        assert key_symbol in shortcut