diff --git a/Orange/widgets/data/owtable.py b/Orange/widgets/data/owtable.py index 9ab9cef40a9..8fd753609e1 100644 --- a/Orange/widgets/data/owtable.py +++ b/Orange/widgets/data/owtable.py @@ -6,7 +6,6 @@ import concurrent.futures from collections import OrderedDict, namedtuple -from typing import List, Tuple, Iterable from math import isnan @@ -17,12 +16,12 @@ QTableView, QHeaderView, QAbstractButton, QApplication, QStyleOptionHeader, QStyle, QStylePainter, QStyledItemDelegate ) -from AnyQt.QtGui import QColor, QClipboard, QMouseEvent +from AnyQt.QtGui import QColor, QClipboard from AnyQt.QtCore import ( Qt, QSize, QEvent, QByteArray, QMimeData, QObject, QMetaObject, QAbstractProxyModel, QIdentityProxyModel, QModelIndex, QItemSelectionModel, QItemSelection, QItemSelectionRange, - Signal) +) from AnyQt.QtCore import pyqtSlot as Slot import Orange.data @@ -33,6 +32,10 @@ from Orange.widgets import gui from Orange.widgets.settings import Setting +from Orange.widgets.utils.itemselectionmodel import ( + BlockSelectionModel, ranges, selection_blocks +) +from Orange.widgets.utils.tableview import TableView from Orange.widgets.utils.widgetpreview import WidgetPreview from Orange.widgets.widget import OWWidget, Input, Output from Orange.widgets.utils import datacaching @@ -160,165 +163,6 @@ def rowCount(self, parent=QModelIndex()): return stop - start -class BlockSelectionModel(QItemSelectionModel): - """ - Item selection model ensuring the selection maintains a simple block - like structure. - - e.g. - - [a b] c [d e] - [f g] h [i j] - - is allowed but this is not - - [a] b c d e - [f g] h [i j] - - I.e. select the Cartesian product of row and column indices. - - """ - def __init__(self, model, parent=None, selectBlocks=True, **kwargs): - super().__init__(model, parent, **kwargs) - self.__selectBlocks = selectBlocks - - def select(self, selection, flags): - """Reimplemented.""" - if isinstance(selection, QModelIndex): - selection = QItemSelection(selection, selection) - - if not self.__selectBlocks: - super().select(selection, flags) - return - - model = self.model() - - def to_ranges(spans): - return list(range(*r) for r in spans) - - if flags & QItemSelectionModel.Current: # no current selection support - flags &= ~QItemSelectionModel.Current - if flags & QItemSelectionModel.Toggle: # no toggle support either - flags &= ~QItemSelectionModel.Toggle - flags |= QItemSelectionModel.Select - - if flags == QItemSelectionModel.ClearAndSelect: - # extend selection ranges in `selection` to span all row/columns - sel_rows = selection_rows(selection) - sel_cols = selection_columns(selection) - selection = QItemSelection() - for row_range, col_range in \ - itertools.product(to_ranges(sel_rows), to_ranges(sel_cols)): - selection.select( - model.index(row_range.start, col_range.start), - model.index(row_range.stop - 1, col_range.stop - 1) - ) - elif flags & (QItemSelectionModel.Select | - QItemSelectionModel.Deselect): - # extend all selection ranges in `selection` with the full current - # row/col spans - rows, cols = selection_blocks(self.selection()) - sel_rows = selection_rows(selection) - sel_cols = selection_columns(selection) - ext_selection = QItemSelection() - for row_range, col_range in \ - itertools.product(to_ranges(rows), to_ranges(sel_cols)): - ext_selection.select( - model.index(row_range.start, col_range.start), - model.index(row_range.stop - 1, col_range.stop - 1) - ) - for row_range, col_range in \ - itertools.product(to_ranges(sel_rows), to_ranges(cols)): - ext_selection.select( - model.index(row_range.start, col_range.start), - model.index(row_range.stop - 1, col_range.stop - 1) - ) - selection.merge(ext_selection, QItemSelectionModel.Select) - super().select(selection, flags) - - def selectBlocks(self): - """Is the block selection in effect.""" - return self.__selectBlocks - - def setSelectBlocks(self, state): - """Set the block selection state. - - If set to False, the selection model behaves as the base - QItemSelectionModel - - """ - self.__selectBlocks = state - - -def selection_rows(selection): - # type: (QItemSelection) -> List[Tuple[int, int]] - """ - Return a list of ranges for all referenced rows contained in selection - - Parameters - ---------- - selection : QItemSelection - - Returns - ------- - rows : List[Tuple[int, int]] - """ - spans = set(range(s.top(), s.bottom() + 1) for s in selection) - indices = sorted(set(itertools.chain(*spans))) - return list(ranges(indices)) - - -def selection_columns(selection): - # type: (QItemSelection) -> List[Tuple[int, int]] - """ - Return a list of ranges for all referenced columns contained in selection - - Parameters - ---------- - selection : QItemSelection - - Returns - ------- - rows : List[Tuple[int, int]] - """ - spans = {range(s.left(), s.right() + 1) for s in selection} - indices = sorted(set(itertools.chain(*spans))) - return list(ranges(indices)) - - -def selection_blocks(selection): - # type: (QItemSelection) -> Tuple[List[Tuple[int, int]], List[Tuple[int, int]]] - if selection.count() > 0: - rowranges = {range(span.top(), span.bottom() + 1) - for span in selection} - colranges = {range(span.left(), span.right() + 1) - for span in selection} - else: - return [], [] - - rows = sorted(set(itertools.chain(*rowranges))) - cols = sorted(set(itertools.chain(*colranges))) - return list(ranges(rows)), list(ranges(cols)) - - -def ranges(indices): - # type: (Iterable[int]) -> Iterable[Tuple[int, int]] - """ - Group consecutive indices into `(start, stop)` tuple 'ranges'. - - >>> list(ranges([1, 2, 3, 5, 3, 4])) - >>> [(1, 4), (5, 6), (3, 5)] - - """ - g = itertools.groupby(enumerate(indices), - key=lambda t: t[1] - t[0]) - for _, range_ind in g: - range_ind = list(range_ind) - _, start = range_ind[0] - _, end = range_ind[-1] - yield start, end + 1 - - def table_selection_to_mime_data(table): """Copy the current selection in a QTableView to the clipboard. """ @@ -363,42 +207,7 @@ def table_selection_to_list(table): TableSlot = namedtuple("TableSlot", ["input_id", "table", "summary", "view"]) -class TableView(gui.HScrollStepMixin, QTableView): - #: Signal emitted when selection finished. It is not emitted during - #: mouse drag selection updates. - selectionFinished = Signal() - - __mouseDown = False - __selectionDidChange = False - - def setSelectionModel(self, selectionModel: QItemSelectionModel) -> None: - sm = self.selectionModel() - if sm is not None: - sm.selectionChanged.disconnect(self.__on_selectionChanged) - super().setSelectionModel(selectionModel) - if selectionModel is not None: - selectionModel.selectionChanged.connect(self.__on_selectionChanged) - - def __on_selectionChanged(self): - if self.__mouseDown: - self.__selectionDidChange = True - else: - self.selectionFinished.emit() - - def mousePressEvent(self, event: QMouseEvent) -> None: - self.__mouseDown = event.button() == Qt.LeftButton - super().mousePressEvent(event) - - def mouseReleaseEvent(self, event: QMouseEvent) -> None: - super().mouseReleaseEvent(event) - if self.__mouseDown and event.button() == Qt.LeftButton: - self.__mouseDown = False - if self.__selectionDidChange: - self.__selectionDidChange = False - self.selectionFinished.emit() - - -class DataTableView(TableView): +class DataTableView(gui.HScrollStepMixin, TableView): dataset: Table input_slot: TableSlot @@ -500,7 +309,6 @@ def set_dataset(self, data, tid=None): else: view = DataTableView() view.setSortingEnabled(True) - view.setHorizontalScrollMode(QTableView.ScrollPerPixel) if self.select_rows: view.setSelectionBehavior(QTableView.SelectRows) diff --git a/Orange/widgets/unsupervised/owdistancematrix.py b/Orange/widgets/unsupervised/owdistancematrix.py index ad7b93909e2..ffd6ef62f4b 100644 --- a/Orange/widgets/unsupervised/owdistancematrix.py +++ b/Orange/widgets/unsupervised/owdistancematrix.py @@ -6,16 +6,15 @@ from AnyQt.QtWidgets import QTableView, QItemDelegate, QHeaderView, QStyle, \ QStyleOptionViewItem from AnyQt.QtGui import QColor, QPen, QBrush -from AnyQt.QtCore import Qt, QAbstractTableModel, QModelIndex, \ - QItemSelectionModel, QItemSelection, QSize +from AnyQt.QtCore import Qt, QAbstractTableModel, QSize from Orange.data import Table, Variable, StringVariable from Orange.misc import DistMatrix from Orange.widgets import widget, gui -from Orange.widgets.data.owtable import ranges from Orange.widgets.gui import OrangeUserRole from Orange.widgets.settings import Setting, ContextSetting, ContextHandler from Orange.widgets.utils.itemmodels import VariableListModel +from Orange.widgets.utils.itemselectionmodel import SymmetricSelectionModel from Orange.widgets.utils.widgetpreview import WidgetPreview from Orange.widgets.widget import Input, Output @@ -47,7 +46,6 @@ def set_data(self, distances): self.endResetModel() def set_labels(self, labels, variable=None, values=None): - self.beginResetModel() self.labels = labels self.variable = variable self.values = values @@ -56,7 +54,12 @@ def set_labels(self, labels, variable=None, values=None): self.label_colors = variable.palette.values_to_qcolors(values) else: self.label_colors = None - self.endResetModel() + self.headerDataChanged.emit(Qt.Vertical, 0, self.rowCount() - 1) + self.headerDataChanged.emit(Qt.Horizontal, 0, self.columnCount() - 1) + self.dataChanged.emit( + self.index(0, 0), + self.index(self.rowCount() - 1, self.columnCount() - 1) + ) def dimension(self, parent=None): if parent and parent.isValid() or self.distances is None: @@ -121,44 +124,6 @@ def paint(self, painter, option, index): painter.restore() -class SymmetricSelectionModel(QItemSelectionModel): - def select(self, selection, flags): - if isinstance(selection, QModelIndex): - selection = QItemSelection(selection, selection) - - model = self.model() - indexes = selection.indexes() - sel_inds = {ind.row() for ind in indexes} | \ - {ind.column() for ind in indexes} - if flags == QItemSelectionModel.ClearAndSelect: - selected = set() - else: - selected = {ind.row() for ind in self.selectedIndexes()} - if flags & QItemSelectionModel.Select: - selected |= sel_inds - elif flags & QItemSelectionModel.Deselect: - selected -= sel_inds - new_selection = QItemSelection() - regions = list(ranges(sorted(selected))) - for r_start, r_end in regions: - for c_start, c_end in regions: - top_left = model.index(r_start, c_start) - bottom_right = model.index(r_end - 1, c_end - 1) - new_selection.select(top_left, bottom_right) - QItemSelectionModel.select(self, new_selection, - QItemSelectionModel.ClearAndSelect) - - def selected_items(self): - return list({ind.row() for ind in self.selectedIndexes()}) - - def set_selected_items(self, inds): - index = self.model().index - selection = QItemSelection() - for i in inds: - selection.select(index(i, i), index(i, i)) - self.select(selection, QItemSelectionModel.ClearAndSelect) - - class TableView(gui.HScrollStepMixin, QTableView): def sizeHintForColumn(self, column: int) -> int: model = self.model() @@ -207,12 +172,12 @@ def settings_from_widget(self, widget, *args): context = widget.current_context if context is not None: context.annotation = widget.annot_combo.currentText() - context.selection = widget.tableview.selectionModel().selected_items() + context.selection = widget.tableview.selectionModel().selectedItems() def settings_to_widget(self, widget, *args): context = widget.current_context widget.annotation_idx = context.annotations.index(context.annotation) - widget.tableview.selectionModel().set_selected_items(context.selection) + widget.tableview.selectionModel().setSelectedItems(context.selection) class OWDistanceMatrix(widget.OWWidget): @@ -284,7 +249,7 @@ def set_distances(self, distances): self.distances = distances self.tablemodel.set_data(self.distances) self.selection = [] - self.tableview.selectionModel().set_selected_items([]) + self.tableview.selectionModel().clear() self.items = items = distances is not None and distances.row_items annotations = ["None", "Enumerate"] @@ -330,21 +295,19 @@ def _update_labels(self): var = self.annot_combo.model()[self.annotation_idx] column, _ = self.items.get_column_view(var) labels = [var.str_val(value) for value in column] - saved_selection = self.tableview.selectionModel().selected_items() - self.tablemodel.set_labels(labels, var, column) if labels: self.tableview.horizontalHeader().show() self.tableview.verticalHeader().show() else: self.tableview.horizontalHeader().hide() self.tableview.verticalHeader().hide() + self.tablemodel.set_labels(labels, var, column) self.tableview.resizeColumnsToContents() - self.tableview.selectionModel().set_selected_items(saved_selection) def commit(self): sub_table = sub_distances = None if self.distances is not None: - inds = self.tableview.selectionModel().selected_items() + inds = self.tableview.selectionModel().selectedItems() if inds: sub_distances = self.distances.submatrix(inds) if self.distances.axis and isinstance(self.items, Table): diff --git a/Orange/widgets/utils/headerview.py b/Orange/widgets/utils/headerview.py new file mode 100644 index 00000000000..4551c9af9a9 --- /dev/null +++ b/Orange/widgets/utils/headerview.py @@ -0,0 +1,227 @@ +from AnyQt.QtCore import Qt, QRect +from AnyQt.QtGui import QBrush, QIcon, QCursor, QPalette, QPainter, QMouseEvent +from AnyQt.QtWidgets import ( + QHeaderView, QStyleOptionHeader, QStyle, QApplication +) + + +class HeaderView(QHeaderView): + """ + A QHeaderView reimplementing `paintSection` to better deal with + selections in large models. + + In particular: + * `isColumnSelected`/`isRowSelected` are never queried, only + `rowIntersectsSelection`/`columnIntersectsSelection` are used. + * when `highlightSections` is not enabled the selection model is not + queried at all. + """ + def __init__(self, *args, **kwargs): + self.__pressed = -1 # Tracking the pressed section index + super().__init__(*args, **kwargs) + + def set_pressed(index): + self.__pressed = index + self.sectionPressed.connect(set_pressed) + self.sectionEntered.connect(set_pressed) + # Workaround for QTBUG-89910 + self.setFont(QApplication.font("QHeaderView")) + + def mouseReleaseEvent(self, event: QMouseEvent): + self.__pressed = -1 + super().mouseReleaseEvent(event) + + def __sectionIntersectsSelection(self, logicalIndex: int) -> bool: + selmodel = self.selectionModel() + if selmodel is None: + return False # pragma: no cover + root = self.rootIndex() + if self.orientation() == Qt.Horizontal: + return selmodel.columnIntersectsSelection(logicalIndex, root) + else: + return selmodel.rowIntersectsSelection(logicalIndex, root) + + def __isFirstVisibleSection(self, visualIndex): + log = self.logicalIndex(visualIndex) + if log != -1: + return (self.sectionPosition(log) == 0 and + self.sectionSize(log) > 0) + else: + return False # pragma: no cover + + def __isLastVisibleSection(self, visualIndex): + log = self.logicalIndex(visualIndex) + if log != -1: + pos = self.sectionPosition(log) + size = self.sectionSize(log) + return size > 0 and pos + size == self.length() + else: + return False # pragma: no cover + + # pylint: disable=too-many-branches + def initStyleOptionForIndex( + self, option: QStyleOptionHeader, logicalIndex: int + ) -> None: + """ + Similar to initStyleOptionForIndex in Qt 6.0 with the difference that + `isSectionSelected` is not used, only `sectionIntersectsSelection` + is used (isSectionSelected will scan the entire model column/row + when the whole column/row is selected). + """ + hover = self.logicalIndexAt(self.mapFromGlobal(QCursor.pos())) + pressed = self.__pressed + + if self.highlightSections(): + is_selected = self.__sectionIntersectsSelection + else: + is_selected = lambda _: False + + state = QStyle.State_None + if self.isEnabled(): + state |= QStyle.State_Enabled + if self.window().isActiveWindow(): + state |= QStyle.State_Active + if self.sectionsClickable(): + if logicalIndex == hover: + state |= QStyle.State_MouseOver + if logicalIndex == pressed: + state |= QStyle.State_Sunken + if self.highlightSections(): + if is_selected(logicalIndex): + state |= QStyle.State_On + if self.isSortIndicatorShown() and \ + self.sortIndicatorSection() == logicalIndex: + option.sortIndicator = ( + QStyleOptionHeader.SortDown + if self.sortIndicatorOrder() == Qt.AscendingOrder + else QStyleOptionHeader.SortUp + ) + + style = self.style() + model = self.model() + orientation = self.orientation() + textAlignment = model.headerData(logicalIndex, self.orientation(), + Qt.TextAlignmentRole) + defaultAlignment = self.defaultAlignment() + textAlignment = (textAlignment if isinstance(textAlignment, int) + else defaultAlignment) + + option.section = logicalIndex + option.state |= state + option.textAlignment = Qt.Alignment(textAlignment) + + option.iconAlignment = Qt.AlignVCenter + text = model.headerData(logicalIndex, self.orientation(), + Qt.DisplayRole) + text = str(text) if text is not None else "" + option.text = text + + icon = model.headerData( + logicalIndex, self.orientation(), Qt.DecorationRole) + try: + option.icon = QIcon(icon) + except (TypeError, ValueError): # pragma: no cover + pass + + margin = 2 * style.pixelMetric(QStyle.PM_HeaderMargin, None, self) + + headerArrowAlignment = style.styleHint(QStyle.SH_Header_ArrowAlignment, + None, self) + isHeaderArrowOnTheSide = headerArrowAlignment & Qt.AlignVCenter + if self.isSortIndicatorShown() and \ + self.sortIndicatorSection() == logicalIndex \ + and isHeaderArrowOnTheSide: + margin += style.pixelMetric(QStyle.PM_HeaderMarkSize, None, self) + + if not option.icon.isNull(): + margin += style.pixelMetric(QStyle.PM_SmallIconSize, None, self) + margin += style.pixelMetric(QStyle.PM_HeaderMargin, None, self) + + if self.textElideMode() != Qt.ElideNone: + elideMode = self.textElideMode() + if hasattr(option, 'textElideMode'): # Qt 6.0 + option.textElideMode = elideMode # pragma: no cover + else: + option.text = option.fontMetrics.elidedText( + option.text, elideMode, option.rect.width() - margin) + + foregroundBrush = model.headerData(logicalIndex, orientation, + Qt.ForegroundRole) + try: + foregroundBrush = QBrush(foregroundBrush) + except (TypeError, ValueError): + pass + else: + option.palette.setBrush(QPalette.ButtonText, foregroundBrush) + + backgroundBrush = model.headerData(logicalIndex, orientation, + Qt.BackgroundRole) + try: + backgroundBrush = QBrush(backgroundBrush) + except (TypeError, ValueError): + pass + else: + option.palette.setBrush(QPalette.Button, backgroundBrush) + option.palette.setBrush(QPalette.Window, backgroundBrush) + + # the section position + visual = self.visualIndex(logicalIndex) + assert visual != -1 + first = self.__isFirstVisibleSection(visual) + last = self.__isLastVisibleSection(visual) + if first and last: + option.position = QStyleOptionHeader.OnlyOneSection + elif first: + option.position = QStyleOptionHeader.Beginning + elif last: + option.position = QStyleOptionHeader.End + else: + option.position = QStyleOptionHeader.Middle + option.orientation = orientation + + # the selected position (in QHeaderView this is always computed even if + # highlightSections is False). + if self.highlightSections(): + previousSelected = is_selected(self.logicalIndex(visual - 1)) + nextSelected = is_selected(self.logicalIndex(visual + 1)) + else: + previousSelected = nextSelected = False + + if previousSelected and nextSelected: + option.selectedPosition = QStyleOptionHeader.NextAndPreviousAreSelected + elif previousSelected: + option.selectedPosition = QStyleOptionHeader.PreviousIsSelected + elif nextSelected: + option.selectedPosition = QStyleOptionHeader.NextIsSelected + else: + option.selectedPosition = QStyleOptionHeader.NotAdjacent + + def paintSection(self, painter, rect, logicalIndex): + # type: (QPainter, QRect, int) -> None + """ + Reimplemented from `QHeaderView`. + """ + # What follows is similar to QHeaderView::paintSection@Qt 6.0 + if not rect.isValid(): + return # pragma: no cover + oldBO = painter.brushOrigin() + + opt = QStyleOptionHeader() + opt.rect = rect + self.initStyleOption(opt) + + oBrushButton = opt.palette.brush(QPalette.Button) + oBrushWindow = opt.palette.brush(QPalette.Window) + + self.initStyleOptionForIndex(opt, logicalIndex) + opt.rect = rect + + nBrushButton = opt.palette.brush(QPalette.Button) + nBrushWindow = opt.palette.brush(QPalette.Window) + + if oBrushButton != nBrushButton or oBrushWindow != nBrushWindow: + painter.setBrushOrigin(opt.rect.topLeft()) + # draw the section + self.style().drawControl(QStyle.CE_Header, opt, painter, self) + + painter.setBrushOrigin(oldBO) diff --git a/Orange/widgets/utils/itemselectionmodel.py b/Orange/widgets/utils/itemselectionmodel.py new file mode 100644 index 00000000000..2638e29233b --- /dev/null +++ b/Orange/widgets/utils/itemselectionmodel.py @@ -0,0 +1,261 @@ +from itertools import chain, starmap, product, groupby, islice +from functools import reduce +from operator import itemgetter +from typing import List, Tuple, Iterable, Sequence, Optional, Union + +from AnyQt.QtCore import ( + QModelIndex, QAbstractItemModel, QItemSelectionModel, QItemSelection, + QObject +) + + +class BlockSelectionModel(QItemSelectionModel): + """ + Item selection model ensuring the selection maintains a simple block + like structure. + + e.g. + + [a b] c [d e] + [f g] h [i j] + + is allowed but this is not + + [a] b c d e + [f g] h [i j] + + I.e. select the Cartesian product of row and column indices. + + """ + def __init__( + self, model: QAbstractItemModel, parent: Optional[QObject] = None, + selectBlocks=True, **kwargs + ) -> None: + super().__init__(model, parent, **kwargs) + self.__selectBlocks = selectBlocks + + def select(self, selection: Union[QItemSelection, QModelIndex], + flags: QItemSelectionModel.SelectionFlags) -> None: + """Reimplemented.""" + if isinstance(selection, QModelIndex): + selection = QItemSelection(selection, selection) + + if not self.__selectBlocks: + super().select(selection, flags) + return + + model = self.model() + + if flags & QItemSelectionModel.Current: # no current selection support + flags &= ~QItemSelectionModel.Current + if flags & QItemSelectionModel.Toggle: # no toggle support either + flags &= ~QItemSelectionModel.Toggle + flags |= QItemSelectionModel.Select + + if flags == QItemSelectionModel.ClearAndSelect: + # extend selection ranges in `selection` to span all row/columns + sel_rows = selection_rows(selection) + sel_cols = selection_columns(selection) + selection = QItemSelection() + for row_range, col_range in \ + product(to_ranges(sel_rows), to_ranges(sel_cols)): + qitemselection_select_range( + selection, model, row_range, col_range + ) + elif flags & (QItemSelectionModel.Select | + QItemSelectionModel.Deselect): + # extend all selection ranges in `selection` with the full current + # row/col spans + rows, cols = selection_blocks(self.selection()) + sel_rows = selection_rows(selection) + sel_cols = selection_columns(selection) + ext_selection = QItemSelection() + for row_range, col_range in \ + product(to_ranges(rows), to_ranges(sel_cols)): + qitemselection_select_range( + ext_selection, model, row_range, col_range + ) + for row_range, col_range in \ + product(to_ranges(sel_rows), to_ranges(cols)): + qitemselection_select_range( + ext_selection, model, row_range, col_range + ) + selection.merge(ext_selection, QItemSelectionModel.Select) + super().select(selection, flags) + + def selectBlocks(self): + """Is the block selection in effect.""" + return self.__selectBlocks + + def setSelectBlocks(self, state): + """Set the block selection state. + + If set to False, the selection model behaves as the base + QItemSelectionModel + + """ + self.__selectBlocks = state + + +def selection_rows(selection): + # type: (QItemSelection) -> List[Tuple[int, int]] + """ + Return a list of ranges for all referenced rows contained in selection + + Parameters + ---------- + selection : QItemSelection + + Returns + ------- + rows : List[Tuple[int, int]] + """ + spans = set(range(s.top(), s.bottom() + 1) for s in selection) + indices = sorted(set(chain.from_iterable(spans))) + return list(ranges(indices)) + + +def selection_columns(selection): + # type: (QItemSelection) -> List[Tuple[int, int]] + """ + Return a list of ranges for all referenced columns contained in selection + + Parameters + ---------- + selection : QItemSelection + + Returns + ------- + rows : List[Tuple[int, int]] + """ + spans = {range(s.left(), s.right() + 1) for s in selection} + indices = sorted(set(chain.from_iterable(spans))) + return list(ranges(indices)) + + +def selection_blocks(selection): + # type: (QItemSelection) -> Tuple[List[Tuple[int, int]], List[Tuple[int, int]]] + if selection.count() > 0: + rowranges = {range(span.top(), span.bottom() + 1) + for span in selection} + colranges = {range(span.left(), span.right() + 1) + for span in selection} + else: + return [], [] + + rows = sorted(set(chain.from_iterable(rowranges))) + cols = sorted(set(chain.from_iterable(colranges))) + return list(ranges(rows)), list(ranges(cols)) + + +def ranges(indices): + # type: (Iterable[int]) -> Iterable[Tuple[int, int]] + """ + Group consecutive indices into `(start, stop)` tuple 'ranges'. + + >>> list(ranges([1, 2, 3, 5, 3, 4])) + >>> [(1, 4), (5, 6), (3, 5)] + + """ + g = groupby(enumerate(indices), key=lambda t: t[1] - t[0]) + for _, range_ind in g: + range_ind = list(range_ind) + _, start = range_ind[0] + _, end = range_ind[-1] + yield start, end + 1 + + +def merge_ranges( + ranges: Iterable[Tuple[int, int]] +) -> Sequence[Tuple[int, int]]: + def merge_range_seq_accum( + accum: List[Tuple[int, int]], r: Tuple[int, int] + ) -> List[Tuple[int, int]]: + last_start, last_stop = accum[-1] + r_start, r_stop = r + assert last_start <= r_start + if r_start <= last_stop: + # merge into last + accum[-1] = last_start, max(last_stop, r_stop) + else: + # push a new (disconnected) range interval + accum.append(r) + return accum + + ranges = sorted(ranges, key=itemgetter(0)) + if ranges: + return reduce(merge_range_seq_accum, islice(ranges, 1, None), + [ranges[0]]) + else: + return [] + + +def qitemselection_select_range( + selection: QItemSelection, + model: QAbstractItemModel, + rows: range, + columns: range +) -> None: + assert rows.step == 1 and columns.step == 1 + selection.select( + model.index(rows.start, columns.start), + model.index(rows.stop - 1, columns.stop - 1) + ) + + +def to_ranges(spans: Iterable[Tuple[int, int]]) -> Sequence[range]: + return list(starmap(range, spans)) + + +class SymmetricSelectionModel(QItemSelectionModel): + """ + Item selection model ensuring the selection is symmetric + + """ + def select(self, selection: Union[QItemSelection, QModelIndex], + flags: QItemSelectionModel.SelectionFlags) -> None: + if isinstance(selection, QModelIndex): + selection = QItemSelection(selection, selection) + + if flags & QItemSelectionModel.Current: # no current selection support + flags &= ~QItemSelectionModel.Current + if flags & QItemSelectionModel.Toggle: # no toggle support either + flags &= ~QItemSelectionModel.Toggle + flags |= QItemSelectionModel.Select + + model = self.model() + rows, cols = selection_blocks(selection) + sym_ranges = to_ranges(merge_ranges(chain(rows, cols))) + if flags == QItemSelectionModel.ClearAndSelect: + # extend ranges in `selection` to symmetric selection + # row/columns. + selection = QItemSelection() + for rows, cols in product(sym_ranges, sym_ranges): + qitemselection_select_range(selection, model, rows, cols) + elif flags & (QItemSelectionModel.Select | + QItemSelectionModel.Deselect): + # extend ranges in sym_ranges to span all current rows/columns + rows_current, cols_current = selection_blocks(self.selection()) + ext_selection = QItemSelection() + for rrange, crange in product(sym_ranges, sym_ranges): + qitemselection_select_range(selection, model, rrange, crange) + for rrange, crange in product(sym_ranges, to_ranges(cols_current)): + qitemselection_select_range(selection, model, rrange, crange) + for rrange, crange in product(to_ranges(rows_current), sym_ranges): + qitemselection_select_range(selection, model, rrange, crange) + selection.merge(ext_selection, QItemSelectionModel.Select) + super().select(selection, flags) + + def selectedItems(self) -> Sequence[int]: + """Return the indices of the the symmetric selection.""" + ranges_ = starmap(range, selection_rows(self.selection())) + return sorted(chain.from_iterable(ranges_)) + + def setSelectedItems(self, inds: Iterable[int]): + """Set and select the `inds` indices""" + model = self.model() + selection = QItemSelection() + sym_ranges = to_ranges(ranges(inds)) + for rows, cols in product(sym_ranges, sym_ranges): + qitemselection_select_range(selection, model, rows, cols) + self.select(selection, QItemSelectionModel.ClearAndSelect) diff --git a/Orange/widgets/utils/tableview.py b/Orange/widgets/utils/tableview.py new file mode 100644 index 00000000000..0e924182c01 --- /dev/null +++ b/Orange/widgets/utils/tableview.py @@ -0,0 +1,79 @@ +from AnyQt.QtCore import Signal, QItemSelectionModel, Qt, QSize, QEvent +from AnyQt.QtGui import QMouseEvent +from AnyQt.QtWidgets import QTableView, QStyleOptionViewItem, QStyle + +from .headerview import HeaderView + + +def table_view_compact(view: QTableView) -> None: + """ + Give the view a more compact default vertical header section size. + """ + vheader = view.verticalHeader() + option = view.viewOptions() + option.text = "X" + option.features |= QStyleOptionViewItem.HasDisplay + size = view.style().sizeFromContents( + QStyle.CT_ItemViewItem, option, + QSize(20, 20), view + ) + vheader.ensurePolished() + vheader.setDefaultSectionSize( + max(size.height(), vheader.minimumSectionSize()) + ) + + +class TableView(QTableView): + """ + A QTableView subclass that is more suited for displaying large data models. + """ + #: Signal emitted when selection finished. It is not emitted during + #: mouse drag selection updates. + selectionFinished = Signal() + + __mouseDown = False + __selectionDidChange = False + + def __init__(self, *args, **kwargs,): + kwargs.setdefault("horizontalScrollMode", QTableView.ScrollPerPixel) + kwargs.setdefault("verticalScrollMode", QTableView.ScrollPerPixel) + super().__init__(*args, **kwargs) + hheader = HeaderView(Qt.Horizontal, self, highlightSections=True) + vheader = HeaderView(Qt.Vertical, self, highlightSections=True) + hheader.setSectionsClickable(True) + vheader.setSectionsClickable(True) + self.setHorizontalHeader(hheader) + self.setVerticalHeader(vheader) + table_view_compact(self) + + def setSelectionModel(self, selectionModel: QItemSelectionModel) -> None: + """Reimplemented from QTableView""" + sm = self.selectionModel() + if sm is not None: + sm.selectionChanged.disconnect(self.__on_selectionChanged) + super().setSelectionModel(selectionModel) + if selectionModel is not None: + selectionModel.selectionChanged.connect(self.__on_selectionChanged) + + def __on_selectionChanged(self): + if self.__mouseDown: + self.__selectionDidChange = True + else: + self.selectionFinished.emit() + + def mousePressEvent(self, event: QMouseEvent) -> None: + self.__mouseDown = event.button() == Qt.LeftButton + super().mousePressEvent(event) + + def mouseReleaseEvent(self, event: QMouseEvent) -> None: + super().mouseReleaseEvent(event) + if self.__mouseDown and event.button() == Qt.LeftButton: + self.__mouseDown = False + if self.__selectionDidChange: + self.__selectionDidChange = False + self.selectionFinished.emit() + + def changeEvent(self, event: QEvent) -> None: + if event.type() in (QEvent.StyleChange, QEvent.FontChange): + table_view_compact(self) + super().changeEvent(event) diff --git a/Orange/widgets/utils/tests/test_headerview.py b/Orange/widgets/utils/tests/test_headerview.py new file mode 100644 index 00000000000..0078b47fcb5 --- /dev/null +++ b/Orange/widgets/utils/tests/test_headerview.py @@ -0,0 +1,105 @@ +# pylint: disable=all +from AnyQt.QtGui import QStandardItemModel, QIcon, QColor +from AnyQt.QtCore import Qt, QItemSelectionModel, QPoint +from AnyQt.QtWidgets import QStyleOptionHeader, QStyle +from AnyQt.QtTest import QTest + + +from Orange.widgets.tests.base import GuiTest +from Orange.widgets.utils.headerview import HeaderView +from Orange.widgets.utils.textimport import StampIconEngine + + +class TestHeaderView(GuiTest): + def test_header(self): + model = QStandardItemModel() + + hheader = HeaderView(Qt.Horizontal) + vheader = HeaderView(Qt.Vertical) + hheader.setSortIndicatorShown(True) + + # paint with no model. + vheader.grab() + hheader.grab() + + hheader.setModel(model) + vheader.setModel(model) + + hheader.adjustSize() + vheader.adjustSize() + # paint with an empty model + vheader.grab() + hheader.grab() + + model.setRowCount(1) + model.setColumnCount(1) + icon = QIcon(StampIconEngine("A", Qt.red)) + model.setHeaderData(0, Qt.Horizontal, icon, Qt.DecorationRole) + model.setHeaderData(0, Qt.Vertical, icon, Qt.DecorationRole) + model.setHeaderData(0, Qt.Horizontal, QColor(Qt.blue), Qt.ForegroundRole) + model.setHeaderData(0, Qt.Vertical, QColor(Qt.blue), Qt.ForegroundRole) + model.setHeaderData(0, Qt.Horizontal, QColor(Qt.white), Qt.BackgroundRole) + model.setHeaderData(0, Qt.Vertical, QColor(Qt.white), Qt.BackgroundRole) + + # paint with single col/row model + vheader.grab() + hheader.grab() + + model.setRowCount(3) + model.setColumnCount(3) + + hheader.adjustSize() + vheader.adjustSize() + + # paint with single col/row model + vheader.grab() + hheader.grab() + + hheader.setSortIndicator(0, Qt.AscendingOrder) + vheader.setHighlightSections(True) + hheader.setHighlightSections(True) + + vheader.grab() + hheader.grab() + + vheader.setSectionsClickable(True) + hheader.setSectionsClickable(True) + + vheader.grab() + hheader.grab() + + vheader.setTextElideMode(Qt.ElideRight) + hheader.setTextElideMode(Qt.ElideRight) + + selmodel = QItemSelectionModel(model, model) + + vheader.setSelectionModel(selmodel) + hheader.setSelectionModel(selmodel) + + selmodel.select(model.index(1, 1), QItemSelectionModel.Rows | QItemSelectionModel.Select) + selmodel.select(model.index(1, 1), QItemSelectionModel.Columns | QItemSelectionModel.Select) + + vheader.grab() + vheader.grab() + + def test_header_view_clickable(self): + model = QStandardItemModel() + model.setColumnCount(3) + header = HeaderView(Qt.Horizontal) + header.setModel(model) + header.setSectionsClickable(True) + header.adjustSize() + pos = header.sectionViewportPosition(0) + size = header.sectionSize(0) + # center of first section + point = QPoint(pos + size // 2, header.viewport().height() / 2) + QTest.mousePress(header.viewport(), Qt.LeftButton, Qt.NoModifier, point) + + opt = QStyleOptionHeader() + header.initStyleOptionForIndex(opt, 0) + self.assertTrue(opt.state & QStyle.State_Sunken) + + QTest.mouseRelease(header.viewport(), Qt.LeftButton, Qt.NoModifier, point) + opt = QStyleOptionHeader() + header.initStyleOptionForIndex(opt, 0) + self.assertFalse(opt.state & QStyle.State_Sunken) diff --git a/Orange/widgets/utils/tests/test_itemselectionmodel.py b/Orange/widgets/utils/tests/test_itemselectionmodel.py new file mode 100644 index 00000000000..1265ac01aaf --- /dev/null +++ b/Orange/widgets/utils/tests/test_itemselectionmodel.py @@ -0,0 +1,50 @@ +from unittest import TestCase +from typing import Tuple, Set + +from AnyQt.QtCore import QItemSelectionModel +from AnyQt.QtGui import QStandardItemModel + +from Orange.widgets.utils.itemselectionmodel import BlockSelectionModel, \ + SymmetricSelectionModel + + +def selected(sel: QItemSelectionModel) -> Set[Tuple[int, int]]: + return set((r.row(), r.column()) for r in sel.selectedIndexes()) + + +class TestBlockSelectionModel(TestCase): + def test_blockselectionmodel(self): + model = QStandardItemModel() + model.setRowCount(4) + model.setColumnCount(4) + sel = BlockSelectionModel(model) + sel.select(model.index(0, 0), BlockSelectionModel.Select) + self.assertSetEqual(selected(sel), {(0, 0)}) + sel.select(model.index(0, 1), BlockSelectionModel.Select) + self.assertSetEqual(selected(sel), {(0, 0), (0, 1)}) + sel.select(model.index(1, 1), BlockSelectionModel.Select) + self.assertSetEqual(selected(sel), {(0, 0), (0, 1), (1, 0), (1, 1)}) + sel.select(model.index(0, 0), BlockSelectionModel.Deselect) + self.assertSetEqual(selected(sel), {(1, 1)}) + sel.select(model.index(3, 3), BlockSelectionModel.ClearAndSelect) + self.assertSetEqual(selected(sel), {(3, 3)}) + + +class TestSymmetricSelectionModel(TestCase): + def test_symmetricselectionmodel(self): + model = QStandardItemModel() + model.setRowCount(4) + model.setColumnCount(4) + sel = SymmetricSelectionModel(model) + sel.select(model.index(0, 0), BlockSelectionModel.Select) + self.assertSetEqual(selected(sel), {(0, 0)}) + sel.select(model.index(0, 2), BlockSelectionModel.Select) + self.assertSetEqual(selected(sel), {(0, 0), (0, 2), (2, 0), (2, 2)}) + sel.select(model.index(0, 0), BlockSelectionModel.Deselect) + self.assertSetEqual(selected(sel), {(2, 2)}) + sel.select(model.index(2, 3), BlockSelectionModel.ClearAndSelect) + self.assertSetEqual(selected(sel), {(2, 2), (2, 3), (3, 2), (3, 3)}) + + self.assertSetEqual(set(sel.selectedItems()), {2, 3}) + sel.setSelectedItems([1, 2]) + self.assertSetEqual(set(sel.selectedItems()), {1, 2}) diff --git a/Orange/widgets/utils/tests/test_tableview.py b/Orange/widgets/utils/tests/test_tableview.py new file mode 100644 index 00000000000..01c51af928e --- /dev/null +++ b/Orange/widgets/utils/tests/test_tableview.py @@ -0,0 +1,37 @@ +from AnyQt.QtCore import Qt +from AnyQt.QtGui import QStandardItemModel +from AnyQt.QtTest import QTest, QSignalSpy + +from Orange.widgets.tests.base import GuiTest +from Orange.widgets.utils.tableview import TableView + + +class TestTableView(GuiTest): + def test_table_view_selection_finished(self): + model = QStandardItemModel() + model.setRowCount(10) + model.setColumnCount(4) + + view = TableView() + view.setModel(model) + view.adjustSize() + + spy = QSignalSpy(view.selectionFinished) + rect0 = view.visualRect(model.index(0, 0)) + rect4 = view.visualRect(model.index(4, 2)) + QTest.mousePress( + view.viewport(), Qt.LeftButton, Qt.NoModifier, rect0.center(), + ) + self.assertEqual(len(spy), 0) + QTest.mouseRelease( + view.viewport(), Qt.LeftButton, Qt.NoModifier, rect4.center(), + ) + self.assertEqual(len(spy), 1) + + def test_table_view_default_vsection_size(self): + view = TableView() + vheader = view.verticalHeader() + font = view.font() + font.setPixelSize(38) + view.setFont(font) + self.assertGreaterEqual(vheader.defaultSectionSize(), 38)