Repository: napari/napari

Issue:
Ctrl + Q shuts down napari without asking
## 🐛 Bug

Hi all,

I was just in the middle of something and hit Ctrl+Q unintentionally. This shut down napari without asking. I'm a windows user and on Windows, Ctrl+Q is not common short cut, and it's unexpected that one can shut down applications like this. I'm wondering why this short-cut was introduced. Windows does have a short cut for exiting applications: Alt + F4, which also works with napari.

## To Reproduce

Steps to reproduce the behavior:

1. Start napari
2. hit Ctrl + Q

<!-- If you have a code sample, error messages, stack traces, please provide it here as well -->

## Expected behavior

I'm proposing to disable the Ctrl+Q short-cut. There are cooler things we could do with this short-cut.

## Environment

 - Please copy and paste the information at napari info option in help menubar here:

```
napari: 0.4.16
Platform: Windows-10-10.0.19044-SP0
Python: 3.9.7 (default, Sep 16 2021, 16:59:28) [MSC v.1916 64 bit (AMD64)]
Qt: 5.12.9
PyQt5: 5.12.3
NumPy: 1.22.4
SciPy: 1.8.0
Dask: 0+unknown
VisPy: 0.10.0

OpenGL:
- GL version: 4.6.0 NVIDIA 511.69
- MAX_TEXTURE_SIZE: 32768

Screens:
- screen 1: resolution 1920x1200, scale 1.0

Plugins:
- PlatyMatch: 0.0.3
- RedLionfish: 0.6
- StarDist: 2021.10.25
- animation: 0.0.2
- cellpose-napari: 0.1.4
- clEsperanto: 0.18.1
- console: 0.0.4
- devbio_napari: 0.5.5
- napari-3d-ortho-viewer: 0.0.1
- napari-accelerated-pixel-and-object-classification: 0.7.4
- napari-assistant: 0.3.7
- napari-brightness-contrast: 0.1.7
- napari-clusters-plotter: 0.5.0
- napari-crop: 0.1.5
- napari-cupy-image-processing: 0.2.0
- napari-curtain: 0.1.1
- napari-folder-browser: 0.1.3
- napari-itk-io: 0.1.0
- napari-layer-details-display: 0.1.3
- napari-mouse-controls: 0.1.3
- napari-ome-zarr: 0.4.0
- napari-plot-profile: 0.2.1
- napari-plugin-search: 0.1.3
- napari-process-points-and-surfaces: 0.2.0
- napari-segment-blobs-and-things-with-membranes: 0.2.19
- napari-simpleitk-image-processing: 0.3.1
- napari-stress: 0.0.14
- napari-svg: 0.1.6
- napari-tabu: 0.1.5
- napari-time-slicer: 0.4.6
- napari-tools-menu: 0.1.15
- napari-workflow-inspector: 0.2.2
- napari-workflow-optimizer: 0.1.4
- napari_skimage_regionprops1: 0.5.2
- napari_skimage_regionprops2: 0.5.2
- ome-types: 0.2.10
- scikit-image: 0.4.16
- the-segmentation-game: 0.1.0
```

 - Any other relevant information:

## Additional context

<!-- Add any other context about the problem here. -->



Discussion:
I agree that missing all jobs because of a single shortcut click may be frustrating. 
Just another note: Ctrl + Q is not in the list of shortcuts in File > Preferences. Instead it's a short-cut provided for the Exit menu:

![image](https://user-images.githubusercontent.com/12660498/172241918-dd534c30-d43b-4944-9f16-ef35cf0767e5.png)

I also propose to remove the short-cut Ctrl + W, which also closes napari.
@haesleinhuepf Ctrl-W is absolutely a standard shortcut for closing a window, both on Mac and Linux. Ditto for Ctrl/Cmd-Q (just checked on Firefox for Linux). So I don't want to remove these shortcuts. I'm totally open to adding a preference to check for "confirm before closing main window", though.
Confirm before closing with a populated layer list and/or maybe open dock widget would be a nice feature.
Personally I would want this to be a preference and/or a distinction between the bundle and IPython versions, because I open and close viewers many times within an IPython session. (ie I use napari a lot as a quick "plt.imshow" replacement.)
> Personally I would want this to be a preference and/or a distinction between the bundle and IPython versions, because I open and close viewers many times within an IPython session. (ie I use napari a lot as a quick "plt.imshow" replacement.)

maybe the second `Ctrl+Q` on the asking dialog should be counted as confirmation?

Resolving patch:
diff --git a/napari/_qt/menus/_util.py b/napari/_qt/menus/_util.py
--- a/napari/_qt/menus/_util.py
+++ b/napari/_qt/menus/_util.py
@@ -1,3 +1,4 @@
+import contextlib
 from typing import TYPE_CHECKING, Callable, List, Union
 
 from qtpy.QtWidgets import QAction, QMenu
@@ -125,18 +126,14 @@ def _destroy(self):
         for ax in self.actions():
             ax.setData(None)
 
-            try:
+            with contextlib.suppress(AttributeError):
                 ax._destroy()
-            except AttributeError:
-                pass
-
         if self in self._INSTANCES:
             self._INSTANCES.remove(self)
 
     def update(self, event=None):
         """Update action enabled/disabled state based on action data."""
         for ax in self.actions():
-            data = ax.data()
-            if data:
+            if data := ax.data():
                 enabled_func = data.get('enabled', lambda event: True)
                 ax.setEnabled(bool(enabled_func(event)))
diff --git a/napari/_qt/menus/file_menu.py b/napari/_qt/menus/file_menu.py
--- a/napari/_qt/menus/file_menu.py
+++ b/napari/_qt/menus/file_menu.py
@@ -2,7 +2,8 @@
 from typing import TYPE_CHECKING
 
 from qtpy.QtCore import QSize
-from qtpy.QtWidgets import QAction
+from qtpy.QtGui import QKeySequence
+from qtpy.QtWidgets import QAction, QMessageBox
 
 from napari._qt.dialogs.qt_reader_dialog import handle_gui_reading
 from napari.errors.reader_errors import MultipleReaderError
@@ -103,7 +104,7 @@ def __init__(self, window: 'Window'):
             {},
             {
                 'text': trans._('Close Window'),
-                'slot': window._qt_window.close_window,
+                'slot': self._close_window,
                 'shortcut': 'Ctrl+W',
             },
             {
@@ -115,7 +116,7 @@ def __init__(self, window: 'Window'):
             # This quits the entire QApplication and closes all windows.
             {
                 'text': trans._('Exit'),
-                'slot': lambda: window._qt_window.close(quit_app=True),
+                'slot': self._close_app,
                 'shortcut': 'Ctrl+Q',
                 'menuRole': QAction.QuitRole,
             },
@@ -133,6 +134,45 @@ def __init__(self, window: 'Window'):
         self._rebuild_samples_menu()
         self.update()
 
+    def _close_app(self):
+        message = QMessageBox(
+            QMessageBox.Icon.Warning,
+            trans._("Close application?"),
+            trans._(
+                "Do you want to close the application? ('{shortcut}' to confirm). This will close all Qt Windows in this process",
+                shortcut=QKeySequence('Ctrl+Q').toString(
+                    QKeySequence.NativeText
+                ),
+            ),
+            QMessageBox.StandardButton.Ok | QMessageBox.StandardButton.Cancel,
+            self._win._qt_window,
+        )
+        close_btn = message.button(QMessageBox.StandardButton.Ok)
+        close_btn.setShortcut(QKeySequence('Ctrl+Q'))
+        if message.exec_() == QMessageBox.StandardButton.Ok:
+            self._win._qt_window.close(quit_app=True)
+
+    def _close_window(self):
+        if not get_settings().application.confirm_close_window:
+            self._win._qt_window.close(quit_app=False)
+            return
+        message = QMessageBox(
+            QMessageBox.Icon.Question,
+            trans._("Close window?"),
+            trans._(
+                "Confirm to close window (or press '{shortcut}')",
+                shortcut=QKeySequence('Ctrl+W').toString(
+                    QKeySequence.NativeText
+                ),
+            ),
+            QMessageBox.StandardButton.Ok | QMessageBox.StandardButton.Cancel,
+            self._win._qt_window,
+        )
+        close_btn = message.button(QMessageBox.StandardButton.Ok)
+        close_btn.setShortcut(QKeySequence('Ctrl+W'))
+        if message.exec_() == QMessageBox.StandardButton.Ok:
+            self._win._qt_window.close(quit_app=False)
+
     def _layer_count(self, event=None):
         return len(self._win._qt_viewer.viewer.layers)
 
diff --git a/napari/settings/_application.py b/napari/settings/_application.py
--- a/napari/settings/_application.py
+++ b/napari/settings/_application.py
@@ -7,6 +7,7 @@
 from ..utils._base import _DEFAULT_LOCALE
 from ..utils.events.custom_types import conint
 from ..utils.events.evented_model import EventedModel
+from ..utils.interactions import Shortcut
 from ..utils.notifications import NotificationSeverity
 from ..utils.translations import trans
 from ._constants import LoopMode
@@ -155,6 +156,14 @@ class ApplicationSettings(EventedModel):
         title=trans._("Grid Height"),
         description=trans._("Number of rows in the grid."),
     )
+    confirm_close_window: bool = Field(
+        default=True,
+        title=trans._("Confirm window closing"),
+        description=trans._(
+            "Ask for confirmation before closing window with {shortcut}",
+            shortcut=Shortcut("Control-W").platform,
+        ),
+    )
 
     @validator('window_state')
     def _validate_qbtye(cls, v):


Test patch:
diff --git a/napari/_qt/menus/_tests/test_file_menu.py b/napari/_qt/menus/_tests/test_file_menu.py
--- a/napari/_qt/menus/_tests/test_file_menu.py
+++ b/napari/_qt/menus/_tests/test_file_menu.py
@@ -3,6 +3,8 @@
 from npe2 import DynamicPlugin
 from npe2.manifest.contributions import SampleDataURI
 
+from napari._qt.menus import file_menu
+from napari.settings import get_settings
 from napari.utils.action_manager import action_manager
 
 
@@ -35,6 +37,78 @@ def test_sample_data_triggers_reader_dialog(
     mock_read.assert_called_once()
 
 
+def test_close_window_cancel(make_napari_viewer):
+    v = make_napari_viewer()
+    with mock.patch(
+        'napari._qt.qt_main_window._QtMainWindow.close'
+    ) as close_mock:
+        with mock.patch(
+            "napari._qt.menus.file_menu.QMessageBox.exec_",
+            return_value=file_menu.QMessageBox.StandardButton.Cancel,
+        ) as message_mock:
+            v.window.file_menu._close_window()
+            message_mock.assert_called_once()
+            close_mock.assert_not_called()
+
+
+def test_close_window_ok(make_napari_viewer):
+    v = make_napari_viewer()
+    with mock.patch(
+        'napari._qt.qt_main_window._QtMainWindow.close'
+    ) as close_mock:
+        with mock.patch(
+            "napari._qt.menus.file_menu.QMessageBox.exec_",
+            return_value=file_menu.QMessageBox.StandardButton.Ok,
+        ) as message_mock:
+            v.window.file_menu._close_window()
+            message_mock.assert_called_once()
+            close_mock.assert_called_once_with(quit_app=False)
+
+
+def test_close_window_no_confirm(make_napari_viewer, monkeypatch):
+    v = make_napari_viewer()
+    with mock.patch(
+        'napari._qt.qt_main_window._QtMainWindow.close'
+    ) as close_mock:
+        monkeypatch.setattr(
+            get_settings().application, "confirm_close_window", False
+        )
+        with mock.patch(
+            "napari._qt.menus.file_menu.QMessageBox.exec_"
+        ) as message_mock:
+            v.window.file_menu._close_window()
+            message_mock.assert_not_called()
+            close_mock.assert_called_once_with(quit_app=False)
+
+
+def test_close_app_cancel(make_napari_viewer):
+    v = make_napari_viewer()
+    with mock.patch(
+        'napari._qt.qt_main_window._QtMainWindow.close'
+    ) as close_mock:
+        with mock.patch(
+            "napari._qt.menus.file_menu.QMessageBox.exec_",
+            return_value=file_menu.QMessageBox.StandardButton.Cancel,
+        ) as message_mock:
+            v.window.file_menu._close_app()
+            message_mock.assert_called_once()
+            close_mock.assert_not_called()
+
+
+def test_close_app_ok(make_napari_viewer):
+    v = make_napari_viewer()
+    with mock.patch(
+        'napari._qt.qt_main_window._QtMainWindow.close'
+    ) as close_mock:
+        with mock.patch(
+            "napari._qt.menus.file_menu.QMessageBox.exec_",
+            return_value=file_menu.QMessageBox.StandardButton.Ok,
+        ) as message_mock:
+            v.window.file_menu._close_app()
+            message_mock.assert_called_once()
+            close_mock.assert_called_once_with(quit_app=True)
+
+
 def test_show_shortcuts_actions(make_napari_viewer):
     viewer = make_napari_viewer()
     assert viewer.window.file_menu._pref_dialog is None