Repository: napari/napari

Issue:
consider orphaning dock_widget children before removing dock widgets
I'm _finally_ understanding an issue @stefanv posted a long time ago on magicgui (https://github.com/napari/magicgui/issues/28).  I think it _may_ be something that we could/should fix in our dock widget mechanisms.  Basically, when we call `viewer.window.add_dock_widget` we assign the new `QDockWidget` as the parent of whatever widget was passed in:
https://github.com/napari/napari/blob/ddd6715208eb02774d01c49444d45764b42b5e25/napari/_qt/widgets/qt_viewer_dock_widget.py#L100-L101

however, if that `QDockWidget` gets removed from the viewer, _and_ the user hasn't saved a reference somewhere else to the `QDockWidget`, then the original widget that was passed to `add_dock_widget` will get deleted, _even if they have a reference to it somewhere else_, because a QWidget cleans up all of its children when it gets destroyed.  One possible fix is to call `widget._widget.setParent(None)` right here before we remove the dock widget:
https://github.com/napari/napari/blob/ddd6715208eb02774d01c49444d45764b42b5e25/napari/_qt/qt_main_window.py#L612
(where `widget` was `QDockWidget` instance, and `widget._widget` was the original widget passed by the user)


Discussion:
it's also possible that we shouldn't be calling `setParent` on the original widget in the first place (but if we don't, I'm not sure that styles will be passed on to the child)
Nice! Your proposed solution sounds reasonable to me.
Sounds reasonable to me too.

One other thing I noted in #1800 was I think we also need to remove the toggle view action https://github.com/napari/napari/pull/1800/files#diff-a668967bb40dcd4fe528babf5641ceb5f8c2bafb6b6e57e5ff6a4bce15e150a3R593 when removing the dock widget. (I know this isn't directly related, but I just remembered so thought I'd mention too.

Resolving patch:
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
@@ -588,7 +588,6 @@ def _add_viewer_dock_widget(self, dock_widget: QtViewerDockWidget):
         dock_widget : QtViewerDockWidget
             `dock_widget` will be added to the main window.
         """
-        dock_widget.setParent(self._qt_window)
         self._qt_window.addDockWidget(dock_widget.qt_area, dock_widget)
         action = dock_widget.toggleViewAction()
         action.setStatusTip(dock_widget.name)
@@ -597,9 +596,13 @@ def _add_viewer_dock_widget(self, dock_widget: QtViewerDockWidget):
             action.setShortcut(dock_widget.shortcut)
         self.window_menu.addAction(action)
 
-    def remove_dock_widget(self, widget):
+    def remove_dock_widget(self, widget: QWidget):
         """Removes specified dock widget.
 
+        If a QDockWidget is not provided, the existing QDockWidgets will be
+        searched for one whose inner widget (``.widget()``) is the provided
+        ``widget``.
+
         Parameters
         ----------
         widget : QWidget | str
@@ -608,8 +611,29 @@ def remove_dock_widget(self, widget):
         if widget == 'all':
             for dw in self._qt_window.findChildren(QDockWidget):
                 self._qt_window.removeDockWidget(dw)
+            return
+
+        if not isinstance(widget, QDockWidget):
+            for dw in self._qt_window.findChildren(QDockWidget):
+                if dw.widget() is widget:
+                    _dw: QDockWidget = dw
+                    break
+            else:
+                raise LookupError(
+                    f"Could not find a dock widget containing: {widget}"
+                )
         else:
-            self._qt_window.removeDockWidget(widget)
+            _dw = widget
+
+        if _dw.widget():
+            _dw.widget().setParent(None)
+        self._qt_window.removeDockWidget(_dw)
+        self.window_menu.removeAction(_dw.toggleViewAction())
+        # Deleting the dock widget means any references to it will no longer
+        # work but it's not really useful anyway, since the inner widget has
+        # been removed. and anyway: people should be using add_dock_widget
+        # rather than directly using _add_viewer_dock_widget
+        _dw.deleteLater()
 
     def resize(self, width, height):
         """Resize the window.
diff --git a/napari/_qt/widgets/qt_viewer_dock_widget.py b/napari/_qt/widgets/qt_viewer_dock_widget.py
--- a/napari/_qt/widgets/qt_viewer_dock_widget.py
+++ b/napari/_qt/widgets/qt_viewer_dock_widget.py
@@ -79,9 +79,9 @@ def __init__(
         self.setMinimumWidth(50)
         self.setObjectName(name)
 
-        self.widget = combine_widgets(
-            widget, vertical=area in {'left', 'right'}
-        )
+        widget = combine_widgets(widget, vertical=area in {'left', 'right'})
+        self.setWidget(widget)
+
         self._features = self.features()
         self.dockLocationChanged.connect(self._set_title_orientation)
 
@@ -90,17 +90,6 @@ def __init__(
         self.setTitleBarWidget(self.title)
         self.visibilityChanged.connect(self._on_visibility_changed)
 
-    @property
-    def widget(self):
-        """QWidget: widget that will be added as QDockWidget's main widget."""
-        return self._widget
-
-    @widget.setter
-    def widget(self, widget):
-        self.setWidget(widget)
-        widget.setParent(self)
-        self._widget = widget
-
     def setFeatures(self, features):
         super().setFeatures(features)
         self._features = self.features()


Test patch:
diff --git a/napari/_qt/widgets/_tests/test_qt_dock_widget.py b/napari/_qt/widgets/_tests/test_qt_dock_widget.py
--- a/napari/_qt/widgets/_tests/test_qt_dock_widget.py
+++ b/napari/_qt/widgets/_tests/test_qt_dock_widget.py
@@ -9,14 +9,14 @@ def test_add_dock_widget(make_test_viewer):
     dwidg = viewer.window.add_dock_widget(widg, name='test')
     assert not dwidg.is_vertical
     assert viewer.window._qt_window.findChild(QDockWidget, 'test')
-    assert dwidg.widget == widg
+    assert dwidg.widget() == widg
     dwidg._on_visibility_changed(True)  # smoke test
 
     widg2 = QPushButton('button')
     dwidg2 = viewer.window.add_dock_widget(widg2, name='test2', area='right')
     assert dwidg2.is_vertical
     assert viewer.window._qt_window.findChild(QDockWidget, 'test2')
-    assert dwidg2.widget == widg2
+    assert dwidg2.widget() == widg2
     dwidg2._on_visibility_changed(True)  # smoke test
 
     with pytest.raises(ValueError):
@@ -46,13 +46,13 @@ def test_add_dock_widget_from_list(make_test_viewer):
         [widg, widg2], name='test', area='right'
     )
     assert viewer.window._qt_window.findChild(QDockWidget, 'test')
-    assert isinstance(dwidg.widget.layout, QVBoxLayout)
+    assert isinstance(dwidg.widget().layout, QVBoxLayout)
 
     dwidg = viewer.window.add_dock_widget(
         [widg, widg2], name='test2', area='bottom'
     )
     assert viewer.window._qt_window.findChild(QDockWidget, 'test2')
-    assert isinstance(dwidg.widget.layout, QHBoxLayout)
+    assert isinstance(dwidg.widget().layout, QHBoxLayout)
 
 
 def test_add_dock_widget_raises(make_test_viewer):
@@ -62,3 +62,32 @@ def test_add_dock_widget_raises(make_test_viewer):
 
     with pytest.raises(TypeError):
         viewer.window.add_dock_widget(widg, name='test')
+
+
+def test_remove_dock_widget_orphans_widget(make_test_viewer):
+    viewer = make_test_viewer()
+    widg = QPushButton('button')
+
+    assert not widg.parent()
+    dw = viewer.window.add_dock_widget(widg, name='test')
+    assert widg.parent() is dw
+    assert dw.toggleViewAction() in viewer.window.window_menu.actions()
+    viewer.window.remove_dock_widget(dw)
+    assert dw.toggleViewAction() not in viewer.window.window_menu.actions()
+    del dw
+    # if dw didn't release widg, we'd get an exception when next accessing widg
+    assert not widg.parent()
+
+
+def test_remove_dock_widget_by_widget_reference(make_test_viewer):
+    viewer = make_test_viewer()
+    widg = QPushButton('button')
+
+    dw = viewer.window.add_dock_widget(widg, name='test')
+    assert widg.parent() is dw
+    assert dw in viewer.window._qt_window.findChildren(QDockWidget)
+    viewer.window.remove_dock_widget(widg)
+    with pytest.raises(LookupError):
+        # it's gone this time:
+        viewer.window.remove_dock_widget(widg)
+    assert not widg.parent()