Repository: biolab/orange3

Issue:
Rank Widget: Variables as column, not header
**What's your use case?**
**In other words, what's your pain point?**

Variable names and their icons are shown as vertical header. This
- is ugly,
- doesn't show the selection properly,
- doesn't allow sorting by variable names,
- doesn't allow selection by dragging across a range of variables (though one can drag across rows in the table itself),
- and possibly something else.

<img width="690" alt="Screenshot 2021-08-28 at 20 03 59" src="https://user-images.githubusercontent.com/2387315/131226961-8e0858af-86d0-489c-a4cc-ceac65527a27.png">

**What's your proposed solution?**

Put them in a column, possibly with a different background or bold.

**Are there any alternative solutions?**

Color the selection properly and implement sorting on ... well, it's not a viable alternative. :)






Discussion:

Resolving patch:
diff --git a/Orange/widgets/data/owrank.py b/Orange/widgets/data/owrank.py
--- a/Orange/widgets/data/owrank.py
+++ b/Orange/widgets/data/owrank.py
@@ -11,7 +11,6 @@
     QItemSelection, QItemSelectionModel, QItemSelectionRange, Qt,
     pyqtSignal as Signal
 )
-from AnyQt.QtGui import QFontMetrics
 from AnyQt.QtWidgets import (
     QButtonGroup, QCheckBox, QGridLayout, QHeaderView, QItemDelegate,
     QRadioButton, QStackedWidget, QTableView
@@ -74,6 +73,8 @@ def from_variable(cls, variable):
 ]
 SCORES = CLS_SCORES + REG_SCORES
 
+VARNAME_COL, NVAL_COL = range(2)
+
 
 class TableView(QTableView):
     manualSelection = Signal()
@@ -87,14 +88,12 @@ def __init__(self, parent=None, **kwargs):
                          cornerButtonEnabled=False,
                          alternatingRowColors=False,
                          **kwargs)
-        self.setItemDelegate(gui.ColoredBarItemDelegate(self))
-        self.setItemDelegateForColumn(0, QItemDelegate())
-
-        header = self.verticalHeader()
-        header.setSectionResizeMode(header.Fixed)
-        header.setFixedWidth(50)
-        header.setDefaultSectionSize(22)
-        header.setTextElideMode(Qt.ElideMiddle)  # Note: https://bugreports.qt.io/browse/QTBUG-62091
+        # setItemDelegate(ForColumn) doesn't take ownership of delegates
+        self._bar_delegate = gui.ColoredBarItemDelegate(self)
+        self._del0, self._del1 = QItemDelegate(), QItemDelegate()
+        self.setItemDelegate(self._bar_delegate)
+        self.setItemDelegateForColumn(VARNAME_COL, self._del0)
+        self.setItemDelegateForColumn(NVAL_COL, self._del1)
 
         header = self.horizontalHeader()
         header.setSectionResizeMode(header.Fixed)
@@ -102,11 +101,6 @@ def __init__(self, parent=None, **kwargs):
         header.setDefaultSectionSize(80)
         header.setTextElideMode(Qt.ElideMiddle)
 
-    def setVHeaderFixedWidthFromLabel(self, max_label):
-        header = self.verticalHeader()
-        width = QFontMetrics(header.font()).horizontalAdvance(max_label)
-        header.setFixedWidth(min(width + 40, 400))
-
     def mousePressEvent(self, event):
         super().mousePressEvent(event)
         self.manualSelection.emit()
@@ -126,20 +120,21 @@ def data(self, index, role=Qt.DisplayRole):
             value = (value - vmin) / ((vmax - vmin) or 1)
             return value
 
-        if role == Qt.DisplayRole:
+        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 the first column
-        if role == Qt.EditRole and index.column() == 0 and np.isnan(value):
+        # 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
+            return Qt.DescendingOrder if section > 0 else Qt.AscendingOrder
         return super().headerData(section, orientation, role)
 
     def setExtremesFrom(self, column, values):
@@ -166,10 +161,14 @@ def resetSorting(self, yes_reset=False):
             super().resetSorting()
 
     def _argsortData(self, data, order):
-        """Always sort NaNs last"""
+        if data.dtype not in (float, int):
+            data = np.char.lower(data)
         indices = np.argsort(data, kind='mergesort')
         if order == Qt.DescendingOrder:
-            return np.roll(indices[::-1], -np.isnan(data).sum())
+            indices = indices[::-1]
+            if data.dtype == float:
+                # Always sort NaNs last
+                return np.roll(indices, -np.isnan(data).sum())
         return indices
 
 
@@ -310,7 +309,7 @@ def __init__(self):
         self.ranksView = view = TableView(self)            # type: TableView
         self.mainArea.layout().addWidget(view)
         view.setModel(model)
-        view.setColumnWidth(0, 30)
+        view.setColumnWidth(NVAL_COL, 30)
         view.selectionModel().selectionChanged.connect(self.on_select)
 
         def _set_select_manual():
@@ -424,10 +423,6 @@ def set_data(self, data):
             if problem_type is not None:
                 self.switchProblemType(problem_type)
 
-            self.ranksModel.setVerticalHeaderLabels(domain.attributes)
-            self.ranksView.setVHeaderFixedWidthFromLabel(
-                max((a.name for a in domain.attributes), key=len))
-
             self.selectionMethod = OWRank.SelectNBest
 
         self.openContext(data)
@@ -519,28 +514,31 @@ def on_done(self, result: Results) -> None:
 
         labels = method_labels + tuple(chain.from_iterable(scorer_labels))
         model_array = np.column_stack(
-            (
-                [len(a.values) if a.is_discrete else np.nan
+            (list(self.data.domain.attributes), )
+            + (
+                [float(len(a.values)) if a.is_discrete else np.nan
                  for a in self.data.domain.attributes],
             )
             + method_scores
             + scorer_scores
         )
-        for column, values in enumerate(model_array.T):
+        for column, values in enumerate(model_array.T[2:].astype(float),
+                                        start=2):
             self.ranksModel.setExtremesFrom(column, values)
 
         self.ranksModel.wrap(model_array.tolist())
-        self.ranksModel.setHorizontalHeaderLabels(('#',) + labels)
-        self.ranksView.setColumnWidth(0, 40)
+        self.ranksModel.setHorizontalHeaderLabels(('', '#',) + labels)
+        self.ranksView.setColumnWidth(NVAL_COL, 40)
+        self.ranksView.resizeColumnToContents(VARNAME_COL)
 
         # Re-apply sort
         try:
             sort_column, sort_order = self.sorting
             if sort_column < len(labels):
-                # adds 1 for '#' (discrete count) column
-                self.ranksModel.sort(sort_column + 1, sort_order)
+                # adds 2 to skip the first two columns
+                self.ranksModel.sort(sort_column + 2, sort_order)
                 self.ranksView.horizontalHeader().setSortIndicator(
-                    sort_column + 1, sort_order
+                    sort_column + 2, sort_order
                 )
         except ValueError:
             pass
@@ -598,13 +596,13 @@ def autoSelection(self):
         selModel.select(selection, QItemSelectionModel.ClearAndSelect)
 
     def headerClick(self, index):
-        if index >= 1 and self.selectionMethod == OWRank.SelectNBest:
+        if index >= 2 and self.selectionMethod == OWRank.SelectNBest:
             # Reselect the top ranked attributes
             self.autoSelection()
 
         # Store the header states
         sort_order = self.ranksModel.sortOrder()
-        sort_column = self.ranksModel.sortColumn() - 1  # -1 for '#' (discrete count) column
+        sort_column = self.ranksModel.sortColumn() - 2  # -2 for '#' (discrete count) column
         self.sorting = (sort_column, sort_order)
 
     def methodSelectionChanged(self, state, method_name):
@@ -640,7 +638,7 @@ def commit(self):
     def create_scores_table(self, labels):
         self.Warning.renamed_variables.clear()
         model_list = self.ranksModel.tolist()
-        if not model_list or len(model_list[0]) == 1:  # Empty or just n_values column
+        if not model_list or len(model_list[0]) == 2:  # Empty or just first two columns
             return None
         unique, renamed = get_unique_names_duplicates(labels + ('Feature',),
                                                       return_duplicated=True)
@@ -652,7 +650,7 @@ def create_scores_table(self, labels):
 
         # Prevent np.inf scores
         finfo = np.finfo(np.float64)
-        scores = np.clip(np.array(model_list)[:, 1:], finfo.min, finfo.max)
+        scores = np.clip(np.array(model_list)[:, 2:], finfo.min, finfo.max)
 
         feature_names = np.array([a.name for a in self.data.domain.attributes])
         # Reshape to 2d array as Table does not like 1d arrays


Test patch:
diff --git a/Orange/widgets/data/tests/test_owrank.py b/Orange/widgets/data/tests/test_owrank.py
--- a/Orange/widgets/data/tests/test_owrank.py
+++ b/Orange/widgets/data/tests/test_owrank.py
@@ -7,10 +7,11 @@
 import numpy as np
 from sklearn.exceptions import ConvergenceWarning
 
-from AnyQt.QtCore import Qt, QItemSelection
-from AnyQt.QtWidgets import QCheckBox
+from AnyQt.QtCore import Qt, QItemSelection, QItemSelectionModel
+from AnyQt.QtWidgets import QCheckBox, QApplication
 
 from orangewidget.settings import Context, IncompatibleContext
+from orangewidget.tests.base import GuiTest
 
 from Orange.data import Table, Domain, ContinuousVariable, DiscreteVariable
 from Orange.modelling import RandomForestLearner, SGDLearner
@@ -18,7 +19,8 @@
 from Orange.classification import LogisticRegressionLearner
 from Orange.regression import LinearRegressionLearner
 from Orange.projection import PCA
-from Orange.widgets.data.owrank import OWRank, ProblemType, CLS_SCORES, REG_SCORES
+from Orange.widgets.data.owrank import OWRank, ProblemType, CLS_SCORES, \
+    REG_SCORES, TableModel
 from Orange.widgets.tests.base import WidgetTest, datasets
 from Orange.widgets.widget import AttributeList
 
@@ -308,7 +310,7 @@ def test_scores_sorting(self):
         order1 = self.widget.ranksModel.mapToSourceRows(...).tolist()
         self._get_checkbox('FCBF').setChecked(True)
         self.wait_until_finished()
-        self.widget.ranksView.horizontalHeader().setSortIndicator(3, Qt.DescendingOrder)
+        self.widget.ranksView.horizontalHeader().setSortIndicator(4, Qt.DescendingOrder)
         order2 = self.widget.ranksModel.mapToSourceRows(...).tolist()
         self.assertNotEqual(order1, order2)
 
@@ -323,9 +325,9 @@ def test_scores_nan_sorting(self):
         # Assert last row is all nan
         for order in (Qt.AscendingOrder,
                       Qt.DescendingOrder):
-            self.widget.ranksView.horizontalHeader().setSortIndicator(1, order)
+            self.widget.ranksView.horizontalHeader().setSortIndicator(2, order)
             last_row = self.widget.ranksModel[self.widget.ranksModel.mapToSourceRows(...)[-1]]
-            np.testing.assert_array_equal(last_row, np.repeat(np.nan, 3))
+            np.testing.assert_array_equal(last_row[1:], np.repeat(np.nan, 3))
 
     def test_default_sort_indicator(self):
         self.send_signal(self.widget.Inputs.data, self.iris)
@@ -396,7 +398,7 @@ def test_auto_selection_manual(self):
 
         # Sort by number of values and set selection to attributes with most
         # values. This must select the top 4 rows.
-        self.widget.ranksView.horizontalHeader().setSortIndicator(0, Qt.DescendingOrder)
+        self.widget.ranksView.horizontalHeader().setSortIndicator(1, Qt.DescendingOrder)
         w.selectionMethod = w.SelectManual
         w.selected_attrs = [dom["chest pain"], dom["rest ECG"],
                             dom["slope peak exc ST"], dom["thal"]]
@@ -405,6 +407,69 @@ def test_auto_selection_manual(self):
             sorted({idx.row() for idx in w.ranksView.selectedIndexes()}),
             [0, 1, 2, 3])
 
+    def test_resorting_and_selection(self):
+        def sortby(col):
+            model.sort(col)
+            view.horizontalHeader().sectionClicked.emit(col)
+            QApplication.processEvents()
+
+        Names, Values, Gain = range(3)
+
+        w = self.widget
+
+        data = Table("heart_disease")
+        self.send_signal(w.Inputs.data, data)
+        self.wait_until_finished()
+
+        first4 = set(sorted((var.name for var in data.domain.attributes), key=str.lower)[:4])
+
+        view = w.ranksView
+        model = w.ranksModel
+        selModel = view.selectionModel()
+        columnCount = model.columnCount()
+
+        w.selectionMethod = w.SelectNBest
+        w.nSelected = 4
+
+        # Sort by gain ratio, store selection
+        sortby(Gain)
+        gain_sel_4 = w.selected_attrs[:]
+        self.assertEqual(len(gain_sel_4), 4)
+
+        # Sort by names or number of values: selection unchanged
+        sortby(Values)
+        self.assertEqual(w.selected_attrs[:], gain_sel_4)
+
+        sortby(Names)
+        self.assertEqual(w.selected_attrs[:], gain_sel_4)
+
+        # Select first four (alphabetically)
+        w.selectionMethod = w.SelectManual
+        selection = QItemSelection(
+            model.index(0, 0),
+            model.index(3, columnCount - 1)
+        )
+        selModel.select(selection, QItemSelectionModel.ClearAndSelect)
+        # Sanity check
+        self.assertEqual({var.name for var in w.selected_attrs}, first4)
+
+        # Manual sorting: sorting by score does not change selection
+        sortby(Gain)
+        self.assertEqual({var.name for var in w.selected_attrs}, first4)
+
+        # Sort by first four, again
+        sortby(Names)
+        # Sanity check
+        self.assertEqual({var.name for var in w.selected_attrs}, first4)
+
+        w.selectionMethod = w.SelectNBest
+        # Sanity check
+        self.assertEqual({var.name for var in w.selected_attrs}, first4)
+
+        # Sorting by gain must change selection
+        sortby(Gain)
+        self.assertEqual(set(w.selected_attrs), set(gain_sel_4))
+
     def test_auto_send(self):
         widget = self.widget
         model = widget.ranksModel
@@ -485,5 +550,24 @@ def test_concurrent_cancel(self):
         self.assertEqual(len(output), len(self.housing))
 
 
+class TestRankModel(GuiTest):
+    @staticmethod
+    def test_argsort():
+        func = TableModel()._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])
+        assert_equal(func(test_array, Qt.AscendingOrder)[:3], [3, 0, 1])
+        assert_equal(func(test_array, Qt.DescendingOrder)[:3], [1, 0, 3])
+
+        test_array = np.array([4, 7, 2])
+        assert_equal(func(test_array, Qt.AscendingOrder), [2, 0, 1])
+        assert_equal(func(test_array, Qt.DescendingOrder), [1, 0, 2])
+
+        test_array = np.array(["Bertha", "daniela", "ann", "Cecilia"])
+        assert_equal(func(test_array, Qt.AscendingOrder), [2, 0, 3, 1])
+        assert_equal(func(test_array, Qt.DescendingOrder), [1, 3, 0, 2])
+
+
 if __name__ == "__main__":
     unittest.main()