Repository: napari/napari

Issue:
Allow to provide list of docked widgets which will be open on viewer start
## 🚀 Feature
<!-- A clear and concise description of the feature proposal -->

Add to `Viewer` constructor argument `widgets` or `docked_widgets` with a list of widgets that need to be docked on Viewer start. This could be a list of names or a list of classes (not instances).

## Motivation

When working with the development of plugins this is annoying to all-time open widgets from the main menu. of course it possible to use `add_dock_widget` from `viewer.window` but this is handled differently than widgets from the main menu. 

List of main problems:

1. Widgets added with `add_dock_widget` could be closed without the option to reopen it.
2. When the widget is added with `add_dock_widget` and registered with hook it is possible to have two instances of a given widget.

These two ones are not so hard when developing, but problematic when providing GUI to non programming person. 

<!-- Please outline the motivation for the proposal. Is your feature request related to a problem? e.g., I'm always frustrated when [...]. If this is related to another GitHub issue, please link here too -->




Discussion:
I'm not sure this will be a complete solution to what you request, but I could imagine keeping track of which plugin widgets were open when you last closed the viewer and restarting napari with those plugin widgets displayed. This will at least help our plugin developers and users.
I do not think that it will solve this problem. This base on the previous run, not the context of a current situation.  
Another solution when something like this may be useful is using naprai from a notebook. That you could simply adjust the interface to the current task. 
I'm not sure I'm following what you want to happen.  Is the issue that you want to be able to start napari with a plugin dock widget already open (without manually needing to open it in the menu)?  Is it that you want to be able to have multiple instances of the dock widget?  Is it that you _want_ to be able to close and reopen it, or _don't_ want to be able to close and reopen it?

Would this be solved by us making a better public interface for `_add_plugin_dock_widget`?
I would like to start napari with already docked widgets and without the possibility of multiply instances (which could be done with add_docked_widget). 

> Would this be solved by us making a better public interface for `_add_plugin_dock_widget`?

I think yes. 

This would be saved as part of the geometry saving, we just need to track the list of visible plugins/docks on shutdown. Dockwidgets also need to have a unique and object name assigned
> This would be saved as part of the geometry saving, we just need to track the list of visible plugins/docks on shutdown. Dockwidgets also need to have a unique and object name assigned

This is a nice idea for Viewer used as bundled app. Not for viewer used from a notebook. A different notebook may need a different set of widgets. 

Resolving patch:
diff --git a/napari/__main__.py b/napari/__main__.py
--- a/napari/__main__.py
+++ b/napari/__main__.py
@@ -145,6 +145,18 @@ def _run():
         default=0,
         help="increase output verbosity",
     )
+    parser.add_argument(
+        '-w',
+        '--with',
+        dest='with_',
+        nargs='+',
+        metavar=('PLUGIN_NAME', 'WIDGET_NAME'),
+        help=(
+            "open napari with dock widget from specified plugin name."
+            "(If plugin provides multiple dock widgets, widget name must also "
+            "be provided)"
+        ),
+    )
     parser.add_argument(
         '--version',
         action='version',
@@ -234,6 +246,18 @@ def _run():
         runpy.run_path(args.paths[0])
 
     else:
+        if args.with_:
+            from . import plugins
+
+            # if a plugin widget has been requested, this will fail immediately
+            # if the requested plugin/widget is not available.
+            plugins.discover_dock_widgets()
+            pname, *wnames = args.with_
+            if wnames:
+                for wname in wnames:
+                    plugins.get_plugin_widget(pname, wname)
+            else:
+                plugins.get_plugin_widget(pname)
 
         from ._qt.widgets.qt_splash_screen import NapariSplashScreen
 
@@ -246,13 +270,22 @@ def _run():
         # but in the meantime if the garbage collector runs;
         # it will collect it and hang napari at start time.
         # in a way that is machine, os, time (and likely weather dependant).
-        _viewer = view_path(  # noqa: F841
+        viewer = view_path(  # noqa: F841
             args.paths,
             stack=args.stack,
             plugin=args.plugin,
             layer_type=args.layer_type,
             **kwargs,
         )
+
+        if args.with_:
+            pname, *wnames = args.with_
+            if wnames:
+                for wname in wnames:
+                    viewer.window.add_plugin_dock_widget(pname, wname)
+            else:
+                viewer.window.add_plugin_dock_widget(pname)
+
         run(gui_exceptions=True)
 
 
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
@@ -5,7 +5,6 @@
 import inspect
 import os
 import time
-from collections import Counter
 from itertools import chain, repeat
 from typing import Dict
 
@@ -238,7 +237,6 @@ def __init__(self, viewer, *, show: bool = True):
 
         # Dictionary holding dock widgets
         self._dock_widgets: Dict[str, QtViewerDockWidget] = {}
-        self._plugin_menus: Dict[str, QMenu] = {}
 
         # since we initialize canvas before window, we need to manually connect them again.
         if self._qt_window.windowHandle() is not None:
@@ -563,39 +561,33 @@ def _add_plugins_menu(self):
         if not plugins.dock_widgets:
             plugins.discover_dock_widgets()
 
-        # Get names of all plugins providing dock widgets or functions
-        plugin_widgets = chain(plugins.dock_widgets, plugins.function_widgets)
-        plugin_counts = Counter(plug_name for plug_name, _ in plugin_widgets)
-
-        # Add submenu for each plugin with more than 1 item
-        for plugin_name, count in plugin_counts.items():
-            if count > 1:
+        # Add a menu item (QAction) for each available plugin widget
+        docks = zip(repeat("dock"), plugins.dock_widgets.items())
+        funcs = zip(repeat("func"), plugins.function_widgets.items())
+        for hook_type, (plugin_name, widgets) in chain(docks, funcs):
+            multiprovider = len(widgets) > 1
+            if multiprovider:
                 menu = QMenu(plugin_name, self._qt_window)
-                self._plugin_menus[plugin_name] = menu
                 self._plugin_dock_widget_menu.addMenu(menu)
-
-        # Add a menu item (QAction) for each available plugin widget
-        docks = zip(repeat("dock"), plugins.dock_widgets)
-        funcs = zip(repeat("func"), plugins.function_widgets)
-        for hook_type, key in chain(docks, funcs):
-            plugin_name, wdg_name = key
-            if plugin_name in self._plugin_menus:
-                # this plugin has a submenu.
-                action = QAction(wdg_name, parent=self._qt_window)
-                self._plugin_menus[plugin_name].addAction(action)
             else:
-                # this plugin only has one widget, add a namespaced menu item
-                full_name = plugins.menu_item_template.format(*key)
-                action = QAction(full_name, parent=self._qt_window)
-                self._plugin_dock_widget_menu.addAction(action)
-
-            def _add_widget(*args, key=key, hook_type=hook_type):
-                if hook_type == 'dock':
-                    self._add_plugin_dock_widget(key)
+                menu = self._plugin_dock_widget_menu
+
+            for wdg_name in widgets:
+                key = (plugin_name, wdg_name)
+                if multiprovider:
+                    action = QAction(wdg_name, parent=self._qt_window)
                 else:
-                    self._add_plugin_function_widget(key)
+                    full_name = plugins.menu_item_template.format(*key)
+                    action = QAction(full_name, parent=self._qt_window)
 
-            action.triggered.connect(_add_widget)
+                def _add_widget(*args, key=key, hook_type=hook_type):
+                    if hook_type == 'dock':
+                        self.add_plugin_dock_widget(*key)
+                    else:
+                        self._add_plugin_function_widget(*key)
+
+                menu.addAction(action)
+                action.triggered.connect(_add_widget)
 
         self.plugins_menu.addMenu(self._plugin_dock_widget_menu)
 
@@ -679,23 +671,35 @@ def _toggle_play(self, state):
             axis = self.qt_viewer.viewer.dims.last_used or 0
             self.qt_viewer.dims.play(axis)
 
-    def _add_plugin_dock_widget(self, key):
+    def add_plugin_dock_widget(
+        self, plugin_name: str, widget_name: str = None
+    ):
         """Add plugin dock widget if not already added.
 
         Parameters
         ----------
-        key : 2-tuple of str
-            Plugin name and widget name.
+        plugin_name : str
+            Name of a plugin providing a widget
+        widget_name : str, optional
+            Name of a widget provided by `plugin_name`. If `None`, and the
+            specified plugin provides only a single widget, that widget will be
+            returned, otherwise a ValueError will be raised, by default None
         """
         from ..viewer import Viewer
 
-        full_name = plugins.menu_item_template.format(*key)
+        Widget, dock_kwargs = plugins.get_plugin_widget(
+            plugin_name, widget_name
+        )
+        if not widget_name:
+            # if widget_name wasn't provided, `get_plugin_widget` will have
+            # ensured that there is a single widget available.
+            widget_name = list(plugins.dock_widgets[plugin_name])[0]
+
+        full_name = plugins.menu_item_template.format(plugin_name, widget_name)
         if full_name in self._dock_widgets:
             self._dock_widgets[full_name].show()
             return
 
-        Widget, dock_kwargs = plugins.dock_widgets[key]
-
         # if the signature is looking a for a napari viewer, pass it.
         kwargs = {}
         for param in inspect.signature(Widget.__init__).parameters.values():
@@ -714,32 +718,33 @@ def _add_plugin_dock_widget(self, key):
         # Add dock widget
         self.add_dock_widget(
             wdg,
-            name=plugins.menu_item_template.format(*key),
+            name=full_name,
             area=dock_kwargs.get('area', 'right'),
             allowed_areas=dock_kwargs.get('allowed_areas', None),
         )
 
-    def _add_plugin_function_widget(self, key):
+    def _add_plugin_function_widget(self, plugin_name: str, widget_name: str):
         """Add plugin function widget if not already added.
 
         Parameters
         ----------
-        key : 2-tuple of str
-            Plugin name and function name.
+        plugin_name : str
+            Name of a plugin providing a widget
+        widget_name : str, optional
+            Name of a widget provided by `plugin_name`. If `None`, and the
+            specified plugin provides only a single widget, that widget will be
+            returned, otherwise a ValueError will be raised, by default None
         """
-        full_name = plugins.menu_item_template.format(*key)
+        full_name = plugins.menu_item_template.format(plugin_name, widget_name)
         if full_name in self._dock_widgets:
             self._dock_widgets[full_name].show()
             return
 
-        func = plugins.function_widgets[key]
+        func = plugins.function_widgets[plugin_name][widget_name]
 
         # Add function widget
         self.add_function_widget(
-            func,
-            name=plugins.menu_item_template.format(*key),
-            area=None,
-            allowed_areas=None,
+            func, name=full_name, area=None, allowed_areas=None
         )
 
     def add_dock_widget(
diff --git a/napari/plugins/__init__.py b/napari/plugins/__init__.py
--- a/napari/plugins/__init__.py
+++ b/napari/plugins/__init__.py
@@ -1,15 +1,14 @@
-import os
 import sys
-from inspect import isclass, signature
+from inspect import signature
 from types import FunctionType
 from typing import (
     TYPE_CHECKING,
+    Any,
     Callable,
     Dict,
     List,
-    Sequence,
+    Optional,
     Tuple,
-    Type,
     Union,
 )
 from warnings import warn
@@ -37,12 +36,11 @@
     plugin_manager.add_hookspecs(hook_specifications)
     plugin_manager.register(_builtins, name='builtins')
 
-
+WidgetCallable = Callable[..., Union['FunctionGui', 'QWidget']]
 dock_widgets: Dict[
-    Tuple[str, str],
-    Tuple[Callable[..., Union['FunctionGui', 'QWidget']], dict],
+    str, Dict[str, Tuple[WidgetCallable, Dict[str, Any]]]
 ] = dict()
-function_widgets: Dict[Tuple[str, str], Callable] = dict()
+function_widgets: Dict[str, Dict[str, Callable[..., Any]]] = dict()
 
 
 def register_dock_widget(
@@ -83,13 +81,71 @@ def register_dock_widget(
         # Get widget name
         name = str(kwargs.get('name', '')) or camel_to_spaces(_cls.__name__)
 
-        key = (plugin_name, name)
-        if key in dock_widgets:
+        if plugin_name not in dock_widgets:
+            # tried defaultdict(dict) but got odd KeyErrors...
+            dock_widgets[plugin_name] = {}
+        elif name in dock_widgets[plugin_name]:
             warn(
                 "Plugin '{}' has already registered a dock widget '{}' "
-                'which has now been overwritten'.format(*key)
+                'which has now been overwritten'.format(plugin_name, name)
+            )
+
+        dock_widgets[plugin_name][name] = (_cls, kwargs)
+
+
+def get_plugin_widget(
+    plugin_name: str, widget_name: Optional[str] = None
+) -> Tuple[WidgetCallable, Dict[str, Any]]:
+    """Get widget `widget_name` provided by plugin `plugin_name`.
+
+    Note: it's important that :func:`discover_dock_widgets` has been called
+    first, otherwise plugins may not be found yet.  (Typically, that is done
+    in qt_main_window)
+
+    Parameters
+    ----------
+    plugin_name : str
+        Name of a plugin providing a widget
+    widget_name : str, optional
+        Name of a widget provided by `plugin_name`. If `None`, and the
+        specified plugin provides only a single widget, that widget will be
+        returned, otherwise a ValueError will be raised, by default None
+
+    Returns
+    -------
+    plugin_widget : Tuple[Callable, dict]
+        Tuple of (widget_class, options).
+
+    Raises
+    ------
+    KeyError
+        If plugin `plugin_name` does not provide any widgets
+    KeyError
+        If plugin does not provide a widget named `widget_name`.
+    ValueError
+        If `widget_name` is not provided, but `plugin_name` provides more than
+        one widget
+    """
+    plg_wdgs = dock_widgets.get(plugin_name)
+    if not plg_wdgs:
+        raise KeyError(
+            f'Plugin {plugin_name!r} does not provide any dock widgets'
+        )
+
+    if not widget_name:
+        if len(plg_wdgs) > 1:
+            raise ValueError(
+                f'Plugin {plugin_name!r} provides more than 1 dock_widget. '
+                f'Must also provide "widget_name" from {set(plg_wdgs)}'
+            )
+        widget_name = list(plg_wdgs)[0]
+    else:
+        if widget_name not in plg_wdgs:
+            raise KeyError(
+                f'Plugin {plugin_name!r} does not provide '
+                f'a widget named {widget_name!r}'
             )
-        dock_widgets[key] = (_cls, kwargs)
+    return plg_wdgs[widget_name]
 
 
 _magicgui_sig = {
@@ -122,14 +178,16 @@ def register_function_widget(
         # Get function name
         name = func.__name__.replace('_', ' ')
 
-        key = (plugin_name, name)
-        if key in function_widgets:
+        if plugin_name not in function_widgets:
+            # tried defaultdict(dict) but got odd KeyErrors...
+            function_widgets[plugin_name] = {}
+        elif name in function_widgets[plugin_name]:
             warn(
                 "Plugin '{}' has already registered a function widget '{}' "
-                'which has now been overwritten'.format(*key)
+                'which has now been overwritten'.format(plugin_name, name)
             )
 
-        function_widgets[key] = func
+        function_widgets[plugin_name][name] = func
 
 
 def discover_dock_widgets():


Test patch:
diff --git a/napari/_qt/_tests/test_plugin_widgets.py b/napari/_qt/_tests/test_plugin_widgets.py
--- a/napari/_qt/_tests/test_plugin_widgets.py
+++ b/napari/_qt/_tests/test_plugin_widgets.py
@@ -43,7 +43,7 @@ def magicfunc(viewer: 'napari.Viewer'):
 # monkeypatch, request, recwarn fixtures are from pytest
 @pytest.mark.parametrize('arg', dwidget_args.values(), ids=dwidget_args.keys())
 def test_dock_widget_registration(
-    arg, test_plugin_manager, add_implementation, monkeypatch, request, recwarn
+    arg, test_plugin_manager, monkeypatch, request, recwarn
 ):
     """Test that dock widgets get validated and registerd correctly."""
     test_plugin_manager.project_name = 'napari'
@@ -54,11 +54,13 @@ def test_dock_widget_registration(
         registered = {}
         m.setattr(plugins, "dock_widgets", registered)
 
-        @napari_hook_implementation
-        def napari_experimental_provide_dock_widget():
-            return arg
+        class Plugin:
+            @napari_hook_implementation
+            def napari_experimental_provide_dock_widget():
+                return arg
+
+        test_plugin_manager.register(Plugin)
 
-        add_implementation(napari_experimental_provide_dock_widget)
         hook.call_historic(
             result_callback=plugins.register_dock_widget, with_impl=True
         )
@@ -67,9 +69,9 @@ def napari_experimental_provide_dock_widget():
             assert not registered
         else:
             assert len(recwarn) == 0
-            assert registered[(None, 'Widg1')][0] == Widg1
+            assert registered['Plugin']['Widg1'][0] == Widg1
             if 'tuple_list' in request.node.name:
-                assert registered[(None, 'Widg2')][0] == Widg2
+                assert registered['Plugin']['Widg2'][0] == Widg2
 
 
 @pytest.fixture
@@ -77,15 +79,12 @@ def test_plugin_widgets(monkeypatch):
     """A smattering of example registered dock widgets and function widgets."""
     with monkeypatch.context() as m:
         dock_widgets = {
-            ("TestP1", "Widg1"): (Widg1, {}),
-            ("TestP1", "Widg2"): (Widg2, {}),
-            ("TestP2", "Widg3"): (Widg3, {}),
+            "TestP1": {"Widg1": (Widg1, {}), "Widg2": (Widg2, {})},
+            "TestP2": {"Widg3": (Widg3, {})},
         }
         m.setattr(plugins, "dock_widgets", dock_widgets)
 
-        function_widgets = {
-            ("TestP3", "magic"): magicfunc,
-        }
+        function_widgets = {'TestP3': {'magic': magicfunc}}
         m.setattr(plugins, "function_widgets", function_widgets)
         yield
 
diff --git a/napari/plugins/_tests/test_plugin_widgets.py b/napari/plugins/_tests/test_plugin_widgets.py
--- a/napari/plugins/_tests/test_plugin_widgets.py
+++ b/napari/plugins/_tests/test_plugin_widgets.py
@@ -34,7 +34,7 @@ def func2(x, y):
 # monkeypatch, request, recwarn fixtures are from pytest
 @pytest.mark.parametrize('arg', fwidget_args.values(), ids=fwidget_args.keys())
 def test_function_widget_registration(
-    arg, test_plugin_manager, add_implementation, monkeypatch, request, recwarn
+    arg, test_plugin_manager, monkeypatch, request, recwarn
 ):
     """Test that function widgets get validated and registerd correctly."""
     test_plugin_manager.project_name = 'napari'
@@ -45,11 +45,12 @@ def test_function_widget_registration(
         registered = {}
         m.setattr(plugins, "function_widgets", registered)
 
-        @napari_hook_implementation
-        def napari_experimental_provide_function():
-            return arg
+        class Plugin:
+            @napari_hook_implementation
+            def napari_experimental_provide_function():
+                return arg
 
-        add_implementation(napari_experimental_provide_function)
+        test_plugin_manager.register(Plugin)
         hook.call_historic(
             result_callback=plugins.register_function_widget, with_impl=True
         )
@@ -57,10 +58,10 @@ def napari_experimental_provide_function():
             assert not registered
             assert len(recwarn) == 1
         else:
-            assert registered[(None, 'func')] == func
+            assert registered['Plugin']['func'] == func
             assert len(recwarn) == 0
             if 'list_func' in request.node.name:
-                assert registered[(None, 'func2')] == func2
+                assert registered['Plugin']['func2'] == func2
 
 
 # test_dock_widget_registration is done in `_qt._tests.test_plugin_widgets`