Interactive widgets#

What are widgets?#

Widgets are eventful python objects that have a representation in the browser, often as a control like a slider, textbox, etc.

This notebook is an introduction to ipywidgets (see docs here), one of the most popular widgets libraries for Jupyter.

What can they be used for?#

You can use widgets to build interactive GUIs for your notebooks. You can also use widgets to synchronize stateful and stateless information between Python and JavaScript.

Using widgets#

To use the widget framework, you need to import ipywidgets.

import ipywidgets as ipw

repr#

Widgets have their own display repr which allows them to be displayed using IPython’s display framework. Constructing and returning an IntSlider automatically displays the widget (as seen below). Widgets are displayed inside the output area below the code cell. Clearing cell output will also remove the widget.

ipw.IntSlider()

display()#

You can also explicitly display the widget using display(…).

from IPython.display import display

w = ipw.IntSlider()
display(w)

Closing widgets#

You can close a widget by calling its close() method.

w.close()

Widget properties#

All of the IPython widgets share a similar naming scheme. To read the value of a widget, you can query its value property.

w = ipw.IntSlider()
display(w)
w.value
0

Similarly, to set a widget’s value, you can set its value property.

w.value = 100

Widget list#

This is a non-exhaustive list of the mostly used widgets. You can see the full list here.

Numeric widgets#

There are many widgets distributed with ipywidgets that are designed to display numeric values. Widgets exist for displaying integers and floats, both bounded and unbounded. The integer widgets share a similar naming scheme to their floating point counterparts. By replacing Float with Int in the widget name, you can find the Integer equivalent.

IntSlider#

ipw.IntSlider(
    value=7,
    min=0,
    max=10,
    step=1,
    description="Test:",
    disabled=False,
    continuous_update=False,
    orientation="horizontal",
    readout=True,
    readout_format="d",
)

FloatSlider#

ipw.FloatSlider(
    value=7.5,
    min=0,
    max=10.0,
    step=0.1,
    description="Test:",
    disabled=False,
    continuous_update=False,
    orientation="horizontal",
    readout=True,
    readout_format=".1f",
)

IntRangeSlider#

ipw.IntRangeSlider(
    value=[2, 7],
    min=0,
    max=10,
    step=1,
    description="Test:",
    disabled=False,
    continuous_update=False,
    orientation="horizontal",
    readout=True,
    readout_format="d",
)

FloatProgress#

ipw.FloatProgress(
    value=7.5,
    min=0,
    max=10.0,
    step=0.1,
    description="Loading:",
    bar_style="info",
    orientation="horizontal",
)

IntText#

ipw.IntText(value=7, description="An integer:", disabled=False)

Boolean widgets#

There are three widgets that are designed to display a boolean value.

ToggleButton#

ipw.ToggleButton(
    value=False,
    description="Click me",
    disabled=False,
    button_style="",  # 'success', 'info', 'warning', 'danger' or ''
    tooltip="Description",
    icon="check",
)

Checkbox#

ipw.Checkbox(value=False, description="Check me", disabled=False)

Selection widgets#

There are several widgets that can be used to display single selection lists, and two that can be used to select multiple values. All inherit from the same base class. You can specify the enumeration of selectable options by passing a list (options are either (label, value) pairs, or simply values for which the labels are derived by calling str).

RadioButtons#

ipw.RadioButtons(
    options=["pepperoni", "pineapple", "anchovies"],
    #     value='pineapple',
    description="Pizza topping:",
    disabled=False,
)

ToggleButtons#

ipw.ToggleButtons(
    options=["Slow", "Regular", "Fast"],
    description="Speed:",
    disabled=False,
    button_style="",  # 'success', 'info', 'warning', 'danger' or ''
    tooltips=["Description of slow", "Description of regular", "Description of fast"],
    # icons=['check'] * 3
)

String widgets#

There are several widgets that can be used to display a string value. The Text and Textarea widgets accept input. The HTML and HTMLMath widgets display a string as HTML (HTMLMath also renders math). The Label widget can be used to construct a custom control label.

Text#

ipw.Text(
    value="Hello World",
    placeholder="Type something",
    description="String:",
    disabled=False,
)

Textarea#

ipw.Textarea(
    value="Hello World",
    placeholder="Type something",
    description="String:",
    disabled=False,
)

Label#

The Label widget is useful if you need add custom text and place it next to other widgets.

ipw.HBox(
    [
        ipw.Label(value=r"This is before the slider"),
        ipw.FloatSlider(),
        ipw.Label(value=r"This is after the slider"),
    ]
)

HTML#

ipw.HTML(
    value="Hello <b>World</b>",
)

Image#

file = open("../../images/ESS_02_small.jpg", "rb")
image = file.read()
ipw.Image(
    value=image,
    format="jpg",
    width=300,
    height=400,
)

Button#

ipw.Button(
    description="Click me",
    disabled=False,
    button_style="",  # 'success', 'info', 'warning', 'danger' or ''
    tooltip="Click me",
    icon="check",
)

Container/Layout widgets#

These widgets are used to hold other widgets, called children. Each has a children property that may be set either when the widget is created or later.

HBox#

items = [ipw.Button(description=str(i)) for i in range(4)]
ipw.HBox(items)

VBox#

left_box = ipw.VBox([items[0], items[1]])
right_box = ipw.VBox([items[2], items[3]])
ipw.HBox([left_box, right_box])

Widget events#

Widget properties are IPython traitlets and traitlets are eventful. To handle changes, the observe method of the widget can be used to register a callback. The doc string for observe can be seen below.

help(ipw.Widget.observe)
Help on function observe in module traitlets.traitlets:

observe(self, handler: 't.Callable[..., t.Any]', names: 'Sentinel | str | t.Iterable[Sentinel | str]' = traitlets.All, type: 'Sentinel | str' = 'change') -> 'None'
    Setup a handler to be called when a trait changes.
    
    This is used to setup dynamic notifications of trait changes.
    
    Parameters
    ----------
    handler : callable
        A callable that is called when a trait changes. Its
        signature should be ``handler(change)``, where ``change`` is a
        dictionary. The change dictionary at least holds a 'type' key.
        * ``type``: the type of notification.
        Other keys may be passed depending on the value of 'type'. In the
        case where type is 'change', we also have the following keys:
        * ``owner`` : the HasTraits instance
        * ``old`` : the old value of the modified trait attribute
        * ``new`` : the new value of the modified trait attribute
        * ``name`` : the name of the modified trait attribute.
    names : list, str, All
        If names is All, the handler will apply to all traits.  If a list
        of str, handler will apply to all names in the list.  If a
        str, the handler will apply just to that name.
    type : str, All (default: 'change')
        The type of notification to filter by. If equal to All, then all
        notifications are passed to the observe handler.

Mentioned in the doc string, the callback registered must have the signature handler(change) where change is a dictionary holding the information about the change.

Using this method, an example of how to output an IntSlider’s value as it is changed can be seen below.

sl = ipw.IntSlider()
but = ipw.Button()


def on_value_change(change):
    but.description = str(change["new"])


sl.observe(on_value_change, names="value")

ipw.HBox([sl, but])

Linking Widgets#

Often, you may want to simply link widget attributes together. Synchronization of attributes can be done in a simpler way than by using bare traitlets events.

The method is to use the link and dlink functions from the traitlets module (these two functions are re-exported by the ipywidgets module for convenience). This only works if we are interacting with a live kernel.

caption = ipw.Label(value="The values of slider1 and slider2 are synchronized")
sliders1, slider2 = ipw.IntSlider(description="Slider 1"), ipw.IntSlider(
    description="Slider 2"
)
l = ipw.link((sliders1, "value"), (slider2, "value"))
display(caption, sliders1, slider2)
caption = ipw.Label(value="Changes in source values are reflected in target1")
source, target1 = ipw.IntSlider(description="Source"), ipw.IntSlider(
    description="Target 1"
)
dl = ipw.dlink((source, "value"), (target1, "value"))
display(caption, source, target1)

The link() and dlink() functions return a Link or DLink object. The link can be broken by calling the unlink method.

l.unlink()
dl.unlink()

Using “Interact”#

ipywidgets also comes with a high-level function called interact which tries to do a lot of the work for you, by guessing what kind of widget you would like to have to interact with a function, depending on the function input types.

from ipywidgets import interact

To use interact, you need to define a function that you want to explore. Here is a function that returns its only argument x.

def f(x):
    return x

When you pass this function as the first argument to interact along with an integer keyword argument (x=10), a slider is generated and bound to the function parameter.

interact(f, x=10);

When you move the slider, the function is called, and its return value is printed.

You can also pass a range of values to interact for more control over the valid values:

interact(f, x=(5, 15));

If you pass True or False, interact will generate a checkbox:

interact(f, x=True);

If you pass a string, interact will generate a text box.

interact(f, x="Hi there!");

interact can also be used as a decorator. This allows you to define a function and interact with it in a single shot. In this example, we also do something slightly more complex in the decorated function:

@interact(x=(5, 15))
def ifExample(x=10):
    if x > 10:
        print("Hello")

Multiple widgets are generated when multiple arguments are passed to the function:

@interact(x=(5, 15), y=(5, 15))
def ifElIfExample(x=10, y=12):
    if x > y:
        print("x > y")
    elif x < y:
        print("x < y")
    else:
        print("x = y")

Exercises#

1. Dynamically disabling and hiding widgets#

In this exercise, we will learn how to use the disabled and visibility properties of the widgets, to control their behaviour and appearance. The goal is to create an interface that allows to enter into text boxes a number of vector components which depend on how many dimensions are available.

  1. Create a RadioButtons widget with 3 options: "1D", "2D", and "3D", and the "1D" value should be the selected value to begin with.

  2. Create 3 FloatText widgets which all have 0 as a value and "x:", "y:", and "z:" as descriptions for the vector position.

  3. Create a further 3 FloatText widgets which all have 0 as a value and "v_x:", "v_y:", and "v_z:" as descriptions for the velocity components.

  4. Arrange your widgets into 3 columns using the VBox and HBox widgets.

  5. Disable the y and z position components by using the .disabled property of the widgets.

  6. Hide the v_y and v_z velocity components by using the .layout.visibility="hidden" property of the widgets.

  7. In the end, it should look something like this: widgets

  8. Create a function that will run through the position and velocity components and update the .disabled and .visibility="hidden/visible" properties according to the selected value in the RadioButtons widget.

  9. Install a call-back using the .observe property from the RadioButtons to the update function so that the function is triggered every time the selected value in the RadioButtons is changed and check that everything is working as it should.

  10. Voila!

  11. Bonus: Instead of a RadioButtons, use a IntText widget to allow any number of dimensions to be entered. This means you have to dynamically create/destroy FloatText widgets in the positions and velocity columns. The contents of a VBox can be modified by changing its list of .children. It also does not make much sense to have disabled or hidden widgets since we do not know a priori the maximum number of dimensions. The goal is to only keep the widgets that we need, and they will all always be visible and enabled.

Solution:

Hide code cell content
rb = ipw.RadioButtons(
    options=["1D", "2D", "3D"],
    description="Ndims:",
    value="1D",
    layout={"width": "50px"},
)

ndim = int(rb.value[0])

pos = []
vel = []
for i, x in enumerate("xyz"):
    pos.append(
        ipw.FloatText(
            value=0,
            description=f"{x}:",
            disabled=ndim < 1 + i,
            layout={"width": "200px"},
        )
    )
    vel.append(ipw.FloatText(value=0, description=f"v_{x}:", layout={"width": "200px"}))
    if i > ndim - 1:
        vel[-1].layout.visibility = "hidden"


def update_components(change):
    ndim = int(change["new"][0])
    for i in range(len(pos)):
        pos[i].disabled = ndim < 1 + i
        if i > ndim - 1:
            vel[i].layout.visibility = "hidden"
        else:
            vel[i].layout.visibility = "visible"


rb.observe(update_components, names="value")

box = ipw.HBox([rb, ipw.VBox(pos), ipw.VBox(vel)])
box

Bonus:

Hide code cell content
layout = {"width": "200px"}
ndim = ipw.IntText(value=1, description="Ndims:", layout=layout)

pos = []
vel = []
for i in range(ndim.value):
    pos.append(ipw.FloatText(value=0, description=f"pos_{i}:", layout=layout))
    vel.append(ipw.FloatText(value=0, description=f"vel_{i}:", layout=layout))

pos_box = ipw.VBox(pos)
vel_box = ipw.VBox(vel)


def update_components(change):
    ndim_new = change["new"]
    ndim_old = len(pos_box.children)
    if ndim_old < ndim_new:
        new_pos = []
        new_vel = []
        for i in range(ndim_new - ndim_old):
            new_pos.append(
                ipw.FloatText(
                    value=0, description=f"pos_{i + ndim_old}:", layout=layout
                )
            )
            new_vel.append(
                ipw.FloatText(
                    value=0, description=f"vel_{i + ndim_old}:", layout=layout
                )
            )
        pos_box.children = list(pos_box.children) + new_pos
        vel_box.children = list(vel_box.children) + new_vel
    else:
        pos_box.children = [pos_box.children[i] for i in range(ndim_new)]
        vel_box.children = [vel_box.children[i] for i in range(ndim_new)]


ndim.observe(update_components, names="value")

box = ipw.HBox([ndim, pos_box, vel_box])
box

2. Use a slider to change the number of scatter points in a Matplotlib figure#

  1. Create a scatter plot with Matplotlib

import numpy as np
import matplotlib.pyplot as plt

%matplotlib widget

N = 100
x = np.random.normal(0.0, scale=20.0, size=N)
y = np.random.normal(0.0, scale=20.0, size=N)

fig, ax = plt.subplots()
scat = ax.scatter(x, y, color="blue", alpha=0.5)
  1. Add a FloatLogSlider that ranges from 1 to 1.0e5

  2. Define a function that would take in the value of the slider (via the change argument) and update the number of points in the scatter plot (for simplicity, you can delete the current scatter plot and draw a new one on the axes)

  3. Add a callback from the slider to the update function using the observe method.

  4. Display the slider above or below the figure.

  5. Once you got this working, try adding a ColorPicker widget that would be used to update the color of the scatter points.

Solution:

Hide code cell content
N = 100
x = np.random.normal(0.0, scale=20.0, size=N)
y = np.random.normal(0.0, scale=20.0, size=N)

fig, ax = plt.subplots()
scat = ax.scatter(x, y, color="blue", alpha=0.5)

cp = ipw.ColorPicker(
    concise=False, description="Pick a color", value="blue", disabled=False
)


def update_scatter(change):
    ax.collections[0].remove()
    M = int(change["new"])
    x = np.random.normal(0.0, scale=20.0, size=M)
    y = np.random.normal(0.0, scale=20.0, size=M)
    ax.scatter(x, y, color=cp.value, alpha=0.5)


sl = ipw.FloatLogSlider(
    value=N,
    base=10,
    min=0,  # max exponent of base
    max=5,  # min exponent of base
    step=0.1,  # exponent step
    description="Log Slider",
)

sl.observe(update_scatter, names="value")


def update_color(change):
    ax.collections[-1].set_color(change["new"])


cp.observe(update_color, names="value")

ipw.HBox([sl, cp])

3. Create an interface to dynamically resize an image#

In this exercise, we will create an interface with a displayed image and some control widgets below, that will enable dynamic resizing of the image.

  1. Using the PIL (Python Image Library), get an image from a web url:

import PIL.Image as Image

img = Image.open("../../images/ESS_02_small.jpg")
img
../../_images/97da3cbe0765772e599a75ef4929014250118303cdddbf5807a629c39d9c734c.png
  1. Place the image inside an Image widget:

im_widget = ipw.Image(value=img._repr_png_(), width=img.width, height=img.height)
  1. Create two IntText widgets (one for the image width and one for the image height) and a Button widget that will run the image resizing function when clicked.

  2. Place the widgets inside a HBox container widget to keep them in the same line, and place the im_widget and the HBox inside a VBox container to place the image above the control widgets.

  3. At this point, it is probably a good idea to try and display the VBox to check that everything is set up properly.

  4. Next, create a function that, when called, would resize the image. In that function, you will need to:

  • Make a copy of the original image

  • Resize the copy with the PIL resize() function: copied_image.resize((new_width, new_height))

  • Update the value of the im_widget with the new_image._repr_png_()

  1. Finally, you need to install a on_click call-back (see here) from the Button to the resizing function so that the function is triggered when the Button is clicked.

  2. Voila!

  3. Bonus: Try to achieve the same but using two IntSliders instead of IntText so that you can resize the image by simply dragging sliders!

Solution:

Hide code cell content
im_size = [img.width, img.height]

width = ipw.IntText(value=img.width, description="Width:")
height = ipw.IntText(value=img.height, description="Height:")
button = ipw.Button(description="Resize!")

hbox = ipw.HBox([width, height, button])


def resize_image(event):
    resized = img.copy().resize((width.value, height.value))
    im_widget.value = resized._repr_png_()
    im_widget.width = width.value
    im_widget.height = height.value
    return


button.on_click(resize_image)

sl_width = ipw.IntSlider(value=img.width, min=2, max=img.width)
sl_height = ipw.IntSlider(value=img.height, min=2, max=img.height)


def update_width(change):
    width.value = change["new"]
    resize_image(None)
    return


def update_height(change):
    height.value = change["new"]
    resize_image(None)
    return


sl_width.observe(update_width, names="value")
sl_height.observe(update_height, names="value")

ipw.VBox([im_widget, hbox])

Bonus:

Hide code cell content
sl_width = ipw.IntSlider(description="width", value=img.width, min=2, max=img.width)
sl_height = ipw.IntSlider(
    descriptiuon="height", value=img.height, min=2, max=img.height
)


def update_width(change):
    width.value = change["new"]
    resize_image(None)
    return


def update_height(change):
    height.value = change["new"]
    resize_image(None)
    return


sl_width.observe(update_width, names="value")
sl_height.observe(update_height, names="value")

ipw.VBox([ipw.HBox([sl_width, sl_height]), im_widget])