Skip to content

Commit

Permalink
Merge pull request #2392 from jfoster17/add-filter-to-table-viewer
Browse files Browse the repository at this point in the history
Add filter/search to table viewer
  • Loading branch information
astrofrog authored May 15, 2023
2 parents b8ff0b1 + e7f55d7 commit a170e3e
Show file tree
Hide file tree
Showing 5 changed files with 243 additions and 8 deletions.
2 changes: 1 addition & 1 deletion glue/viewers/table/compat.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ def update_table_viewer_state(rec, context):
rec['state'] = {}
rec['state']['values'] = {}

rec.pop('properties')
_ = rec.pop('properties')

layer_states = []

Expand Down
64 changes: 57 additions & 7 deletions glue/viewers/table/qt/data_viewer.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
import os
from functools import lru_cache
import re

import numpy as np

from qtpy.QtCore import Qt
from qtpy import QtCore, QtGui, QtWidgets
from matplotlib.colors import ColorConverter

from echo.qt import autoconnect_callbacks_to_qt
from glue.utils.qt import get_qapp
from glue.config import viewer_tool
from glue.core import BaseData, Data
Expand All @@ -20,6 +22,7 @@
from glue.utils.qt import mpl_to_qt_color, messagebox_on_error
from glue.core.exceptions import IncompatibleAttribute
from glue.viewers.table.compat import update_table_viewer_state
from glue.viewers.table.state import TableViewerState

try:
import dask.array as da
Expand All @@ -40,6 +43,8 @@ def __init__(self, table_viewer):
raise ValueError("Can only use Table widget for 1D data")
self._table_viewer = table_viewer
self._data = table_viewer.data
self._state = table_viewer.state
self.filter_mask = None
self.show_coords = False
self.order = np.arange(self._data.shape[0])
self._update_visible()
Expand Down Expand Up @@ -149,21 +154,43 @@ def sort(self, column, ascending):
self.data_by_row_and_column.cache_clear()
self.layoutChanged.emit()

def get_filter_mask(self):
if (self._state.filter is None) or (self._state.filter_att is None):
self.filter_mask = np.ones(self.order.shape, dtype=bool)
return
comp = self._data.get_component(self._state.filter_att)

if self._state.regex:
p = re.compile(self._state.filter)
self.filter_mask = np.array([bool(p.search(x)) for x in comp.data])
else:
self.filter_mask = np.array([self._state.filter in x for x in comp.data])
self.data_changed() # This might be overkill

def _update_visible(self):
"""
Given which layers are visible or not, convert order to order_visible.
Given which layers are visible or not, convert order to order_visible
after applying the current filter_mask
"""

self.data_by_row_and_column.cache_clear()

# First, if the data layer is visible, show all rows
for layer_artist in self._table_viewer.layers:
if layer_artist.visible and isinstance(layer_artist.layer, BaseData):
self.order_visible = self.order
return
if self.filter_mask is None:
self.order_visible = self.order
return
else:
mask = self.filter_mask[self.order]
self.order_visible = self.order[mask]
return

# If not then we need to show only the rows with visible subsets
visible = np.zeros(self.order.shape, dtype=bool)
if self.filter_mask is None:
visible = np.zeros(self.order.shape, dtype=bool)
else:
visible = self.filter_mask[self.order]
for layer_artist in self._table_viewer.layers:
if layer_artist.visible:
mask = layer_artist.layer.to_mask()[self.order]
Expand Down Expand Up @@ -214,7 +241,7 @@ def __init__(self, viewer):
def activate(self):
self.viewer.ui.table.setSelectionMode(QtWidgets.QAbstractItemView.ExtendedSelection)

def deactivate(self):
def deactivate(self, block=False):
# Don't do anything if the viewer has already been closed
if self.viewer is None:
return
Expand All @@ -223,7 +250,9 @@ def deactivate(self):


class TableViewWithSelectionSignal(QtWidgets.QTableView):

"""
This is the TableViewer.ui.table object
"""
selection_changed = QtCore.Signal()

def selectionChanged(self, *args, **kwargs):
Expand All @@ -238,6 +267,7 @@ class TableViewer(DataViewer):
_toolbar_cls = BasicToolbar
_data_artist_cls = TableLayerArtist
_subset_artist_cls = TableLayerArtist
_state_cls = TableViewerState

inherit_tools = False
tools = ['table:rowselect']
Expand All @@ -260,8 +290,13 @@ def __init__(self, session, state=None, parent=None, widget=None):
self.data = None
self.model = None

self.ui.table.selection_changed.connect(self.selection_changed)
self._connections = autoconnect_callbacks_to_qt(self.state, self.ui)

self.state.add_callback('regex', self._on_filter_changed)
self.state.add_callback('filter', self._on_filter_changed)
self.state.add_callback('filter_att', self._on_filter_changed)

self.ui.table.selection_changed.connect(self.selection_changed)
self.state.add_callback('layers', self._on_layers_changed)
self._on_layers_changed()

Expand Down Expand Up @@ -289,6 +324,18 @@ def finalize_selection(self, clear=True):
self.ui.table.clearSelection()
self.ui.table.blockSignals(False)

def _on_filter_changed(self, *args):
# If we change the filter we deactivate the toolbar to keep
# any subset defined before we change what is displayed
if self.toolbar.active_tool is self.toolbar.tools['table:rowselect']:
old_tool = self.toolbar.active_tool
old_tool.deactivate(block=True)
button = self.toolbar.actions[old_tool.tool_id]
if button.isChecked():
button.setChecked(False)
if self.model:
self.model.get_filter_mask()

def _on_layers_changed(self, *args):
for layer_state in self.state.layers:
if isinstance(layer_state.layer, BaseData):
Expand All @@ -303,8 +350,11 @@ def _on_layers_changed(self, *args):
return

self.data = layer_state.layer

self.setUpdatesEnabled(False)
self.model = DataTableModel(self)
self.model.get_filter_mask()

self.ui.table.setModel(self.model)
self.setUpdatesEnabled(True)

Expand Down
44 changes: 44 additions & 0 deletions glue/viewers/table/qt/data_viewer.ui
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,50 @@
</property>
<layout class="QGridLayout" name="gridLayout">
<item row="0" column="0">
<layout class="QHBoxLayout" name="horizontalLayout">
<item>
<spacer name="horizontalSpacer">
<property name="orientation">
<enum>Qt::Horizontal</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>60</width>
<height>20</height>
</size>
</property>
</spacer>
</item>
<item>
<widget class="QLabel" name="filter_label">
<property name="text">
<string>Search:</string>
</property>
</widget>
</item>
<item>
<widget class="QLineEdit" name="text_filter"/>
</item>
<item>
<widget class="QLabel" name="filter_label_2">
<property name="text">
<string>in</string>
</property>
</widget>
</item>
<item>
<widget class="QComboBox" name="combosel_filter_att"/>
</item>
<item>
<widget class="QCheckBox" name="bool_regex">
<property name="text">
<string>Use regex</string>
</property>
</widget>
</item>
</layout>
</item>
<item row="1" column="0">
<layout class="QVBoxLayout" name="verticalLayout">
<item>
<widget class="TableViewWithSelectionSignal" name="table">
Expand Down
97 changes: 97 additions & 0 deletions glue/viewers/table/qt/tests/test_data_viewer.py
Original file line number Diff line number Diff line change
Expand Up @@ -611,3 +611,100 @@ def press_key(key):
colors[1] = color

check_values_and_color(post_model, data, colors)


def test_table_widget_filter(tmpdir):

# Test table interactions with filtering

app = get_qapp() # noqa

d = Data(a=[1, 2, 3, 4, 5],
b=['cat', 'dog', 'cat', 'dog', 'fish'],
c=['fluffy', 'rover', 'fluffball', 'spot', 'moby'], label='test')

dc = DataCollection([d])

gapp = GlueApplication(dc)

widget = gapp.new_data_viewer(TableViewer)
widget.add_data(d)

widget.state.filter_att = d.components[2]

widget.state.filter = 'cat'
model = widget.ui.table.model()

np.testing.assert_equal(model.filter_mask, [True, False, True, False, False])

widget.state.filter_att = d.components[3]
widget.state.filter = 'ff'
np.testing.assert_equal(model.filter_mask, [True, False, True, False, False])

# Test matching regular expressions
widget.state.filter_att = d.components[3]
widget.state.regex = True

widget.state.filter = '^[a-z]{1}o'
np.testing.assert_equal(model.filter_mask, [False, True, False, False, True])

sg1 = dc.new_subset_group('test subset 1', d.id['a'] > 2)
sg1.style.color = '#aa0000'
data = {'a': [2, 5],
'b': ['dog', 'fish'],
'c': ['rover', 'moby']}

colors = [None, '#aa0000']

check_values_and_color(model, data, colors)

widget.state.regex = False
widget.state.filter = '^[a-z]{1}o'
np.testing.assert_equal(model.filter_mask, [False, False, False, False, False])

# Check that changing the filter disables the rowselect tool
widget.toolbar.actions['table:rowselect'].toggle()
process_events()
widget.state.filter_att = d.components[2]
assert widget.toolbar.active_tool is None


def test_table_widget_session_filter(tmpdir):

# Test that filtering works with save/restore

app = get_qapp() # noqa

d = Data(a=[1, 2, 3, 4, 5],
b=['cat', 'dog', 'cat', 'dog', 'fish'],
c=['fluffy', 'rover', 'fluffball', 'spot', 'moby'], label='test')

dc = DataCollection([d])

gapp = GlueApplication(dc)

widget = gapp.new_data_viewer(TableViewer)
widget.add_data(d)

widget.state.filter_att = d.components[2]
widget.state.filter = 'cat'
model = widget.ui.table.model()

np.testing.assert_equal(model.filter_mask, [True, False, True, False, False])

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()

assert widget2.state.filter_att == d.components[2]
assert widget2.state.filter == 'cat'
np.testing.assert_equal(model2.filter_mask, [True, False, True, False, False])
44 changes: 44 additions & 0 deletions glue/viewers/table/state.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
from echo import CallbackProperty, SelectionCallbackProperty
from glue.core.data_combo_helper import ComponentIDComboHelper

from glue.viewers.common.state import ViewerState


__all__ = ['TableViewerState']


class TableViewerState(ViewerState):
"""
A state class that includes all the attributes for a table viewer.
"""

filter_att = SelectionCallbackProperty(docstring='The component/column to filter/search on', default_index=0)
filter = CallbackProperty(docstring='The text string to filter/search on')
regex = CallbackProperty(docstring='Whether to apply regex to filter/search', default=False)

def __init__(self, **kwargs):

super(TableViewerState, self).__init__()
self.filter_att_helper = ComponentIDComboHelper(self, 'filter_att', categorical=True, numeric=False)
self.add_callback('layers', self._layers_changed)

self.update_from_dict(kwargs)

def _layers_changed(self, *args):

layers_data = self.layers_data

layers_data_cache = getattr(self, '_layers_data_cache', [])

if layers_data == layers_data_cache:
return

self.filter_att_helper.set_multiple_data(self.layers_data)

self._layers_data_cache = layers_data

def _update_priority(self, name):
if name == 'layers':
return 2
else:
return 1

0 comments on commit a170e3e

Please sign in to comment.