Repository: napari/napari

Issue:
Threaded `progress` objects get added to `Viewer` before `worker` starts
## 🐛 Bug

When we create a threaded `progress` bar, the `QtProgressBar` gets added to the `Viewer` when the worker is initialized, not when the worker is started.

## To Reproduce

Steps to reproduce the behavior:

1. Run this script
```
import napari

@thread_worker(
    start_thread=False,
    progress={'total': 5}
)
def get_worker():
    for i in range(5):
        print(i)
        yield


viewer = napari.Viewer()
my_worker = get_worker()

napari.run()
```

2. Observe progress bar in activity dialog and busy indicator in status bar

## Expected behavior

The `progress` object should only be added to the viewer when the `worker` is started, as this is otherwise giving misleading information to the user about the state of progress of a given action. Additionally depending on the workflow, the `worker` could be created but never started, further confusing the situation. 

I also wonder whether it's dangerous to have the `progress` object hanging around in the case where the worker is never `started`? When we add `progress` to the `Viewer` we also connect its `close` method to `ActivityDialog.destroyed`, so it gets cleaned up ok, but if the worker never starts, I don't think this triggers. @tlambert03 any thoughts? We *could* just create the `progress` object itself on `worker.start`, but then issues like the one being addressed in #4034 would get more complex, since the `worker` wouldn't have a `progress` at all.



Discussion:

Resolving patch:
diff --git a/napari/_qt/qthreading.py b/napari/_qt/qthreading.py
--- a/napari/_qt/qthreading.py
+++ b/napari/_qt/qthreading.py
@@ -1,6 +1,6 @@
 import inspect
 import warnings
-from functools import wraps
+from functools import partial, wraps
 from types import FunctionType, GeneratorType
 from typing import (
     Callable,
@@ -168,7 +168,17 @@ def long_function(duration):
             )
             total = 0
 
-        pbar = progress(total=total, desc=desc)
+        with progress._all_instances.events.changed.blocker():
+            pbar = progress(total=total, desc=desc)
+
+        worker.started.connect(
+            partial(
+                lambda prog: progress._all_instances.events.changed(
+                    added={prog}, removed={}
+                ),
+                pbar,
+            )
+        )
         worker.finished.connect(pbar.close)
         if total != 0 and isinstance(worker, GeneratorWorker):
             worker.yielded.connect(pbar.increment_with_overflow)


Test patch:
diff --git a/napari/_qt/_tests/test_threading_progress.py b/napari/_qt/_tests/test_threading_progress.py
--- a/napari/_qt/_tests/test_threading_progress.py
+++ b/napari/_qt/_tests/test_threading_progress.py
@@ -1,6 +1,7 @@
 import pytest
 
 from napari._qt import qthreading
+from napari._qt.widgets.qt_progress_bar import QtLabeledProgressBar
 
 pytest.importorskip(
     'qtpy', reason='Cannot test threading progress without qtpy.'
@@ -133,3 +134,24 @@ def func():
     )
     worker = thread_func()
     assert worker.pbar.total == 0
+
+
+def test_unstarted_worker_no_widget(make_napari_viewer):
+    viewer = make_napari_viewer()
+
+    def func():
+        for _ in range(5):
+            yield
+
+    thread_func = qthreading.thread_worker(
+        func,
+        progress={'total': 5},
+        start_thread=False,
+    )
+
+    thread_func()
+    assert not bool(
+        viewer.window._qt_viewer.window()._activity_dialog.findChildren(
+            QtLabeledProgressBar
+        )
+    )