Writing a custom viewer for glue with Qt#

In Writing a custom viewer for glue, we looked at the basics of building data viewers for glue. In this follow-up tutorial, we look at specifically how to use this to build a data viewer for the Qt front-end of glue.

Options widgets#

We mentioned in Writing a custom viewer for glue that there are state classes that contain a conceptual representation of the overall viewer options and the settings pertaining to each layer in the visualization. What is now needed are widgets that will allow users to easily change this state, and also reflect changes to the state that are made programmatically.

In the Qt version of glue, viewers typically define a widget to control the viewer state, which is usually shown is the area indicated as C in the following diagram:

../_images/main_window.png

and a widget to control the layer state, which is usually shown is the area indicated as B in the above diagram (in addition to the layer list).

The only requirement for these widgets is that the widget for the viewer options should take an argument which is the viewer state (as well as a session keyword argument which is a Session object that contains a reference to the data collection and hub), and the widget for the layer settings should take an argument which is the layer artist (in future this will likely be changed to the layer state), but beyond this, you can implement the widgets any way you like. Let’s take the simple layer state example above with the fill option. You could implement a layer options widget by doing:

from echo.qt import connect_checkable_button
from qtpy.QtWidgets import QWidget, QVBoxLayout, QCheckBox

class TutorialLayerStateWidget(QWidget):

     def __init__(self, layer_artist):

         super(LayerEditWidget, self).__init__()

         self.checkbox = QCheckBox('Fill markers')
         layout = QVBoxLayout()
         layout.addWidget(self.checkbox)
         self.setLayout(layout)

         self.layer_state = layer_artist.state
         self._connection = connect_checkable_button(self.layer_state, 'fill', self.checkbox)

In the above example, you can see that we use the connect_checkable_button function to link the fill property from the layer state with the checkbox. For a full list of available functions, see here.

For more complex cases, you may want to use Qt Designer to create a ui file with your layout (such as viewer_state.ui), then load it into the options widget - you can then also automatically connect UI elements to state properties using the autoconnect_callbacks_to_qt() function. Let’s use this to create a widget to control the viewer state:

from echo.qt import autoconnect_callbacks_to_qt
from qtpy.QtWidgets import QWidget
from glue_qt.utils import load_ui

class TutorialViewerStateWidget(QWidget):

     def __init__(self, viewer_state, session=None):

         super(TutorialViewerStateWidget, self).__init__()

         # The dirname= is used to indicate that the .ui file is in the same
         # directory as the present file.
         self.ui = load_ui('options_widget.ui', dirname=os.path.dirname(__file__))

         self.viewer_state = viewer_state
         self._connections = autoconnect_callbacks_to_qt(self.viewer_state, self.ui)

For autoconnect_callbacks_to_qt() to work, you need to follow certain naming conventions for the UI elements in the .ui file. You can read up more about this convention here.

Data viewer#

In the case of Qt, defining a data viewer is similar to the general case described in Writing a custom viewer for glue but this time we need to use the DataViewer class from glue_qt.viewers.common.data_viewer and define two additional attributes to point to the widgets that control the viewer and layer state:

from glue_qt.viewers.common.data_viewer import DataViewer

class TutorialDataViewer(DataViewer):

    LABEL = 'Tutorial viewer'
    _state_cls = TutorialViewerState
    _data_artist_cls = TutorialLayerArtist
    _subset_artist_cls = TutorialLayerArtist

    # Additional attributes for Qt viewers
    _options_cls = TutorialViewerStateWidget
    _layer_style_widget_cls = TutorialLayerStateWidget

As mentioned previously, you will need to set up the actual visualization in the __init__ method for the data viewer, and this time you should use the setCentralWidget method to add your custom Qt visualization to the widget, e.g.:

from matplotlib import pyplot as plt

def __init__(self, *args, **kwargs):
    super(TutorialDataViewer, self).__init__(*args, **kwargs)
    self.axes = plt.subplot(1, 1, 1)
    self.setCentralWidget(self.axes.figure.canvas)

Functional example#

Let’s now take all these pieces and construct a functional example. To try this out you can simply copy the code below into a config.py file in the directory from where you are starting glue. In addition you will also need the viewer_state.ui file. In File layout in glue we discuss how this code are split into different files in glue.

Note that if you are interested in building a Matplotlib-based viewer, you can make use of the glue.viewers.matplotlib sub-package to simplify things as described in Writing a custom viewer for glue with Qt and Matplotlib.

import os

import numpy as np
import matplotlib
matplotlib.use('Qt5Agg')
from matplotlib import pyplot as plt
from qtpy.QtWidgets import QWidget, QVBoxLayout, QCheckBox

from glue_qt.config import qt_client
from glue.core.data_combo_helper import ComponentIDComboHelper

from echo import CallbackProperty, SelectionCallbackProperty
from echo.qt import connect_checkable_button, autoconnect_callbacks_to_qt

from glue.viewers.common.layer_artist import LayerArtist
from glue.viewers.common.state import ViewerState, LayerState
from glue.viewers.common.data_viewer import DataViewer

from glue.utils import load_ui


class TutorialViewerState(ViewerState):

    x_att = SelectionCallbackProperty(docstring='The attribute to use on the x-axis')
    y_att = SelectionCallbackProperty(docstring='The attribute to use on the y-axis')

    def __init__(self, *args, **kwargs):
        super(TutorialViewerState, self).__init__(*args, **kwargs)
        self._x_att_helper = ComponentIDComboHelper(self, 'x_att')
        self._y_att_helper = ComponentIDComboHelper(self, 'y_att')
        self.add_callback('layers', self._on_layers_change)

    def _on_layers_change(self, value):
        # self.layers_data is a shortcut for
        # [layer_state.layer for layer_state in self.layers]
        self._x_att_helper.set_multiple_data(self.layers_data)
        self._y_att_helper.set_multiple_data(self.layers_data)


class TutorialLayerState(LayerState):
    fill = CallbackProperty(False, docstring='Whether to show the markers as filled or not')


class TutorialLayerArtist(LayerArtist):

    _layer_state_cls = TutorialLayerState

    def __init__(self, axes, *args, **kwargs):

        super(TutorialLayerArtist, self).__init__(*args, **kwargs)

        self.axes = axes

        self.artist = self.axes.plot([], [], 'o', color=self.state.layer.style.color)[0]

        self.state.add_callback('fill', self._on_fill_change)
        self.state.add_callback('visible', self._on_visible_change)
        self.state.add_callback('zorder', self._on_zorder_change)

        self._viewer_state.add_callback('x_att', self._on_attribute_change)
        self._viewer_state.add_callback('y_att', self._on_attribute_change)

    def _on_fill_change(self, value=None):
        if self.state.fill:
            self.artist.set_markerfacecolor(self.state.layer.style.color)
        else:
            self.artist.set_markerfacecolor('none')
        self.redraw()

    def _on_visible_change(self, value=None):
        self.artist.set_visible(self.state.visible)
        self.redraw()

    def _on_zorder_change(self, value=None):
        self.artist.set_zorder(self.state.zorder)
        self.redraw()

    def _on_attribute_change(self, value=None):

        if self._viewer_state.x_att is None or self._viewer_state.y_att is None:
            return

        x = self.state.layer[self._viewer_state.x_att]
        y = self.state.layer[self._viewer_state.y_att]

        self.artist.set_data(x, y)

        self.axes.set_xlim(np.nanmin(x), np.nanmax(x))
        self.axes.set_ylim(np.nanmin(y), np.nanmax(y))

        self.redraw()

    def clear(self):
        self.artist.set_visible(False)

    def remove(self):
        self.artist.remove()

    def redraw(self):
        self.axes.figure.canvas.draw_idle()

    def update(self):
        self._on_fill_change()
        self._on_attribute_change()


class TutorialViewerStateWidget(QWidget):

    def __init__(self, viewer_state=None, session=None):

        super(TutorialViewerStateWidget, self).__init__()

        self.ui = load_ui('viewer_state.ui', self,
                          directory=os.path.dirname(__file__))

        self.viewer_state = viewer_state
        self._connections = autoconnect_callbacks_to_qt(self.viewer_state, self.ui)


class TutorialLayerStateWidget(QWidget):

    def __init__(self, layer_artist):

        super(TutorialLayerStateWidget, self).__init__()

        self.checkbox = QCheckBox('Fill markers')
        layout = QVBoxLayout()
        layout.addWidget(self.checkbox)
        self.setLayout(layout)

        self.layer_state = layer_artist.state
        self._connection = connect_checkable_button(self.layer_state, 'fill', self.checkbox)


class TutorialDataViewer(DataViewer):

    LABEL = 'Tutorial viewer'
    _state_cls = TutorialViewerState
    _options_cls = TutorialViewerStateWidget
    _layer_style_widget_cls = TutorialLayerStateWidget
    _data_artist_cls = TutorialLayerArtist
    _subset_artist_cls = TutorialLayerArtist

    def __init__(self, *args, **kwargs):
        super(TutorialDataViewer, self).__init__(*args, **kwargs)
        self.axes = plt.subplot(1, 1, 1)
        self.setCentralWidget(self.axes.figure.canvas)

    def get_layer_artist(self, cls, layer=None, layer_state=None):
        return cls(self.axes, self.state, layer=layer, layer_state=layer_state)


qt_client.add(TutorialDataViewer)

Try opening a tabular dataset in glue, drag it onto the canvas area, and select Tutorial viewer - you should now get something that looks like:

../_images/tutorial_viewer1.png

File layout in glue#

In glue, we split up the classes using the following layout:

Filename

Description

state.py

State classes for the viewer and layer

layer_artist.py

Layer artist class

qt/options_widget.ui

Qt ui file for the viewer state widget

qt/options_widget.py

Qt viewer state widget

qt/layer_style_editor.ui

Qt ui file for the layer state widget

qt/layer_style_editor.py

Qt layer state widget

qt/data_viewer.py

Qt data viewer

You are of course free to organize the files how you wish, but this should help understand the existing viewers in glue if needed.

Further reading#

To find out how to add tools to your custom viewer, see the Custom tools for viewers and custom toolbars tutorial, and for information on building a data viewer with Matplotlib, take a look at the Writing a custom viewer for glue with Qt and Matplotlib tutorial.