diff --git a/Orange/widgets/data/owrank.py b/Orange/widgets/data/owrank.py index de1a68c5052..bcc17e7d81b 100644 --- a/Orange/widgets/data/owrank.py +++ b/Orange/widgets/data/owrank.py @@ -1,5 +1,4 @@ import logging -import warnings from collections import namedtuple from functools import partial from itertools import chain @@ -15,6 +14,8 @@ QButtonGroup, QCheckBox, QGridLayout, QHeaderView, QItemDelegate, QRadioButton, QStackedWidget, QTableView ) + +from Orange.widgets.gui import TableView, BarRatioTableModel from orangewidget.settings import IncompatibleContext from scipy.sparse import issparse @@ -29,7 +30,6 @@ ) from Orange.widgets.unsupervised.owdistances import InterruptException from Orange.widgets.utils.concurrent import ConcurrentWidgetMixin, TaskState -from Orange.widgets.utils.itemmodels import PyTableModel from Orange.widgets.utils.sql import check_sql_input from Orange.widgets.utils.widgetpreview import WidgetPreview from Orange.widgets.widget import AttributeList, Input, MultiInput, Output, Msg, OWWidget @@ -106,72 +106,6 @@ def mousePressEvent(self, event): self.manualSelection.emit() -class TableModel(PyTableModel): - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self._extremes = {} - - def data(self, index, role=Qt.DisplayRole): - if role == gui.BarRatioRole and index.isValid(): - value = super().data(index, Qt.EditRole) - if not isinstance(value, float): - return None - vmin, vmax = self._extremes.get(index.column(), (-np.inf, np.inf)) - value = (value - vmin) / ((vmax - vmin) or 1) - return value - - if role == Qt.DisplayRole and index.column() != VARNAME_COL: - role = Qt.EditRole - - value = super().data(index, role) - - # Display nothing for non-existent attr value counts in column 1 - if role == Qt.EditRole \ - and index.column() == NVAL_COL and np.isnan(value): - return '' - - return value - - def headerData(self, section, orientation, role=Qt.DisplayRole): - if role == Qt.InitialSortOrderRole: - return Qt.DescendingOrder if section > 0 else Qt.AscendingOrder - return super().headerData(section, orientation, role) - - def setExtremesFrom(self, column, values): - """Set extremes for columnn's ratio bars from values""" - try: - with warnings.catch_warnings(): - warnings.filterwarnings( - "ignore", ".*All-NaN slice encountered.*", RuntimeWarning) - vmin = np.nanmin(values) - if np.isnan(vmin): - raise TypeError - except TypeError: - vmin, vmax = -np.inf, np.inf - else: - vmax = np.nanmax(values) - self._extremes[column] = (vmin, vmax) - - def resetSorting(self, yes_reset=False): - # pylint: disable=arguments-differ - """We don't want to invalidate our sort proxy model everytime we - wrap a new list. Our proxymodel only invalidates explicitly - (i.e. when new data is set)""" - if yes_reset: - super().resetSorting() - - def _argsortData(self, data, order): - if data.dtype not in (float, int): - data = np.char.lower(data) - indices = np.argsort(data, kind='mergesort') - if order == Qt.DescendingOrder: - indices = indices[::-1] - if data.dtype == float: - # Always sort NaNs last - return np.roll(indices, -np.isnan(data).sum()) - return indices - - class Results(SimpleNamespace): method_scores: Tuple[ScoreMeta, np.ndarray] = None scorer_scores: Tuple[ScoreMeta, Tuple[np.ndarray, List[str]]] = None @@ -305,11 +239,12 @@ def __init__(self): if method.is_default} # GUI - self.ranksModel = model = TableModel(parent=self) # type: TableModel + self.ranksModel = model = BarRatioTableModel(parent=self) # type: + # BarRatioTableModel self.ranksView = view = TableView(self) # type: TableView self.mainArea.layout().addWidget(view) view.setModel(model) - view.setColumnWidth(NVAL_COL, 30) + view.setColumnWidth(1, 30) view.selectionModel().selectionChanged.connect(self.on_select) def _set_select_manual(): @@ -528,8 +463,8 @@ def on_done(self, result: Results) -> None: self.ranksModel.wrap(model_array.tolist()) self.ranksModel.setHorizontalHeaderLabels(('', '#',) + labels) - self.ranksView.setColumnWidth(NVAL_COL, 40) - self.ranksView.resizeColumnToContents(VARNAME_COL) + self.ranksView.setColumnWidth(1, 40) + self.ranksView.resizeColumnToContents(0) # Re-apply sort try: diff --git a/Orange/widgets/data/tests/test_owrank.py b/Orange/widgets/data/tests/test_owrank.py index 92c71a8b0b5..9c466ff6d01 100644 --- a/Orange/widgets/data/tests/test_owrank.py +++ b/Orange/widgets/data/tests/test_owrank.py @@ -21,7 +21,7 @@ from Orange.regression import LinearRegressionLearner from Orange.projection import PCA from Orange.widgets.data.owrank import OWRank, ProblemType, CLS_SCORES, \ - REG_SCORES, TableModel + REG_SCORES, BarRatioTableModel from Orange.widgets.tests.base import WidgetTest, datasets from Orange.widgets.widget import AttributeList @@ -556,7 +556,7 @@ def test_concurrent_cancel(self): class TestRankModel(GuiTest): @staticmethod def test_argsort(): - func = TableModel()._argsortData # pylint: disable=protected-access + func = BarRatioTableModel()._argsortData # pylint: disable=protected-access assert_equal = np.testing.assert_equal test_array = np.array([4.2, 7.2, np.nan, 1.3, np.nan]) diff --git a/Orange/widgets/gui.py b/Orange/widgets/gui.py index 5df440a391f..f42f71dfaf7 100644 --- a/Orange/widgets/gui.py +++ b/Orange/widgets/gui.py @@ -2,6 +2,7 @@ Wrappers for controls used in widgets """ import math +import numpy as np import logging import sys @@ -10,9 +11,10 @@ from collections.abc import Sequence from AnyQt import QtWidgets, QtCore, QtGui -from AnyQt.QtCore import Qt, QSize, QItemSelection +from AnyQt.QtCore import Qt, QSize, QItemSelection, pyqtSignal as Signal from AnyQt.QtGui import QColor, QWheelEvent -from AnyQt.QtWidgets import QWidget, QListView, QComboBox +from AnyQt.QtWidgets import QWidget, QListView, QComboBox, QTableView, \ + QItemDelegate from orangewidget.utils.itemdelegates import ( BarItemDataDelegate as _BarItemDataDelegate @@ -40,7 +42,7 @@ ControlledCallback, ControlledCallFront, ValueCallback, connectControl, is_macstyle ) - +from orangewidget.utils.itemmodels import PyTableModel try: # Some Orange widgets might expect this here @@ -50,11 +52,10 @@ pass # Neither WebKit nor WebEngine are available import Orange.data -from Orange.widgets.utils import getdeepattr +from Orange.widgets.utils import getdeepattr, vartype from Orange.data import \ ContinuousVariable, StringVariable, TimeVariable, DiscreteVariable, \ Variable, Value -from Orange.widgets.utils import vartype __all__ = [ # Re-exported @@ -663,3 +664,73 @@ def wheelEvent(self, event: QWheelEvent): super().wheelEvent(new_event) else: super().wheelEvent(event) + + +class BarRatioTableModel(PyTableModel): + """A model for displaying python tables. + Adds a BarRatioRole that returns Data, normalized between the extremes. + NaNs are listed last when sorting.""" + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self._extremes = {} + + def data(self, index, role=Qt.DisplayRole): + if role == BarRatioRole and index.isValid(): + value = super().data(index, Qt.EditRole) + if not isinstance(value, float): + return None + vmin, vmax = self._extremes.get(index.column(), (-np.inf, np.inf)) + value = (value - vmin) / ((vmax - vmin) or 1) + return value + + if role == Qt.DisplayRole and index.column() != 0: + role = Qt.EditRole + + value = super().data(index, role) + + # Display nothing for non-existent attr value counts in column 1 + if role == Qt.EditRole \ + and index.column() == 1 and np.isnan(value): + return '' + + return value + + def headerData(self, section, orientation, role=Qt.DisplayRole): + if role == Qt.InitialSortOrderRole: + return Qt.DescendingOrder if section > 0 else Qt.AscendingOrder + return super().headerData(section, orientation, role) + + def setExtremesFrom(self, column, values): + """Set extremes for column's ratio bars from values""" + try: + with warnings.catch_warnings(): + warnings.filterwarnings( + "ignore", ".*All-NaN slice encountered.*", RuntimeWarning) + vmin = np.nanmin(values) + if np.isnan(vmin): + raise TypeError + except TypeError: + vmin, vmax = -np.inf, np.inf + else: + vmax = np.nanmax(values) + self._extremes[column] = (vmin, vmax) + + def resetSorting(self, yes_reset=False): + # pylint: disable=arguments-differ + """We don't want to invalidate our sort proxy model everytime we + wrap a new list. Our proxymodel only invalidates explicitly + (i.e. when new data is set)""" + if yes_reset: + super().resetSorting() + + def _argsortData(self, data, order): + if data.dtype not in (float, int): + data = np.char.lower(data) + indices = np.argsort(data, kind='mergesort') + if order == Qt.DescendingOrder: + indices = indices[::-1] + if data.dtype == float: + # Always sort NaNs last + return np.roll(indices, -np.isnan(data).sum()) + return indices