Repository: napari/napari

Issue:
`show_info` is not displayed in `thread_worker` functions 
## 🐛 Bug

<!-- A clear and concise description of what the bug is. -->
When using a `thread_worker` decorator to define a threaded function, the calls to `show_info` in that function are not routed to the  


## To Reproduce

Steps to reproduce the behavior:

Execute the following code from the napari IPython prompt
```python

import napari
from napari_plugin_engine import napari_hook_implementation
from magicgui import magic_factory

from napari.qt import thread_worker
from napari.utils.notifications import show_info

show_info('Test from main')  # This is displayed in the activity window 
import time

@thread_worker
def test_thread():
    print("started thread")
    show_info("in thread info")  # This prints "Notification" to stdout
    return 0

test_thread().start()
```

<!-- If you have a code sample, error messages, stack traces, please provide it here as well -->

## Expected behavior

The message in a `show_info` call in the separate thread should be displayed

<!-- A clear and concise description of what you expected to happen. -->

## Environment

 - Please copy and paste the information at napari info option in help menubar here:
napari: 0.4.12.dev51+g87f1ccac
Platform: Linux-5.13.0-7614-generic-x86_64-with-glibc2.33
System: Pop!_OS 21.04
Python: 3.9.7 | packaged by conda-forge | (default, Sep 14 2021, 01:17:55) [GCC 9.4.0]
Qt: 5.15.2
PyQt5: 5.15.4
NumPy: 1.21.2
SciPy: 1.7.1
Dask: 2021.09.0
VisPy: 0.8.1

OpenGL:
- GL version: 4.6 (Compatibility Profile) Mesa 21.2.1
- MAX_TEXTURE_SIZE: 16384

Screens:
- screen 1: resolution 1920x1080, scale 1.0
- screen 2: resolution 1920x1080, scale 1.0

Plugins:
- console: 0.0.4
- napari-useg: 0.0.1
- scikit-image: 0.4.12.dev51+g87f1ccac
- svg: 0.1.5



Discussion:
Thanks @glyg 

I think the fix here could be as easy as using @Czaki's new `ensure_main_thread` decorator from superqt on the `show_info` function [here](https://github.com/tlambert03/napari/blob/3217587ba8cb9080eef5a983c6cd3b0849efafb3/napari/utils/notifications.py#L290-L291)

```py
from superqt import ensure_main_thread

@ensure_main_thread
def show_info(message: str):
    notification_manager.receive_info(message)
```

@Czaki ... would you have time to give that a shot?
I would rather decorate clasmethod `show_notification` of `NapariQtNotification` (to keep qt code in qt part) or eventually `dispatch` of `NotificationManager`. 

The code attached to this issue does not work on my computer (there is even no napari window create), s I could sit on this tomorrow (because I nee try to create my own code for reproduction).  

@glyg Could you update your script to allow to reproduce the error when run as `python path_to_script.py`
Here is a script version<sup>1</sup>:

```python
import time

from skimage import data
import napari
from napari.qt import thread_worker
from napari.utils.notifications import show_info

napari.view_image(data.astronaut(), rgb=True)

@thread_worker
def test_thread():
    print("started thread")
    time.sleep(1)
    show_info("in thread info")  # This prints "Notification" to stdout
    return 0


test_thread().start()

show_info("Test from main")  # This is displayed as a notification
napari.run()

```

1. ~~This segfaults on my machine -- I suspect due to newest Qt? -- but this is unrelated to the bug~~ (edited, I forgot the `napari.run()` line)
Ok, I could reproduce the error and found the source of the problem:

This part of code properly handle information that calls comes from the wrong thread and pass it to Qt Signal 
https://github.com/napari/napari/blob/ab7bb40a37dc6d2faa46e1b74f8ca93ed396d7a9/napari/_qt/dialogs/qt_notification.py#L380-L388

but the connection is moved (last modification in #2703) to update tooltip method of main window (so it is not connected):

https://github.com/napari/napari/blob/ab7bb40a37dc6d2faa46e1b74f8ca93ed396d7a9/napari/_qt/qt_main_window.py#L128-L140

This time fastest solution may be to use `ensure_main_thread`  to `show_notification` of `NapariQtNotification` but @tlambert03 maybe you prefer to fix existing code? 
I don't have a strong opinion here.  If @goanpeca moved that code to another place, perhaps he has a thought here.  I'm happy to go with whatever solves this immediate issue here
Hmmmm, that was an old PR 🤔 

>This time fastest solution may be to use ensure_main_thread to show_notification of NapariQtNotification

I would go with this, and perhaps leave a note @Czaki while I think of this a bit more 

Resolving patch:
diff --git a/napari/_qt/dialogs/qt_notification.py b/napari/_qt/dialogs/qt_notification.py
--- a/napari/_qt/dialogs/qt_notification.py
+++ b/napari/_qt/dialogs/qt_notification.py
@@ -4,15 +4,12 @@
 
 from qtpy.QtCore import (
     QEasingCurve,
-    QObject,
     QPoint,
     QPropertyAnimation,
     QRect,
     QSize,
     Qt,
-    QThread,
     QTimer,
-    Signal,
 )
 from qtpy.QtWidgets import (
     QApplication,
@@ -26,7 +23,7 @@
     QVBoxLayout,
     QWidget,
 )
-from superqt import QElidingLabel
+from superqt import QElidingLabel, ensure_main_thread
 
 from ...utils.notifications import Notification, NotificationSeverity
 from ...utils.translations import trans
@@ -34,15 +31,6 @@
 ActionSequence = Sequence[Tuple[str, Callable[[], None]]]
 
 
-class NotificationDispatcher(QObject):
-    """
-    This is a helper class to allow the propagation of notifications
-    generated from exceptions or warnings inside threads.
-    """
-
-    sig_notified = Signal(Notification)
-
-
 class NapariQtNotification(QDialog):
     """Notification dialog frame, appears at the bottom right of the canvas.
 
@@ -365,6 +353,7 @@ def _enter_debug_mode():
         )
 
     @classmethod
+    @ensure_main_thread
     def show_notification(cls, notification: Notification):
         from ...settings import get_settings
 
@@ -376,17 +365,6 @@ def show_notification(cls, notification: Notification):
             notification.severity
             >= settings.application.gui_notification_level
         ):
-            application_instance = QApplication.instance()
-            if (
-                application_instance
-                and application_instance.thread() != QThread.currentThread()
-            ):
-                dispatcher = getattr(application_instance, "_dispatcher", None)
-                if dispatcher:
-                    dispatcher.sig_notified.emit(notification)
-
-                return
-
             cls.from_notification(notification).show()
 
 
diff --git a/napari/_qt/qt_event_loop.py b/napari/_qt/qt_event_loop.py
--- a/napari/_qt/qt_event_loop.py
+++ b/napari/_qt/qt_event_loop.py
@@ -19,10 +19,7 @@
 )
 from ..utils.perf import perf_config
 from ..utils.translations import trans
-from .dialogs.qt_notification import (
-    NapariQtNotification,
-    NotificationDispatcher,
-)
+from .dialogs.qt_notification import NapariQtNotification
 from .qt_event_filters import QtToolTipEventFilter
 from .qt_resources import _register_napari_resources
 from .qthreading import wait_for_workers_to_quit
@@ -191,9 +188,6 @@ def get_app(
 
     # Add the dispatcher attribute to the application to be able to dispatch
     # notifications coming from threads
-    dispatcher = getattr(app, "_dispatcher", None)
-    if dispatcher is None:
-        app._dispatcher = NotificationDispatcher()
 
     return app
 
diff --git a/napari/_qt/qt_main_window.py b/napari/_qt/qt_main_window.py
--- a/napari/_qt/qt_main_window.py
+++ b/napari/_qt/qt_main_window.py
@@ -131,14 +131,6 @@ def update_tooltip(self, event):
         else:
             self.qt_viewer.setToolTip("")
 
-        # Connect the notification dispacther to correctly propagate
-        # notifications from threads. See: `napari._qt.qt_event_loop::get_app`
-        application_instance = QApplication.instance()
-        if application_instance:
-            application_instance._dispatcher.sig_notified.connect(
-                self.show_notification
-            )
-
     @classmethod
     def current(cls):
         return cls._instances[-1] if cls._instances else None


Test patch:
diff --git a/napari/_qt/_tests/test_qt_notifications.py b/napari/_qt/_tests/test_qt_notifications.py
--- a/napari/_qt/_tests/test_qt_notifications.py
+++ b/napari/_qt/_tests/test_qt_notifications.py
@@ -2,11 +2,12 @@
 import threading
 import time
 import warnings
+from concurrent.futures import Future
 from unittest.mock import patch
 
 import dask.array as da
 import pytest
-from qtpy.QtCore import Qt
+from qtpy.QtCore import Qt, QThread
 from qtpy.QtWidgets import QPushButton
 
 from napari._qt.dialogs.qt_notification import NapariQtNotification
@@ -88,9 +89,38 @@ def test_notification_manager_via_gui(
             notification_manager.records = []
 
 
+@patch('napari._qt.dialogs.qt_notification.QDialog.show')
+def test_show_notification_from_thread(mock_show, monkeypatch, qtbot):
+    from napari.settings import get_settings
+
+    settings = get_settings()
+
+    monkeypatch.setattr(
+        settings.application,
+        'gui_notification_level',
+        NotificationSeverity.INFO,
+    )
+
+    class CustomThread(QThread):
+        def run(self):
+            notif = Notification(
+                'hi',
+                NotificationSeverity.INFO,
+                actions=[('click', lambda x: None)],
+            )
+            res = NapariQtNotification.show_notification(notif)
+            assert isinstance(res, Future)
+            assert res.result() is None
+            mock_show.assert_called_once()
+
+    thread = CustomThread()
+    with qtbot.waitSignal(thread.finished):
+        thread.start()
+
+
 @pytest.mark.parametrize('severity', NotificationSeverity.__members__)
 @patch('napari._qt.dialogs.qt_notification.QDialog.show')
-def test_notification_display(mock_show, severity, monkeypatch):
+def test_notification_display(mock_show, severity, monkeypatch, qtbot):
     """Test that NapariQtNotification can present a Notification event.
 
     NOTE: in napari.utils._tests.test_notification_manager, we already test
@@ -127,6 +157,7 @@ def test_notification_display(mock_show, severity, monkeypatch):
     dialog.toggle_expansion()
     assert not dialog.property('expanded')
     dialog.close()
+    dialog.deleteLater()
 
 
 @patch('napari._qt.dialogs.qt_notification.QDialog.show')