Skip to content

Commit

Permalink
Merge pull request #5164 from ales-erjavec/header-view-paint-section
Browse files Browse the repository at this point in the history
[FIX] Data Table: Fix freeze with large selections
  • Loading branch information
janezd authored Jan 15, 2021
2 parents 3bfb8f5 + 82fbbe7 commit c25f185
Show file tree
Hide file tree
Showing 5 changed files with 452 additions and 39 deletions.
43 changes: 4 additions & 39 deletions Orange/widgets/data/owtable.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,12 +17,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
Expand All @@ -33,6 +33,7 @@

from Orange.widgets import gui
from Orange.widgets.settings import Setting
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
Expand Down Expand Up @@ -363,42 +364,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

Expand Down Expand Up @@ -500,7 +466,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)
Expand Down
227 changes: 227 additions & 0 deletions Orange/widgets/utils/headerview.py
Original file line number Diff line number Diff line change
@@ -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)
79 changes: 79 additions & 0 deletions Orange/widgets/utils/tableview.py
Original file line number Diff line number Diff line change
@@ -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)
Loading

0 comments on commit c25f185

Please sign in to comment.