From 9968121de4e8b7957224b4f525dc961471e42f8e Mon Sep 17 00:00:00 2001 From: Lucy Liu Date: Mon, 22 Apr 2024 07:29:27 +1000 Subject: [PATCH] Improve our 'Creating widgets' section (#407) # References and relevant issues Follow on from #68 # Description * adds more info on when you would want to choose each type of widget, clarifying that widgets with access to the native QWidget are equally extensible. * clarify you can use `create_widget` in any widget class * Move the "Avoid imports with forward references" info to plugin best practices, where we already have a section about avoiding unnecessary imports * Move '`magicgui` function widgets as plugin contributions' to be next to the intro to magicgui function widgets --- docs/howtos/extending/magicgui.md | 338 +++++++++--------- .../building_a_plugin/_layer_data_guide.md | 8 +- .../building_a_plugin/best_practices.md | 22 +- 3 files changed, 197 insertions(+), 171 deletions(-) diff --git a/docs/howtos/extending/magicgui.md b/docs/howtos/extending/magicgui.md index 7ec17e032..9be04618f 100644 --- a/docs/howtos/extending/magicgui.md +++ b/docs/howtos/extending/magicgui.md @@ -22,20 +22,28 @@ interface. The easiest way to add a widget is by using in building widgets. It is a general abstraction layer on GUI toolkit backends (like Qt), with an emphasis on mapping Python types to widgets. This enables you to easily create widgets using annotations. -If you require more extensibility though, you can create your own widget `class` that -subclasses [`QtWidgets.QWidget`](https://doc.qt.io/qt-5/qwidget.html). +If you require more extensibility, you can create your own widget `class` that +subclasses [`QtWidgets.QWidget`](https://doc.qt.io/qt-5/qwidget.html) or +{class}`magicgui.widgets.bases.Widget`. This document will describe each widget creation method, in increasing order of extensibility; -1. creating a widget from a function and - [`magicgui`](https://pyapp-kit.github.io/magicgui/) (simplest but least extensible - and flexible) -2. create a widget class that subclasses a - [`magicgui` widget class](https://pyapp-kit.github.io/magicgui/widgets/#the-widget-hierarchy) -3. create a widget class that subclasses - [`QtWidgets.QWidget`](https://doc.qt.io/qt-5/qwidget.html) (most extensible but also - the most difficult to implement) +1. [{func}`@magicgui ` decorator](magicgui_decorator) - create a + widget from a function and [`magicgui`](https://pyapp-kit.github.io/magicgui/). + This is the simplest but least extensible option. It would suit one + wishing to build a widget to simply run a function, with input widgets to select + function parameters. +2. [](#magicgui-class-widgets) - subclass a + [`magicgui` widget class](https://pyapp-kit.github.io/magicgui/widgets/#the-widget-hierarchy). + This option provides you with `magicgui` conveniences (via their useful defaults) + while enabling you access to the native `QWidget`. This enables maximum widget + extensibility, allowing you to connect event callbacks, perform processing, + have conditional selection options, customize display of outputs and much more. +3. [](#qwidget-class-widgets) - subclass + [`QtWidgets.QWidget`](https://doc.qt.io/qt-5/qwidget.html). This option is the + most difficult to implement and is suitable for those who wish to build a widget + from a 'blank slate', without `magicgui` defaults. More examples of widget use can be found in the ['GUI' gallery examples](https://napari.org/stable/_tags/gui.html) (note: not every @@ -43,7 +51,11 @@ example includes a widget). Additionally, [cookiecutter-napari-plugin](https://github.com/napari/cookiecutter-napari-plugin) has more robust widget examples that you can adapt to your needs. -There are two ways to then add a widget to a napari viewer: +(adding_widgets)= + +## Adding widgets to napari viewer + +There are two ways to add a widget to a napari viewer: * via {meth}`napari.qt.Window.add_dock_widget` in a Python script or interactive console (see [How to launch napari](getting_started) for details on launching @@ -55,20 +67,28 @@ There is an important implementation distinction between the two methods; {meth}`~napari.qt.Window.add_dock_widget` expects an *instance* of a widget, like an instance of class {class}`~magicgui.widgets.FunctionGui` or [`QtWidgets.QWidget`](https://doc.qt.io/qt-5/qwidget.html), whereas -[widget contributions](widgets-contribution-guide), expect a `callable` -(like a function or class) that will return a widget instance. When describing +[widget contributions](widgets-contribution-guide) expect a `callable` +(e.g., a function or class) that will return a widget instance. When describing each of the three widget creation methods below, we will first show how to create a widget and add it to the viewer with {meth}`~napari.qt.Window.add_dock_widget`, then how to adapt the widget for a widget contribution. -(magicgui)= - +(magicgui_decorator)= ## `magicgui` decorated functions [`magicgui`](https://pyapp-kit.github.io/magicgui/) makes building widgets to represent -function inputs easy via the -{func}`@magicgui ` decorator: +function inputs easy via the {func}`@magicgui ` decorator. +It uses [type hints](https://peps.python.org/pep-0484/) to infer +the appropriate widget type for a given function parameter, and to indicate a +context-dependent action for the object returned from the function (in the +absence of a type hint, the type of the default value will be used). +You can also customize your widget using {func}`magicgui.magicgui` parameters. + +First we demonstrate how to create a generic non-`napari` widget. The +{func}`@magicgui ` argument `call_button` specifies the button text +and the parameter specific `slider_float` and `dropdown` let you customize the widget +associated with those function parameters. ```{code-cell} python :tags: [remove-stderr] @@ -97,20 +117,10 @@ def widget_demo( widget_demo.show() ``` -`magicgui` uses [type hints](https://peps.python.org/pep-0484/) to infer -the appropriate widget type for a given function parameter, and to indicate a -context-dependent action for the object returned from the function (in the -absence of a type hint, the type of the default value will be used). You can also -customize your widget using {func}`magicgui.magicgui` -parameters. In the example above, `call_button` specifies the button text and the -`param_options` `slider_float` and `dropdown` let you customize the widget -associated with those function parameters. - -Third party packages (like napari in this case) may provide support for their types -using {func}`magicgui.type_map.register_type`. Indeed napari uses -{func}`~magicgui.type_map.register_type` to provide support for napari-specific type -annotations. This makes it easy to use `magicgui` to build widgets in napari. Note all -type annotations below *require* that the resulting widget be added to a napari viewer. +Third party packages (like `napari` in this case) can add support for their types +using {func}`magicgui.type_map.register_type`. Indeed `napari` uses +{func}`~magicgui.type_map.register_type` to provide support for `napari`-specific type +annotations. This makes it easy to use `magicgui` to build widgets in `napari`. Below we demonstrate how to create a simple threshold widget using `magicgui` and add it to the viewer. Note the `auto_call` parameter tells `magicgui` to call the function @@ -146,25 +156,79 @@ viewer.window._qt_window.resize(1225, 900) nbscreenshot(viewer, alt_text="A magicgui threshold widget") ``` -Below we first document how to use napari-specific -[parameter](magicgui-parameter-annotations) and -[return type](magicgui-return-annotations) annotations to easily create your own -widgets. We then explain how to use -[`magicgui` function widgets in plugin widget contributions](magicgui-plugin-widgets). - ```{note} For a more complex example of a {func}`magicgui.magicgui` widget, see the [gaussian blur example](https://pyapp-kit.github.io/magicgui/generated_examples/napari/napari_parameter_sweep/#napari-parameter-sweeps) in the `magicgui` documentation. ``` +See [](magicgui-parameter-annotations) for details on how to use `napari` types +to get information from the napari viewer into your widget. See +[](magicgui-return-annotations) for information how to use `napari` types to add +output to the napari viewer. + +For type annotations to work as described, the resulting widget needs to be added to a +napari viewer. + +To use these `magicgui` function widgets as plugin widget contributions, +see below. + +(magicgui-plugin-widgets)= + +### `magicgui` function widgets as plugin contributions + +Recall [above](creating-widgets) that plugin +[widget contributions](widgets-contribution-guide) expects a `callable` that returns +a widget instance, whereas {meth}`~napari.qt.Window.add_dock_widget` expects an +*instance* of a widget. The {meth}`~napari.qt.Window.add_dock_widget` examples +above can be easily adapted to be plugin widgets by using +the {func}`@magic_factory ` decorator instead of the +{func}`@magicgui ` decorator. + +For example, the threshold widget [shown above](returning-napari-types-data) +could be provided as a napari plugin as follows: + +```python +from magicgui import magic_factory + +@magic_factory(auto_call=True, threshold={'max': 2 ** 16}) +def threshold( + data: 'napari.types.ImageData', threshold: int +) -> 'napari.types.LabelsData': + return (data > threshold).astype(int) +``` + +This function can now be added to the plugin manifest as a widget contribution. +See the [widget contribution guide](widgets-contribution-guide) for details. + +:::{note} +{func}`@magic_factory ` behaves very much like +{func}`functools.partial`: it returns a callable that "remembers" some or +all of the parameters required for a "future" call to {func}`magicgui.magicgui`. +The parameters provided to {func}`@magic_factory ` can +also be overridden when creating a widget from a factory: + +```python +@magic_factory(call_button=True) +def my_factory(x: int): + ... + +widget1 = my_factory() +widget2 = my_factory(call_button=False, x={'widget_type': 'Slider'}) +``` + +::: + (magicgui-parameter-annotations)= ### Parameter annotations -The following napari types may be used as *parameter* type annotations in -`magicgui` functions to get information from the napari viewer into your -`magicgui` function. +The following `napari` types may be used as *parameter* type annotations in +`magicgui` functions or in the `annotation` argument of +{func}`magicgui.widgets.create_widget`. {func}`~magicgui.widgets.create_widget` +can be used when adding an input widget to your [widget class](#widget-classes). + +This enables you to get information from the napari viewer into your widget. - any napari {class}`~napari.layers.Layer` subclass, such as {class}`~napari.layers.Image` or {class}`~napari.layers.Points` @@ -174,24 +238,28 @@ The following napari types may be used as *parameter* type annotations in - {class}`napari.Viewer` ```{note} -When creating a widget that is not a -{class}`~magicgui.widgets.bases.ContainerWidget` subclass, -adding a layer input widget requires more than just -parameter annotation. -See the [`QWidget` example](#qtwidgetsqwidget) below. +When using {func}`~magicgui.widgets.create_widget` for a 'layer' type in a +[`QtWidgets.QWidget`](https://doc.qt.io/qt-5/qwidget.html) subclass, +you will need to manually connect the `reset_choices` of the resulting +{class}`~magicgui.widgets.ComboBox` (i.e., "dropdown menu") to layer events. This +is so it will synchronize with layer changes. +See the [`QWidget` example](#qwidget-class-widgets) for details. ``` The consequence of each type annotation is described below: #### Annotating as a `Layer` subclass -If you annotate one of your function parameters as a +Annotating a function parameter or setting `annotation` in `create_widget` to be a {class}`~napari.layers.Layer` subclass (such as {class}`~napari.layers.Image` or -{class}`~napari.layers.Points`), it will be rendered as a +{class}`~napari.layers.Points`), will result in a {class}`~magicgui.widgets.ComboBox` widget (i.e. "dropdown menu"), where the options in the dropdown box are the layers of the corresponding type currently in the viewer. +Using `Image` annotation in a {func}`@magic_factory ` +decorated function: + ```python from napari.layers import Image @@ -202,6 +270,21 @@ def my_widget(image: Image): ... ``` +Using `Image` annotation in `create_widget`: + +```python +from magicgui.widgets import Container, create_widget + +class ImageWidget(Container): + def __init__(self, viewer: "napari.viewer.Viewer"): + super().__init__() + self._viewer = viewer + # use create_widget to generate widgets from type annotations + self._image_layer_combo = create_widget( + label="Image", annotation="napari.layers.Image" + ) +``` + Here's a complete example: ```{code-cell} python @@ -233,7 +316,7 @@ In the previous example, the dropdown menu will *only* show {class}`~napari.layers.Image` layers, because the parameter was annotated as an {class}`~napari.layers.Image`. If you'd like a dropdown menu that allows the user to pick from *all* layers in the layer list, annotate your parameter as -{class}`~napari.layers.Layer` +{class}`~napari.layers.Layer`. ```python from napari.layers import Layer @@ -245,16 +328,19 @@ def my_widget(layer: Layer): ... ``` +You can also use `Layer` annotation in `create_widget` in the same way as in +[](#annotating-as-a-layer-subclass). + (annotating-as-napari-types-data)= #### Annotating as `napari.types.*Data` -In the previous example, the object passed to your function will be the actual +In the previous example, the object passed to your function/widget will be +the actual {class}`~napari.layers.Layer` instance, meaning you will need to access any -attributes (like `layer.data`) on your own. If your function is designed to -accept a numpy array, you can use any of the special `Data` types +attributes (like `layer.data`) on your own. If your function/widget is designed +to accept a numpy array, you can use any of the special `Data` types from {mod}`napari.types` to indicate that you only want the data attribute from -the layer (where `` is one of the available layer types). Here's an -example using {attr}`napari.types.ImageData`: +the layer (where `` is one of the available layer types). ```python from napari.types import ImageData @@ -267,16 +353,16 @@ def my_widget(array: ImageData): assert isinstance(array, np.ndarray) # it will be! ``` +You can also use `ImageData` annotation in `create_widget` in the same way as in +[](#annotating-as-a-layer-subclass). + Like above, it will be rendered as a {class}`~magicgui.widgets.ComboBox`. #### Annotating as `napari.Viewer` Lastly, if you need to access the actual {class}`~napari.viewer.Viewer` instance in which the widget is docked, you can annotate one of your parameters as a -{class}`napari.Viewer`. This will not automatically render as a -{class}`~magicgui.widgets.ComboBox` so you will need to -[specify the *widget option*](https://pyapp-kit.github.io/magicgui/type_map/#customizing-widget-options-with-typingannotated) -to map this parameter to. +{class}`napari.Viewer`. ```python from napari import Viewer @@ -541,97 +627,6 @@ viewer.window._qt_window.resize(1225, 900) nbscreenshot(viewer, alt_text="A magicgui widget updating an existing layer") ``` -### Avoid imports with forward references - -Sometimes, it is undesirable to import and/or depend on napari directly just -to provide type annotations. It is possible to avoid importing napari -entirely by annotating with the string form of the napari type. This is called -a [Forward -reference](https://peps.python.org/pep-0484/#forward-references): - -```python -@magicgui -def my_func(data: 'napari.types.ImageData') -> 'napari.types.ImageData': - ... -``` - -:::{tip} - -If you'd like to maintain IDE type support and autocompletion, you can -do so by hiding the napari imports inside of a {attr}`typing.TYPE_CHECKING` -clause: - -```python -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - import napari - -@magicgui -def my_func(data: 'napari.types.ImageData') -> 'napari.types.ImageData': - ... -``` - -This will not require napari at runtime, but if it is installed in your -development environment, you will still get all the type inference. - -::: - -(magicgui-plugin-widgets)= - -## `magicgui` function widgets as plugin contributions - -Recall [above](creating-widgets) that plugin -[widget contributions](widgets-contribution-guide) expects a `callable` that returns -a widget instance, whereas {meth}`~napari.qt.Window.add_dock_widget` expects an -*instance* of a widget. The {meth}`~napari.qt.Window.add_dock_widget` examples -above can be easily adapted to be plugin widgets by using -the {func}`@magic_factory ` decorator instead of the -{func}`@magicgui ` decorator. - -For example, the threshold widget [shown above](returning-napari-types-data) -could be provided as a napari plugin as follows: - -```python -from magicgui import magic_factory -from napari_plugin_engine import napari_hook_implementation - -@magic_factory(auto_call=True, threshold={'max': 2 ** 16}) -def threshold( - data: 'napari.types.ImageData', threshold: int -) -> 'napari.types.LabelsData': - return (data > threshold).astype(int) -``` - -This function can now be added to the plugin manifest as a widget contribution. -See the [widget contribution guide](widgets-contribution-guide) for details. - -Alternatively, you can also directly subclass {class}`~magicgui.widgets.FunctionGui` -(which is the type that is returned by the {func}`@magicgui ` -decorator). This method would give you more control over your widget. -See [widget classes](widget-classes) below for more. - -:::{note} -{func}`@magic_factory ` behaves very much like -{func}`functools.partial`: it returns a callable that "remembers" some or -all of the parameters required for a "future" call to {func}`magicgui.magicgui`. -The parameters provided to {func}`@magic_factory ` can -also be overridden when creating a widget from a factory: - -```python -@magic_factory(call_button=True) -def my_factory(x: int): - ... - -widget1 = my_factory() -widget2 = my_factory(call_button=False, x={'widget_type': 'Slider'}) -``` - -::: - - -(widget-classes)= - ## Widget classes Generating a widget by creating a widget class allows you to have more control over @@ -640,29 +635,30 @@ your widget. Your widget class must subclass {class}`magicgui.widgets.bases.Widg [`magicgui` widget class](https://pyapp-kit.github.io/magicgui/widgets/#the-widget-hierarchy)) or [`QtWidgets.QWidget`](https://doc.qt.io/qt-5/qwidget.html). It can then be added to the napari viewer -by instantiating the widget class, then adding this via +by instantiating the widget class, then adding it to the viewer via {meth}`~napari.qt.Window.add_dock_widget`. You can also create a plugin and add your widget class (*not* instantiated widget) as a [widget contribution](widgets-contribution-guide). Below we will detail how to use various parent classes to generate a widget. There are several `magicgui` widget classes so we will only document the use of the -two most useful in the napari context; {class}`~magicgui.widgets.FunctionGui` and -{class}`~magicgui.widgets.Container`. -We will begin with the simplest but least extensible parent class and end with the -parent class the most extensible. +two most useful in the napari context; {class}`~magicgui.widgets.FunctionGui` +and {class}`~magicgui.widgets.Container` (more complex). + +### `magicgui` class widgets -### `magicgui.widgets.FunctionGui` +#### `magicgui.widgets.FunctionGui` -Creating a widget by subclassing {class}`~magicgui.widgets.FunctionGui` is similar in +{class}`~magicgui.widgets.FunctionGui` is the type that is returned by the +{func}`@magicgui ` decorator. Creating a widget by directly +subclassing {class}`~magicgui.widgets.FunctionGui` is thus similar in principle to using the {func}`@magicgui ` decorator. Decorating a function with {func}`@magicgui ` is equivalent to passing the same function to {class}`~magicgui.widgets.FunctionGui`'s `function` parameter. The remaining {class}`~magicgui.widgets.FunctionGui` parameters essentially mirror {func}`@magicgui `'s parameters. -Indeed, {class}`~magicgui.widgets.FunctionGui` is the type that is returned by -{func}`@magicgui `. Subclassing -{class}`~magicgui.widgets.FunctionGui` however, gives you access to the + +Subclassing {class}`~magicgui.widgets.FunctionGui` however, gives you access to the `native` `QWidget` of your widget, allowing you change its appearance and add custom elements. @@ -692,11 +688,14 @@ my_widg = MyGui(my_function) viewer.window.add_dock_widget(my_widg) ``` -Class widgets are easy to use as -[plugin widget contributions](widgets-contribution-guide). Simply provide the +Notice above that we first instantiated the widget class, then add to the viewer via +{meth}`~napari.qt.Window.add_dock_widget`. + +To use {class}`~magicgui.widgets.FunctionGui` widget as a +[plugin widget contribution](widgets-contribution-guide), simply provide the class definition and add to the plugin manifest. -### `magicgui.widgets.Container` +#### `magicgui.widgets.Container` The {class}`~magicgui.widgets.Container` allows you to build more complex widgets from sub-widgets. This gives you more control over each sub-widget and how callbacks @@ -751,20 +750,27 @@ my_widg = ImageThreshold() viewer.window.add_dock_widget(my_widg) ``` -As above to turn this into a [plugin widget contribution](widgets-contribution-guide), +As above, to turn this into a [plugin widget contribution](widgets-contribution-guide), simply provide the class definition and add to the plugin manifest. -### `QtWidgets.QWidget` +To build your widget from a 'blank slate', you can subclass +[`QtWidgets.QWidget`](https://doc.qt.io/qt-5/qwidget.html). +See [](#qwidget-class-widgets) for details. + +### `QWidget` class widgets -For the most control over your widget, subclass +To build your widget from a 'blank slate', subclass [`QtWidgets.QWidget`](https://doc.qt.io/qt-5/qwidget.html). + In the following example, we create a button and a dropdown list. The available choices for the dropdown list are the current layers in the viewer. For this, we use {func}`create_widget ` with the annotation {attr}`napari.types.ImageData`. + Because the layer selection widget will be housed by a native `QWidget` -and not by a `magicgui` subclass as [shown above](#parameter-annotations), we now need to +and not by a `magicgui` subclass (as with {func}`@magicgui ` +decoratored functions and `magicgui` subclasses), we now need to manually connect the `reset_choices` of the created widget with the `viewer.layers.events` so that the available choices are synchronized with the current layers of the viewer: @@ -818,5 +824,5 @@ my_widg = ExampleLayerListWidget(viewer) viewer.window.add_dock_widget(my_widg) ``` -As above to turn this into a [plugin widget contribution](widgets-contribution-guide), +As above, to turn this into a [plugin widget contribution](widgets-contribution-guide), simply provide the class definition and add to the plugin manifest. diff --git a/docs/plugins/building_a_plugin/_layer_data_guide.md b/docs/plugins/building_a_plugin/_layer_data_guide.md index 8ad698cc4..d6c5784f4 100644 --- a/docs/plugins/building_a_plugin/_layer_data_guide.md +++ b/docs/plugins/building_a_plugin/_layer_data_guide.md @@ -6,10 +6,10 @@ directly. Instead, it passes (mostly) pure-python and array-like types, deconstructed into a {class}`tuple` that we refer to as a `LayerData` tuple. This type shows up often in plugins and is explained here. -Note that when writing your own plugin, type annotations are optional, -except in the case of [`magicgui` function widgets](magicgui). -For several types related to `LayerData` tuples, napari defines a type alias -which better indicates a value's _functional role_ in a plugin. +Note that when writing your own plugin, type annotations are optional, +except in the case of [`magicgui` function widgets](magicgui_decorator). +For several types related to `LayerData` tuples, napari defines a type alias +which better indicates a value's _functional role_ in a plugin. We describe these below. ### Informal description diff --git a/docs/plugins/building_a_plugin/best_practices.md b/docs/plugins/building_a_plugin/best_practices.md index 2cb43da87..4041835c6 100644 --- a/docs/plugins/building_a_plugin/best_practices.md +++ b/docs/plugins/building_a_plugin/best_practices.md @@ -174,10 +174,30 @@ but it's still a good idea!) It's good practice to not depend on `napari` if not strictly necessary. If you only use `napari` for type annotations, we recommend that you use strings -instead of importing the types. For example, you can see in the +instead of importing the types. This is called a +[Forward reference](https://peps.python.org/pep-0484/#forward-references). +For example, you can see in the [widget contribution guide](widgets-contribution-guide) that napari type annotations are strings and not imported. +If you'd like to maintain IDE type support and autocompletion, you can +still do so by hiding the napari imports inside of a {attr}`typing.TYPE_CHECKING` +clause: + +```python +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + import napari + +@magicgui +def my_func(data: 'napari.types.ImageData') -> 'napari.types.ImageData': + ... +``` + +This will not require napari at runtime, but if it is installed in your +development environment, you will still get all the type inference. + ## Don't leave resources open It's always good practice to clean up resources like open file handles and