From 86e32aeff6ecd1533d0e069e2e8e8efa2cce0cb5 Mon Sep 17 00:00:00 2001 From: Ales Erjavec Date: Mon, 8 Jul 2019 17:20:29 +0200 Subject: [PATCH 01/13] utils/stickygraphicsview: Add a StickyGraphicsView utility --- Orange/widgets/utils/stickygraphicsview.py | 242 ++++++++++++++++++ .../utils/tests/test_stickygraphicsview.py | 71 +++++ 2 files changed, 313 insertions(+) create mode 100644 Orange/widgets/utils/stickygraphicsview.py create mode 100644 Orange/widgets/utils/tests/test_stickygraphicsview.py diff --git a/Orange/widgets/utils/stickygraphicsview.py b/Orange/widgets/utils/stickygraphicsview.py new file mode 100644 index 00000000000..050f07154a8 --- /dev/null +++ b/Orange/widgets/utils/stickygraphicsview.py @@ -0,0 +1,242 @@ +import sys +import math + +from PyQt5.QtCore import Qt, QRectF, QEvent, QCoreApplication, QObject +from PyQt5.QtGui import QBrush +from PyQt5.QtWidgets import ( + QGraphicsView, QGraphicsScene, QWidget, QVBoxLayout, QSizePolicy, + QScrollBar, +) + +from orangewidget.utils.overlay import OverlayWidget + +__all__ = [ + "StickyGraphicsView" +] + + +class _OverlayWidget(OverlayWidget): + def eventFilter(self, recv: QObject, event: QEvent) -> bool: + if event.type() in (QEvent.Show, QEvent.Hide) and recv is self.widget(): + return False + else: + return super().eventFilter(recv, event) + + +class _HeaderGraphicsView(QGraphicsView): + def viewportEvent(self, event: QEvent) -> bool: + if event.type() == QEvent.Wheel: + # delegate wheel events to parent StickyGraphicsView + parent = self.parent().parent().parent() + if isinstance(parent, StickyGraphicsView): + QCoreApplication.sendEvent(parent.viewport(), event) + if event.isAccepted(): + return True + return super().viewportEvent(event) + + +class StickyGraphicsView(QGraphicsView): + """ + A graphics view with sticky header/footer views. + + Set the scene rect of the header/footer geometry with + setHeaderRect/setFooterRect. When scrolling they will be displayed + top/bottom of the viewport. + """ + def __init__(self, *args, **kwargs) -> None: + if args and isinstance(args[0], QGraphicsScene): + scene, args = args[0], args[1:] + else: + scene = None + super().__init__(*args, **kwargs) + self.__headerRect = QRectF() + self.__footerRect = QRectF() + self.__headerView: QGraphicsView = ... + self.__footerView: QGraphicsView = ... + self.setAlignment(Qt.AlignLeft | Qt.AlignVCenter) + self.setupViewport(self.viewport()) + + if scene is not None: + self.setScene(scene) + + def setHeaderSceneRect(self, rect: QRectF) -> None: + """ + Set the header scene rect. + + Parameters + ---------- + rect : QRectF + """ + if self.__headerRect != rect: + self.__headerRect = QRectF(rect) + self.__updateHeader() + + def headerSceneRect(self) -> QRectF: + return QRectF(self.__headerRect) + + def setFooterSceneRect(self, rect: QRectF) -> None: + """ + Set the footer scene rect. + + Parameters + ---------- + rect : QRectF + """ + if self.__footerRect != rect: + self.__footerRect = QRectF(rect) + self.__updateFooter() + + def footerSceneRect(self) -> QRectF: + return QRectF(self.__headerRect) + + def setScene(self, scene: QGraphicsScene) -> None: + """Reimplemented""" + super().setScene(scene) + self.headerView().setScene(scene) + self.footerView().setScene(scene) + self.__headerRect = QRectF() + self.__footerRect = QRectF() + + def setupViewport(self, widget: QWidget) -> None: + """Reimplemented""" + super().setupViewport(widget) + sp = QSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) + header = _HeaderGraphicsView( + objectName="sticky-header-view", sizePolicy=sp, + verticalScrollBarPolicy=Qt.ScrollBarAlwaysOff, + horizontalScrollBarPolicy=Qt.ScrollBarAlwaysOff, + alignment=self.alignment() + ) + header.setFocusProxy(self) + header.viewport().installEventFilter(self) + header.setFrameStyle(QGraphicsView.NoFrame) + + footer = _HeaderGraphicsView( + objectName="sticky-footer-view", sizePolicy=sp, + verticalScrollBarPolicy=Qt.ScrollBarAlwaysOff, + horizontalScrollBarPolicy=Qt.ScrollBarAlwaysOff, + alignment=self.alignment() + ) + footer.setFocusProxy(self) + footer.viewport().installEventFilter(self) + footer.setFrameStyle(QGraphicsView.NoFrame) + + over = _OverlayWidget( + widget, objectName="sticky-header-overlay-container", + alignment=Qt.AlignTop, + sizePolicy=sp, + visible=False, + ) + over.setLayout(QVBoxLayout(margin=0)) + over.layout().addWidget(header) + over.setWidget(widget) + + over = _OverlayWidget( + widget, objectName="sticky-footer-overlay-container", + alignment=Qt.AlignBottom, + sizePolicy=sp, + visible=False + ) + over.setLayout(QVBoxLayout(margin=0)) + over.layout().addWidget(footer) + over.setWidget(widget) + + def bind(source: QScrollBar, target: QScrollBar) -> None: + # bind target scroll bar to `source` (range and value). + target.setRange(source.minimum(), source.maximum()) + target.setValue(source.value()) + source.rangeChanged.connect(target.setRange) + source.valueChanged.connect(target.setValue) + + hbar = self.horizontalScrollBar() + footer_hbar = footer.horizontalScrollBar() + header_hbar = header.horizontalScrollBar() + + bind(hbar, footer_hbar) + bind(hbar, header_hbar) + + self.__headerView = header + self.__footerView = footer + self.__updateView(header, self.__footerRect) + self.__updateView(footer, self.__footerRect) + + def headerView(self) -> QGraphicsView: + """ + Return the header view. + + Returns + ------- + view: QGraphicsView + """ + return self.__headerView + + def footerView(self) -> QGraphicsView: + """ + Return the footer view. + + Returns + ------- + view: QGraphicsView + """ + return self.__footerView + + def __updateView(self, view: QGraphicsView, rect: QRectF) -> None: + view.setSceneRect(rect) + view.setFixedHeight(int(math.ceil(rect.height()))) + container = view.parent() + if rect.isEmpty(): + container.setVisible(False) + return + # map the rect to (main) viewport coordinates + viewrect = self.mapFromScene(rect).boundingRect() + viewportrect = self.viewport().rect() + visible = not (viewrect.top() >= viewportrect.top() + and viewrect.bottom() <= viewportrect.bottom()) + container.setVisible(visible) + # force immediate layout of the container overlay + QCoreApplication.sendEvent(container, QEvent(QEvent.LayoutRequest)) + + def __updateHeader(self) -> None: + view = self.headerView() + self.__updateView(view, self.__headerRect) + + def __updateFooter(self) -> None: + view = self.footerView() + self.__updateView(view, self.__footerRect) + + def scrollContentsBy(self, dx: int, dy: int) -> None: + """Reimplemented.""" + super().scrollContentsBy(dx, dy) + self.__updateFooter() + self.__updateHeader() + + def viewportEvent(self, event: QEvent) -> bool: + """Reimplemented.""" + if event.type() == QEvent.Resize: + self.__updateHeader() + self.__updateFooter() + return super().viewportEvent(event) + + +def main(args): # pragma: no cover + from PyQt5.QtWidgets import QApplication + app = QApplication(args) + view = StickyGraphicsView() + scene = QGraphicsScene(view) + scene.setBackgroundBrush(QBrush(Qt.lightGray, Qt.CrossPattern)) + view.setScene(scene) + scene.addRect( + QRectF(0, 0, 300, 20), Qt.red, QBrush(Qt.red, Qt.BDiagPattern)) + scene.addRect(QRectF(0, 25, 300, 100)) + scene.addRect( + QRectF(0, 130, 300, 20), + Qt.darkGray, QBrush(Qt.darkGray, Qt.BDiagPattern) + ) + view.setHeaderSceneRect(QRectF(0, 0, 300, 20)) + view.setFooterSceneRect(QRectF(0, 130, 300, 20)) + view.show() + return app.exec() + + +if __name__ == "__main__": + main(sys.argv) diff --git a/Orange/widgets/utils/tests/test_stickygraphicsview.py b/Orange/widgets/utils/tests/test_stickygraphicsview.py new file mode 100644 index 00000000000..57c5788f495 --- /dev/null +++ b/Orange/widgets/utils/tests/test_stickygraphicsview.py @@ -0,0 +1,71 @@ +from PyQt5.QtCore import Qt, QRectF, QPoint, QPointF +from PyQt5.QtGui import QBrush, QWheelEvent +from PyQt5.QtWidgets import QGraphicsScene, QWidget, QApplication + +from Orange.widgets.tests.base import GuiTest + +from Orange.widgets.utils.stickygraphicsview import StickyGraphicsView + + +class TestStickyGraphicsView(GuiTest): + def test(self): + view = StickyGraphicsView() + scene = QGraphicsScene(view) + scene.setBackgroundBrush(QBrush(Qt.lightGray, Qt.CrossPattern)) + view.setScene(scene) + scene.addRect( + QRectF(0, 0, 300, 20), Qt.red, QBrush(Qt.red, Qt.BDiagPattern)) + scene.addRect(QRectF(0, 25, 300, 100)) + scene.addRect( + QRectF(0, 130, 300, 20), + Qt.darkGray, QBrush(Qt.darkGray, Qt.BDiagPattern) + ) + view.setHeaderSceneRect(QRectF(0, 0, 300, 20)) + view.setFooterSceneRect(QRectF(0, 130, 300, 20)) + + header = view.headerView() + footer = view.footerView() + + view.resize(310, 310) + view.grab() + + self.assertFalse(header.isVisibleTo(view)) + self.assertFalse(footer.isVisibleTo(view)) + + view.resize(310, 100) + view.verticalScrollBar().setValue(0) # scroll to top + view.grab() + + self.assertFalse(header.isVisibleTo(view)) + self.assertTrue(footer.isVisibleTo(view)) + + view.verticalScrollBar().setValue( + view.verticalScrollBar().maximum()) # scroll to bottom + view.grab() + + self.assertTrue(header.isVisibleTo(view)) + self.assertFalse(footer.isVisibleTo(view)) + + qWheelScroll(header.viewport(), angleDelta=QPoint(0, -720 * 8)) + + +def qWheelScroll( + widget: QWidget, buttons=Qt.NoButton, modifiers=Qt.NoModifier, + pos=QPoint(), angleDelta=QPoint(0, 1), +): + if pos.isNull(): + pos = widget.rect().center() + globalPos = widget.mapToGlobal(pos) + + if angleDelta.y() >= angleDelta.x(): + qt4orient = Qt.Vertical + qt4delta = angleDelta.y() + else: + qt4orient = Qt.Horizontal + qt4delta = angleDelta.x() + + event = QWheelEvent( + QPointF(pos), QPointF(globalPos), QPoint(), angleDelta, + qt4delta, qt4orient, buttons, modifiers + ) + QApplication.sendEvent(widget, event) From a37413d24069ef038623723cb62777c0531f5ebe Mon Sep 17 00:00:00 2001 From: Ales Erjavec Date: Fri, 3 May 2019 15:26:42 +0200 Subject: [PATCH 02/13] owhierarchicalclustering: Move dendrogram and axis to the same scene Use sticky header/footers to keep the scale visible --- .../unsupervised/owhierarchicalclustering.py | 188 +++++++++--------- .../tests/test_owhierarchicalclustering.py | 15 +- 2 files changed, 99 insertions(+), 104 deletions(-) diff --git a/Orange/widgets/unsupervised/owhierarchicalclustering.py b/Orange/widgets/unsupervised/owhierarchicalclustering.py index b031a72a20d..ca114b5df63 100644 --- a/Orange/widgets/unsupervised/owhierarchicalclustering.py +++ b/Orange/widgets/unsupervised/owhierarchicalclustering.py @@ -3,21 +3,22 @@ from contextlib import contextmanager import typing -from typing import Any, List, Tuple, Dict, Optional, Set +from typing import Any, List, Tuple, Dict, Optional, Set, Union import numpy as np from AnyQt.QtWidgets import ( QGraphicsWidget, QGraphicsObject, QGraphicsLinearLayout, QGraphicsPathItem, - QGraphicsScene, QGraphicsView, QGridLayout, QFormLayout, QSizePolicy, + QGraphicsScene, QGridLayout, QFormLayout, QSizePolicy, QGraphicsSimpleTextItem, QGraphicsLayoutItem, QAction, QComboBox, - QGraphicsItemGroup) + QGraphicsItemGroup, QGraphicsGridLayout, QGraphicsSceneMouseEvent +) from AnyQt.QtGui import ( QTransform, QPainterPath, QPainterPathStroker, QColor, QBrush, QPen, QFont, QFontMetrics, QPolygonF, QKeySequence ) from AnyQt.QtCore import Qt, QSize, QSizeF, QPointF, QRectF, QLineF, QEvent -from AnyQt.QtCore import pyqtSignal as Signal +from AnyQt.QtCore import pyqtSignal as Signal, pyqtSlot as Slot import pyqtgraph as pg @@ -36,6 +37,8 @@ from Orange.widgets.utils.widgetpreview import WidgetPreview from Orange.widgets.widget import Input, Output, Msg +from Orange.widgets.utils.stickygraphicsview import StickyGraphicsView + __all__ = ["OWHierarchicalClustering"] @@ -1011,41 +1014,31 @@ def __init__(self): box=False) self.scene = QGraphicsScene() - self.view = QGraphicsView( + self.view = StickyGraphicsView( self.scene, horizontalScrollBarPolicy=Qt.ScrollBarAlwaysOff, verticalScrollBarPolicy=Qt.ScrollBarAlwaysOn, alignment=Qt.AlignLeft | Qt.AlignVCenter ) + self.mainArea.layout().setSpacing(1) + self.mainArea.layout().addWidget(self.view) def axis_view(orientation): - ax = pg.AxisItem(orientation=orientation, maxTickLength=7) - scene = QGraphicsScene() - scene.addItem(ax) - view = QGraphicsView( - scene, - horizontalScrollBarPolicy=Qt.ScrollBarAlwaysOff, - verticalScrollBarPolicy=Qt.ScrollBarAlwaysOn, - alignment=Qt.AlignLeft | Qt.AlignVCenter - ) - view.setFixedHeight(ax.size().height()) - ax.line = SliderLine(orientation=Qt.Horizontal, - length=ax.size().height()) - scene.addItem(ax.line) - return view, ax + ax = AxisItem(orientation=orientation, maxTickLength=7) + ax.mousePressed.connect(self._activate_cut_line) + ax.mouseMoved.connect(self._activate_cut_line) + ax.mouseReleased.connect(self._activate_cut_line) + return ax - self.top_axis_view, self.top_axis = axis_view("top") - self.mainArea.layout().setSpacing(1) - self.mainArea.layout().addWidget(self.top_axis_view) - self.mainArea.layout().addWidget(self.view) - self.bottom_axis_view, self.bottom_axis = axis_view("bottom") - self.mainArea.layout().addWidget(self.bottom_axis_view) + self.top_axis = axis_view("top") + self.bottom_axis = axis_view("bottom") self._main_graphics = QGraphicsWidget() - self._main_layout = QGraphicsLinearLayout(Qt.Horizontal) - self._main_layout.setSpacing(10) + scenelayout = QGraphicsGridLayout() + scenelayout.setHorizontalSpacing(10) + scenelayout.setVerticalSpacing(10) - self._main_graphics.setLayout(self._main_layout) + self._main_graphics.setLayout(scenelayout) self.scene.addItem(self._main_graphics) self.dendrogram = DendrogramWidget() @@ -1060,26 +1053,22 @@ def axis_view(orientation): self.labels.setMaximumWidth(200) self.labels.layout().setSpacing(0) - self._main_layout.addItem(self.dendrogram) - self._main_layout.addItem(self.labels) - - self._main_layout.setAlignment( - self.dendrogram, Qt.AlignLeft | Qt.AlignVCenter) - self._main_layout.setAlignment( - self.labels, Qt.AlignLeft | Qt.AlignVCenter) - + scenelayout.addItem(self.top_axis, 0, 0, + alignment=Qt.AlignLeft | Qt.AlignVCenter) + scenelayout.addItem(self.dendrogram, 1, 0, + alignment=Qt.AlignLeft | Qt.AlignVCenter) + scenelayout.addItem(self.labels, 1, 1, + alignment=Qt.AlignLeft | Qt.AlignVCenter) + scenelayout.addItem(self.bottom_axis, 2, 0, + alignment=Qt.AlignLeft | Qt.AlignVCenter) self.view.viewport().installEventFilter(self) - self.top_axis_view.viewport().installEventFilter(self) - self.bottom_axis_view.viewport().installEventFilter(self) self._main_graphics.installEventFilter(self) - self.cut_line = SliderLine(self.dendrogram, + self.top_axis.setZValue(self.dendrogram.zValue() + 10) + self.bottom_axis.setZValue(self.dendrogram.zValue() + 10) + self.cut_line = SliderLine(self.top_axis, orientation=Qt.Horizontal) self.cut_line.valueChanged.connect(self._dendrogram_slider_changed) - self.cut_line.hide() - - self.bottom_axis.line.valueChanged.connect(self._axis_slider_changed) - self.top_axis.line.valueChanged.connect(self._axis_slider_changed) self.dendrogram.geometryChanged.connect(self._dendrogram_geom_changed) self._set_cut_line_visible(self.selection_method == 1) self.__update_font_scale() @@ -1421,18 +1410,15 @@ def eventFilter(self, obj, event): event.type() == QEvent.LayoutRequest: # layout preserving the width (vertical re layout) self.__layout_main_graphics() - elif event.type() == QEvent.MouseButtonPress and \ - (obj is self.top_axis_view.viewport() or - obj is self.bottom_axis_view.viewport()): - self.selection_method = 1 - # Map click point to cut line local coordinates - pos = self.top_axis_view.mapToScene(event.pos()) - cut = self.top_axis.line.mapFromScene(pos) - self.top_axis.line.setValue(cut.x()) - # update the line visibility, output, ... - self._selection_method_changed() return super().eventFilter(obj, event) + @Slot(QPointF) + def _activate_cut_line(self, pos: QPointF): + """Activate cut line selection an set cut value to `pos.x()`.""" + self.selection_method = 1 + self.cut_line.setValue(pos.x()) + self._selection_method_changed() + def onDeleteWidget(self): super().onDeleteWidget() self._clear_plot() @@ -1442,30 +1428,28 @@ def onDeleteWidget(self): def _dendrogram_geom_changed(self): pos = self.dendrogram.pos_at_height(self.cutoff_height) geom = self.dendrogram.geometry() - crect = self.dendrogram.contentsRect() - self._set_slider_value(pos.x(), geom.width()) - self.cut_line.setLength(geom.height()) - - self.top_axis.resize(crect.width(), self.top_axis.height()) - self.top_axis.setPos(geom.left() + crect.left(), 0) - self.top_axis.line.setPos(self.cut_line.scenePos().x(), 0) - self.bottom_axis.resize(crect.width(), self.bottom_axis.height()) - self.bottom_axis.setPos(geom.left() + crect.left(), 0) - self.bottom_axis.line.setPos(self.cut_line.scenePos().x(), 0) + self.cut_line.setLength( + self.bottom_axis.geometry().bottom() + - self.top_axis.geometry().top() + ) geom = self._main_graphics.geometry() assert geom.topLeft() == QPointF(0, 0) - self.scene.setSceneRect(geom) - - geom.setHeight(self.top_axis.size().height()) - self.top_axis.scene().setSceneRect(geom) - self.bottom_axis.scene().setSceneRect(geom) + def adjustLeft(rect): + rect = QRectF(rect) + rect.setLeft(geom.left()) + return rect - def _axis_slider_changed(self, value): - self.cut_line.setValue(value) + self.view.setSceneRect(geom) + self.view.setHeaderSceneRect( + adjustLeft(self.top_axis.geometry()).adjusted(0, 0, 0, 1) + ) + self.view.setFooterSceneRect( + adjustLeft(self.bottom_axis.geometry()).adjusted(0, 0, 0, -1) + ) def _dendrogram_slider_changed(self, value): p = QPointF(value, 0) @@ -1473,22 +1457,11 @@ def _dendrogram_slider_changed(self, value): self.set_cutoff_height(cl_height) - # Sync the cut positions between the dendrogram and the axis. - self._set_slider_value(value, self.dendrogram.size().width()) - def _set_slider_value(self, value, span): with blocked(self.cut_line): self.cut_line.setRange(0, span) self.cut_line.setValue(value) - with blocked(self.top_axis.line): - self.top_axis.line.setRange(0, span) - self.top_axis.line.setValue(value) - - with blocked(self.bottom_axis.line): - self.bottom_axis.line.setRange(0, span) - self.bottom_axis.line.setValue(value) - def set_cutoff_height(self, height): self.cutoff_height = height if self.root: @@ -1497,8 +1470,6 @@ def set_cutoff_height(self, height): def _set_cut_line_visible(self, visible): self.cut_line.setVisible(visible) - self.top_axis.line.setVisible(visible) - self.bottom_axis.line.setVisible(visible) def select_top_n(self, n): root = self._displayed_root @@ -1776,6 +1747,31 @@ def setToolTip(self, tip): self.item.setToolTip(tip) +class AxisItem(pg.AxisItem): + mousePressed = Signal(QPointF, Qt.MouseButton) + mouseMoved = Signal(QPointF, Qt.MouseButtons) + mouseReleased = Signal(QPointF, Qt.MouseButton) + + #: \reimp + def wheelEvent(self, event): + event.ignore() # ignore event to propagate to the view -> scroll + + def mousePressEvent(self, event: QGraphicsSceneMouseEvent) -> None: + self.mousePressed.emit(event.pos(), event.button()) + super().mousePressEvent(event) + event.accept() + + def mouseMoveEvent(self, event): + self.mouseMoved.emit(event.pos(), event.buttons()) + super().mouseMoveEvent(event) + event.accept() + + def mouseReleaseEvent(self, event): + self.mouseReleased.emit(event.pos(), event.button()) + super().mouseReleaseEvent(event) + event.accept() + + class SliderLine(QGraphicsObject): """A movable slider line.""" valueChanged = Signal(float) @@ -1792,7 +1788,7 @@ def __init__(self, parent=None, orientation=Qt.Vertical, value=0.0, self._length = length self._min = 0.0 self._max = 1.0 - self._line = QLineF() + self._line = QLineF() # type: Optional[QLineF] self._pen = QPen() super().__init__(parent, **kwargs) @@ -1805,7 +1801,7 @@ def __init__(self, parent=None, orientation=Qt.Vertical, value=0.0, else: self.setCursor(Qt.SizeHorCursor) - def setPen(self, pen): + def setPen(self, pen: Union[QPen, Qt.GlobalColor, Qt.PenStyle]) -> None: pen = QPen(pen) if self._pen != pen: self.prepareGeometryChange() @@ -1813,10 +1809,10 @@ def setPen(self, pen): self._line = None self.update() - def pen(self): + def pen(self) -> QPen: return QPen(self._pen) - def setValue(self, value): + def setValue(self, value: float): value = min(max(value, self._min), self._max) if self._value != value: @@ -1825,10 +1821,10 @@ def setValue(self, value): self._line = None self.valueChanged.emit(value) - def value(self): + def value(self) -> float: return self._value - def setRange(self, minval, maxval): + def setRange(self, minval: float, maxval: float) -> None: maxval = max(minval, maxval) if minval != self._min or maxval != self._max: self._min = minval @@ -1836,16 +1832,16 @@ def setRange(self, minval, maxval): self.rangeChanged.emit(minval, maxval) self.setValue(self._value) - def setLength(self, length): + def setLength(self, length: float): if self._length != length: self.prepareGeometryChange() self._length = length self._line = None - def length(self): + def length(self) -> float: return self._length - def setOrientation(self, orientation): + def setOrientation(self, orientation: Qt.Orientation): if self._orientation != orientation: self.prepareGeometryChange() self._orientation = orientation @@ -1855,11 +1851,11 @@ def setOrientation(self, orientation): else: self.setCursor(Qt.SizeHorCursor) - def mousePressEvent(self, event): + def mousePressEvent(self, event: QGraphicsSceneMouseEvent) -> None: event.accept() self.linePressed.emit() - def mouseMoveEvent(self, event): + def mouseMoveEvent(self, event: QGraphicsSceneMouseEvent) -> None: pos = event.pos() if self._orientation == Qt.Vertical: self.setValue(pos.y()) @@ -1868,7 +1864,7 @@ def mouseMoveEvent(self, event): self.lineMoved.emit() event.accept() - def mouseReleaseEvent(self, event): + def mouseReleaseEvent(self, event: QGraphicsSceneMouseEvent) -> None: if self._orientation == Qt.Vertical: self.setValue(event.pos().y()) else: @@ -1876,7 +1872,7 @@ def mouseReleaseEvent(self, event): self.lineReleased.emit() event.accept() - def boundingRect(self): + def boundingRect(self) -> QRectF: if self._line is None: if self._orientation == Qt.Vertical: self._line = QLineF(0, self._value, self._length, self._value) diff --git a/Orange/widgets/unsupervised/tests/test_owhierarchicalclustering.py b/Orange/widgets/unsupervised/tests/test_owhierarchicalclustering.py index 284e637605f..5529bebc820 100644 --- a/Orange/widgets/unsupervised/tests/test_owhierarchicalclustering.py +++ b/Orange/widgets/unsupervised/tests/test_owhierarchicalclustering.py @@ -4,8 +4,8 @@ import numpy as np -from AnyQt.QtCore import QPoint, Qt, QEvent -from AnyQt.QtGui import QMouseEvent +from AnyQt.QtCore import QPoint, Qt +from AnyQt.QtTest import QTest import Orange.misc from Orange.data import Table, Domain, ContinuousVariable, DiscreteVariable @@ -141,17 +141,16 @@ def test_output_cut_ratio(self): self.assertIsNotNone(annotated) # selecting clusters with cutoff should select all data - self.widget.eventFilter(self.widget.top_axis_view.viewport(), - self._mouse_button_press_event()) + QTest.mousePress( + self.widget.view.headerView().viewport(), + Qt.LeftButton, Qt.NoModifier, + QPoint(100, 10) + ) selected = self.get_output(self.widget.Outputs.selected_data) annotated = self.get_output(self.widget.Outputs.annotated_data) self.assertEqual(len(selected), len(self.data)) self.assertIsNotNone(annotated) - def _mouse_button_press_event(self): - return QMouseEvent(QEvent.MouseButtonPress, QPoint(100, 10), - Qt.LeftButton, Qt.LeftButton, Qt.NoModifier) - def test_retain_selection(self): """Hierarchical Clustering didn't retain selection. GH-1563""" self.send_signal(self.widget.Inputs.distances, self.distances) From 08a76b0ee23db98e54bc1d13481189306bd4e942 Mon Sep 17 00:00:00 2001 From: Ales Erjavec Date: Mon, 8 Jul 2019 21:35:46 +0200 Subject: [PATCH 03/13] owsilhouetteplot: Use sticky header/footer scales --- Orange/widgets/visualize/owsilhouetteplot.py | 39 ++++++++++++++++++-- 1 file changed, 36 insertions(+), 3 deletions(-) diff --git a/Orange/widgets/visualize/owsilhouetteplot.py b/Orange/widgets/visualize/owsilhouetteplot.py index 06423c2ecca..c6e55b5682d 100644 --- a/Orange/widgets/visualize/owsilhouetteplot.py +++ b/Orange/widgets/visualize/owsilhouetteplot.py @@ -10,7 +10,7 @@ import sklearn.metrics from AnyQt.QtWidgets import ( - QGraphicsScene, QGraphicsView, QGraphicsWidget, QGraphicsGridLayout, + QGraphicsScene, QGraphicsWidget, QGraphicsGridLayout, QGraphicsItemGroup, QGraphicsSimpleTextItem, QGraphicsRectItem, QSizePolicy, QStyleOptionGraphicsItem, QWidget, QWIDGETSIZE_MAX, ) @@ -24,6 +24,7 @@ import Orange.distance from Orange.widgets import widget, gui, settings +from Orange.widgets.utils.stickygraphicsview import StickyGraphicsView from Orange.widgets.utils import itemmodels from Orange.widgets.utils.annotated_data import (create_annotated_table, ANNOTATED_DATA_SIGNAL_NAME) @@ -159,7 +160,7 @@ def __init__(self): self.controlArea.layout().addWidget(self.buttonsArea) self.scene = QGraphicsScene() - self.view = QGraphicsView(self.scene) + self.view = StickyGraphicsView(self.scene) self.view.setRenderHint(QPainter.Antialiasing, True) self.view.setAlignment(Qt.AlignTop | Qt.AlignLeft) self.mainArea.layout().addWidget(self.view) @@ -374,7 +375,25 @@ def _update_annotations(self): self._silplot.setRowNames(None) def _update_scene_rect(self): - self.scene.setSceneRect(self._silplot.geometry()) + geom = self._silplot.geometry() + self.scene.setSceneRect(geom) + self.view.setSceneRect(geom) + + header = self._silplot.topScaleItem() + footer = self._silplot.bottomScaleItem() + + def extend_horizontal(rect): + # type: (QRectF) -> QRectF + rect = QRectF(rect) + rect.setLeft(geom.left()) + rect.setRight(geom.right()) + return rect + if header is not None: + self.view.setHeaderSceneRect( + extend_horizontal(header.geometry().adjusted(0, 0, 0, 1))) + if footer is not None: + self.view.setFooterSceneRect( + extend_horizontal(footer.geometry().adjusted(0, -1, 0, 0))) def commit(self): """ @@ -466,6 +485,8 @@ def __init__(self, parent=None, **kwargs): self.__pen = QPen(Qt.NoPen) self.__layout = QGraphicsGridLayout() self.__hoveredItem = None + self.__topScale = None # type: Optional[pg.AxisItem] + self.__bottomScale = None # type: Optional[pg.AxisItem] self.setLayout(self.__layout) self.layout().setColumnSpacing(0, 1.) self.setFocusPolicy(Qt.StrongFocus) @@ -592,6 +613,8 @@ def clear(self): child.setParentItem(None) scene.removeItem(child) self.__groups = [] + self.__topScale = None + self.__bottomScale = None def __setup(self): # Setup the subwidgets/groups/layout @@ -613,6 +636,7 @@ def __setup(self): ax = pg.AxisItem(parent=self, orientation="top", maxTickLength=7, pen=axispen) ax.setRange(smin, smax) + self.__topScale = ax self.layout().addItem(ax, 0, 2) for i, group in enumerate(self.__groups): @@ -649,8 +673,17 @@ def __setup(self): ax = pg.AxisItem(parent=self, orientation="bottom", maxTickLength=7, pen=axispen) ax.setRange(smin, smax) + self.__bottomScale = ax self.layout().addItem(ax, len(self.__groups) + 1, 2) + def topScaleItem(self): + # type: () -> Optional[QGraphicsWidget] + return self.__topScale + + def bottomScaleItem(self): + # type: () -> Optional[QGraphicsWidget] + return self.__bottomScale + def __updateTextSizeConstraint(self): # set/update fixed height constraint on the text annotation items so # it matches the silhouette's height From 240503d79a4ca0f78b6345fc0932f3a309f1cec0 Mon Sep 17 00:00:00 2001 From: Ales Erjavec Date: Wed, 10 Jul 2019 10:27:13 +0200 Subject: [PATCH 04/13] stickygraphicsview: Add drop shadow on sticky views --- Orange/widgets/utils/stickygraphicsview.py | 28 +++++++++++++++++++--- 1 file changed, 25 insertions(+), 3 deletions(-) diff --git a/Orange/widgets/utils/stickygraphicsview.py b/Orange/widgets/utils/stickygraphicsview.py index 050f07154a8..ff6398f833c 100644 --- a/Orange/widgets/utils/stickygraphicsview.py +++ b/Orange/widgets/utils/stickygraphicsview.py @@ -1,11 +1,11 @@ import sys import math -from PyQt5.QtCore import Qt, QRectF, QEvent, QCoreApplication, QObject -from PyQt5.QtGui import QBrush +from PyQt5.QtCore import Qt, QRectF, QEvent, QCoreApplication, QObject, QPointF +from PyQt5.QtGui import QBrush, QPalette from PyQt5.QtWidgets import ( QGraphicsView, QGraphicsScene, QWidget, QVBoxLayout, QSizePolicy, - QScrollBar, + QScrollBar, QGraphicsDropShadowEffect ) from orangewidget.utils.overlay import OverlayWidget @@ -16,6 +16,28 @@ class _OverlayWidget(OverlayWidget): + def __init__(self, *args, **kwargs) -> None: + super().__init__(*args, **kwargs) + palette = self.palette() + ds = QGraphicsDropShadowEffect( + parent=self, + objectName="sticky-view-shadow", + color=palette.color(QPalette.Foreground), + blurRadius=15, + offset=QPointF(0, 0), + enabled=True + ) + self.setGraphicsEffect(ds) + + def changeEvent(self, event: QEvent) -> None: + super().changeEvent(event) + if event.type() == QEvent.PaletteChange: + effect = self.findChild( + QGraphicsDropShadowEffect, "sticky-view-shadow") + if effect is not None: + palette = self.palette() + effect.setColor(palette.color(QPalette.Foreground)) + def eventFilter(self, recv: QObject, event: QEvent) -> bool: if event.type() in (QEvent.Show, QEvent.Hide) and recv is self.widget(): return False From bf63281be4dac4409da46ad5b0ad264d1766b707 Mon Sep 17 00:00:00 2001 From: Ales Erjavec Date: Wed, 10 Jul 2019 17:48:30 +0200 Subject: [PATCH 05/13] owhierarchicalclustering: Remove header/footer scene rect adjustments --- Orange/widgets/unsupervised/owhierarchicalclustering.py | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/Orange/widgets/unsupervised/owhierarchicalclustering.py b/Orange/widgets/unsupervised/owhierarchicalclustering.py index ca114b5df63..df4b542e633 100644 --- a/Orange/widgets/unsupervised/owhierarchicalclustering.py +++ b/Orange/widgets/unsupervised/owhierarchicalclustering.py @@ -1444,12 +1444,8 @@ def adjustLeft(rect): return rect self.view.setSceneRect(geom) - self.view.setHeaderSceneRect( - adjustLeft(self.top_axis.geometry()).adjusted(0, 0, 0, 1) - ) - self.view.setFooterSceneRect( - adjustLeft(self.bottom_axis.geometry()).adjusted(0, 0, 0, -1) - ) + self.view.setHeaderSceneRect(adjustLeft(self.top_axis.geometry())) + self.view.setFooterSceneRect(adjustLeft(self.bottom_axis.geometry())) def _dendrogram_slider_changed(self, value): p = QPointF(value, 0) From c9d2f3452438a177544839747c638359dcbc43c4 Mon Sep 17 00:00:00 2001 From: Ales Erjavec Date: Wed, 10 Jul 2019 17:53:22 +0200 Subject: [PATCH 06/13] owsilhouetteplot: Remove header/footer scene rect adjustments --- Orange/widgets/visualize/owsilhouetteplot.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/Orange/widgets/visualize/owsilhouetteplot.py b/Orange/widgets/visualize/owsilhouetteplot.py index c6e55b5682d..07055614b4d 100644 --- a/Orange/widgets/visualize/owsilhouetteplot.py +++ b/Orange/widgets/visualize/owsilhouetteplot.py @@ -389,11 +389,9 @@ def extend_horizontal(rect): rect.setRight(geom.right()) return rect if header is not None: - self.view.setHeaderSceneRect( - extend_horizontal(header.geometry().adjusted(0, 0, 0, 1))) + self.view.setHeaderSceneRect(extend_horizontal(header.geometry())) if footer is not None: - self.view.setFooterSceneRect( - extend_horizontal(footer.geometry().adjusted(0, -1, 0, 0))) + self.view.setFooterSceneRect(extend_horizontal(footer.geometry())) def commit(self): """ From 0cdb20ee73ff9b228e95ff7b4a973d643f19e149 Mon Sep 17 00:00:00 2001 From: Ales Erjavec Date: Tue, 9 Jul 2019 17:38:35 +0200 Subject: [PATCH 07/13] owheatmap: Use sticky header/footer --- Orange/widgets/visualize/owheatmap.py | 38 +++++++++++++++++++++++++-- 1 file changed, 36 insertions(+), 2 deletions(-) diff --git a/Orange/widgets/visualize/owheatmap.py b/Orange/widgets/visualize/owheatmap.py index 40f1a5dbef3..361356d335a 100644 --- a/Orange/widgets/visualize/owheatmap.py +++ b/Orange/widgets/visualize/owheatmap.py @@ -30,6 +30,7 @@ import Orange.distance from Orange.clustering import hierarchical, kmeans +from Orange.widgets.utils.stickygraphicsview import StickyGraphicsView from Orange.widgets.utils import colorbrewer from Orange.widgets.utils.annotated_data import (create_annotated_table, ANNOTATED_DATA_SIGNAL_NAME) @@ -587,7 +588,7 @@ def __init__(self): self.heatmap_scene.itemsBoundingRect() self.heatmap_scene.removeItem(item) - self.sceneView = QGraphicsView( + self.sceneView = StickyGraphicsView( self.scene, verticalScrollBarPolicy=Qt.ScrollBarAlwaysOn, horizontalScrollBarPolicy=Qt.ScrollBarAlwaysOn, @@ -942,7 +943,7 @@ def setup_scene(self, parts, data): self.heatmap_scene.clear() # The top level container widget widget = GraphicsWidget() - widget.layoutDidActivate.connect(self.__update_selection_geometry) + widget.layoutDidActivate.connect(self.__on_layout_activate) grid = QGraphicsGridLayout() grid.setSpacing(self.space_x) @@ -1210,8 +1211,41 @@ def __update_size_constraints(self): def __fixup_grid_layout(self): self.__update_margins() + self.__update_scene_rects() + self.__update_selection_geometry() + + def __update_scene_rects(self): rect = self.scene.widget.geometry() self.heatmap_scene.setSceneRect(rect) + + spacing = self.scene.widget.layout().rowSpacing(2) + headerrect = QRectF(rect) + headerrect.setBottom( + max((w.geometry().bottom() + for w in (self.col_annotation_widgets_top + + self.col_dendrograms) + if w is not None and w.isVisible()), + default=rect.top()) + ) + + if not headerrect.isEmpty(): + headerrect = headerrect.adjusted(0, 0, 0, spacing / 2) + + footerrect = QRectF(rect) + footerrect.setTop( + min((w.geometry().top() for w in self.col_annotation_widgets_bottom + if w is not None and w.isVisible()), + default=rect.bottom()) + ) + if not footerrect.isEmpty(): + footerrect = footerrect.adjusted(0, - spacing / 2, 0, 0) + + self.sceneView.setSceneRect(rect) + self.sceneView.setHeaderSceneRect(headerrect) + self.sceneView.setFooterSceneRect(footerrect) + + def __on_layout_activate(self): + self.__update_scene_rects() self.__update_selection_geometry() def __aspect_mode_changed(self): From 566d48ce5cbabb6a35e22e5d0b23b5a61c7c6bbb Mon Sep 17 00:00:00 2001 From: Ales Erjavec Date: Fri, 19 Jul 2019 11:21:08 +0200 Subject: [PATCH 08/13] owsilhouetteplot: Add back header/footer rect adjustments This time with a larger margin. Ensure there are no noticeable artifacts due to device pixel ratio scaling with the display of the scale's horizontal line. --- Orange/widgets/visualize/owsilhouetteplot.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/Orange/widgets/visualize/owsilhouetteplot.py b/Orange/widgets/visualize/owsilhouetteplot.py index 07055614b4d..b047471a1a7 100644 --- a/Orange/widgets/visualize/owsilhouetteplot.py +++ b/Orange/widgets/visualize/owsilhouetteplot.py @@ -388,10 +388,14 @@ def extend_horizontal(rect): rect.setLeft(geom.left()) rect.setRight(geom.right()) return rect + + margin = 3 if header is not None: - self.view.setHeaderSceneRect(extend_horizontal(header.geometry())) + self.view.setHeaderSceneRect( + extend_horizontal(header.geometry().adjusted(0, 0, 0, margin))) if footer is not None: - self.view.setFooterSceneRect(extend_horizontal(footer.geometry())) + self.view.setFooterSceneRect( + extend_horizontal(footer.geometry().adjusted(0, -margin, 0, 0))) def commit(self): """ From 2e5180caa43a392f6664e44ba258bde792f751e7 Mon Sep 17 00:00:00 2001 From: Ales Erjavec Date: Fri, 19 Jul 2019 11:30:34 +0200 Subject: [PATCH 09/13] owhierarchicalclustering: Add back header/footer rect adjustments This time with a larger margin. Ensure there are no noticeable artifacts due to device pixel ratio scaling with the display of the scale's horizontal line. --- .../widgets/unsupervised/owhierarchicalclustering.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/Orange/widgets/unsupervised/owhierarchicalclustering.py b/Orange/widgets/unsupervised/owhierarchicalclustering.py index df4b542e633..25305612dc7 100644 --- a/Orange/widgets/unsupervised/owhierarchicalclustering.py +++ b/Orange/widgets/unsupervised/owhierarchicalclustering.py @@ -1442,10 +1442,14 @@ def adjustLeft(rect): rect = QRectF(rect) rect.setLeft(geom.left()) return rect - + margin = 3 self.view.setSceneRect(geom) - self.view.setHeaderSceneRect(adjustLeft(self.top_axis.geometry())) - self.view.setFooterSceneRect(adjustLeft(self.bottom_axis.geometry())) + self.view.setHeaderSceneRect( + adjustLeft(self.top_axis.geometry()).adjusted(0, 0, 0, margin) + ) + self.view.setFooterSceneRect( + adjustLeft(self.bottom_axis.geometry()).adjusted(0, -margin, 0, 0) + ) def _dendrogram_slider_changed(self, value): p = QPointF(value, 0) From 569fd458bb8f3a7410c5fa650591a5cf00afcaf8 Mon Sep 17 00:00:00 2001 From: Ales Erjavec Date: Fri, 19 Jul 2019 12:06:35 +0200 Subject: [PATCH 10/13] owsilhouetteplot: Reset the view's scene rects ... when the scene is cleared --- Orange/widgets/visualize/owsilhouetteplot.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/Orange/widgets/visualize/owsilhouetteplot.py b/Orange/widgets/visualize/owsilhouetteplot.py index b047471a1a7..468f29579c8 100644 --- a/Orange/widgets/visualize/owsilhouetteplot.py +++ b/Orange/widgets/visualize/owsilhouetteplot.py @@ -231,6 +231,9 @@ def _clear_scene(self): # Clear the graphics scene and associated objects self.scene.clear() self.scene.setSceneRect(QRectF()) + self.view.setSceneRect(QRectF()) + self.view.setHeaderSceneRect(QRectF()) + self.view.setFooterSceneRect(QRectF()) self._silplot = None def _invalidate_distances(self): From 2635b8947e53a4c632e3c82c5fd6e3afe9279a6f Mon Sep 17 00:00:00 2001 From: Ales Erjavec Date: Fri, 19 Jul 2019 12:09:21 +0200 Subject: [PATCH 11/13] owhierarchicalclustering: Call updateGeometry when clearing the dendrogram --- Orange/widgets/unsupervised/owhierarchicalclustering.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Orange/widgets/unsupervised/owhierarchicalclustering.py b/Orange/widgets/unsupervised/owhierarchicalclustering.py index 25305612dc7..7727f06f486 100644 --- a/Orange/widgets/unsupervised/owhierarchicalclustering.py +++ b/Orange/widgets/unsupervised/owhierarchicalclustering.py @@ -323,6 +323,7 @@ def clear(self): self._selection = OrderedDict() self._highlighted_item = None self._cluster_parent = {} + self.updateGeometry() def set_root(self, root): """Set the root cluster. @@ -347,7 +348,7 @@ def set_root(self, root): self._relayout() self._rescale() - self.updateGeometry() + self.updateGeometry() def item(self, node): """Return the DendrogramNode instance representing the cluster. From 2caf1a2d6fac204927fc3b4d88bf7a94e3a98253 Mon Sep 17 00:00:00 2001 From: Ales Erjavec Date: Fri, 19 Jul 2019 12:33:55 +0200 Subject: [PATCH 12/13] owhierarchicalclustering: Initialize the scale range Ensure the initial scale has the 'inverted' range (descending values from left to right) before the first dendrogram is displayed. --- Orange/widgets/unsupervised/owhierarchicalclustering.py | 1 + 1 file changed, 1 insertion(+) diff --git a/Orange/widgets/unsupervised/owhierarchicalclustering.py b/Orange/widgets/unsupervised/owhierarchicalclustering.py index 7727f06f486..8304df671c2 100644 --- a/Orange/widgets/unsupervised/owhierarchicalclustering.py +++ b/Orange/widgets/unsupervised/owhierarchicalclustering.py @@ -1029,6 +1029,7 @@ def axis_view(orientation): ax.mousePressed.connect(self._activate_cut_line) ax.mouseMoved.connect(self._activate_cut_line) ax.mouseReleased.connect(self._activate_cut_line) + ax.setRange(1.0, 0.0) return ax self.top_axis = axis_view("top") From afc2f646f50ae869aa60acb27b3521e42b75f282 Mon Sep 17 00:00:00 2001 From: Ales Erjavec Date: Fri, 19 Jul 2019 13:10:53 +0200 Subject: [PATCH 13/13] owheatmap: Reset the view's scene/header/footer rects on clear --- Orange/widgets/visualize/owheatmap.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/Orange/widgets/visualize/owheatmap.py b/Orange/widgets/visualize/owheatmap.py index 361356d335a..2f40d2cdf93 100644 --- a/Orange/widgets/visualize/owheatmap.py +++ b/Orange/widgets/visualize/owheatmap.py @@ -647,6 +647,9 @@ def clear_scene(self): self.col_dendrograms = [] self.row_dendrograms = [] self.selection_rects = [] + self.sceneView.setSceneRect(QRectF()) + self.sceneView.setHeaderSceneRect(QRectF()) + self.sceneView.setFooterSceneRect(QRectF()) @Inputs.data def set_dataset(self, data=None):