Skip to content

Commit 928e140

Browse files
committed
owtable: Restore applied table sorting
1 parent 0bdde52 commit 928e140

File tree

2 files changed

+155
-9
lines changed

2 files changed

+155
-9
lines changed

Orange/widgets/data/owtable.py

Lines changed: 114 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import concurrent.futures
22
from dataclasses import dataclass
3-
from typing import Optional, Union, Sequence, TypedDict, Tuple
3+
from typing import Optional, Union, Sequence, List, TypedDict, Tuple, Dict
44

55
from scipy.sparse import issparse
66

@@ -10,6 +10,7 @@
1010
from AnyQt.QtCore import Slot
1111

1212
import Orange.data
13+
from Orange.data import Variable
1314
from Orange.data.table import Table
1415
from Orange.data.sql.table import SqlTable
1516

@@ -19,11 +20,12 @@
1920
from Orange.widgets.utils.itemdelegates import TableDataDelegate
2021
from Orange.widgets.utils.tableview import table_selection_to_mime_data
2122
from Orange.widgets.utils.widgetpreview import WidgetPreview
22-
from Orange.widgets.widget import OWWidget, Input, Output
23+
from Orange.widgets.widget import OWWidget, Input, Output, Msg
2324
from Orange.widgets.utils.annotated_data import (create_annotated_table,
2425
ANNOTATED_DATA_SIGNAL_NAME)
2526
from Orange.widgets.utils.itemmodels import TableModel
2627
from Orange.widgets.utils.state_summary import format_summary_details
28+
from Orange.widgets.utils import disconnected
2729
from Orange.widgets.data.utils.tableview import RichTableView
2830
from Orange.widgets.data.utils import tablesummary as tsummary
2931

@@ -48,6 +50,9 @@ class _Selection(TypedDict):
4850
columns: Tuple[int]
4951

5052

53+
_Sorting = List[Tuple[str, int]]
54+
55+
5156
class OWTable(OWWidget):
5257
name = "Data Table"
5358
description = "View the dataset in a spreadsheet."
@@ -62,6 +67,15 @@ class Outputs:
6267
selected_data = Output("Selected Data", Table, default=True)
6368
annotated_data = Output(ANNOTATED_DATA_SIGNAL_NAME, Table)
6469

70+
class Warning(OWWidget.Warning):
71+
missing_sort_columns = Msg(
72+
"Cannot restore sorting.\n"
73+
"Missing columns in input table: {}"
74+
)
75+
non_sortable_input = Msg(
76+
"Cannot restore sorting.\n"
77+
"Input table cannot be sorted due to implementation constraints."
78+
)
6579
buttons_area_orientation = Qt.Vertical
6680

6781
show_distributions = Setting(False)
@@ -73,12 +87,16 @@ class Outputs:
7387
stored_selection: _Selection = Setting(
7488
{"rows": [], "columns": []}, schema_only=True
7589
)
90+
stored_sort: _Sorting = Setting(
91+
[], schema_only=True
92+
)
7693
settings_version = 1
7794

7895
def __init__(self):
7996
super().__init__()
8097
self.input: Optional[InputData] = None
81-
self.__pending_selection = self.stored_selection
98+
self.__pending_selection: Optional[_Selection] = self.stored_selection
99+
self.__pending_sort: Optional[_Sorting] = self.stored_sort
82100
self.dist_color = QColor(220, 220, 220, 255)
83101

84102
info_box = gui.vBox(self.controlArea, "Info")
@@ -124,7 +142,9 @@ def __init__(self):
124142
header.setSectionsClickable(True)
125143
header.setSortIndicatorShown(True)
126144
header.setSortIndicator(-1, Qt.AscendingOrder)
127-
header.sortIndicatorChanged.connect(self.update_selection)
145+
header.sortIndicatorChanged.connect(
146+
self._on_sort_indicator_changed, Qt.UniqueConnection
147+
)
128148

129149
self.view = view
130150
self.mainArea.layout().addWidget(self.view)
@@ -154,6 +174,8 @@ def set_dataset(self, data: Optional[Table]):
154174

155175
def handleNewSignals(self):
156176
super().handleNewSignals()
177+
self.Warning.non_sortable_input.clear()
178+
self.Warning.missing_sort_columns.clear()
157179
data: Optional[Table] = self.input.table if self.input else None
158180
slot = self.input
159181
if slot is not None and isinstance(slot.summary.len, concurrent.futures.Future):
@@ -164,6 +186,9 @@ def update(_):
164186

165187
self._update_input_summary()
166188

189+
if data is not None and self.__pending_sort is not None:
190+
self.__restore_sort()
191+
167192
if data is not None and self.__pending_selection is not None:
168193
selection = self.__pending_selection
169194
self.__pending_selection = None
@@ -296,11 +321,87 @@ def _on_select_rows_changed(self):
296321
def restore_order(self):
297322
"""Restore the original data order of the current view."""
298323
self.view.sortByColumn(-1, Qt.AscendingOrder)
324+
self.stored_sort = []
325+
self.Warning.missing_sort_columns.clear()
299326

300327
@Slot()
301328
def _update_info(self):
302329
self._update_input_summary()
303330

331+
def _on_sort_indicator_changed(self, index: int, order: Qt.SortOrder) -> None:
332+
if index == -1:
333+
self.stored_sort = []
334+
elif self.input is not None:
335+
model = self.input.model
336+
var = model.headerData(index, Qt.Horizontal, TableModel.VariableRole)
337+
order = -1 if order == Qt.DescendingOrder else 1
338+
# Drop any previously applied sort on this column
339+
self.stored_sort = [(n, d) for n, d in self.stored_sort
340+
if n != var.name]
341+
self.stored_sort.append((var.name, order))
342+
self.update_selection()
343+
self.Warning.missing_sort_columns.clear()
344+
345+
def set_sort_columns(self, sorting: List[Tuple[str, int]]):
346+
"""
347+
Set the model sorting parameters.
348+
349+
Parameters
350+
----------
351+
sorting: List[Tuple[str, int]]
352+
For each (name: str, inc: int) tuple where `name` is the column
353+
name and `inc` is 1 for increasing order and -1 for decreasing
354+
order, the model is sorted by that column.
355+
"""
356+
if self.input is None:
357+
return # pragma: no cover
358+
self.stored_sort = []
359+
# Map header names/titles to column indices
360+
columns = {var.name: i for
361+
var, i in self.__header_variable_indices().items()}
362+
# Suppress the _on_sort_indicator_changed -> commit calls
363+
with disconnected(self.view.horizontalHeader().sortIndicatorChanged,
364+
self._on_sort_indicator_changed, Qt.UniqueConnection):
365+
for name, order in sorting:
366+
if name in columns:
367+
self.view.sortByColumn(
368+
columns[name],
369+
Qt.AscendingOrder if order == 1 else Qt.DescendingOrder
370+
)
371+
self.stored_sort.append((name, order))
372+
373+
def __restore_sort(self) -> None:
374+
assert self.input is not None
375+
sort = self.__pending_sort
376+
self.__pending_sort = None
377+
if sort is None:
378+
return # pragma: no cover
379+
if not self.view.isSortingEnabled() and sort:
380+
self.Warning.non_sortable_input()
381+
self.Warning.missing_sort_columns.clear()
382+
return
383+
# Map header names/titles to column indices
384+
vars_ = self.__header_variable_indices()
385+
columns = {var.name: i for var, i in vars_.items()}
386+
missing_columns = []
387+
sort_ = []
388+
for name, order in sort:
389+
if name in columns:
390+
sort_.append((name, order))
391+
else:
392+
missing_columns.append(name)
393+
self.set_sort_columns(sort_)
394+
if missing_columns:
395+
self.Warning.missing_sort_columns(", ".join(missing_columns))
396+
397+
def __header_variable_indices(self) -> Dict[Variable, int]:
398+
model = self.view.model()
399+
if model is None:
400+
return {} # pragma: no cover
401+
vars_ = [model.headerData(i, Qt.Horizontal, TableModel.VariableRole)
402+
for i in range(model.columnCount())]
403+
return {v: i for i, v in enumerate(vars_) if isinstance(v, Variable)}
404+
304405
def update_selection(self, *_):
305406
self.commit.deferred()
306407

@@ -360,11 +461,16 @@ def select_vars(role):
360461
metas = select_vars(TableModel.Meta)
361462
domain = Orange.data.Domain(attrs, class_vars, metas)
362463

363-
# Send all data by default
364-
if not rowsel:
365-
selected_data = table
366-
else:
464+
sortsection = self.view.horizontalHeader().sortIndicatorSection()
465+
if rowsel:
367466
selected_data = table.from_table(domain, table, rowsel)
467+
elif sortsection != -1:
468+
# Send sorted data
469+
permutation = model.mapToSourceRows(...)
470+
selected_data = table.from_table(table.domain, table, permutation)
471+
else:
472+
# Send all data by default
473+
selected_data = table
368474

369475
self.Outputs.selected_data.send(selected_data)
370476
self.Outputs.annotated_data.send(create_annotated_table(table, rowsel))

Orange/widgets/data/tests/test_owtable.py

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,41 @@ def test_pending_selection(self):
8888
output = self.get_output(widget.Outputs.selected_data)
8989
self.assertEqual(5, len(output))
9090

91+
def test_pending_sorted_selection(self):
92+
rows = [5, 6, 7, 8, 9, 55, 56, 57, 58, 59]
93+
widget = self.create_widget(OWTable, stored_settings={
94+
"stored_selection": {
95+
"rows": rows,
96+
"columns": list(range(len(self.data.domain.variables)))
97+
},
98+
"stored_sort": [("sepal length", 1), ("sepal width", -1)]
99+
})
100+
self.send_signal(widget.Inputs.data, None)
101+
self.send_signal(widget.Inputs.data, self.data)
102+
self.assertEqual(widget.view.horizontalHeader().sortIndicatorOrder(),
103+
Qt.DescendingOrder)
104+
self.assertEqual(widget.view.horizontalHeader().sortIndicatorSection(), 2)
105+
output = self.get_output(widget.Outputs.selected_data)
106+
self.assertEqual(len(rows), len(output))
107+
sepal_width = output.get_column("sepal width").tolist()
108+
sepal_length = output.get_column("sepal length").tolist()
109+
self.assertSequenceEqual(sepal_width, sorted(sepal_width, reverse=True))
110+
dd = list(zip(sepal_length, sepal_width))
111+
dd_sorted = sorted(dd, key=lambda t: t[0])
112+
dd_sorted = sorted(dd_sorted, key=lambda t: t[1], reverse=True)
113+
self.assertSequenceEqual(dd, dd_sorted)
114+
ids = self.data[rows].ids
115+
self.assertSetEqual(set(output.ids), set(ids))
116+
117+
def test_missing_sort_column_shows_warning(self):
118+
widget = self.create_widget(OWTable, stored_settings={
119+
"stored_sort": [("sepal length", 1), ("no such column", -1)]
120+
})
121+
self.send_signal(widget.Inputs.data, self.data)
122+
self.assertTrue(widget.Warning.missing_sort_columns.is_shown())
123+
self.send_signal(widget.Inputs.data, None)
124+
self.assertFalse(widget.Warning.missing_sort_columns.is_shown())
125+
91126
def test_sorting(self):
92127
self.send_signal(self.widget.Inputs.data, self.data)
93128
self.widget.set_selection(
@@ -99,7 +134,7 @@ def test_sorting(self):
99134
output_original = output.tolist()
100135

101136
self.widget.view.sortByColumn(1, Qt.AscendingOrder)
102-
137+
self.assertEqual(self.widget.stored_sort, [('sepal length', 1)])
103138
output = self.get_output(self.widget.Outputs.selected_data)
104139
output = output.get_column(0)
105140
output_sorted = output.tolist()
@@ -115,6 +150,11 @@ def test_sorting(self):
115150
output = self.get_output(self.widget.Outputs.selected_data)
116151
self.assertEqual(output.get_column(0).tolist(), output_original)
117152

153+
# Check that output is the same with no sorting and cleared selection.
154+
self.widget.set_selection([], [])
155+
output = self.get_output(self.widget.Outputs.selected_data)
156+
self.assertIs(output, self.data)
157+
118158
def test_info(self):
119159
info_text = self.widget.info_text
120160
no_input = "No data."

0 commit comments

Comments
 (0)