Repository: napari/napari

Issue:
Weird behavior when trying to set delete and backspace keys as shortcuts with modifiers on some OS+bindings combinations
### 🐛 Bug Report

Seems like a weird behavior exists when setting the delete or backspace keys with modifiers as shortcuts. You can set them but you need to either hold `Ctrl + Delete` until the shortcut is detected or hold `Ctrl` and press `Delete` two times for the shortcut to be detected. This happens on Windows with PyQt5 or PyQt6 but on macOS only with PyQt5. On the other hand, on macOS + PyQt6, things work fotr the modifier + `Delete` but you are not able to set single modifiers shortcuts without holding another modifier key first and then releasing it for the shortcut box to detect the single modifier key being pressed.

Edit: Edited bug report taking into account new findings done while trying to define a reliable way to reproduce this

### 💡 Steps to Reproduce

1. Try to set `Ctrl + Delete` or `Ctrl + Backspace`
2. Shortcut text gets deleted instead of being set or showing a warning about shortcut conflicts

A preview of the behavior:

![delete_backspace_shortcuts](https://github.com/napari/napari/assets/16781833/1b86420e-093c-486a-afc2-d0e5b7bf8f48)



### 💡 Expected Behavior

Using key combinations like `Ctrl + Delete` and `Ctrl + Backspace` should be recognized/settable as shortcuts

As a "workaorund", you need to either hold `Ctrl + Delete` until the shortcut is detected or hold `Ctrl` and press `Delete` two times:

![shortcut_mod_del](https://github.com/napari/napari/assets/16781833/8cd74ef7-3399-4005-a25d-1457f0aa0a61)

### 🌎 Environment

napari: 0.5.0a2.dev425+g899edfe3
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: 2023.10.1
VisPy: 0.14.1
magicgui: 0.7.3
superqt: unknown
in-n-out: 0.1.5
app-model: 0.2.2
npe2: 0.7.3

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 1536x864, scale 1.0

Settings path:
  - C:\Users\dalth\AppData\Local\napari\napari-dev_4454fdbf170edefe23827a4bf512389a6806af69\settings.yaml
Plugins:
  - napari: 0.5.0a2.dev425+g899edfe3 (77 contributions)
  - napari-assistant: 0.4.4 (4 contributions)
  - napari-console: 0.0.6 (0 contributions)
  - napari-generic-SIMulator: 0.0.19 (2 contributions)
  - napari-ome-zarr: 0.5.2 (2 contributions)
  - napari-pyclesperanto-assistant: 0.22.1 (51 contributions)
  - napari-skimage-regionprops: 0.10.0 (2 contributions)
  - napari-svg: 0.1.10 (2 contributions)
  - napari-time-slicer: 0.4.9 (2 contributions)
  - napari-tools-menu: 0.1.19 (0 contributions)
  - napari-ui-tracer: 0.2.0.dev0 (2 contributions)

### 💡 Additional Context

_No response_


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
@@ -4,7 +4,6 @@
 from typing import Optional
 
 from app_model.backends.qt import (
-    qkey2modelkey,
     qkeysequence2modelkeybinding,
 )
 from qtpy.QtCore import QEvent, QPoint, Qt, Signal
@@ -410,11 +409,12 @@ def _set_keybinding(self, row, col):
             # get the current action name
             current_action = self._table.item(row, self._action_col).text()
 
-            # get the original shortcutS
+            # get the original shortcuts
             current_shortcuts = list(
                 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,
@@ -581,7 +581,18 @@ def event(self, event):
         if event.type() == QEvent.Type.ShortcutOverride:
             self.keyPressEvent(event)
             return True
-        if event.type() in [QEvent.Type.KeyPress, QEvent.Type.Shortcut]:
+        if event.type() == QEvent.Type.Shortcut:
+            return True
+        if event.type() == QEvent.Type.KeyPress and event.key() in (
+            Qt.Key.Key_Delete,
+            Qt.Key.Key_Backspace,
+        ):
+            # If there is a shortcut set already, two events are being emitted when
+            # pressing `Delete` or `Backspace`. First a `ShortcutOverride` event and
+            # then a `KeyPress` event. We need to mark the second event (`KeyPress`)
+            # as handled for those keys to not end up processing it multiple times.
+            # Without that, pressing `Backspace` or `Delete` will set those keys as shortcuts
+            # instead of just cleaning/removing the previous shortcut that was set.
             return True
 
         return super().event(event)
@@ -642,6 +653,7 @@ def keyPressEvent(self, event) -> None:
         if (
             event_key in {Qt.Key.Key_Delete, Qt.Key.Key_Backspace}
             and self.text() != ''
+            and self.hasSelectedText()
         ):
             # Allow user to delete shortcut.
             self.setText('')
@@ -655,8 +667,6 @@ def keyPressEvent(self, event) -> None:
         ):
             self._handleEditModifiersOnly(event)
             return
-        if event_key == Qt.Key.Key_Delete:
-            self.setText(Shortcut(qkey2modelkey(event_key)).platform)
 
         if event_key in {
             Qt.Key.Key_Return,


Test patch:
diff --git a/.github/workflows/reusable_pip_test.yml b/.github/workflows/reusable_pip_test.yml
--- a/.github/workflows/reusable_pip_test.yml
+++ b/.github/workflows/reusable_pip_test.yml
@@ -20,6 +20,12 @@ jobs:
 
       - uses: tlambert03/setup-qt-libs@v1
 
+      # setup needed to use pyautogui
+      - name: create .Xauthority file
+        if: runner.os == 'Linux'
+        run: |
+          touch /home/runner/.Xauthority
+
       - name: Install this commit
         run: |
           pip install --upgrade pip
diff --git a/.github/workflows/reusable_run_tox_test.yml b/.github/workflows/reusable_run_tox_test.yml
--- a/.github/workflows/reusable_run_tox_test.yml
+++ b/.github/workflows/reusable_run_tox_test.yml
@@ -81,6 +81,12 @@ jobs:
         run: |
           echo 0 | sudo tee /proc/sys/kernel/yama/ptrace_scope
 
+      # setup needed to use pyautogui
+      - name: create .Xauthority file
+        if: runner.os == 'Linux'
+        run: |
+          touch /home/runner/.Xauthority
+
       # tox and tox-gh-actions will take care of the "actual" installation
       # of python dependendencies into a virtualenv.  see tox.ini for more
       - name: Install dependencies
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,21 @@
 import sys
 from unittest.mock import patch
 
+import pyautogui
 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._tests.utils import skip_local_focus, skip_on_mac_ci
 from napari.settings import get_settings
 from napari.utils.action_manager import action_manager
 from napari.utils.interactions import KEY_SYMBOLS
 
+META_CONTROL_KEY = Qt.KeyboardModifier.ControlModifier
+if sys.platform == 'darwin':
+    META_CONTROL_KEY = Qt.KeyboardModifier.MetaModifier
+
 
 @pytest.fixture
 def shortcut_editor_widget(qtbot):
@@ -80,29 +86,39 @@ def test_restore_defaults(shortcut_editor_widget):
     assert shortcut == KEY_SYMBOLS['Ctrl']
 
 
+@skip_local_focus
 @pytest.mark.parametrize(
     'key, modifier, key_symbols',
     [
         (
-            'U',
-            (
-                Qt.KeyboardModifier.MetaModifier
-                if sys.platform == 'darwin'
-                else Qt.KeyboardModifier.ControlModifier
-            ),
+            Qt.Key.Key_U,
+            META_CONTROL_KEY,
             [KEY_SYMBOLS['Ctrl'], 'U'],
         ),
         (
-            'Y',
-            (
-                Qt.KeyboardModifier.MetaModifier
-                | Qt.KeyboardModifier.ShiftModifier
-                if sys.platform == 'darwin'
-                else Qt.KeyboardModifier.ControlModifier
-                | Qt.KeyboardModifier.ShiftModifier
-            ),
+            Qt.Key.Key_Y,
+            META_CONTROL_KEY | Qt.KeyboardModifier.ShiftModifier,
             [KEY_SYMBOLS['Ctrl'], KEY_SYMBOLS['Shift'], 'Y'],
         ),
+        (
+            Qt.Key.Key_Backspace,
+            META_CONTROL_KEY,
+            [KEY_SYMBOLS['Ctrl'], KEY_SYMBOLS['Backspace']],
+        ),
+        (
+            Qt.Key.Key_Delete,
+            META_CONTROL_KEY | Qt.KeyboardModifier.ShiftModifier,
+            [KEY_SYMBOLS['Ctrl'], KEY_SYMBOLS['Shift'], KEY_SYMBOLS['Delete']],
+        ),
+        (
+            Qt.Key.Key_Backspace,
+            META_CONTROL_KEY | Qt.KeyboardModifier.ShiftModifier,
+            [
+                KEY_SYMBOLS['Ctrl'],
+                KEY_SYMBOLS['Shift'],
+                KEY_SYMBOLS['Backspace'],
+            ],
+        ),
     ],
 )
 def test_keybinding_with_modifiers(
@@ -122,7 +138,7 @@ def test_keybinding_with_modifiers(
         widget._table.viewport(), Qt.MouseButton.LeftButton, pos=item_pos
     )
     qtbot.waitUntil(lambda: QApplication.focusWidget() is not None)
-    qtbot.keyClicks(QApplication.focusWidget(), key, modifier=modifier)
+    qtbot.keyClick(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()
@@ -130,6 +146,7 @@ def test_keybinding_with_modifiers(
         assert key_symbol in shortcut
 
 
+@skip_local_focus
 @pytest.mark.parametrize(
     'modifiers, key_symbols, valid',
     [
@@ -176,3 +193,117 @@ def test_keybinding_with_only_modifiers(
     shortcut = widget._table.item(0, widget._shortcut_col).text()
     for key_symbol in key_symbols:
         assert key_symbol in shortcut
+
+
+@skip_local_focus
+@pytest.mark.parametrize(
+    'removal_trigger_key',
+    [
+        Qt.Key.Key_Delete,
+        Qt.Key.Key_Backspace,
+    ],
+)
+def test_remove_shortcut(shortcut_editor_widget, qtbot, removal_trigger_key):
+    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.keyClick(QApplication.focusWidget(), removal_trigger_key)
+    qtbot.keyClick(QApplication.focusWidget(), Qt.Key.Key_Enter)
+
+    shortcut = widget._table.item(0, widget._shortcut_col).text()
+    assert shortcut == ''
+
+
+@skip_local_focus
+@skip_on_mac_ci
+@pytest.mark.parametrize(
+    'modifier_key, modifiers, key_symbols',
+    [
+        (
+            'shift',
+            None,
+            [KEY_SYMBOLS['Shift']],
+        ),
+        (
+            'ctrl',
+            'shift',
+            [KEY_SYMBOLS['Ctrl'], KEY_SYMBOLS['Shift']],
+        ),
+    ],
+)
+def test_keybinding_editor_modifier_key_detection(
+    shortcut_editor_widget,
+    qtbot,
+    recwarn,
+    modifier_key,
+    modifiers,
+    key_symbols,
+):
+    """
+    Test modifier keys detection with pyautogui to trigger keyboard events
+    from the OS.
+
+    Notes:
+        * Skipped on macOS CI due to accessibility permissions not being
+          settable on macOS GitHub Actions runners.
+        * For this test to pass locally, you need to give the Terminal/iTerm
+          application accessibility permissions:
+              `System Settings > Privacy & Security > Accessibility`
+
+        See https://github.com/asweigart/pyautogui/issues/247 and
+        https://github.com/asweigart/pyautogui/issues/247#issuecomment-437668855
+    """
+    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)
+
+    line_edit = QApplication.focusWidget()
+    with pyautogui.hold(modifier_key):
+        if modifiers:
+            pyautogui.keyDown(modifiers)
+
+        def press_check():
+            line_edit.selectAll()
+            shortcut = line_edit.selectedText()
+            all_pressed = True
+            for key_symbol in key_symbols:
+                all_pressed &= key_symbol in shortcut
+            return all_pressed
+
+        qtbot.waitUntil(lambda: press_check())
+
+        if modifiers:
+            pyautogui.keyUp(modifiers)
+
+    def release_check():
+        line_edit.selectAll()
+        shortcut = line_edit.selectedText()
+        return shortcut == ''
+
+    qtbot.waitUntil(lambda: release_check())
+
+    qtbot.keyClick(line_edit, Qt.Key_Escape)
+    shortcut = widget._table.item(0, widget._shortcut_col).text()
+    assert shortcut == KEY_SYMBOLS['Ctrl']
diff --git a/napari/_tests/utils.py b/napari/_tests/utils.py
--- a/napari/_tests/utils.py
+++ b/napari/_tests/utils.py
@@ -29,12 +29,23 @@
     reason='Screenshot tests are not supported on windows CI.',
 )
 
+skip_on_mac_ci = pytest.mark.skipif(
+    sys.platform.startswith('darwin') and os.getenv('CI', '0') != '0',
+    reason='Unsupported test on macOS CI.',
+)
+
 skip_local_popups = pytest.mark.skipif(
     not os.getenv('CI') and os.getenv('NAPARI_POPUP_TESTS', '0') == '0',
     reason='Tests requiring GUI windows are skipped locally by default.'
     ' Set NAPARI_POPUP_TESTS=1 environment variable to enable.',
 )
 
+skip_local_focus = pytest.mark.skipif(
+    not os.getenv('CI') and os.getenv('NAPARI_FOCUS_TESTS', '0') == '0',
+    reason='Tests requiring GUI windows focus are skipped locally by default.'
+    ' Set NAPARI_FOCUS_TESTS=1 environment variable to enable.',
+)
+
 """
 The default timeout duration in seconds when waiting on tasks running in non-main threads.
 The value was chosen to be consistent with `QtBot.waitSignal` and `QtBot.waitUntil`.