diff --git a/doc/customizing_guide/custom_viewer.rst b/doc/customizing_guide/custom_viewer.rst index 9ea7f0f69..0d61cc633 100644 --- a/doc/customizing_guide/custom_viewer.rst +++ b/doc/customizing_guide/custom_viewer.rst @@ -1,23 +1,25 @@ -Building Custom Data Viewers -============================ +Writing a simple custom data viewer +=================================== .. figure:: images/bball_3.png :align: center -Glue's standard data viewers (scatter plots, images, histograms) are -useful in a wide variety of data exploration settings. However, they -represent a *tiny* fraction of the ways to view a particular -dataset. For this reason, Glue provides a simple mechanism -for creating custom visualizations using matplotlib. - -Creating a :func:`custom data viewer ` requires writing a little bit of Matplotlib -code but involves little to no GUI programming. The next several -sections illustrate how to build a custom data viewer by example. +Glue's standard data viewers (scatter plots, images, histograms) are useful in a +wide variety of data exploration settings. However, they represent a *tiny* +fraction of the ways to view a particular dataset. For this reason, Glue +provides a way to create more deta viewers that me better suited to what you +need. +There are several ways to do this - the tutorial on this page shows the easiest +way for users to develop a new custom visualization, provided that it can be +made using Matplotlib and tht you don't want do have to do any GUI programming. +If you are interested in building more advanced custom viewers, see +:ref:`state-qt-viewer`. The Goal: Basketball Shot Charts -------------------------------- -In Basketball, Shot Charts show the spatial distribution of shots + +In basketball, Shot Charts show the spatial distribution of shots for a particiular player, team, or game. The `New York Times `_ has a nice example. There are three basic features that we might want to incorporate into diff --git a/doc/customizing_guide/full_custom_qt_viewer.rst b/doc/customizing_guide/full_custom_qt_viewer.rst index 1123cad51..2d2a17f97 100644 --- a/doc/customizing_guide/full_custom_qt_viewer.rst +++ b/doc/customizing_guide/full_custom_qt_viewer.rst @@ -1,21 +1,16 @@ +:orphan: + Writing a fully customized Qt viewer (advanced) =============================================== Motivation ---------- -The :func:`~glue.custom_viewer` function and the -:class:`~glue.viewers.custom.qt.custom_viewer.CustomViewer` class described in -:doc:`custom_viewer` are well-suited to developing new custom viewers that -include some kind of Matplotlib plot. But in some cases, you may want to -write a Qt data viewer that doesn't depend on Matplotlib, or may use an -existing widget. In this tutorial, we will assume that you have implemented a -Qt widget that contains the functionality you want, and we will focus on -looking at how to get it to work inside glue. - -If you don't already have an existing widget, but want to make sure it will -work outside glue, start off by developing the widget outside of glue, then -use the instructions below to make it usable inside glue. +This tutorial is intended for people who want to develop a fully custom Qt-based +viewer for glue that does not use Matplotlib and does not use the state class +infrastructure. If you don't already have an existing widget, but want to make +sure it will work outside glue, start off by developing the widget outside of +glue, then use the instructions below to make it usable inside glue. Displaying the widget in glue ----------------------------- @@ -31,12 +26,10 @@ Now let's say we want to use this widget in glue, without having to change anything in ``MyWidget``. The best way to do this is to create a new class, ``MyGlueWidget``, that will wrap around ``MyWidget`` and make it glue-compatible. The glue widget should inherit from -:class:`~glue.viewers.common.qt.data_viewer` (this class does a few +:class:`~glue.viewers.common.qt.data_viewer.DataViewer` (this class does a few boilerplate things such as, for example, adding the ability to drag and drop -data onto your data viewer). - -The simplest glue widget wrapper that you can write that will show -``MyWidget`` is:: +data onto your data viewer). The simplest glue widget wrapper that you can write +that will show ``MyWidget`` is:: from glue.qt.widgets.data_viewer import DataViewer @@ -54,8 +47,8 @@ The simplest glue widget wrapper that you can write that will show If you put the contents above into a ``config.py`` file then launch glue in the same folder as the ``config.py`` file, you will then be able to go to the **Canvas** menu, select **New Data Viewer**, and you should then be presented -with the window to select a data view, which should contain an 'Override -This' entry: +with the window to select a data view, which should contain an **Override +this** entry: .. image:: images/select_override.png :width: 200px @@ -79,7 +72,7 @@ Passing data to the widget Now we want to be able to pass data to this viewer. To do this, you should define the ``add_data`` method which should take a single argument and return `True` if adding the data succeeded, and `False` otherwise. So for now, let's -simply return True and do nothing:: +simply return `True` and do nothing:: def add_data(self, data): return True @@ -99,16 +92,20 @@ However, this will simply plot the initial data and plot more data if you drag datasets onto the window, but you will not for example be able to remove datasets, show subsets, and so on. In some cases, that may be fine, and you can stop at this point, but in other cases, if you want to define a way to -interact with subsets, propagate selections, and so on, you will need to set -up a glue client, which is discussed in `Setting up a client`_. But first, let's take a look at how we can add side panels in the dashboard which can include for example options for controlling the appearance or contents of your visualization. +interact with subsets, propagate selections, and so on, you will need to listen +for events, which is discussed in `Listening for events`_. + +But first, let's take a look at how we can add side panels in the dashboard +which can include for example options for controlling the appearance or contents +of your visualization. Adding side panels ------------------ -In the glue interface, under the data manager is an area we refer to as the -dashboard, where different data viewers can include options for controlling -the appearance or content of visualizations (this is the area indicated as C -in :doc:getting-started). You can add any widget to the two available spaces. +In the glue interface, under the data collection is an area we refer to as the +dashboard, where different data viewers can include options for controlling the +appearance or content of visualizations (this is the area indicated as **C** in +:doc:getting-started). You can add any widget to the two available spaces. In your wrapper class, ``MyGlueWidget`` in the example above, you will need to define a method called ``options_widget``, which returns an instantiated widget @@ -139,10 +136,13 @@ sets the widget for the middle widget in the dashboard. However, this will default to a list of layers which can normally be used as-is (see `Using Layers`_) -Setting up a client -------------------- +Listening for events +-------------------- -Once the data viewer has been instantiated, the main glue application will call the ``register_to_hub`` method on the data viewer, and will pass it the hub as an argument. This allows you to set up your data viewer as a client that can listen to specific messages from the hub:: +Once the data viewer has been instantiated, the main glue application will +automatically call the ``register_to_hub`` method on the data viewer, and will +pass it the hub as an argument. This allows you to set up your data viewer as a +client that can listen to specific messages from the hub:: from glue.core.message import DataCollectionAddMessage @@ -171,10 +171,9 @@ By default, any sub-class of `~glue.viewers.common.qt.data_viewer` will also include a list of layers in the central panel in the dashboard. Layers can be thought of as specific components of visualizations - for example, in a scatter plot, the main dataset will be a layer, while each individual subset -will have its own layer. The 'vertical' order of the layers (i.e. which one +will have its own layer. The order of the layers (which controls which one appears in front of which) can then be set by dragging the layers around, and -the color/style of the layers can also be set from this list of layers (by -control-clicking on any layer). +the color/style of the layers can also be set from this list of layers. Conceptually, layer artists can be used to carry out the actual drawing and include any logic about how to convert data into visualizations. If you are @@ -210,7 +209,7 @@ specific layer artist). So when the user adds a dataset, the viewer should do something along the lines of:: layer_artist = MyLayerArtist(data, ...) - self._container.append(layer_artist) + self._layer_artist_container.append(layer_artist) layer_artist.redraw() If the user removes a layer from the list of layers by e.g. hitting the diff --git a/doc/customizing_guide/matplotlib_qt_viewer.rst b/doc/customizing_guide/matplotlib_qt_viewer.rst new file mode 100644 index 000000000..38783c75f --- /dev/null +++ b/doc/customizing_guide/matplotlib_qt_viewer.rst @@ -0,0 +1,86 @@ +.. _matplotlib-qt-viewer: + +Writing a custom viewer for glue (using Matplotlib) +=================================================== + +If you are a user trying to build a very simple viewer using Matplotlib, you may +want to check out :doc:`custom_viewer` -- the present tutorial is intended for +people who wish to write and distribute a viewer using Matplotlib with full +control over layout and beahvior. This tutorial assumes that you have already +gone over the :ref:`state-qt-viewer` tutorial. + +Glue provides a set of base classes for the state classes, layer artist, and +data viewer which already take care of a number of aspects common to all +Matplotlib-based viewers. We describe each of these in turn in the following +sections, then simplify the example from :ref:`state-qt-viewer` using this +infrastructure. + +State classes +------------- + +The :class:`~glue.viewers.matplotlib.state.MatplotlibDataViewerState` class +provides a subclass of :class:`~glue.viewers.common.state.ViewerState` which +adds properties related to: + +* the appearance of the plot (font and tick sizes) +* the limits of the current view (this currently assumes 2D plots) +* the aspect ratio of the axes +* whether the axes are log or linear + +Note that it does not add e.g. ``x_att`` and ``y_att`` since not all Matplotlib- +based viewers will require the same number of attributes, and since some viewers +may define attributes that aren't specific to the x or y axis (e.g. in the case +of networks). + +The :class:`~glue.viewers.matplotlib.state.MatplotlibLayerState` class +provides a subclass of :class:`~glue.viewers.common.state.LayerState` which +adds the ``color`` and ``alpha`` property (and keeps them in sync with +``layer.style.color`` and ``layer.style.alpha``). + +Layer artist +------------ + +The :class:`~glue.viewers.matplotlib.layer_artist.MatplotlibLayerArtist` class +implements the +:meth:`~glue.viewers.matplotlib.layer_artist.MatplotlibLayerArtist.redraw`, +:meth:`~glue.viewers.matplotlib.layer_artist.MatplotlibLayerArtist.remove`, and +:meth:`~glue.viewers.matplotlib.layer_artist.MatplotlibLayerArtist.clear` +methods assuming that all the contents of the layer use Matplotlib artists. In +the ``__init__`` of your +:class:`~glue.viewers.matplotlib.layer_artist.MatplotlibLayerArtist` sub-class, +you should make sure you add all artist references to the ``mpl_artists`` +property for this to work. + +Data viewer +----------- + +The :class:`~glue.viewers.matplotlib.qt.data_viewer.MatplotlibDataViewer` class +adds functionality on top of the base +:class:`~glue.viewers.common.qt.data_viewer_with_state.DataViewerWithState` +class: + +* It automatically sets up the Matplotlib axes +* It keeps the x/y limits of the plot, the scale (linear/log), the font/tick + parameters, and the aspect ratio in sync with the + :class:`~glue.viewers.matplotlib.state.MatplotlibDataViewerState` +* It adds tools for saving, zooming, panning, and resetting the view +* It recognizes the global glue preferences for foreground/background color + +Functional example +------------------ + +Let's now take the take full example from :ref:`state-qt-viewer` and +update/improve it to use the infrastructure described above: + +.. literalinclude:: mpl_viewer/config.py + +While the code is not much shorter, there is additional functionality available. +In particular, the viewer now has standard Matplotlib buttons in the toolbar: + +.. image:: mpl_viewer/tutorial_viewer.png + :width: 600px + :align: center + +In addition, the layer artist has been improved to take into account the color +and transparency given by the layer state (via the ``_on_visual_change`` +method), and the axis labels are now set in the viewer state class. diff --git a/doc/customizing_guide/mpl_viewer/config.py b/doc/customizing_guide/mpl_viewer/config.py new file mode 100644 index 000000000..cbd8cff0e --- /dev/null +++ b/doc/customizing_guide/mpl_viewer/config.py @@ -0,0 +1,140 @@ +import os + +import numpy as np + +from qtpy.QtWidgets import QWidget, QVBoxLayout, QCheckBox + +from glue.config import qt_client +from glue.core.data_combo_helper import ComponentIDComboHelper + +from glue.external.echo import CallbackProperty, SelectionCallbackProperty +from glue.external.echo.qt import (connect_checkable_button, + autoconnect_callbacks_to_qt) + +from glue.viewers.matplotlib.layer_artist import MatplotlibLayerArtist +from glue.viewers.matplotlib.state import MatplotlibDataViewerState, MatplotlibLayerState +from glue.viewers.matplotlib.qt.data_viewer import MatplotlibDataViewer + +from glue.utils.qt import load_ui + + +class TutorialViewerState(MatplotlibDataViewerState): + + 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) + self.add_callback('x_att', self._on_attribute_change) + self.add_callback('y_att', self._on_attribute_change) + + def _on_layers_change(self, value): + self._x_att_helper.set_multiple_data(self.layers_data) + self._y_att_helper.set_multiple_data(self.layers_data) + + def _on_attribute_change(self, value): + if self.x_att is not None: + self.x_axislabel = self.x_att.label + if self.y_att is not None: + self.y_axislabel = self.y_att.label + + +class TutorialLayerState(MatplotlibLayerState): + fill = CallbackProperty(False, docstring='Whether to show the markers as filled or not') + + +class TutorialLayerArtist(MatplotlibLayerArtist): + + _layer_state_cls = TutorialLayerState + + def __init__(self, axes, *args, **kwargs): + + super(TutorialLayerArtist, self).__init__(axes, *args, **kwargs) + + self.artist = self.axes.plot([], [], 'o', mec='none')[0] + self.mpl_artists.append(self.artist) + + self.state.add_callback('fill', self._on_visual_change) + self.state.add_callback('visible', self._on_visual_change) + self.state.add_callback('zorder', self._on_visual_change) + self.state.add_callback('color', self._on_visual_change) + self.state.add_callback('alpha', self._on_visual_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_visual_change(self, value=None): + + self.artist.set_visible(self.state.visible) + self.artist.set_zorder(self.state.zorder) + self.artist.set_markeredgecolor(self.state.color) + if self.state.fill: + self.artist.set_markerfacecolor(self.state.color) + else: + self.artist.set_markerfacecolor('white') + self.artist.set_alpha(self.state.alpha) + + 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 update(self): + self._on_attribute_change() + self._on_visual_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 + 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 + connect_checkable_button(self.layer_state, 'fill', self.checkbox) + + +class TutorialDataViewer(MatplotlibDataViewer): + + LABEL = 'Tutorial viewer' + _state_cls = TutorialViewerState + _options_cls = TutorialViewerStateWidget + _layer_style_widget_cls = TutorialLayerStateWidget + _data_artist_cls = TutorialLayerArtist + _subset_artist_cls = TutorialLayerArtist + + +qt_client.add(TutorialDataViewer) diff --git a/doc/customizing_guide/mpl_viewer/tutorial_viewer.png b/doc/customizing_guide/mpl_viewer/tutorial_viewer.png new file mode 100644 index 000000000..89d2ffb58 Binary files /dev/null and b/doc/customizing_guide/mpl_viewer/tutorial_viewer.png differ diff --git a/doc/customizing_guide/mpl_viewer/viewer_state.ui b/doc/customizing_guide/mpl_viewer/viewer_state.ui new file mode 100644 index 000000000..1e6308dc5 --- /dev/null +++ b/doc/customizing_guide/mpl_viewer/viewer_state.ui @@ -0,0 +1,33 @@ + + + Form + + + Form + + + + + + x attribute + + + + + + + + + + y attribute + + + + + + + + + + + diff --git a/doc/customizing_guide/state_based_qt_viewer.rst b/doc/customizing_guide/state_based_qt_viewer.rst new file mode 100644 index 000000000..eabdde04c --- /dev/null +++ b/doc/customizing_guide/state_based_qt_viewer.rst @@ -0,0 +1,425 @@ +.. _state-qt-viewer: + +Writing a custom viewer for glue +================================ + +Motivation +---------- + +The simple way of defining new custom viewers described in :doc:`custom_viewer` +are well-suited to developing new custom viewers that include simple Matplotlib +plots. But in some cases, you may want to write a data viewer with more +customized functionality, or that doesn't depend on Matplotlib and may use an +existing third-party widget. + +In this tutorial, we will take a look at the pieces needed to build a data +viewer. Some of the sections here are relevant regardless of whether you are +building a data viewer for e.g. Qt or Jupyter, and some of the later sections +will show an example of building an actual Qt viewer. + +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 :ref:`matplotlib-qt-viewer` - but first be sure to read this +page as the Matplotlib viewer tutorial will build on this on. + +Terminology +----------- + +When we talk about a *data viewer*, we mean specifically one of the +visualization (e.g. scatter plot, histogram, network diagram, etc.). Inside each +visualization, there may be multiple datasets or subsets shown. For example, a +dataset might be shown as markers of a certain color, while a subset might be +shown in a different color. We refer to these as *layers* in the visualization, +and these typically appear in a list on the left of the glue application window. + +State classes +------------- + +Overview +^^^^^^^^ + +The first piece to construct when developing a new data viewer are *state* +classes for the data viewer and layers, which you can think of as a conceptual +representation of the data viewer and layers, but doesn't contain any code +specific to e.g. Qt or Jupyter or even the visualization library you are using. +As an example, a scatter viewer will have a state class that indicates which +attributes are shown on which axes, and what the limits of the axes are. Each +layer then also has a state class which includes information for example about +what the color of the layer should be, and whether it is currently visible or +not. + +Viewer state +^^^^^^^^^^^^ + +To create a viewer, we import the base +:class:`~glue.viewers.common.state.ViewerState` class, as well as the +:class:`~glue.external.echo.CallbackProperty` class:: + + from glue.viewers.common.state import ViewerState + from glue.external.echo import CallbackProperty + +The latter is used to define properties on the state class and we can attach +callback functions to them (more on this soon). Let's now imagine we want to +build a simple scatter plot viewer. Our state class would then look like:: + + class TutorialViewerState(ViewerState): + x_att = CallbackProperty(docstring='The attribute to use on the x-axis') + y_att = CallbackProperty(docstring='The attribute to use on the y-axis') + +Once a state class is defined with callback properties, it is possible to +attach callback functions to them:: + + >>> def on_x_att_change(value): + ... print('x_att has changed and is now', value) + >>> state = TutorialViewerState() + >>> state.add_callback('x_att', on_x_att_change) + >>> state.x_att = 'a' + x_att has changed and is now a + +What this means is that when you are defining the state class for your viewer, +think about whether you want to change certain properties based on others. For +example we could write a state class that changes x to match y (but not y to +match x):: + + class TutorialViewerState(ViewerState): + + x_att = CallbackProperty(docstring='The attribute to use on the x-axis') + y_att = CallbackProperty(docstring='The attribute to use on the y-axis') + + def __init__(self, *args, **kwargs): + super(TutorialViewerState).__init__(*args, **kwargs) + self.add_callback('y_att', self._on_y_att_change) + + def _on_y_att_change(self, value): + self.x_att = self.y_att + +The idea is to implement as much of the logic as possible here rather than +relying on e.g. Qt events, so that your class can be re-used for e.g. both a Qt +and Jupyter data viewer. + +Note that the :class:`~glue.viewers.common.state.ViewerState` defines one +property by default, which is ``layers`` - a container that is used to store +:class:`~glue.viewers.common.state.LayerState` objects (see `Layer state`_). +You shouldn't need to add/remove layers from this manually, but you can attach +callback functions to ``layers`` in case any of the layers change. + +Layer state +^^^^^^^^^^^ + +Similarly to the viewer state, you need to also define a state class for +layers in the visualization using :class:`~glue.viewers.common.state.LayerState`:: + + from glue.viewers.common.state import LayerState + +The :class:`~glue.viewers.common.state.LayerState` class defines the following +properties by default: + +* ``layer``: the :class:`~glue.core.data.Data` or :class:`~glue.core.subset.Subset` + attached to the layer (the naming of this property is historical/confusing and + may be changed to ``data`` in future). +* ``visible``: whether the layer is visible or not +* ``zorder``: a numerical value indicating (when relevant) which layer should + appear in front of which (higher numbers mean the layer should be shown more + in the foreground) + +Furthermore, ``layer.style`` is itself a state class that includes global +settings for the data or subset, such as ``color`` and ``alpha``. + +Let's say that you want to define a way to indicate in the layer whether to +use filled markers or not - this is not one of the settings in ``layer.style``, +so you can define it using:: + + class TutorialLayerState(LayerState): + fill = CallbackProperty(False, docstring='Whether to show the markers as filled or not') + +The optional first value in :class:`~glue.external.echo.CallbackProperty` is the +default value that the property should be set to. + +Multi-choice properties +^^^^^^^^^^^^^^^^^^^^^^^ + +In some cases, you might want the properties on the state classes to be a +selection from a fixed set of values -- for instance line style, or as +demonstrated in `Viewer State`_, the attribute to show on an axis (since +it should be chosen from the existing data attributes). This can be +done by using the :class:`~glue.external.echo.SelectionCallbackProperty` class, +which should be used as follows:: + + class TutorialViewerState(ViewerState): + + linestyle = SelectionCallbackProperty() + + def __init__(self, *args, **kwargs): + super(TutorialViewerState).__init__(*args, **kwargs) + MyExampleState.linestyle.set_choices(['solid', 'dashed', 'dotted']) + +This then makes it so that the ``linestyle`` property knows about what valid +values are, and this will come in useful in `Options widgets`_ to automatically +populate combo/selection boxes for example. + +For the specific case of selecting attributes from the data, we also provide a +class :class:`~glue.core.data_combo_helper.ComponentIDComboHelper` that can +automatically keep the attributes for datasets in sync with the choices in a +:class:`~glue.external.echo.SelectionCallbackProperty` class. Here's an example +of how to use it:: + + 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) + +Now whenever layers are added/removed, the choices for ``x_att`` and ``y_att`` +will automatically be updated. + +Layer artist +------------ + +In the previous section, we saw that we can define classes to hold the +conceptual state of viewers and of the layers in the viewers. The next +type of class we are going to look at is the *layer artist*. + +Conceptually, layer artists can be used to carry out the actual drawing and +include any logic about how to convert data and subsets into layers in your +visualization. + +The minimal layer artist class looks like the following:: + + from glue.viewers.common.layer_artist import LayerArtistWithState + + class TutorialLayerArtist(LayerArtistWithState): + + def clear(self): + pass + + def remove(self): + pass + + def redraw(self): + pass + + def update(self): + pass + +Each layer artist class has to define the four methods shown above. The +:meth:`~glue.viewers.common.layer_artist.LayerArtistWithState.clear` method +should remove the layer from the visualization, bearing in mind +that the layer might be added back (this can happen for example when toggling +the visibility of the layer property), the +:meth:`~glue.viewers.common.layer_artist.LayerArtistWithState.remove` method +should permanently remove the layer from the visualization, the +:meth:`~glue.viewers.common.layer_artist.LayerArtistWithState.redraw` method +should force the layer to be redrawn, and +:meth:`~glue.viewers.common.layer_artist.LayerArtistWithState.update` should +update the apparance of the layer as necessary before redrawing -- note that +:meth:`~glue.viewers.common.layer_artist.LayerArtistWithState.update` is called +for example when a subset has changed. + +By default, layer artists inheriting from +:class:`~glue.viewers.common.layer_artist.LayerArtistWithState` will be +initialized with a reference to the layer state (accessible as ``state``) and +the viewer state (accessible as ``_viewer_state``). + +This means that we can then do the following, asssuming a layer state +with the ``fill`` property defined previously:: + + from glue.viewers.common.layer_artist import LayerArtistWithState + + class TutorialLayerArtist(LayerArtistWithState): + + def __init__(self, *args, **kwargs): + super(MyLayerArtist, self).__init__(*args, **kwargs) + self.state.add_callback('fill', self._on_fill_change) + + def _on_fill_change(self): + # Make adjustments to the visualization layer here + +In practice, you will likely need a reference to the overall visualization to +be passed to the layer artist (for example the axes for a Matplotlib plot, +or an OpenGL canvas). We will take a look at this after introducing the data +viewer class in `Data viewer`_. + +Note that the layer artist doesn't have to be specific to the front-end used +either. If for instance you are developing a widget based on e.g. +Matplotlib, and are then developing a Qt and Jupyter version of the viewer, +you could write the layer artist in such a way that it only cares about the +Matplotlib API and works for either the Qt or Jupyter viewers. + +Options widgets +--------------- + +We mentioned in `State classes`_ 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 then 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: + +.. image:: ../getting_started/images/main_window.png + :width: 600px + :align: center + +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 :class:`~glue.core.session.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 glue.external.echo.qt import connect_checkbable_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 + connect_checkbable_button(self.layer_state, 'fill', self.checkbox) + +In the above example, you can see that we use the +:func:`~glue.external.echo.qt.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 :download:`viewer_state.ui `), then load it +into the options widget - you can then also automatically connect UI elements to +state properties using the +:func:`~glue.external.echo.qt.autoconnect_callbacks_to_qt` function. Let's use +this to create a widget to control the viewer state:: + + from glue.external.echo.qt import autoconnect_callbacks_to_qt + from qtpy.QtWidgets import QWidget + from glue.utils.qt 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 + autoconnect_callbacks_to_qt(self.viewer_state, self.ui) + +For :func:`~glue.external.echo.qt.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 +----------- + +We have now seen how to define state classes for the viewer and layer, +layer artists, and widgets to control the viewer and layer options. The final +piece of the puzzle is the data viewer class itself, which brings everything +together. The simplest definition of the data viewer class is:: + + from glue.viewers.common.qt.data_viewer_with_state import DataViewerWithState + + class TutorialDataViewer(DataViewerWithState): + + LABEL = 'Tutorial viewer' + _state_cls = TutorialViewerState + _options_cls = TutorialViewerStateWidget + _layer_style_widget_cls = TutorialLayerStateWidget + _data_artist_cls = TutorialLayerArtist + _subset_artist_cls = TutorialLayerArtist + +In practice, this isn't quite enough, since we need to actually set up the main +visualization and pass references to it to the layer artists. This can be +done in the initializer of the ``TutorialDataViewer`` class. For example, +if you were building a Matplotlib-based viewer, assuming you imported Matplotlib +as:: + + from matplotlib import pyplot as plt + +you could do:: + + def __init__(self, *args, **kwargs): + super(TutorialDataViewer, self).__init__(*args, **kwargs) + self.axes = plt.subplot(1, 1, 1) + self.setCentralWidget(self.axes.figure.canvas) + +Note however that you need a way to pass the axes to the layer artist. The way +to do this is to add ``axes`` as a positional argument for the +``TutorialLayerArtist`` class defined previously then to add the following +method to the data viewer:: + + def get_layer_artist(self, cls, layer=None, layer_state=None): + return cls(self.axes, self.state, layer=layer, layer_state=layer_state) + +This method defines how the layer artists should be instantiated, and you can +see that we added a ``self.axes`` positional argument, so that the layer artist +classes should now have access to the axes. + +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, but in `File layout in glue`_ +we discuss how these classes 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 :ref:`matplotlib-qt-viewer`. + +.. literalinclude:: state_viewer/config.py + +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: + +.. image:: state_viewer/tutorial_viewer.png + :width: 600px + :align: center + +File layout in glue +------------------- + +In glue, we split up the classes using the following layout: + +============================ ======================================== +Filename Description +============================ ======================================== +``state.py`` State clases 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. diff --git a/doc/customizing_guide/state_viewer/config.py b/doc/customizing_guide/state_viewer/config.py new file mode 100644 index 000000000..4326bc9af --- /dev/null +++ b/doc/customizing_guide/state_viewer/config.py @@ -0,0 +1,154 @@ +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.config import qt_client +from glue.core.data_combo_helper import ComponentIDComboHelper + +from glue.external.echo import CallbackProperty, SelectionCallbackProperty +from glue.external.echo.qt import (connect_checkable_button, + autoconnect_callbacks_to_qt) + +from glue.viewers.common.layer_artist import LayerArtistWithState +from glue.viewers.common.state import ViewerState, LayerState +from glue.viewers.common.qt.data_viewer_with_state import DataViewerWithState + +from glue.utils.qt 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(LayerArtistWithState): + + _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() + + 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 + 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 + connect_checkable_button(self.layer_state, 'fill', self.checkbox) + + +class TutorialDataViewer(DataViewerWithState): + + 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) diff --git a/doc/customizing_guide/state_viewer/tutorial_viewer.png b/doc/customizing_guide/state_viewer/tutorial_viewer.png new file mode 100644 index 000000000..3e6ca696e Binary files /dev/null and b/doc/customizing_guide/state_viewer/tutorial_viewer.png differ diff --git a/doc/customizing_guide/state_viewer/viewer_state.ui b/doc/customizing_guide/state_viewer/viewer_state.ui new file mode 100644 index 000000000..1e6308dc5 --- /dev/null +++ b/doc/customizing_guide/state_viewer/viewer_state.ui @@ -0,0 +1,33 @@ + + + Form + + + Form + + + + + + x attribute + + + + + + + + + + y attribute + + + + + + + + + + + diff --git a/doc/developer_guide/api.rst b/doc/developer_guide/api.rst index 336bdada0..1f16fcf98 100644 --- a/doc/developer_guide/api.rst +++ b/doc/developer_guide/api.rst @@ -50,6 +50,9 @@ Core Data .. automodapi:: glue.core.exceptions :no-inheritance-diagram: +.. automodapi:: glue.core.session + :no-inheritance-diagram: + User Interface ============== @@ -59,12 +62,23 @@ User Interface .. automodapi:: glue.viewers.matplotlib.state :no-inheritance-diagram: +.. automodapi:: glue.viewers.matplotlib.layer_artist + :no-inheritance-diagram: + .. automodapi:: glue.viewers.matplotlib.qt.data_viewer :no-inheritance-diagram: .. automodapi:: glue.core.layer_artist :no-inheritance-diagram: +.. automodapi:: glue.viewers.common.state + :no-inheritance-diagram: + :inherited-members: + +.. automodapi:: glue.viewers.common.layer_artist + :no-inheritance-diagram: + :inherited-members: + .. automodapi:: glue.viewers.common.viz_client :no-inheritance-diagram: @@ -139,6 +153,9 @@ Utilities .. automodapi:: glue.core.client :no-inheritance-diagram: +.. automodapi:: glue.core.data_combo_helper + :no-inheritance-diagram: + .. automodapi:: glue.core.message :no-inheritance-diagram: @@ -165,3 +182,12 @@ Utilities .. automodapi:: glue.utils.qt.widget_properties :no-inheritance-diagram: + +Bundled third-party packages +============================ + +.. automodapi:: glue.external.echo + :no-inheritance-diagram: + +.. automodapi:: glue.external.echo.qt + :no-inheritance-diagram: diff --git a/doc/index.rst b/doc/index.rst index cecd93b29..be13d2277 100644 --- a/doc/index.rst +++ b/doc/index.rst @@ -88,7 +88,15 @@ Customizing/Hacking Glue python_guide/data_viewer_options.rst customizing_guide/custom_viewer.rst python_guide/liveupdate.rst - customizing_guide/full_custom_qt_viewer.rst + +Advanced customization +---------------------- + +.. toctree:: + :maxdepth: 1 + + customizing_guide/state_based_qt_viewer.rst + customizing_guide/matplotlib_qt_viewer.rst customizing_guide/toolbar.rst Getting help diff --git a/glue/config.py b/glue/config.py index c75db8a57..4c7d63d8f 100644 --- a/glue/config.py +++ b/glue/config.py @@ -202,7 +202,7 @@ class QGlueParserRegistry(Registry): with ``data_class``, ``parser`` and ``priority`` attributes, where ``class`` defines the class for which to use the parser, and ``parser`` is a function that takes the input data and returns a list of glue - :class:`~glue.core.Data` objects. The ``parser`` functions should take two + :class:`~glue.core.data.Data` objects. The ``parser`` functions should take two arguments: the variable containing the data being parsed, and a label. In addition, the priority (defaulting to 0) can be specified in case one wants to make sure sub-classes get tested before more general classes. The diff --git a/glue/core/application_base.py b/glue/core/application_base.py index 8ed787ec0..9ee1d6a0e 100644 --- a/glue/core/application_base.py +++ b/glue/core/application_base.py @@ -449,10 +449,6 @@ def apply_roi(self, roi): def session(self): return self._session - @property - def axes(self): - return self.client.axes - def layer_view(self): raise NotImplementedError() diff --git a/glue/core/data_combo_helper.py b/glue/core/data_combo_helper.py index c3e70b5de..866f22941 100644 --- a/glue/core/data_combo_helper.py +++ b/glue/core/data_combo_helper.py @@ -17,7 +17,7 @@ from glue.external.six import string_types __all__ = ['ComponentIDComboHelper', 'ManualDataComboHelper', - 'DataCollectionComboHelper'] + 'DataCollectionComboHelper', 'ComboHelper', 'BaseDataComboHelper'] def unique_data_iter(datasets): @@ -47,7 +47,7 @@ class ComboHelper(HubListener): ---------- state : :class:`~glue.core.state_objects.State` The state to which the selection property belongs - selection_property : :class:`~glue.external.echo.core.SelectionCallbackProperty` + selection_property : :class:`~glue.external.echo.SelectionCallbackProperty` The selection property representing the combo. """ @@ -130,13 +130,13 @@ class ComponentIDComboHelper(ComboHelper): ---------- state : :class:`~glue.core.state_objects.State` The state to which the selection property belongs - selection_property : :class:`~glue.external.echo.core.SelectionCallbackProperty` + selection_property : :class:`~glue.external.echo.SelectionCallbackProperty` The selection property representing the combo. - data_collection : :class:`~glue.core.DataCollection`, optional + data_collection : :class:`~glue.core.data_collection.DataCollection`, optional The data collection to which the datasets belong - if specified, this is used to remove datasets from the combo when they are removed from the data collection. - data : :class:`~glue.core.Data`, optional + data : :class:`~glue.core.data.Data`, optional If specified, set up the combo for this dataset only and don't allow datasets to be added/removed numeric : bool, optional @@ -408,9 +408,9 @@ class BaseDataComboHelper(ComboHelper): ---------- state : :class:`~glue.core.state_objects.State` The state to which the selection property belongs - selection_property : :class:`~glue.external.echo.core.SelectionCallbackProperty` + selection_property : :class:`~glue.external.echo.SelectionCallbackProperty` The selection property representing the combo. - data_collection : :class:`~glue.core.DataCollection` + data_collection : :class:`~glue.core.data_collection.DataCollection` The data collection to which the datasets belong - this is needed because if a dataset is removed from the data collection, we want to remove it here. @@ -489,9 +489,9 @@ class ManualDataComboHelper(BaseDataComboHelper): ---------- state : :class:`~glue.core.state_objects.State` The state to which the selection property belongs - selection_property : :class:`~glue.external.echo.core.SelectionCallbackProperty` + selection_property : :class:`~glue.external.echo.SelectionCallbackProperty` The selection property representing the combo. - data_collection : :class:`~glue.core.DataCollection` + data_collection : :class:`~glue.core.data_collection.DataCollection` The data collection to which the datasets belong - this is needed because if a dataset is removed from the data collection, we want to remove it here. @@ -552,15 +552,15 @@ def register_to_hub(self, hub): class DataCollectionComboHelper(BaseDataComboHelper): """ This is a helper for combo boxes that need to show a list of data objects - that is always in sync with a :class:`~glue.core.DataCollection`. + that is always in sync with a :class:`~glue.core.data_collection.DataCollection`. Parameters ---------- state : :class:`~glue.core.state_objects.State` The state to which the selection property belongs - selection_property : :class:`~glue.external.echo.core.SelectionCallbackProperty` + selection_property : :class:`~glue.external.echo.SelectionCallbackProperty` The selection property representing the combo. - data_collection : :class:`~glue.core.DataCollection` + data_collection : :class:`~glue.core.data_collection.DataCollection` The data collection with which to stay in sync """ diff --git a/glue/core/link_manager.py b/glue/core/link_manager.py index 1b1870fe4..82e125eff 100644 --- a/glue/core/link_manager.py +++ b/glue/core/link_manager.py @@ -305,7 +305,7 @@ def is_equivalent_cid(data, cid1, cid2): Parameters ---------- - data : `~glue.core.Data` + data : `~glue.core.data.Data` The data object in which to check for the component IDs cid1, cid2 : `~glue.core.ComponentID` The two component IDs to compare @@ -327,7 +327,7 @@ def is_convertible_to_single_pixel_cid(data, cid): Parameters ---------- - data : `~glue.core.Data` + data : `~glue.core.data.Data` The data in which to check for pixel components IDs cid : `~glue.core.ComponentID` The component ID to search for diff --git a/glue/core/session.py b/glue/core/session.py index 781014a3e..5573f927c 100644 --- a/glue/core/session.py +++ b/glue/core/session.py @@ -6,6 +6,8 @@ from glue.core.data_collection import DataCollection from glue.core.edit_subset_mode import EditSubsetMode +__all__ = ['Session'] + class Session(object): diff --git a/glue/core/visual.py b/glue/core/visual.py index 3dab1edb3..bf3a5fd6c 100644 --- a/glue/core/visual.py +++ b/glue/core/visual.py @@ -3,7 +3,7 @@ from matplotlib.colors import ColorConverter from glue.config import settings -from glue.external.echo import callback_property +from glue.external.echo import callback_property, HasCallbackProperties from glue.external import six # Define acceptable line styles @@ -12,7 +12,7 @@ __all__ = ['VisualAttributes'] -class VisualAttributes(object): +class VisualAttributes(HasCallbackProperties): ''' This class is used to define visual attributes for any kind of objects @@ -30,6 +30,8 @@ class VisualAttributes(object): def __init__(self, parent=None, washout=False, color=None, alpha=None): + super(VisualAttributes, self).__init__() + # We have to set the defaults here, otherwise the settings are fixed # once the class is defined. color = color or settings.DATA_COLOR diff --git a/glue/external/echo/core.py b/glue/external/echo/core.py index 4e6fed6c3..4fcd57e58 100644 --- a/glue/external/echo/core.py +++ b/glue/external/echo/core.py @@ -526,7 +526,7 @@ def ignore_callback(instance, *props): Temporarily ignore any callbacks from one or more callback properties This is a context manager. Within the context block, no callbacks will be - issued. In contrast with :func:`~echo.delay_callback`, no callbakcs will be + issued. In contrast with `delay_callback`, no callbacks will be called on exiting the context manager Parameters diff --git a/glue/utils/misc.py b/glue/utils/misc.py index b94c2f7b6..01042aa10 100644 --- a/glue/utils/misc.py +++ b/glue/utils/misc.py @@ -62,7 +62,7 @@ def result(*a, **k): def lookup_class(ref): """ - Look up an object via its module string (e.g., 'glue.core.Data') + Look up an object via its module string (e.g., 'glue.core.data.Data') Parameters ---------- diff --git a/glue/viewers/common/layer_artist.py b/glue/viewers/common/layer_artist.py new file mode 100644 index 000000000..57118a1d6 --- /dev/null +++ b/glue/viewers/common/layer_artist.py @@ -0,0 +1,36 @@ +from __future__ import absolute_import, division, print_function + +from glue.external.echo import keep_in_sync, CallbackProperty +from glue.core.layer_artist import LayerArtistBase + +__all__ = ['LayerArtistWithState'] + + +class LayerArtistWithState(LayerArtistBase): + + zorder = CallbackProperty() + visible = CallbackProperty() + + _layer_state_cls = None + + def __init__(self, viewer_state, layer_state=None, layer=None): + + super(LayerArtistWithState, self).__init__(layer) + + self._viewer_state = viewer_state + + self.layer = layer or layer_state.layer + self.state = layer_state or self._layer_state_cls(viewer_state=viewer_state, + layer=self.layer) + + if self.state not in self._viewer_state.layers: + self._viewer_state.layers.append(self.state) + + self.zorder = self.state.zorder + self.visible = self.state.visible + + self._sync_zorder = keep_in_sync(self, 'zorder', self.state, 'zorder') + self._sync_visible = keep_in_sync(self, 'visible', self.state, 'visible') + + def __gluestate__(self, context): + return dict(state=context.id(self.state)) diff --git a/glue/viewers/common/qt/mouse_mode.py b/glue/viewers/common/qt/mouse_mode.py index 8623eed10..a8dc9bb68 100644 --- a/glue/viewers/common/qt/mouse_mode.py +++ b/glue/viewers/common/qt/mouse_mode.py @@ -45,7 +45,7 @@ def __init__(self, viewer, release_callback=None, key_callback=None): - self._axes = viewer.axes + self._axes = getattr(viewer, 'axes', None) self._canvas = viewer.central_widget.canvas self._press_callback = press_callback self._move_callback = move_callback diff --git a/glue/viewers/common/state.py b/glue/viewers/common/state.py new file mode 100644 index 000000000..7cc2134f4 --- /dev/null +++ b/glue/viewers/common/state.py @@ -0,0 +1,34 @@ +from __future__ import absolute_import, division, print_function + +from glue.external.echo import CallbackProperty, ListCallbackProperty + +from glue.core.state_objects import State + +__all__ = ['ViewerState', 'LayerState'] + + +class ViewerState(State): + """ + A base class for all viewer states. + """ + + layers = ListCallbackProperty(docstring='A collection of all layers in the viewer') + + @property + def layers_data(self): + return [layer_state.layer for layer_state in self.layers] + + +class LayerState(State): + """ + A base class for all layer states. + """ + + layer = CallbackProperty(docstring='The :class:`~glue.core.data.Data` or ' + ':class:`~glue.core.subset.Subset` ' + 'represented by the layer') + zorder = CallbackProperty(0, docstring='A value used to indicate which ' + 'layers are shown in front of which ' + '(larger zorder values are on top of ' + 'other layers)') + visible = CallbackProperty(True, docstring='Whether the layer is currently visible') diff --git a/glue/viewers/custom/qt/custom_viewer.py b/glue/viewers/custom/qt/custom_viewer.py index c2e51a74c..b3ac42cfd 100644 --- a/glue/viewers/custom/qt/custom_viewer.py +++ b/glue/viewers/custom/qt/custom_viewer.py @@ -892,6 +892,10 @@ def __init__(self, session, parent=None): self._update_artists = [] self.settings_changed() + @property + def axes(self): + return self.client.axes + def options_widget(self): return self.option_widget diff --git a/glue/viewers/matplotlib/layer_artist.py b/glue/viewers/matplotlib/layer_artist.py index 38ba7b820..5114fab38 100644 --- a/glue/viewers/matplotlib/layer_artist.py +++ b/glue/viewers/matplotlib/layer_artist.py @@ -5,6 +5,8 @@ from glue.viewers.matplotlib.state import DeferredDrawCallbackProperty from glue.core.message import ComputationStartedMessage, ComputationEndedMessage +__all__ = ['MatplotlibLayerArtist'] + try: import qtpy # noqa except Exception: