diff --git a/CHANGES.md b/CHANGES.md index c6a8c1a3e..aba890052 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -31,6 +31,12 @@ v0.9.0 (unreleased) * Refactored code related to toolbars in order to make it easier to define toolbars and toolbar modes that aren't Matplotlib-specific. [#1085] +* Added a new table viewer. [#1084] + +* Fix saving/loading of categorical components. [#1084] + +* Make it possible for tools to define a status bar message. [#1084] + v0.8.3 (unreleased) ------------------- diff --git a/doc/customizing_guide/toolbar.rst b/doc/customizing_guide/toolbar.rst index 7b322635e..6350ef497 100644 --- a/doc/customizing_guide/toolbar.rst +++ b/doc/customizing_guide/toolbar.rst @@ -48,8 +48,8 @@ The class-level variables set at the start of the class are as follows: tool that has the same ``tool_id`` as an existing tool already implemented in glue, you will get an error. -* ``action_text``: a string describing the tool. This is shown in the status bar - at the bottom of the viewer whenever the button is active. +* ``action_text``: a string describing the tool. This is not currently used, + but would be the text that would appear if the tool was accessible by a menu. * ``tool_tip``: this should be a string that will be shown when the user hovers above the button in the toolbar. This can include instructions on how to use @@ -78,7 +78,7 @@ Checkable tools ^^^^^^^^^^^^^^^ The basic structure for a checkable tool is similar to the above, but with an -additional ``deactivate`` method: +additional ``deactivate`` method, and a ``status_tip`` attribute: .. code:: python @@ -92,6 +92,7 @@ additional ``deactivate`` method: tool_id = 'custom_tool' action_text = 'Does cool stuff' tool_tip = 'Does cool stuff' + status_tip = 'Instructions on what to do now' shortcut = 'D' def __init__(self, viewer): @@ -109,7 +110,9 @@ additional ``deactivate`` method: When the tool icon is pressed, the ``activate`` method is called, and when the button is unchecked (either by clicking on it again, or if the user clicks on another tool icon), the ``deactivate`` method is called. As before, when the -viewer is closed, the ``close`` method is called. +viewer is closed, the ``close`` method is called. The ``status_tip`` is a +message shown in the status bar of the viewer when the tool is active. This can +be used to provide instructions to the user as to what they should do next. Drop-down menus ^^^^^^^^^^^^^^^ diff --git a/glue/core/data.py b/glue/core/data.py index 6ffa98ec7..602a69322 100644 --- a/glue/core/data.py +++ b/glue/core/data.py @@ -2,6 +2,7 @@ from collections import OrderedDict +import uuid import numpy as np import pandas as pd @@ -100,6 +101,11 @@ def __init__(self, label="", **kwargs): self._key_joins = {} + # To avoid circular references when saving objects with references to + # the data, we make sure that all Data objects have a UUID that can + # uniquely identify them. + self.uuid = str(uuid.uuid4()) + @property def subsets(self): """ diff --git a/glue/core/state.py b/glue/core/state.py index f8f1f01bb..f7c640260 100644 --- a/glue/core/state.py +++ b/glue/core/state.py @@ -69,8 +69,10 @@ def load(rec, context) from glue.external import six from glue import core -from glue.core.data import (Data, Component, ComponentID, DerivedComponent, - CoordinateComponent) +from glue.core.data import Data +from glue.core.component_id import ComponentID +from glue.core.component import (Component, CategoricalComponent, + DerivedComponent, CoordinateComponent) from glue.core.subset import (OPSYM, SYMOP, CompositeSubsetState, SubsetState, Subset, RoiSubsetState, InequalitySubsetState, RangeSubsetState) @@ -713,6 +715,7 @@ def save_cid_tuple(cids): return tuple(context.id(cid) for cid in cids) result['_key_joins'] = [[context.id(k), save_cid_tuple(v0), save_cid_tuple(v1)] for k, (v0, v1) in data._key_joins.items()] + result['uuid'] = data.uuid return result @@ -724,6 +727,8 @@ def load_cid_tuple(cids): return tuple(context.object(cid) for cid in cids) result._key_joins = dict((context.object(k), (load_cid_tuple(v0), load_cid_tuple(v1))) for k, v0, v1 in rec['_key_joins']) + result.uuid = rec['uuid'] + @saver(ComponentID) def _save_component_id(cid, context): @@ -755,6 +760,29 @@ def _load_component(rec, context): return Component(data=context.object(rec['data']), units=rec['units']) +@saver(CategoricalComponent) +def _save_categorical_component(component, context): + + if not context.include_data and hasattr(component, '_load_log'): + log = component._load_log + return dict(log=context.id(log), + log_item=log.id(component)) + + return dict(categorical_data=context.do(component.labels), + categories=context.do(component.categories), + jitter_method=context.do(component._jitter_method), + units=component.units) + + +@loader(CategoricalComponent) +def _load_categorical_component(rec, context): + if 'log' in rec: + return context.object(rec['log']).component(rec['log_item']) + + return CategoricalComponent(categorical_data=context.object(rec['categorical_data']), + categories=context.object(rec['categories']), + jitter=context.object(rec['jitter_method']), + units=rec['units']) @saver(DerivedComponent) def _save_derived_component(component, context): diff --git a/glue/core/subset.py b/glue/core/subset.py index 32fddc180..801565547 100644 --- a/glue/core/subset.py +++ b/glue/core/subset.py @@ -879,29 +879,41 @@ def __setgluestate__(cls, rec, context): class ElementSubsetState(SubsetState): - def __init__(self, indices=None): + def __init__(self, indices=None, data=None): super(ElementSubsetState, self).__init__() self._indices = indices + if data is None: + self._data_uuid = None + else: + self._data_uuid = data.uuid @memoize def to_mask(self, data, view=None): - # XXX this is inefficient for views - result = np.zeros(data.shape, dtype=bool) - if self._indices is not None: - result.flat[self._indices] = True - if view is not None: - result = result[view] - return result + if data.uuid == self._data_uuid or self._data_uuid is None: + # XXX this is inefficient for views + result = np.zeros(data.shape, dtype=bool) + if self._indices is not None: + result.flat[self._indices] = True + if view is not None: + result = result[view] + return result + else: + raise IncompatibleAttribute() def copy(self): - return ElementSubsetState(self._indices) + state = ElementSubsetState(indices=self._indices) + state._data_uuid = self._data_uuid + return state def __gluestate__(self, context): - return dict(indices=context.do(self._indices)) + return dict(indices=context.do(self._indices), + data_uuid=self._data_uuid) @classmethod def __setgluestate__(cls, rec, context): - return cls(indices=context.object(rec['indices'])) + state = cls(indices=context.object(rec['indices'])) + state._data_uuid = rec['data_uuid'] + return state class InequalitySubsetState(SubsetState): diff --git a/glue/core/tests/test_state.py b/glue/core/tests/test_state.py index bd3d17d99..a1831f83e 100644 --- a/glue/core/tests/test_state.py +++ b/glue/core/tests/test_state.py @@ -8,6 +8,7 @@ from glue.external import six from glue import core +from glue.core.component import CategoricalComponent from glue.tests.helpers import requires_astropy, make_file from ..data_factories import load_data @@ -233,6 +234,15 @@ def test_matplotlib_cmap(): assert clone(cm.gist_heat) is cm.gist_heat +def test_categorical_component(): + c = CategoricalComponent(['a','b','c','a','b'], categories=['a','b','c']) + c2 = clone(c) + assert isinstance(c2, CategoricalComponent) + np.testing.assert_array_equal(c.data, [0, 1, 2, 0, 1]) + np.testing.assert_array_equal(c.labels, ['a','b','c','a','b']) + np.testing.assert_array_equal(c.categories, ['a','b','c']) + + class DummyClass(object): pass diff --git a/glue/core/tests/test_subset.py b/glue/core/tests/test_subset.py index 7fc0d2217..a0aa3b308 100644 --- a/glue/core/tests/test_subset.py +++ b/glue/core/tests/test_subset.py @@ -306,6 +306,7 @@ class TestSubsetIo(object): def setup_method(self, method): self.data = MagicMock(spec=Data) self.data.shape = (4, 4) + self.data.uuid = 'abcde' self.subset = Subset(self.data) inds = np.array([1, 2, 3]) self.subset.subset_state = ElementSubsetState(indices=inds) diff --git a/glue/icons/glue_row_select.png b/glue/icons/glue_row_select.png new file mode 100644 index 000000000..e3bf06eb9 Binary files /dev/null and b/glue/icons/glue_row_select.png differ diff --git a/glue/plugins/tools/spectrum_tool/qt/spectrum_tool.py b/glue/plugins/tools/spectrum_tool/qt/spectrum_tool.py index cc0636eba..c2f350a13 100644 --- a/glue/plugins/tools/spectrum_tool/qt/spectrum_tool.py +++ b/glue/plugins/tools/spectrum_tool/qt/spectrum_tool.py @@ -834,8 +834,8 @@ def _setup_toolbar(self): tb = MatplotlibViewerToolbar(self.widget) # disable ProfileViewer mouse processing during mouse modes - tb.mode_activated.connect(self.profile.disconnect) - tb.mode_deactivated.connect(self.profile.connect) + tb.tool_activated.connect(self.profile.disconnect) + tb.tool_deactivated.connect(self.profile.connect) self._menu_toggle_action = QtWidgets.QAction("Options", tb) self._menu_toggle_action.setCheckable(True) diff --git a/glue/utils/__init__.py b/glue/utils/__init__.py index 1acc330d3..15623ad20 100644 --- a/glue/utils/__init__.py +++ b/glue/utils/__init__.py @@ -11,4 +11,5 @@ from .array import * from .matplotlib import * from .misc import * -from .geometry import * \ No newline at end of file +from .geometry import * +from .colors import * diff --git a/glue/utils/colors.py b/glue/utils/colors.py new file mode 100644 index 000000000..b871f00ea --- /dev/null +++ b/glue/utils/colors.py @@ -0,0 +1,29 @@ +from __future__ import absolute_import, division, print_function + +from matplotlib.colors import ColorConverter + +__all__ = ['alpha_blend_colors'] + +COLOR_CONVERTER = ColorConverter() + + +def alpha_blend_colors(colors, additional_alpha=1.0): + """ + Given a sequence of colors, return the alpha blended color. + + This assumes the last color is the one in front. + """ + + srcr, srcg, srcb, srca = COLOR_CONVERTER.to_rgba(colors[0]) + srca *= additional_alpha + + for color in colors[1:]: + dstr, dstg, dstb, dsta = COLOR_CONVERTER.to_rgba(color) + dsta *= additional_alpha + outa = srca + dsta * (1 - srca) + outr = (srcr * srca + dstr * dsta * (1 - srca)) / outa + outg = (srcg * srca + dstg * dsta * (1 - srca)) / outa + outb = (srcb * srca + dstb * dsta * (1 - srca)) / outa + srca, srcr, srcg, srcb = outa, outr, outg, outb + + return srcr, srcg, srcb, srca diff --git a/glue/utils/qt/colors.py b/glue/utils/qt/colors.py index 56cb48a45..49942a207 100644 --- a/glue/utils/qt/colors.py +++ b/glue/utils/qt/colors.py @@ -17,7 +17,7 @@ 'QColormapCombo'] -def mpl_to_qt4_color(color, alpha=1.0): +def mpl_to_qt4_color(color, alpha=None): """ Convert a matplotlib color stirng into a Qt QColor object @@ -37,9 +37,10 @@ def mpl_to_qt4_color(color, alpha=1.0): return QtGui.QColor(0, 0, 0, 0) cc = ColorConverter() - r, g, b = cc.to_rgb(color) - alpha = max(0, min(255, int(256 * alpha))) - return QtGui.QColor(r * 255, g * 255, b * 255, alpha) + r, g, b, a = cc.to_rgba(color) + if alpha is not None: + a = alpha + return QtGui.QColor(r * 255, g * 255, b * 255, a * 255) def qt4_to_mpl_color(qcolor): diff --git a/glue/viewers/common/qt/data_viewer.py b/glue/viewers/common/qt/data_viewer.py index 2154c06f0..4d4e4cc53 100644 --- a/glue/viewers/common/qt/data_viewer.py +++ b/glue/viewers/common/qt/data_viewer.py @@ -299,3 +299,7 @@ def window_title(self): def update_window_title(self): self.setWindowTitle(self.window_title) + + def set_status(self, message): + sb = self.statusBar() + sb.showMessage(message) diff --git a/glue/viewers/common/qt/mouse_mode.py b/glue/viewers/common/qt/mouse_mode.py index 210aa44b0..aa5ed7c17 100644 --- a/glue/viewers/common/qt/mouse_mode.py +++ b/glue/viewers/common/qt/mouse_mode.py @@ -361,6 +361,8 @@ class PolyMode(ClickRoiMode): tool_tip = ('Lasso a region of interest\n' ' ENTER accepts the path\n' ' ESCAPE clears the path') + status_tip = ('CLICK and DRAG to define lasso, CLICK multiple times to ' + 'define polygon, ENTER to finalize, ESCAPE to cancel') shortcut = 'G' def __init__(self, viewer, **kwargs): @@ -368,6 +370,8 @@ def __init__(self, viewer, **kwargs): self._roi_tool = qt_roi.QtPolygonalROI(self._axes) +# TODO: determine why LassoMode exists since it's the same as PolyMode? + @viewer_tool class LassoMode(RoiMode): """ @@ -375,9 +379,13 @@ class LassoMode(RoiMode): """ icon = 'glue_lasso' - tool_id = 'Lasso' + tool_id = 'select:lasso' action_text = 'Polygonal ROI' - tool_tip = 'Lasso a region of interest' + tool_tip = ('Lasso a region of interest\n' + ' ENTER accepts the path\n' + ' ESCAPE clears the path') + status_tip = ('CLICK and DRAG to define lasso, CLICK multiple times to ' + 'define polygon, ENTER to finalize, ESCAPE to cancel') shortcut = 'L' def __init__(self, viewer, **kwargs): @@ -395,7 +403,7 @@ class HRangeMode(RoiMode): icon = 'glue_xrange_select' tool_id = 'select:xrange' - action_text = 'select:xrange' + action_text = 'X range' tool_tip = 'Select a range of x values' shortcut = 'X' @@ -414,7 +422,7 @@ class VRangeMode(RoiMode): icon = 'glue_yrange_select' tool_id = 'select:yrange' - action_text = 'select:yrange' + action_text = 'Y range' tool_tip = 'Select a range of y values' shortcut = 'Y' diff --git a/glue/viewers/common/qt/mpl_toolbar.py b/glue/viewers/common/qt/mpl_toolbar.py index 3a5fd8fdf..98aebb552 100644 --- a/glue/viewers/common/qt/mpl_toolbar.py +++ b/glue/viewers/common/qt/mpl_toolbar.py @@ -153,16 +153,16 @@ def setup_default_modes(self): self._connections = [] - def activate_mode(self, mode): + def activate_tool(self, mode): if isinstance(mode, MouseMode): self._connections.append(self.canvas.mpl_connect('button_press_event', mode.press)) self._connections.append(self.canvas.mpl_connect('motion_notify_event', mode.move)) self._connections.append(self.canvas.mpl_connect('button_release_event', mode.release)) self._connections.append(self.canvas.mpl_connect('key_press_event', mode.key)) - super(MatplotlibViewerToolbar, self).activate_mode(mode) + super(MatplotlibViewerToolbar, self).activate_tool(mode) - def deactivate_mode(self, mode): + def deactivate_tool(self, mode): for connection in self._connections: self.canvas.mpl_disconnect(connection) self._connections = [] - super(MatplotlibViewerToolbar, self).deactivate_mode(mode) + super(MatplotlibViewerToolbar, self).deactivate_tool(mode) diff --git a/glue/viewers/common/qt/tests/test_toolbar.py b/glue/viewers/common/qt/tests/test_toolbar.py index c9f0cb54d..36f49585b 100644 --- a/glue/viewers/common/qt/tests/test_toolbar.py +++ b/glue/viewers/common/qt/tests/test_toolbar.py @@ -49,8 +49,8 @@ def __init__(self, session, parent=None): def initialize_toolbar(self): super(ExampleViewer, self).initialize_toolbar() - self.mode = MouseModeTest(self, release_callback=self.callback) - self.toolbar.add_tool(self.mode) + self.tool = MouseModeTest(self, release_callback=self.callback) + self.toolbar.add_tool(self.tool) def callback(self, mode): self._called_back = True @@ -71,38 +71,38 @@ def teardown_method(self, method): self.viewer.close() def assert_valid_mode_state(self, target_mode): - for mode in self.viewer.toolbar.buttons: - if mode == target_mode and self.viewer.toolbar.buttons[mode].isCheckable(): - assert self.viewer.toolbar.buttons[mode].isChecked() + for tool_id in self.viewer.toolbar.actions: + if tool_id == target_mode and self.viewer.toolbar.actions[tool_id].isCheckable(): + assert self.viewer.toolbar.actions[tool_id].isChecked() self.viewer.toolbar._active == target_mode else: - assert not self.viewer.toolbar.buttons[mode].isChecked() + assert not self.viewer.toolbar.actions[tool_id].isChecked() def test_callback(self): - self.viewer.toolbar.buttons['mpl:home'].trigger() - self.viewer.mode.release(None) + self.viewer.toolbar.actions['mpl:home'].trigger() + self.viewer.tool.release(None) assert self.viewer._called_back def test_change_mode(self): - self.viewer.toolbar.buttons['mpl:pan'].toggle() - assert self.viewer.toolbar.mode.tool_id == 'mpl:pan' + self.viewer.toolbar.actions['mpl:pan'].toggle() + assert self.viewer.toolbar.active_tool.tool_id == 'mpl:pan' assert self.viewer.toolbar._mpl_nav.mode == 'pan/zoom' - self.viewer.toolbar.buttons['mpl:pan'].toggle() - assert self.viewer.toolbar.mode is None + self.viewer.toolbar.actions['mpl:pan'].toggle() + assert self.viewer.toolbar.active_tool is None assert self.viewer.toolbar._mpl_nav.mode == '' - self.viewer.toolbar.buttons['mpl:zoom'].trigger() - assert self.viewer.toolbar.mode.tool_id == 'mpl:zoom' + self.viewer.toolbar.actions['mpl:zoom'].trigger() + assert self.viewer.toolbar.active_tool.tool_id == 'mpl:zoom' assert self.viewer.toolbar._mpl_nav.mode == 'zoom rect' - self.viewer.toolbar.buttons['mpl:back'].trigger() - assert self.viewer.toolbar.mode is None + self.viewer.toolbar.actions['mpl:back'].trigger() + assert self.viewer.toolbar.active_tool is None assert self.viewer.toolbar._mpl_nav.mode == '' - self.viewer.toolbar.buttons['test'].trigger() - assert self.viewer.toolbar.mode.tool_id == 'test' + self.viewer.toolbar.actions['test'].trigger() + assert self.viewer.toolbar.active_tool.tool_id == 'test' assert self.viewer.toolbar._mpl_nav.mode == '' @@ -139,4 +139,3 @@ def test_duplicate_shortcut(): viewer = ExampleViewer2(session) assert len(w) == 1 assert str(w[0].message) == "Tools 'TEST1' and 'TEST2' have the same shortcut ('A'). Ignoring shortcut for 'TEST2'" - diff --git a/glue/viewers/common/qt/tool.py b/glue/viewers/common/qt/tool.py index 2752d4177..1567c4ebb 100644 --- a/glue/viewers/common/qt/tool.py +++ b/glue/viewers/common/qt/tool.py @@ -15,8 +15,9 @@ class Tool(object): * icon : QIcon object * tool_id : a short name for the tool - * action_text : the action title + * action_text : the action title (used if the tool is made available in a menu) * tool_tip : a tip that is shown when the user hovers over the icon + * status_tip : a tip that is shown in the status bar when the tool is active * shortcut : keyboard shortcut to toggle the tool """ @@ -26,6 +27,7 @@ class Tool(object): tool_id = None action_text = None tool_tip = None + status_tip = None shortcut = None def __init__(self, viewer=None): diff --git a/glue/viewers/common/qt/toolbar.py b/glue/viewers/common/qt/toolbar.py index be8acded0..08a8bc8d6 100644 --- a/glue/viewers/common/qt/toolbar.py +++ b/glue/viewers/common/qt/toolbar.py @@ -8,9 +8,7 @@ from glue.external import six from glue.core.callback_property import add_callback -from glue.utils import nonpartial -from glue.viewers.common.qt.tool import CheckableTool, Tool -from glue.config import viewer_tool +from glue.viewers.common.qt.tool import CheckableTool from glue.icons.qt import get_icon __all__ = ['BasicToolbar'] @@ -18,8 +16,8 @@ class BasicToolbar(QtWidgets.QToolBar): - mode_activated = QtCore.Signal() - mode_deactivated = QtCore.Signal() + tool_activated = QtCore.Signal() + tool_deactivated = QtCore.Signal() def __init__(self, parent): """ @@ -28,125 +26,125 @@ def __init__(self, parent): super(BasicToolbar, self).__init__(parent=parent) - self.buttons = {} + self.actions = {} self.tools = {} self.setIconSize(QtCore.QSize(25, 25)) self.layout().setSpacing(1) self.setFocusPolicy(Qt.StrongFocus) - self._mode = None - + self._active_tool = None self.setup_default_modes() def setup_default_modes(self): pass @property - def mode(self): - return self._mode + def active_tool(self): + return self._active_tool - @mode.setter - def mode(self, new_mode): + @active_tool.setter + def active_tool(self, new_tool): - old_mode = self._mode + old_tool = self._active_tool - # If the mode is as before, we don't need to do anything - if old_mode is new_mode: + # If the tool is as before, we don't need to do anything + if old_tool is new_tool: return - # Otheriwse, if the mode changes, then we need to disable the previous - # mode... - if old_mode is not None: - self.deactivate_mode(old_mode) - if isinstance(old_mode, CheckableTool): - button = self.buttons[old_mode.tool_id] + # Otheriwse, if the tool changes, then we need to disable the previous + # tool... + if old_tool is not None: + self.deactivate_tool(old_tool) + if isinstance(old_tool, CheckableTool): + button = self.actions[old_tool.tool_id] if button.isChecked(): button.blockSignals(True) button.setChecked(False) button.blockSignals(False) # ... and enable the new one - if new_mode is not None: - self.activate_mode(new_mode) - if isinstance(new_mode, CheckableTool): - button = self.buttons[new_mode.tool_id] + if new_tool is not None: + self.activate_tool(new_tool) + if isinstance(new_tool, CheckableTool): + button = self.actions[new_tool.tool_id] if button.isChecked(): button.blockSignals(True) button.setChecked(True) button.blockSignals(False) - if isinstance(new_mode, CheckableTool): - self._mode = new_mode - self.mode_activated.emit() + if isinstance(new_tool, CheckableTool): + self._active_tool = new_tool + self.parent().set_status(new_tool.status_tip) + self.tool_activated.emit() else: - self._mode = None - self.mode_deactivated.emit() + self._active_tool = None + self.parent().set_status('') + self.tool_deactivated.emit() - def activate_mode(self, mode): - mode.activate() + def activate_tool(self, tool): + tool.activate() - def deactivate_mode(self, mode): - if isinstance(mode, CheckableTool): - mode.deactivate() + def deactivate_tool(self, tool): + if isinstance(tool, CheckableTool): + tool.deactivate() - def add_tool(self, mode): + def add_tool(self, tool): parent = QtWidgets.QToolBar.parent(self) - if isinstance(mode.icon, six.string_types): - if os.path.exists(mode.icon): - icon = QtGui.QIcon(mode.icon) + if isinstance(tool.icon, six.string_types): + if os.path.exists(tool.icon): + icon = QtGui.QIcon(tool.icon) else: - icon = get_icon(mode.icon) + icon = get_icon(tool.icon) else: - icon = mode.icon + icon = tool.icon - action = QtWidgets.QAction(icon, mode.action_text, parent) + action = QtWidgets.QAction(icon, tool.action_text, parent) def toggle(checked): if checked: - self.mode = mode + self.active_tool = tool else: - self.mode = None + self.active_tool = None def trigger(checked): - self.mode = mode + self.active_tool = tool parent.addAction(action) - if isinstance(mode, CheckableTool): + if isinstance(tool, CheckableTool): action.toggled.connect(toggle) else: action.triggered.connect(trigger) shortcut = None - if mode.shortcut is not None: + if tool.shortcut is not None: # Make sure that the keyboard shortcut is unique for m in self.tools.values(): - if mode.shortcut == m.shortcut: + if tool.shortcut == m.shortcut: warnings.warn("Tools '{0}' and '{1}' have the same shortcut " "('{2}'). Ignoring shortcut for " - "'{1}'".format(m.tool_id, mode.tool_id, mode.shortcut)) + "'{1}'".format(m.tool_id, tool.tool_id, tool.shortcut)) break else: - shortcut = mode.shortcut - action.setShortcut(mode.shortcut) + shortcut = tool.shortcut + action.setShortcut(tool.shortcut) action.setShortcutContext(Qt.WidgetShortcut) - if shortcut is None: - action.setToolTip(mode.tool_tip) + action.setToolTip(tool.tool_tip) else: - action.setToolTip(mode.tool_tip + " [shortcut: {0}]".format(shortcut)) + action.setToolTip(tool.tool_tip + " [shortcut: {0}]".format(shortcut)) - action.setCheckable(isinstance(mode, CheckableTool)) - self.buttons[mode.tool_id] = action + action.setCheckable(isinstance(tool, CheckableTool)) + self.actions[tool.tool_id] = action - menu_actions = mode.menu_actions() + menu_actions = tool.menu_actions() if len(menu_actions) > 0: menu = QtWidgets.QMenu(self) - for ma in mode.menu_actions(): + for ma in tool.menu_actions(): ma.setParent(self) menu.addAction(ma) action.setMenu(menu) @@ -154,12 +152,12 @@ def trigger(checked): self.addAction(action) - # Bind tool visibility to mode.enabled + # Bind tool visibility to tool.enabled def toggle(state): action.setVisible(state) action.setEnabled(state) - add_callback(mode, 'enabled', toggle) + add_callback(tool, 'enabled', toggle) - self.tools[mode.tool_id] = mode + self.tools[tool.tool_id] = tool return action diff --git a/glue/viewers/image/qt/viewer_widget.py b/glue/viewers/image/qt/viewer_widget.py index fc8dc46a9..6cb3dc338 100644 --- a/glue/viewers/image/qt/viewer_widget.py +++ b/glue/viewers/image/qt/viewer_widget.py @@ -378,9 +378,9 @@ def initialize_toolbar(self): # connect viewport update buttons to client commands to # allow resampling cl = self.client - self.toolbar.buttons['mpl:home'].triggered.connect(nonpartial(cl.check_update)) - self.toolbar.buttons['mpl:forward'].triggered.connect(nonpartial(cl.check_update)) - self.toolbar.buttons['mpl:back'].triggered.connect(nonpartial(cl.check_update)) + self.toolbar.actions['mpl:home'].triggered.connect(nonpartial(cl.check_update)) + self.toolbar.actions['mpl:forward'].triggered.connect(nonpartial(cl.check_update)) + self.toolbar.actions['mpl:back'].triggered.connect(nonpartial(cl.check_update)) def paintEvent(self, event): super(ImageWidget, self).paintEvent(event) @@ -530,4 +530,3 @@ def initialize_toolbar(self): self.toolbar.add_tool(mode) self.addToolBar(self.toolbar) - diff --git a/glue/viewers/table/qt/tests/test_viewer_widget.py b/glue/viewers/table/qt/tests/test_viewer_widget.py index 502ebd5e8..4b4bc24c1 100644 --- a/glue/viewers/table/qt/tests/test_viewer_widget.py +++ b/glue/viewers/table/qt/tests/test_viewer_widget.py @@ -1,12 +1,20 @@ from __future__ import absolute_import, division, print_function import pytest +import numpy as np +from qtpy import QtCore, QtGui +from glue.utils.qt import get_qapp from qtpy.QtCore import Qt from glue.core import Data, DataCollection, Session +from glue.utils.qt import qt4_to_mpl_color +from glue.app.qt import GlueApplication from ..viewer_widget import DataTableModel, TableWidget +from glue.core.edit_subset_mode import (EditSubsetMode, AndNotMode, OrMode, + ReplaceMode) + class TestDataTableModel(): @@ -51,12 +59,209 @@ def test_data_2d(self): assert float(result) == self.data[c].ravel()[j] -def test_table_widget(): +def check_values_and_color(model, data, colors): + + for i in range(len(colors)): + + for j, colname in enumerate('abc'): + + # Get index of cell + idx = model.index(i, j) + + # Check values + value = model.data(idx, Qt.DisplayRole) + assert value == str(data[colname][i]) + + # Check colors + brush = model.data(idx, Qt.BackgroundRole) + if colors[i] is None: + assert brush is None + else: + assert qt4_to_mpl_color(brush.color()) == colors[i] + + +def test_table_widget(tmpdir): + + # Start off by creating a glue application instance with a table viewer and + # some data pre-loaded. + + app = get_qapp() + + d = Data(a=[1, 2, 3, 4, 5], + b=[3.2, 1.2, 4.5, 3.3, 2.2], + c=['e', 'b', 'c', 'a', 'f']) - d = Data(a=[1,2,3,4,5], b=[1.2, 3.3, 4.5, 3.2, 2.2], c=['a','b','c','d','e']) dc = DataCollection([d]) - session = Session(dc, hub=dc.hub) - widget = TableWidget(session) + gapp = GlueApplication(dc) + + widget = gapp.new_data_viewer(TableWidget) widget.add_data(d) - widget.show() + + subset_mode = EditSubsetMode() + + # Create two subsets + + sg1 = dc.new_subset_group('D <= 3', d.id['a'] <= 3) + sg1.style.color = '#aa0000' + sg2 = dc.new_subset_group('1 < D < 4', (d.id['a'] > 1) & (d.id['a'] < 4)) + sg2.style.color = '#0000cc' + + model = widget.ui.table.model() + + # We now check what the data and colors of the table are, and try various + # sorting methods to make sure that things are still correct. + + data = { + 'a': [1, 2, 3, 4, 5], + 'b': [3.2, 1.2, 4.5, 3.3, 2.2], + 'c': ['e', 'b', 'c', 'a', 'f'] + } + + colors = ['#aa0000', '#380088', '#380088', None, None] + + check_values_and_color(model, data, colors) + + model.sort(1, Qt.AscendingOrder) + + data = { + 'a': [2, 5, 1, 4, 3], + 'b': [1.2, 2.2, 3.2, 3.3, 4.5], + 'c': ['b', 'f', 'e', 'a', 'c'] + } + + colors = ['#380088', None, '#aa0000', None, '#380088'] + + check_values_and_color(model, data, colors) + + model.sort(2, Qt.AscendingOrder) + + data = { + 'a': [4, 2, 3, 1, 5], + 'b': [3.3, 1.2, 4.5, 3.2, 2.2], + 'c': ['a', 'b', 'c', 'e', 'f'] + } + + colors = [None, '#380088', '#380088', '#aa0000', None] + + check_values_and_color(model, data, colors) + + model.sort(0, Qt.DescendingOrder) + + data = { + 'a': [5, 4, 3, 2, 1], + 'b': [2.2, 3.3, 4.5, 1.2, 3.2], + 'c': ['f', 'a', 'c', 'b', 'e'] + } + + colors = [None, None, '#380088', '#380088', '#aa0000'] + + check_values_and_color(model, data, colors) + + model.sort(0, Qt.AscendingOrder) + + # We now modify the subsets using the table. + + selection = widget.ui.table.selectionModel() + + widget.toolbar.actions['table:rowselect'].toggle() + + def press_key(key): + event = QtGui.QKeyEvent(QtCore.QEvent.KeyPress, key, Qt.NoModifier) + app.postEvent(widget.ui.table, event) + app.processEvents() + + app.processEvents() + + # We now use key presses to navigate down to the third row + + press_key(Qt.Key_Tab) + press_key(Qt.Key_Down) + press_key(Qt.Key_Down) + + indices = selection.selectedRows() + + # We make sure that the third row is selected + + assert len(indices) == 1 + assert indices[0].row() == 2 + + # At this point, the subsets haven't changed yet + + np.testing.assert_equal(d.subsets[0].to_mask(), [1, 1, 1, 0, 0]) + np.testing.assert_equal(d.subsets[1].to_mask(), [0, 1, 1, 0, 0]) + + # We specify that we are editing the second subset, and use a 'not' logical + # operation to remove the currently selected line from the second subset. + + d.edit_subset = [d.subsets[1]] + + subset_mode.mode = AndNotMode + + press_key(Qt.Key_Enter) + + np.testing.assert_equal(d.subsets[0].to_mask(), [1, 1, 1, 0, 0]) + np.testing.assert_equal(d.subsets[1].to_mask(), [0, 1, 0, 0, 0]) + + # At this point, the selection should be cleared + + indices = selection.selectedRows() + assert len(indices) == 0 + + # We move to the fourth row and now do an 'or' selection with the first + # subset. + + press_key(Qt.Key_Down) + + subset_mode.mode = OrMode + + d.edit_subset = [d.subsets[0]] + + press_key(Qt.Key_Enter) + + np.testing.assert_equal(d.subsets[0].to_mask(), [1, 1, 1, 1, 0]) + np.testing.assert_equal(d.subsets[1].to_mask(), [0, 1, 0, 0, 0]) + + # Finally we move to the fifth row and deselect all subsets so that + # pressing enter now creates a new subset. + + press_key(Qt.Key_Down) + + subset_mode.mode = ReplaceMode + + d.edit_subset = None + + press_key(Qt.Key_Enter) + + np.testing.assert_equal(d.subsets[0].to_mask(), [1, 1, 1, 1, 0]) + np.testing.assert_equal(d.subsets[1].to_mask(), [0, 1, 0, 0, 0]) + np.testing.assert_equal(d.subsets[2].to_mask(), [0, 0, 0, 0, 1]) + + # Make the color for the new subset deterministic + dc.subset_groups[2].style.color = '#bababa' + + # Now finally check saving and restoring session + + session_file = tmpdir.join('table.glu').strpath + + gapp.save_session(session_file) + + gapp2 = GlueApplication.restore_session(session_file) + gapp2.show() + + d = gapp2.data_collection[0] + + widget2 = gapp2.viewers[0][0] + + model2 = widget2.ui.table.model() + + data = { + 'a': [1, 2, 3, 4, 5], + 'b': [3.2, 1.2, 4.5, 3.3, 2.2], + 'c': ['e', 'b', 'c', 'a', 'f'] + } + + # Need to take into account new selections above + colors = ['#aa0000', '#380088', '#aa0000', "#aa0000", "#bababa"] + + check_values_and_color(model2, data, colors) diff --git a/glue/viewers/table/qt/viewer_widget.py b/glue/viewers/table/qt/viewer_widget.py index 339485ae6..4a530223a 100644 --- a/glue/viewers/table/qt/viewer_widget.py +++ b/glue/viewers/table/qt/viewer_widget.py @@ -4,25 +4,44 @@ import numpy as np from qtpy.QtCore import Qt -from qtpy import QtGui, QtCore, QtWidgets +from qtpy import QtCore, QtGui, QtWidgets from qtpy import PYQT5 -from glue.core.subset import ElementSubsetState -from glue.core.edit_subset_mode import EditSubsetMode +from matplotlib.colors import ColorConverter + +from glue.config import viewer_tool +from glue.core.layer_artist import LayerArtistBase from glue.core import message as msg +from glue.core import Data +from glue.utils import nonpartial from glue.utils.qt import load_ui from glue.viewers.common.qt.data_viewer import DataViewer from glue.viewers.common.qt.toolbar import BasicToolbar +from glue.viewers.common.qt.tool import CheckableTool +from glue.core.subset import ElementSubsetState +from glue.core.edit_subset_mode import EditSubsetMode +from glue.core.state import lookup_class_with_patches +from glue.utils.colors import alpha_blend_colors +from glue.utils.qt import mpl_to_qt4_color +from glue.core.exceptions import IncompatibleAttribute + +COLOR_CONVERTER = ColorConverter() class DataTableModel(QtCore.QAbstractTableModel): - def __init__(self, data): + def __init__(self, table_viewer): super(DataTableModel, self).__init__() - if data.ndim != 1: - raise ValueError("Can only use Table widget for one-dimensional data") - self._data = data + if table_viewer.data.ndim != 1: + raise ValueError("Can only use Table widget for 1D data") + self._table_viewer = table_viewer + self._data = table_viewer.data self.show_hidden = False - self.order = np.arange(data.shape[0]) + self.order = np.arange(self._data.shape[0]) + + def data_changed(self): + top_left = self.index(0, 0) + bottom_right = self.index(self.columnCount(), self.rowCount()) + self.dataChanged.emit(top_left, bottom_right) @property def columns(self): @@ -53,6 +72,7 @@ def data(self, index, role): return None if role == Qt.DisplayRole: + c = self.columns[index.column()] idx = self.order[index.row()] comp = self._data.get_component(c) @@ -65,6 +85,29 @@ def data(self, index, role): else: return str(comp[idx]) + elif role == Qt.BackgroundRole: + + idx = self.order[index.row()] + + # Find all subsets that this index is part of + colors = [] + for layer_artist in self._table_viewer.layers[::-1]: + if layer_artist.visible: + subset = layer_artist.layer + try: + if subset.to_mask(view=slice(idx, idx + 1))[0]: + colors.append(subset.style.color) + except IncompatibleAttribute as exc: + layer_artist.disable_invalid_attributes(*exc.args) + else: + layer_artist.enabled = True + + # Blend the colors using alpha blending + if len(colors) > 0: + color = alpha_blend_colors(colors, additional_alpha=0.5) + color = mpl_to_qt4_color(color) + return QtGui.QBrush(color) + def sort(self, column, ascending): c = self.columns[column] comp = self._data.get_component(c) @@ -77,12 +120,46 @@ def sort(self, column, ascending): self.layoutChanged.emit() +class TableLayerArtist(LayerArtistBase): + def __init__(self, layer, table_viewer): + self._table_viewer = table_viewer + super(TableLayerArtist, self).__init__(layer) + def redraw(self): + self._table_viewer.model.data_changed() + def update(self): + pass + def clear(self): + pass + + +@viewer_tool +class RowSelectTool(CheckableTool): + + tool_id = 'table:rowselect' + icon = 'glue_row_select' + action_text = 'Select row(s)' + tool_tip = ('Select rows by clicking on rows and pressing enter ' + 'once the selection is ready to be applied') + status_tip = 'CLICK to select, press ENTER to finalize selection' + + def __init__(self, viewer): + super(RowSelectTool, self).__init__(viewer) + self.deactivate() + + def activate(self): + self.viewer.ui.table.setSelectionMode(QtWidgets.QAbstractItemView.ExtendedSelection) + + def deactivate(self): + self.viewer.ui.table.setSelectionMode(QtWidgets.QAbstractItemView.NoSelection) + self.viewer.ui.table.clearSelection() + + class TableWidget(DataViewer): LABEL = "Table Viewer" _toolbar_cls = BasicToolbar - tools = [] + tools = ['table:rowselect'] def __init__(self, session, parent=None, widget=None): @@ -107,103 +184,94 @@ def __init__(self, session, parent=None, widget=None): else: hdr.setResizeMode(hdr.Interactive) - self.ui.table.clicked.connect(self._clicked) - self.model = None - self.subset = None - self.selected_rows = [] + + def keyPressEvent(self, event): + if event.key() in [Qt.Key_Enter, Qt.Key_Return]: + self.finalize_selection() + super(TableWidget, self).keyPressEvent(event) + + def finalize_selection(self): + model = self.ui.table.selectionModel() + selected_rows = [self.model.order[x.row()] for x in model.selectedRows()] + subset_state = ElementSubsetState(indices=selected_rows, data=self.data) + mode = EditSubsetMode() + mode.update(self._data, subset_state, focus_data=self.data) + self.ui.table.clearSelection() def register_to_hub(self, hub): super(TableWidget, self).register_to_hub(hub) - dfilter = lambda x: True - dcfilter = lambda x: True - subfilter = lambda x: True + def dfilter(x): + return x.sender.data is self.data hub.subscribe(self, msg.SubsetCreateMessage, - handler=self._add_subset, + handler=nonpartial(self._refresh), filter=dfilter) hub.subscribe(self, msg.SubsetUpdateMessage, - handler=self._update_subset, - filter=subfilter) + handler=nonpartial(self._refresh), + filter=dfilter) hub.subscribe(self, msg.SubsetDeleteMessage, - handler=self._remove_subset) + handler=nonpartial(self._refresh), + filter=dfilter) hub.subscribe(self, msg.DataUpdateMessage, - handler=self.update_window_title) - - def _clicked(self, mode): - self._broadcast_selection() - - def _broadcast_selection(self): - - if self.data is not None: - - model = self.ui.table.selectionModel() - self.selected_rows = [self.model.order[x.row()] for x in model.selectedRows()] - subset_state = ElementSubsetState(self.selected_rows) - - mode = EditSubsetMode() - mode.update(self.data, subset_state, focus_data=self.data) - - def _add_subset(self, message): - self.subset = message.subset - self.selected_rows = self.subset.to_index_list() - self._update_selection() - - def _update_selection(self): - - self.ui.table.clearSelection() - selection_mode = self.ui.table.selectionMode() - self.ui.table.setSelectionMode(QtWidgets.QAbstractItemView.MultiSelection) - - # The following is more efficient than just calling selectRow - model = self.ui.table.selectionModel() - for index in self.selected_rows: - index = self.model.order[index] - model_index = self.model.createIndex(index, 0) - model.select(model_index, - QtCore.QItemSelectionModel.Select | QtCore.QItemSelectionModel.Rows) + handler=nonpartial(self._refresh), + filter=dfilter) - self.ui.table.setSelectionMode(selection_mode) + def _refresh(self): + self._sync_layers() + self.model.data_changed() - def _update_subset(self, message): - self._add_subset(message) + def _sync_layers(self): - def _remove_subset(self, message): - self.ui.table.clearSelection() + # For now we don't show the data in the list because it always has to + # be shown - def _update_data(self, message): - self.set_data(message.data) + for layer_artist in self.layers: + if layer_artist.layer not in self.data.subsets: + self._layer_artist_container.remove(layer_artist) - def unregister(self, hub): - pass + for subset in self.data.subsets: + if subset not in self._layer_artist_container: + self._layer_artist_container.append(TableLayerArtist(subset, self)) def add_data(self, data): self.data = data - self.set_data(data) + self.setUpdatesEnabled(False) + self.model = DataTableModel(self) + self.ui.table.setModel(self.model) + self.setUpdatesEnabled(True) + self._sync_layers() return True def add_subset(self, subset): return True - def set_data(self, data): - self.setUpdatesEnabled(False) - self.model = DataTableModel(data) - self.model.layoutChanged.connect(self._update_selection) - self.ui.table.setModel(self.model) - self.setUpdatesEnabled(True) + def unregister(self, hub): + pass def closeEvent(self, event): """ - On close, QT seems to scan through the entire model + On close, Qt seems to scan through the entire model if the data set is big. To sidestep that, we swap out with a tiny data set before closing """ - from glue.core import Data d = Data(x=[0]) self.ui.table.setModel(DataTableModel(d)) event.accept() + + def restore_layers(self, rec, context): + # For now this is a bit of a hack, we assume that all subsets saved + # for this viewer are from dataset, so we just get Data object + # then just sync the layers. + for layer in rec: + c = lookup_class_with_patches(layer.pop('_type')) + props = dict((k, context.object(v)) for k, v in layer.items()) + layer = props['layer'] + self.add_data(layer.data) + break + self._sync_layers() diff --git a/setup.py b/setup.py index 7657a04dc..bd33d584a 100755 --- a/setup.py +++ b/setup.py @@ -84,7 +84,7 @@ def run(self): image_viewer = glue.viewers.image:setup scatter_viewer = glue.viewers.scatter:setup histogram_viewer = glue.viewers.histogram:setup -# table_viewer = glue.viewers.table:setup +table_viewer = glue.viewers.table:setup [console_scripts] glue-config = glue.config_gen:main