Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[ENH] Data Table: Subset input #6405

Merged
merged 2 commits into from
Apr 21, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
238 changes: 189 additions & 49 deletions Orange/widgets/data/owtable.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,23 @@
import concurrent.futures
from dataclasses import dataclass
from typing import Optional, Union, Sequence, List, TypedDict, Tuple
from typing import (
Optional, Union, Sequence, List, TypedDict, Tuple, Any, Container
)

from scipy.sparse import issparse

from AnyQt.QtWidgets import QTableView, QHeaderView, QApplication, QStyle
from AnyQt.QtGui import QColor, QClipboard
from AnyQt.QtCore import Qt, QSize, QMetaObject, QItemSelectionModel
from AnyQt.QtWidgets import (
QTableView, QHeaderView, QApplication, QStyle, QStyleOptionHeader,
QStyleOptionViewItem
)
from AnyQt.QtGui import QColor, QClipboard, QPainter
from AnyQt.QtCore import (
Qt, QSize, QMetaObject, QItemSelectionModel, QModelIndex, QRect
)
from AnyQt.QtCore import Slot

from orangewidget.gui import OrangeUserRole

import Orange.data
from Orange.data.table import Table
from Orange.data.sql.table import SqlTable
Expand All @@ -25,18 +34,130 @@
from Orange.widgets.utils.itemmodels import TableModel
from Orange.widgets.utils.state_summary import format_summary_details
from Orange.widgets.utils import disconnected
from Orange.widgets.utils.headerview import HeaderView
from Orange.widgets.data.utils.tableview import RichTableView
from Orange.widgets.data.utils import tablesummary as tsummary


SubsetRole = next(OrangeUserRole)


class HeaderViewWithSubsetIndicator(HeaderView):
_IndicatorChar = "\N{BULLET}"

def paintSection(
self, painter: QPainter, rect: QRect, logicalIndex: int
) -> None:
opt = QStyleOptionHeader()
self.initStyleOption(opt)
self.initStyleOptionForIndex(opt, logicalIndex)
model = self.model()
if model is None:
return # pragma: no cover
opt.rect = rect
issubset = model.headerData(logicalIndex, Qt.Vertical, SubsetRole)
style = self.style()
# draw background
style.drawControl(QStyle.CE_HeaderSection, opt, painter, self)
indicator_rect = QRect(rect)
text_rect = QRect(rect)
indicator_width = opt.fontMetrics.horizontalAdvance(
self._IndicatorChar + " "
)
indicator_rect.setWidth(indicator_width)
text_rect.setLeft(indicator_width)
if issubset:
optindicator = QStyleOptionHeader(opt)
optindicator.rect = indicator_rect
optindicator.textAlignment = Qt.AlignCenter
optindicator.text = self._IndicatorChar
# draw subset indicator
style.drawControl(QStyle.CE_HeaderLabel, optindicator, painter, self)
opt.rect = text_rect
# draw section label
style.drawControl(QStyle.CE_HeaderLabel, opt, painter, self)

def sectionSizeFromContents(self, logicalIndex: int) -> QSize:
opt = QStyleOptionHeader()
self.initStyleOption(opt)
super().initStyleOptionForIndex(opt, logicalIndex)
opt.text = self._IndicatorChar + " " + opt.text
return self.style().sizeFromContents(QStyle.CT_HeaderSection, opt, QSize(), self)


class DataTableView(gui.HScrollStepMixin, RichTableView):
pass
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
vheader = HeaderViewWithSubsetIndicator(
Qt.Vertical, self, highlightSections=True
)
vheader.setSectionsClickable(True)
self.setVerticalHeader(vheader)


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

class TableBarItemDelegate(gui.TableBarItem, TableDataDelegate):

class SubsetTableDataDelegate(_TableDataDelegate):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.subset_opacity = 0.5

def paint(
self, painter: QPainter, option: QStyleOptionViewItem,
index: QModelIndex
) -> None:
issubset = self.cachedData(index, SubsetRole)
opacity = painter.opacity()
if not issubset:
painter.setOpacity(self.subset_opacity)
super().paint(painter, option, index)
if not issubset:
painter.setOpacity(opacity)


class TableBarItemDelegate(SubsetTableDataDelegate, gui.TableBarItem,
_TableDataDelegate):
pass


class _TableModel(RichTableModel):
SubsetRole = SubsetRole

def __init__(self, *args, subsets=None, **kwargs):
super().__init__(*args, **kwargs)
self._subset = subsets or set()

def setSubsetRowIds(self, subsetids: Container[int]):
self._subset = subsetids
if self.rowCount():
self.headerDataChanged.emit(Qt.Vertical, 0, self.rowCount() - 1)
self.dataChanged.emit(
self.index(0, 0),
self.index(self.rowCount() - 1, self.columnCount() - 1),
[SubsetRole],
)

def _is_subset(self, row):
row = self.mapToSourceRows(row)
try:
id_ = self.source.ids[row]
except (IndexError, AttributeError): # pragma: no cover
return False
return int(id_) in self._subset

def data(self, index: QModelIndex, role=Qt.DisplayRole) -> Any:
if role == _TableModel.SubsetRole:
return self._is_subset(index.row())
return super().data(index, role)

def headerData(self, section, orientation, role):
if orientation == Qt.Vertical and role == _TableModel.SubsetRole:
return self._is_subset(section)
return super().headerData(section, orientation, role)


@dataclass
class InputData:
table: Table
Expand All @@ -60,7 +181,8 @@ class OWTable(OWWidget):
keywords = "data table, view"

class Inputs:
data = Input("Data", Table)
data = Input("Data", Table, default=True)
data_subset = Input("Data Subset", Table)

class Outputs:
selected_data = Output("Selected Data", Table, default=True)
Expand Down Expand Up @@ -94,8 +216,11 @@ class Warning(OWWidget.Warning):
def __init__(self):
super().__init__()
self.input: Optional[InputData] = None
self._subset_ids: Optional[set] = None
self.__pending_selection: Optional[_Selection] = self.stored_selection
self.__pending_sort: Optional[_Sorting] = self.stored_sort
self.__have_new_data = False
self.__have_new_subset = False
self.dist_color = QColor(220, 220, 220, 255)

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

view = DataTableView(
sortingEnabled=True
)
view.setSortingEnabled(True)
view.setItemDelegate(TableDataDelegate(view))
view = DataTableView(sortingEnabled=True)
view.setItemDelegate(SubsetTableDataDelegate(view))
view.selectionFinished.connect(self.update_selection)

if self.select_rows:
Expand Down Expand Up @@ -163,39 +285,61 @@ def set_dataset(self, data: Optional[Table]):
self.view.setModel(None)
self.view.horizontalHeader().setSortIndicator(-1, Qt.AscendingOrder)
if data is not None:
summary = tsummary.table_summary(data)
self.input = InputData(
table=data,
summary=tsummary.table_summary(data),
model=RichTableModel(data)
summary=summary,
model=_TableModel(data)
)
self._setup_table_view()
if isinstance(summary.len, concurrent.futures.Future):
def update(_):
QMetaObject.invokeMethod(
self, "_update_info", Qt.QueuedConnection)
summary.len.add_done_callback(update)
else:
self.input = None
self.__have_new_data = True

@Inputs.data_subset
def set_subset_dataset(self, subset: Optional[Table]):
"""Set the data subset"""
if subset is not None and not isinstance(subset, SqlTable):
ids = set(subset.ids)
else:
ids = None
self._subset_ids = ids
self.__have_new_subset = True

def handleNewSignals(self):
super().handleNewSignals()
self.Warning.non_sortable_input.clear()
self.Warning.missing_sort_columns.clear()
data: Optional[Table] = self.input.table if self.input else None
slot = self.input
if slot is not None and isinstance(slot.summary.len, concurrent.futures.Future):
def update(_):
QMetaObject.invokeMethod(
self, "_update_info", Qt.QueuedConnection)
slot.summary.len.add_done_callback(update)
model = self.input.model if self.input else None

self._update_input_summary()
if self.__have_new_data:
self._setup_table_view()
self._update_input_summary()

if data is not None and self.__pending_sort is not None:
self.__restore_sort()

if data is not None and self.__pending_selection is not None:
selection = self.__pending_selection
self.__pending_selection = None
rows = selection["rows"]
columns = selection["columns"]
self.set_selection(rows, columns)

if data is not None and self.__pending_sort is not None:
self.__restore_sort()
if self.__have_new_subset and model is not None:
model.setSubsetRowIds(self._subset_ids or set())
self.__have_new_subset = False

if data is not None and self.__pending_selection is not None:
selection = self.__pending_selection
self.__pending_selection = None
rows = selection["rows"]
columns = selection["columns"]
self.set_selection(rows, columns)
self.commit.now()
self._setup_view_delegate()

if self.__have_new_data:
self.commit.now()
self.__have_new_data = False

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

datamodel = self.input.model
datamodel.setSubsetRowIds(self._subset_ids or set())

view = self.view
data = self.input.table
rowcount = data.approx_len()

if self.color_by_class and data.domain.has_discrete_class:
color_schema = [
QColor(*c) for c in data.domain.class_var.colors]
else:
color_schema = None
if self.show_distributions:
view.setItemDelegate(
TableBarItemDelegate(
view, color=self.dist_color, color_schema=color_schema)
)
else:
view.setItemDelegate(TableDataDelegate(view))

view.setModel(datamodel)

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

self._setup_view_delegate()
# update the header (attribute names)
self._update_variable_labels()

Expand Down Expand Up @@ -285,9 +419,13 @@ def _update_variable_labels(self):
model.setRichHeaderFlags(RichTableModel.Name)

def _on_distribution_color_changed(self):
if self.input is None:
return # pragma: no cover
self._setup_view_delegate()

def _setup_view_delegate(self):
if self.input is None:
return
widget = self.view
model = self.input.model
data = model.source
class_var = data.domain.class_var
Expand All @@ -296,11 +434,13 @@ def _on_distribution_color_changed(self):
else:
color_schema = None
if self.show_distributions:
delegate = TableBarItemDelegate(widget, color=self.dist_color,
color_schema=color_schema)
delegate = TableBarItemDelegate(
self.view, color=self.dist_color, color_schema=color_schema
)
else:
delegate = TableDataDelegate(widget)
widget.setItemDelegate(delegate)
delegate = SubsetTableDataDelegate(self.view)
delegate.subset_opacity = 0.5 if self._subset_ids is not None else 1.0
self.view.setItemDelegate(delegate)

def _on_select_rows_changed(self):
if self.input is None:
Expand Down
24 changes: 24 additions & 0 deletions Orange/widgets/data/tests/test_owtable.py
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,30 @@ def test_show_attribute_labels(self):
w.controls.show_attribute_labels.toggle()
self.assertFalse(w.show_attribute_labels)

def test_subset_input(self):
w = self.widget
self.send_signal(w.Inputs.data, self.data)
with patch.object(w.signalManager, "send") as m:
self.send_signal(w.Inputs.data_subset, self.data[[0, 1, 5]])
m.assert_not_called()
w.view.grab() # cover delegate painting methods

model = w.view.model()
self.assertTrue(model.index(0, 0).data(model.SubsetRole))
self.assertFalse(model.index(2, 0).data(model.SubsetRole))
self.assertTrue(model.headerData(0, Qt.Vertical, model.SubsetRole))
self.assertFalse(model.headerData(2, Qt.Vertical, model.SubsetRole))

with patch.object(w.signalManager, "send") as m:
self.send_signal(w.Inputs.data_subset, None)
m.assert_not_called()

w.view.grab()

model = w.view.model()
self.assertFalse(model.index(0, 0).data(model.SubsetRole))
self.assertFalse(model.headerData(0, Qt.Vertical, model.SubsetRole))


class TestOWTableSQL(TestOWTable, dbt):
def setUpDB(self):
Expand Down
4 changes: 2 additions & 2 deletions Orange/widgets/utils/itemdelegates.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import math
from typing import Optional, Tuple
from typing import Optional, Tuple, ClassVar

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