Skip to content

Commit f993763

Browse files
authored
Merge pull request #6405 from ales-erjavec/owtable-subset-input
[ENH] Data Table: Subset input
2 parents 6d7c500 + e379aa8 commit f993763

File tree

3 files changed

+215
-51
lines changed

3 files changed

+215
-51
lines changed

Orange/widgets/data/owtable.py

Lines changed: 189 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,23 @@
11
import concurrent.futures
22
from dataclasses import dataclass
3-
from typing import Optional, Union, Sequence, List, TypedDict, Tuple
3+
from typing import (
4+
Optional, Union, Sequence, List, TypedDict, Tuple, Any, Container
5+
)
46

57
from scipy.sparse import issparse
68

7-
from AnyQt.QtWidgets import QTableView, QHeaderView, QApplication, QStyle
8-
from AnyQt.QtGui import QColor, QClipboard
9-
from AnyQt.QtCore import Qt, QSize, QMetaObject, QItemSelectionModel
9+
from AnyQt.QtWidgets import (
10+
QTableView, QHeaderView, QApplication, QStyle, QStyleOptionHeader,
11+
QStyleOptionViewItem
12+
)
13+
from AnyQt.QtGui import QColor, QClipboard, QPainter
14+
from AnyQt.QtCore import (
15+
Qt, QSize, QMetaObject, QItemSelectionModel, QModelIndex, QRect
16+
)
1017
from AnyQt.QtCore import Slot
1118

19+
from orangewidget.gui import OrangeUserRole
20+
1221
import Orange.data
1322
from Orange.data.table import Table
1423
from Orange.data.sql.table import SqlTable
@@ -25,18 +34,130 @@
2534
from Orange.widgets.utils.itemmodels import TableModel
2635
from Orange.widgets.utils.state_summary import format_summary_details
2736
from Orange.widgets.utils import disconnected
37+
from Orange.widgets.utils.headerview import HeaderView
2838
from Orange.widgets.data.utils.tableview import RichTableView
2939
from Orange.widgets.data.utils import tablesummary as tsummary
3040

3141

42+
SubsetRole = next(OrangeUserRole)
43+
44+
45+
class HeaderViewWithSubsetIndicator(HeaderView):
46+
_IndicatorChar = "\N{BULLET}"
47+
48+
def paintSection(
49+
self, painter: QPainter, rect: QRect, logicalIndex: int
50+
) -> None:
51+
opt = QStyleOptionHeader()
52+
self.initStyleOption(opt)
53+
self.initStyleOptionForIndex(opt, logicalIndex)
54+
model = self.model()
55+
if model is None:
56+
return # pragma: no cover
57+
opt.rect = rect
58+
issubset = model.headerData(logicalIndex, Qt.Vertical, SubsetRole)
59+
style = self.style()
60+
# draw background
61+
style.drawControl(QStyle.CE_HeaderSection, opt, painter, self)
62+
indicator_rect = QRect(rect)
63+
text_rect = QRect(rect)
64+
indicator_width = opt.fontMetrics.horizontalAdvance(
65+
self._IndicatorChar + " "
66+
)
67+
indicator_rect.setWidth(indicator_width)
68+
text_rect.setLeft(indicator_width)
69+
if issubset:
70+
optindicator = QStyleOptionHeader(opt)
71+
optindicator.rect = indicator_rect
72+
optindicator.textAlignment = Qt.AlignCenter
73+
optindicator.text = self._IndicatorChar
74+
# draw subset indicator
75+
style.drawControl(QStyle.CE_HeaderLabel, optindicator, painter, self)
76+
opt.rect = text_rect
77+
# draw section label
78+
style.drawControl(QStyle.CE_HeaderLabel, opt, painter, self)
79+
80+
def sectionSizeFromContents(self, logicalIndex: int) -> QSize:
81+
opt = QStyleOptionHeader()
82+
self.initStyleOption(opt)
83+
super().initStyleOptionForIndex(opt, logicalIndex)
84+
opt.text = self._IndicatorChar + " " + opt.text
85+
return self.style().sizeFromContents(QStyle.CT_HeaderSection, opt, QSize(), self)
86+
87+
3288
class DataTableView(gui.HScrollStepMixin, RichTableView):
33-
pass
89+
def __init__(self, *args, **kwargs):
90+
super().__init__(*args, **kwargs)
91+
vheader = HeaderViewWithSubsetIndicator(
92+
Qt.Vertical, self, highlightSections=True
93+
)
94+
vheader.setSectionsClickable(True)
95+
self.setVerticalHeader(vheader)
96+
3497

98+
class _TableDataDelegate(TableDataDelegate):
99+
DefaultRoles = TableDataDelegate.DefaultRoles + (SubsetRole,)
35100

36-
class TableBarItemDelegate(gui.TableBarItem, TableDataDelegate):
101+
102+
class SubsetTableDataDelegate(_TableDataDelegate):
103+
def __init__(self, *args, **kwargs):
104+
super().__init__(*args, **kwargs)
105+
self.subset_opacity = 0.5
106+
107+
def paint(
108+
self, painter: QPainter, option: QStyleOptionViewItem,
109+
index: QModelIndex
110+
) -> None:
111+
issubset = self.cachedData(index, SubsetRole)
112+
opacity = painter.opacity()
113+
if not issubset:
114+
painter.setOpacity(self.subset_opacity)
115+
super().paint(painter, option, index)
116+
if not issubset:
117+
painter.setOpacity(opacity)
118+
119+
120+
class TableBarItemDelegate(SubsetTableDataDelegate, gui.TableBarItem,
121+
_TableDataDelegate):
37122
pass
38123

39124

125+
class _TableModel(RichTableModel):
126+
SubsetRole = SubsetRole
127+
128+
def __init__(self, *args, subsets=None, **kwargs):
129+
super().__init__(*args, **kwargs)
130+
self._subset = subsets or set()
131+
132+
def setSubsetRowIds(self, subsetids: Container[int]):
133+
self._subset = subsetids
134+
if self.rowCount():
135+
self.headerDataChanged.emit(Qt.Vertical, 0, self.rowCount() - 1)
136+
self.dataChanged.emit(
137+
self.index(0, 0),
138+
self.index(self.rowCount() - 1, self.columnCount() - 1),
139+
[SubsetRole],
140+
)
141+
142+
def _is_subset(self, row):
143+
row = self.mapToSourceRows(row)
144+
try:
145+
id_ = self.source.ids[row]
146+
except (IndexError, AttributeError): # pragma: no cover
147+
return False
148+
return int(id_) in self._subset
149+
150+
def data(self, index: QModelIndex, role=Qt.DisplayRole) -> Any:
151+
if role == _TableModel.SubsetRole:
152+
return self._is_subset(index.row())
153+
return super().data(index, role)
154+
155+
def headerData(self, section, orientation, role):
156+
if orientation == Qt.Vertical and role == _TableModel.SubsetRole:
157+
return self._is_subset(section)
158+
return super().headerData(section, orientation, role)
159+
160+
40161
@dataclass
41162
class InputData:
42163
table: Table
@@ -60,7 +181,8 @@ class OWTable(OWWidget):
60181
keywords = "data table, view"
61182

62183
class Inputs:
63-
data = Input("Data", Table)
184+
data = Input("Data", Table, default=True)
185+
data_subset = Input("Data Subset", Table)
64186

65187
class Outputs:
66188
selected_data = Output("Selected Data", Table, default=True)
@@ -94,8 +216,11 @@ class Warning(OWWidget.Warning):
94216
def __init__(self):
95217
super().__init__()
96218
self.input: Optional[InputData] = None
219+
self._subset_ids: Optional[set] = None
97220
self.__pending_selection: Optional[_Selection] = self.stored_selection
98221
self.__pending_sort: Optional[_Sorting] = self.stored_sort
222+
self.__have_new_data = False
223+
self.__have_new_subset = False
99224
self.dist_color = QColor(220, 220, 220, 255)
100225

101226
info_box = gui.vBox(self.controlArea, "Info")
@@ -127,11 +252,8 @@ def __init__(self):
127252
attribute=Qt.WA_LayoutUsesWidgetRect)
128253
gui.auto_send(self.buttonsArea, self, "auto_commit")
129254

130-
view = DataTableView(
131-
sortingEnabled=True
132-
)
133-
view.setSortingEnabled(True)
134-
view.setItemDelegate(TableDataDelegate(view))
255+
view = DataTableView(sortingEnabled=True)
256+
view.setItemDelegate(SubsetTableDataDelegate(view))
135257
view.selectionFinished.connect(self.update_selection)
136258

137259
if self.select_rows:
@@ -163,39 +285,61 @@ def set_dataset(self, data: Optional[Table]):
163285
self.view.setModel(None)
164286
self.view.horizontalHeader().setSortIndicator(-1, Qt.AscendingOrder)
165287
if data is not None:
288+
summary = tsummary.table_summary(data)
166289
self.input = InputData(
167290
table=data,
168-
summary=tsummary.table_summary(data),
169-
model=RichTableModel(data)
291+
summary=summary,
292+
model=_TableModel(data)
170293
)
171-
self._setup_table_view()
294+
if isinstance(summary.len, concurrent.futures.Future):
295+
def update(_):
296+
QMetaObject.invokeMethod(
297+
self, "_update_info", Qt.QueuedConnection)
298+
summary.len.add_done_callback(update)
172299
else:
173300
self.input = None
301+
self.__have_new_data = True
302+
303+
@Inputs.data_subset
304+
def set_subset_dataset(self, subset: Optional[Table]):
305+
"""Set the data subset"""
306+
if subset is not None and not isinstance(subset, SqlTable):
307+
ids = set(subset.ids)
308+
else:
309+
ids = None
310+
self._subset_ids = ids
311+
self.__have_new_subset = True
174312

175313
def handleNewSignals(self):
176314
super().handleNewSignals()
177315
self.Warning.non_sortable_input.clear()
178316
self.Warning.missing_sort_columns.clear()
179317
data: Optional[Table] = self.input.table if self.input else None
180-
slot = self.input
181-
if slot is not None and isinstance(slot.summary.len, concurrent.futures.Future):
182-
def update(_):
183-
QMetaObject.invokeMethod(
184-
self, "_update_info", Qt.QueuedConnection)
185-
slot.summary.len.add_done_callback(update)
318+
model = self.input.model if self.input else None
186319

187-
self._update_input_summary()
320+
if self.__have_new_data:
321+
self._setup_table_view()
322+
self._update_input_summary()
323+
324+
if data is not None and self.__pending_sort is not None:
325+
self.__restore_sort()
326+
327+
if data is not None and self.__pending_selection is not None:
328+
selection = self.__pending_selection
329+
self.__pending_selection = None
330+
rows = selection["rows"]
331+
columns = selection["columns"]
332+
self.set_selection(rows, columns)
188333

189-
if data is not None and self.__pending_sort is not None:
190-
self.__restore_sort()
334+
if self.__have_new_subset and model is not None:
335+
model.setSubsetRowIds(self._subset_ids or set())
336+
self.__have_new_subset = False
191337

192-
if data is not None and self.__pending_selection is not None:
193-
selection = self.__pending_selection
194-
self.__pending_selection = None
195-
rows = selection["rows"]
196-
columns = selection["columns"]
197-
self.set_selection(rows, columns)
198-
self.commit.now()
338+
self._setup_view_delegate()
339+
340+
if self.__have_new_data:
341+
self.commit.now()
342+
self.__have_new_data = False
199343

200344
def _setup_table_view(self):
201345
"""Setup the view with current input data."""
@@ -204,23 +348,12 @@ def _setup_table_view(self):
204348
return
205349

206350
datamodel = self.input.model
351+
datamodel.setSubsetRowIds(self._subset_ids or set())
352+
207353
view = self.view
208354
data = self.input.table
209355
rowcount = data.approx_len()
210356

211-
if self.color_by_class and data.domain.has_discrete_class:
212-
color_schema = [
213-
QColor(*c) for c in data.domain.class_var.colors]
214-
else:
215-
color_schema = None
216-
if self.show_distributions:
217-
view.setItemDelegate(
218-
TableBarItemDelegate(
219-
view, color=self.dist_color, color_schema=color_schema)
220-
)
221-
else:
222-
view.setItemDelegate(TableDataDelegate(view))
223-
224357
view.setModel(datamodel)
225358

226359
vheader = view.verticalHeader()
@@ -248,6 +381,7 @@ def _setup_table_view(self):
248381
assert view.model().rowCount() <= maxrows
249382
assert vheader.sectionSize(0) > 1 or datamodel.rowCount() == 0
250383

384+
self._setup_view_delegate()
251385
# update the header (attribute names)
252386
self._update_variable_labels()
253387

@@ -285,9 +419,13 @@ def _update_variable_labels(self):
285419
model.setRichHeaderFlags(RichTableModel.Name)
286420

287421
def _on_distribution_color_changed(self):
422+
if self.input is None:
423+
return # pragma: no cover
424+
self._setup_view_delegate()
425+
426+
def _setup_view_delegate(self):
288427
if self.input is None:
289428
return
290-
widget = self.view
291429
model = self.input.model
292430
data = model.source
293431
class_var = data.domain.class_var
@@ -296,11 +434,13 @@ def _on_distribution_color_changed(self):
296434
else:
297435
color_schema = None
298436
if self.show_distributions:
299-
delegate = TableBarItemDelegate(widget, color=self.dist_color,
300-
color_schema=color_schema)
437+
delegate = TableBarItemDelegate(
438+
self.view, color=self.dist_color, color_schema=color_schema
439+
)
301440
else:
302-
delegate = TableDataDelegate(widget)
303-
widget.setItemDelegate(delegate)
441+
delegate = SubsetTableDataDelegate(self.view)
442+
delegate.subset_opacity = 0.5 if self._subset_ids is not None else 1.0
443+
self.view.setItemDelegate(delegate)
304444

305445
def _on_select_rows_changed(self):
306446
if self.input is None:

Orange/widgets/data/tests/test_owtable.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -217,6 +217,30 @@ def test_show_attribute_labels(self):
217217
w.controls.show_attribute_labels.toggle()
218218
self.assertFalse(w.show_attribute_labels)
219219

220+
def test_subset_input(self):
221+
w = self.widget
222+
self.send_signal(w.Inputs.data, self.data)
223+
with patch.object(w.signalManager, "send") as m:
224+
self.send_signal(w.Inputs.data_subset, self.data[[0, 1, 5]])
225+
m.assert_not_called()
226+
w.view.grab() # cover delegate painting methods
227+
228+
model = w.view.model()
229+
self.assertTrue(model.index(0, 0).data(model.SubsetRole))
230+
self.assertFalse(model.index(2, 0).data(model.SubsetRole))
231+
self.assertTrue(model.headerData(0, Qt.Vertical, model.SubsetRole))
232+
self.assertFalse(model.headerData(2, Qt.Vertical, model.SubsetRole))
233+
234+
with patch.object(w.signalManager, "send") as m:
235+
self.send_signal(w.Inputs.data_subset, None)
236+
m.assert_not_called()
237+
238+
w.view.grab()
239+
240+
model = w.view.model()
241+
self.assertFalse(model.index(0, 0).data(model.SubsetRole))
242+
self.assertFalse(model.headerData(0, Qt.Vertical, model.SubsetRole))
243+
220244

221245
class TestOWTableSQL(TestOWTable, dbt):
222246
def setUpDB(self):

Orange/widgets/utils/itemdelegates.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import math
2-
from typing import Optional, Tuple
2+
from typing import Optional, Tuple, ClassVar
33

44
from AnyQt.QtCore import QModelIndex, QSize, Qt
55
from AnyQt.QtWidgets import QStyle, QStyleOptionViewItem, QApplication
@@ -101,7 +101,7 @@ class TableDataDelegate(DataDelegate):
101101
:class:`Orange.widgets.utils.itemmodels.TableModel`
102102
"""
103103
#: Roles supplied by TableModel we want DataDelegate to use.
104-
DefaultRoles = (
104+
DefaultRoles: ClassVar[Tuple[int, ...]] = (
105105
Qt.DisplayRole, Qt.TextAlignmentRole, Qt.BackgroundRole,
106106
Qt.ForegroundRole
107107
)

0 commit comments

Comments
 (0)