Mouse hit detection with QPainterPath in QGraphicsItem - python

I implementing custom chart. But I stucked with mouse hitting detection with QPainterPath.
I tried with graphicsitem's shape(), boundingRect(). but that only checks rough shape of boundary.
I want to check mouse hit system with exact position on QPainterPath path instance. But seems to no api like that functionality.
My app's QGraphicsScene is set with same coordinate with QGraphicsView in view's resizeEvent().
scene: MyScene = self.scene()
scene.setSceneRect(self.rect().x(), self.rect().y(),
self.rect().width(), self.rect().height())
At the same time, my plot QGraphicsItem scales by QTransform.
plot: QGraphicsItem = scene.plot
trans = QTransform()
data = plot.df['data']
data = data - data.min()
data_max = data.max()
data_min = data.min()
trans.scale(self.width() / len(data),
self.height() / (data_max - data_min))
plot.trans = trans
plot.setTransform(trans)
And in the MyScene, add rect item mouse_rec. So, I check mouse_rec and plot item's path with mouse_rec.collidesWithPath(path)
It just works only with original path.
Here is all code. Just copy and paste, you could run it.
Red plot is original path and yellow plot is scaled path. Mouse hit check is only works with red plot...
import numpy
import pandas
from PyQt5 import QtGui
from PyQt5.QtCore import Qt, QRectF, QRect
from PyQt5.QtGui import QRadialGradient, QGradient, QPen, QPainterPath, QTransform, QPainter, QColor
from PyQt5.QtWidgets import QApplication, QGraphicsScene, QGraphicsView, QGraphicsSceneMouseEvent, QGraphicsItem, \
QStyleOptionGraphicsItem, QWidget, QGraphicsRectItem
class MyItem(QGraphicsItem):
def __init__(self, df, parent=None):
QGraphicsItem.__init__(self, parent)
self.num = 1
self.df = df
self.path = QPainterPath()
self.trans = QTransform()
self.cached = False
self.printed = False
self.setZValue(0)
def paint(self, painter: QtGui.QPainter, option: 'QStyleOptionGraphicsItem', widget: QWidget = ...):
data = self.df['data']
data = data - data.min()
data_max = data.max()
data_min = data.min()
if not self.cached:
for i in range(data.size - 1):
self.path.moveTo(i, data[i])
self.path.lineTo(i+1, data[i+1])
self.cached = True
pen = QPen(Qt.white)
pen.setCosmetic(True)
painter.setPen(pen)
painter.drawRect(0, 0, data.size, data_max - data_min)
pen.setColor(Qt.yellow)
painter.setPen(pen)
painter.drawPath(self.path)
if not self.printed:
rec_item = self.scene().addPath(self.path, QPen(Qt.red))
rec_item.setZValue(-10)
self.printed = True
def boundingRect(self):
data = self.df['data']
data_max = data.max()
data_min = data.min()
return QRectF(0, 0, data.size, data_max - data_min)
class MyScene(QGraphicsScene):
def __init__(self, data, parent=None):
QGraphicsScene.__init__(self, parent)
self.data = data
self.mouse_rect = QGraphicsRectItem()
self.plot: MyItem(data) = None
self.bounding_rect = QGraphicsRectItem()
self.setBackgroundBrush(QColor('#14161f'))
self.addItem(self.bounding_rect)
self.printed = False
def mouseMoveEvent(self, event: 'QGraphicsSceneMouseEvent'):
print()
print("rec rect : ", self.mouse_rect.rect())
print("Scene rect : ", self.sceneRect())
print("ItemBounding rect : ", self.itemsBoundingRect())
print("transform : ", self.plot.transform().m11(), ", ", self.plot.transform().m22())
item = self.itemAt(event.scenePos(), self.plot.transform())
if item and isinstance(item, MyItem):
print()
print('collides path : ', self.mouse_rect.collidesWithPath(item.path))
print('collides item : ', self.mouse_rect.collidesWithItem(item))
super().mouseMoveEvent(event)
def print_bound(self, rect):
self.bounding_rect.setPen(QPen(Qt.green))
self.bounding_rect.setRect(rect.x() + 5, rect.y() + 5,
rect.width() - 10, rect.height() - 10)
class MyView(QGraphicsView):
def __init__(self, data, parent=None):
QGraphicsView.__init__(self, parent)
self.data = data
self.setMouseTracking(True)
self.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
self.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
def wheelEvent(self, event: QtGui.QWheelEvent):
print("pixel / Data : {}".format(self.width() / len(self.data)))
def resizeEvent(self, event: QtGui.QResizeEvent):
scene: MyScene = self.scene()
scene.setSceneRect(self.rect().x(), self.rect().y(),
self.rect().width(), self.rect().height())
scene.print_bound(self.rect())
plot: QGraphicsItem = scene.plot
trans = QTransform()
data = plot.df['data']
data = data - data.min()
data_max = data.max()
data_min = data.min()
trans.scale(self.width() / len(data),
self.height() / (data_max - data_min))
plot.trans = trans
plot.setTransform(trans)
def mouseMoveEvent(self, event: QtGui.QMouseEvent):
mouse_rect: QGraphicsRectItem = self.scene().mouse_rect
mouse_rect.setRect(event.pos().x() - 2, event.pos().y() - 2, 4, 4)
super().mouseMoveEvent(event)
if __name__ == '__main__':
df = pandas.DataFrame({'data': numpy.random.randint(0, 20, 50)})
app = QApplication([])
scene = MyScene(df)
view = MyView(df)
view.setScene(scene)
rec = QGraphicsRectItem(-2, -2, 4, 4)
rec.setPen(Qt.white)
scene.mouse_rect = rec
scene.addItem(rec)
plot = MyItem(df)
scene.addItem(plot)
scene.plot = plot
view.show()
app.exec_()
Any idea checking mouse point with path ?? I first tried custom math function calculating [point <-> line] distance, but that need much time and making lagging app..
I will make not only line plot but also bar, area, points, candlestick plot.. Is there any idea to solve this problem?

You have to convert the position of the path with respect to the item that is scaled to the position relative to the scene using mapToScene():
if item and isinstance(item, MyItem):
print('collides path : ', self.mouse_rect.collidesWithPath(item.mapToScene(item.path)))
print('collides item : ', self.mouse_rect.collidesWithItem(item))

Related

How can I make the animation to rotate a widget in PyQt5? [duplicate]

I am a new to pyqt and need help with rotating the label. I am confused and cannot understand how to rotate the whole widget on a specific angle. Not the content of the widget, but the widget itself. I am searching for the solution but cannot find anything.
A QWidget does not support rotation, but a workaround is to insert the widget into a QGraphicsProxyWidget and add it to a QGraphicsScene, and then rotate the QGraphicsProxyWidget that visually generates the same widget rotation effect.
from PyQt5 import QtCore, QtGui, QtWidgets
def main():
import sys
app = QtWidgets.QApplication(sys.argv)
label = QtWidgets.QLabel("Stack Overflow", alignment=QtCore.Qt.AlignCenter)
graphicsview = QtWidgets.QGraphicsView()
scene = QtWidgets.QGraphicsScene(graphicsview)
graphicsview.setScene(scene)
proxy = QtWidgets.QGraphicsProxyWidget()
proxy.setWidget(label)
proxy.setTransformOriginPoint(proxy.boundingRect().center())
scene.addItem(proxy)
slider = QtWidgets.QSlider(minimum=0, maximum=359, orientation=QtCore.Qt.Horizontal)
slider.valueChanged.connect(proxy.setRotation)
label_text = QtWidgets.QLabel(
"{}°".format(slider.value()), alignment=QtCore.Qt.AlignCenter
)
slider.valueChanged.connect(
lambda value: label_text.setText("{}°".format(slider.value()))
)
slider.setValue(45)
w = QtWidgets.QWidget()
lay = QtWidgets.QVBoxLayout(w)
lay.addWidget(graphicsview)
lay.addWidget(slider)
lay.addWidget(label_text)
w.resize(640, 480)
w.show()
sys.exit(app.exec_())
if __name__ == "__main__":
main()
As #eyllanesc correctly explains, there's no "widget rotation" support in Qt (as in most standard frameworks).
There are a couple of tricks on your hand, though.
"Simple" label (not using a QLabel)
That's the "simple" solution. Since you're talking about a "label", that can be implemented using some math.
The biggest advantage in this approach is that the size hint is "simple", meaning that it's only based on the text contents (as in QFontMetrics.boundingRect()), and whenever the main font, text or alignment is changed, the size hint reflects them.
While it supports multi-line labels, the biggest problem about this approach comes in place if you need to use rich text, though; a QTextDocument can be used instead of a standard string, but that would require a more complex implementation for size hint computing.
from math import radians, sin, cos
from random import randrange
from PyQt5 import QtCore, QtGui, QtWidgets
class AngledLabel(QtWidgets.QWidget):
_alignment = QtCore.Qt.AlignLeft | QtCore.Qt.AlignTop
def __init__(self, text='', angle=0, parent=None):
super(AngledLabel, self).__init__(parent)
self._text = text
self._angle = angle % 360
# keep radians of the current angle *and* its opposite; we're using
# rectangles to get the overall area of the text, and since they use
# right angles, that opposite is angle + 90
self._radians = radians(-angle)
self._radiansOpposite = radians(-angle + 90)
def alignment(self):
return self._alignment
def setAlignment(self, alignment):
# text alignment might affect the text size!
if alignment == self._alignment:
return
self._alignment = alignment
self.setMinimumSize(self.sizeHint())
def angle(self):
return self._angle
def setAngle(self, angle):
# the angle clearly affects the overall size
angle %= 360
if angle == self._angle:
return
self._angle = angle
# update the radians to improve optimization of sizeHint and paintEvent
self._radians = radians(-angle)
self._radiansOpposite = radians(-angle + 90)
self.setMinimumSize(self.sizeHint())
def text(self):
return self._text
def setText(self, text):
if text == self._text:
return
self._text = text
self.setMinimumSize(self.sizeHint())
def sizeHint(self):
# get the bounding rectangle of the text
rect = self.fontMetrics().boundingRect(QtCore.QRect(), self._alignment, self._text)
# use trigonometry to get the actual size of the rotated rectangle
sinWidth = abs(sin(self._radians) * rect.width())
cosWidth = abs(cos(self._radians) * rect.width())
sinHeight = abs(sin(self._radiansOpposite) * rect.height())
cosHeight = abs(cos(self._radiansOpposite) * rect.height())
return QtCore.QSize(cosWidth + cosHeight, sinWidth + sinHeight)
def minimumSizeHint(self):
return self.sizeHint()
def paintEvent(self, event):
qp = QtGui.QPainter(self)
textRect = self.fontMetrics().boundingRect(
QtCore.QRect(), self._alignment, self._text)
width = textRect.width()
height = textRect.height()
# we have to translate the painting rectangle, and that depends on which
# "angle sector" the current angle is
if self._angle <= 90:
deltaX = 0
deltaY = sin(self._radians) * width
elif 90 < self._angle <= 180:
deltaX = cos(self._radians) * width
deltaY = sin(self._radians) * width + sin(self._radiansOpposite) * height
elif 180 < self._angle <= 270:
deltaX = cos(self._radians) * width + cos(self._radiansOpposite) * height
deltaY = sin(self._radiansOpposite) * height
else:
deltaX = cos(self._radiansOpposite) * height
deltaY = 0
qp.translate(.5 - deltaX, .5 - deltaY)
qp.rotate(-self._angle)
qp.drawText(self.rect(), self._alignment, self._text)
class TestWindow(QtWidgets.QWidget):
def __init__(self):
super(TestWindow, self).__init__()
layout = QtWidgets.QGridLayout()
self.setLayout(layout)
self.randomizeButton = QtWidgets.QPushButton('Randomize!')
layout.addWidget(self.randomizeButton, 0, 0, 1, 3)
self.randomizeButton.clicked.connect(self.randomize)
layout.addWidget(QtWidgets.QLabel('Standard label'), 1, 0)
text = 'Some text'
layout.addWidget(QtWidgets.QLabel(text), 1, 2)
self.labels = []
for row, angle in enumerate([randrange(360) for _ in range(8)], 2):
angleLabel = QtWidgets.QLabel(u'{}°'.format(angle))
angleLabel.setSizePolicy(QtWidgets.QSizePolicy.Maximum, QtWidgets.QSizePolicy.Maximum)
layout.addWidget(angleLabel, row, 0)
label = AngledLabel(text, angle)
layout.addWidget(label, row, 2)
self.labels.append((angleLabel, label))
separator = QtWidgets.QFrame()
separator.setFrameShape(separator.VLine|separator.Sunken)
layout.addWidget(separator, 1, 1, layout.rowCount() - 1, 1)
def randomize(self):
for angleLabel, label in self.labels:
angle = randrange(360)
angleLabel.setText(str(angle))
label.setAngle(angle)
self.adjustSize()
if __name__ == '__main__':
import sys
app = QtWidgets.QApplication(sys.argv)
w = TestWindow()
w.show()
sys.exit(app.exec_())
QGraphicsView implementation
I would also like to expand the solution proposed by eyllanesc, as it is more modular and allows to use "any" widget; unfortunately, while his answer works as expected, I'm afraid that it's an answer that is just valid "for the sake of the argument".
From the graphical point of view, the obvious issues are the QGraphicsView visual hints (borders and background). But, since we're talking about widgets that might have to be inserted in a graphical interface, the size (and its hint[s]) require some care.
The main advantage of this approach is that almost any type of widget can be added to the interface, but due to the nature of per-widget size policy and QGraphicsView implementations, if the content of the "rotated" widget changes, perfect drawing will always be something hard to achieve.
from random import randrange
from PyQt5 import QtCore, QtGui, QtWidgets
class AngledObject(QtWidgets.QGraphicsView):
_angle = 0
def __init__(self, angle=0, parent=None):
super(AngledObject, self).__init__(parent)
# to prevent the graphics view to draw its borders or background, set the
# FrameShape property to 0 and a transparent background
self.setFrameShape(0)
self.setStyleSheet('background: transparent')
self.setScene(QtWidgets.QGraphicsScene())
# ignore scroll bars!
self.setHorizontalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOff)
self.setVerticalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOff)
self.setSizePolicy(QtWidgets.QSizePolicy.Preferred, QtWidgets.QSizePolicy.Preferred)
def angle(self):
return self._angle
def setAngle(self, angle):
angle %= 360
if angle == self._angle:
return
self._angle = angle
self._proxy.setTransform(QtGui.QTransform().rotate(-angle))
self.adjustSize()
def resizeEvent(self, event):
super(AngledObject, self).resizeEvent(event)
# ensure that the scene is fully visible after resizing
QtCore.QTimer.singleShot(0, lambda: self.centerOn(self.sceneRect().center()))
def sizeHint(self):
return self.scene().itemsBoundingRect().size().toSize()
def minimumSizeHint(self):
return self.sizeHint()
class AngledLabel(AngledObject):
def __init__(self, text='', angle=0, parent=None):
super(AngledLabel, self).__init__(angle, parent)
self._label = QtWidgets.QLabel(text)
self._proxy = self.scene().addWidget(self._label)
self._label.setStyleSheet('background: transparent')
self.setAngle(angle)
self.alignment = self._label.alignment
def setAlignment(self, alignment):
# text alignment might affect the text size!
if alignment == self._label.alignment():
return
self._label.setAlignment(alignment)
self.setMinimumSize(self.sizeHint())
def text(self):
return self._label.text()
def setText(self, text):
if text == self._label.text():
return
self._label.setText(text)
self.setMinimumSize(self.sizeHint())
class AngledButton(AngledObject):
def __init__(self, text='', angle=0, parent=None):
super(AngledButton, self).__init__(angle, parent)
self._button = QtWidgets.QPushButton(text)
self._proxy = self.scene().addWidget(self._button)
self.setAngle(angle)
class TestWindow(QtWidgets.QWidget):
def __init__(self):
super(TestWindow, self).__init__()
layout = QtWidgets.QGridLayout()
self.setLayout(layout)
self.randomizeButton = QtWidgets.QPushButton('Randomize!')
layout.addWidget(self.randomizeButton, 0, 0, 1, 3)
self.randomizeButton.clicked.connect(self.randomize)
layout.addWidget(QtWidgets.QLabel('Standard label'), 1, 0)
text = 'Some text'
layout.addWidget(QtWidgets.QLabel(text), 1, 2)
self.labels = []
for row, angle in enumerate([randrange(360) for _ in range(4)], 2):
angleLabel = QtWidgets.QLabel(u'{}°'.format(angle))
angleLabel.setSizePolicy(QtWidgets.QSizePolicy.Maximum, QtWidgets.QSizePolicy.Maximum)
layout.addWidget(angleLabel, row, 0)
label = AngledLabel(text, angle)
layout.addWidget(label, row, 2)
self.labels.append((angleLabel, label))
for row, angle in enumerate([randrange(360) for _ in range(4)], row + 1):
angleLabel = QtWidgets.QLabel(u'{}°'.format(angle))
angleLabel.setSizePolicy(QtWidgets.QSizePolicy.Maximum, QtWidgets.QSizePolicy.Maximum)
layout.addWidget(angleLabel, row, 0)
label = AngledButton('Button!', angle)
layout.addWidget(label, row, 2)
self.labels.append((angleLabel, label))
separator = QtWidgets.QFrame()
separator.setFrameShape(separator.VLine|separator.Sunken)
layout.addWidget(separator, 1, 1, layout.rowCount() - 1, 1)
def randomize(self):
for angleLabel, label in self.labels:
angle = randrange(360)
angleLabel.setText(str(angle))
label.setAngle(angle)
self.adjustSize()
if __name__ == '__main__':
import sys
app = QtWidgets.QApplication(sys.argv)
w = TestWindow()
w.show()
sys.exit(app.exec_())
As you can see, the "randomize" functions have very different results. While the second approach allows using more complex widgets, the first one better reacts to contents changes.

Frozen widgets in QScrollArea

I'm trying to create a grid of square buttons that is scrollable if the window is too small to show all of them. I'd like there to be labels on the left-most column and top-most row showing the button indices.
Is there a way to create a QScrollArea with the widgets (labels) in the top-most row and left-most column "frozen". Similar to how you can freeze rows and columns in an Excel Sheet where they follow the view around as you scroll.
See a mockup here:
Either Qt and PyQt are welcome.
I solved my problem with multiple QScrollAreas using the method outlined in this answer. The idea is to have the frozen areas be QScrollArea with disabled scrolling, while the unfrozen QScrollArea scrollbar signals are connected to the frozen QScrollArea scrollbar slots.
Here is the code of my mockup with the top-most row and left-most column frozen. The especially relevant parts are the FrozenScrollArea class and the connections inside the Window class.
import sys
from PyQt5.QtCore import Qt
from PyQt5.QtWidgets import (
QApplication,
QPushButton,
QWidget,
QScrollArea,
QGridLayout,
QLabel,
QFrame,
QSpacerItem,
QSizePolicy,
)
ROWS = 10
COLS = 20
SIZE = 35
style = """
Button {
padding: 0;
margin: 0;
border: 1px solid black;
}
Button::checked {
background-color: lightgreen;
}
"""
class Button(QPushButton):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.setFixedSize(SIZE, SIZE)
self.setCheckable(True)
self.setStyleSheet(style)
class Label(QLabel):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.setAlignment(Qt.AlignCenter)
self.setFixedSize(SIZE, SIZE)
class Labels(QWidget):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
layout = QGridLayout()
layout.setHorizontalSpacing(0)
layout.setVerticalSpacing(0)
layout.setContentsMargins(0, 0, 0, 0)
self.setLayout(layout)
class FrozenScrollArea(QScrollArea):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.setWidgetResizable(True)
self.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
self.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
self.verticalScrollBar().setEnabled(False)
self.horizontalScrollBar().setEnabled(False)
class FrozenRow(FrozenScrollArea):
def __init__(self, parent):
super().__init__()
labels = Labels(parent)
for c in range(COLS):
label = Label(self, text = str(c))
labels.layout().addWidget(label, 0, c, 1, 1, Qt.AlignCenter)
labels.layout().addItem(QSpacerItem(0, 0, QSizePolicy.Expanding, QSizePolicy.Minimum), 0, COLS, 1, 1)
self.setFrameShape(QFrame.NoFrame)
self.setFixedHeight(SIZE)
self.setWidget(labels)
class FrozenColumn(FrozenScrollArea):
def __init__(self, parent):
super().__init__()
labels = Labels(parent)
for r in range(ROWS):
label = Label(self, text = str(r))
labels.layout().addWidget(label, r, 0, 1, 1, Qt.AlignCenter)
labels.layout().addItem(QSpacerItem(0, 0, QSizePolicy.Minimum, QSizePolicy.Expanding), ROWS, 0, 1, 1)
self.setFrameShape(QFrame.NoFrame)
self.setFixedWidth(SIZE)
self.setWidget(labels)
class ButtonGroup(QWidget):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
layout = QGridLayout()
for r in range(ROWS):
for c in range(COLS):
button = Button(self)
layout.addWidget(button, r, c, 1, 1)
layout.setHorizontalSpacing(0)
layout.setVerticalSpacing(0)
layout.setContentsMargins(0, 0, 0, 0)
self.setLayout(layout)
class Buttons(QScrollArea):
def __init__(self, parent):
super().__init__()
self.setFrameShape(QFrame.NoFrame)
self.setWidget(ButtonGroup(parent))
class Window(QWidget):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# layout
layout = QGridLayout()
self.setLayout(layout)
layout.setHorizontalSpacing(0)
layout.setVerticalSpacing(0)
layout.setContentsMargins(0, 0, 0, 0)
# frozen row (top)
self.frozenRow = FrozenRow(self)
layout.addWidget(self.frozenRow, 0, 1, 1, 1)
# frozen column (left)
self.frozenColumn = FrozenColumn(self)
layout.addWidget(self.frozenColumn, 1, 0, 1, 1)
# button grid
self.buttons = Buttons(self)
layout.addWidget(self.buttons, 1, 1, 1, 1)
# scrollbar connections
self.buttons.horizontalScrollBar().valueChanged.connect(self.frozenRow.horizontalScrollBar().setValue) # horizontal scroll affects frozen row only
self.buttons.verticalScrollBar().valueChanged.connect(self.frozenColumn.verticalScrollBar().setValue) # vertical scroll affects frozemn column only
self.show()
if __name__ == "__main__":
app = QApplication(sys.argv)
window = Window()
sys.exit(app.exec())
While the frozen scroll area method is effective, it has some drawbacks; most importantly, it:
is not dynamic;
does not consider basic box layouts;
does not support different directions (for boxed layouts) or origin points (for grid layouts);
While this is more a "fringe case", I'd like to suggest an alternative, based on QHeaderView and a "private" model that uses the layout manager for the header sizes.
It doesn't directly support resizing as one would expect from a standard QHeaderView, but that's almost impossible: for boxed layouts it's not possible to set a layout item size (if not by completely overriding the way the layout sets geometries), and for grid layouts there's no way to know if rows or columns are "actually" removed, since rowCount() and columnCount() are never updated dynamically when the grid size changes.
The concept is based on overriding the event filter of the scroll area and check whether geometry changes are happening and if the layout has to lay out items again. Then, the implementation uses the layout information to update the underlying model and provide appropriate values for the SizeHintRole for headerData().
The subclassed QScrollArea creates two QHeaderViews and updates them whenever required using the ResizeToContents section resize mode (which queries headerData()) and uses setViewportMargins based on the size hints of the headers.
class LayoutModel(QtCore.QAbstractTableModel):
reverse = {
QtCore.Qt.Horizontal: False,
QtCore.Qt.Vertical: False
}
def __init__(self, rows=None, columns=None):
super().__init__()
self.rows = rows or []
self.columns = columns or []
def setLayoutData(self, hSizes, vSizes, reverseH=False, reverseV=False):
self.beginResetModel()
self.reverse = {
QtCore.Qt.Horizontal: reverseH,
QtCore.Qt.Vertical: reverseV
}
self.rows = vSizes
self.columns = hSizes
opt = QtWidgets.QStyleOptionHeader()
opt.text = str(len(vSizes))
style = QtWidgets.QApplication.style()
self.headerSizeHint = style.sizeFromContents(style.CT_HeaderSection, opt, QtCore.QSize())
self.endResetModel()
def rowCount(self, parent=None):
return len(self.rows)
def columnCount(self, parent=None):
return len(self.columns)
def headerData(self, section, orientation, role=QtCore.Qt.DisplayRole):
if role == QtCore.Qt.DisplayRole:
if self.reverse[orientation]:
if orientation == QtCore.Qt.Horizontal:
section = len(self.columns) - 1 - section
else:
section = len(self.rows) - 1 - section
# here you can add support for custom header labels
return str(section + 1)
elif role == QtCore.Qt.SizeHintRole:
if orientation == QtCore.Qt.Horizontal:
return QtCore.QSize(self.columns[section], self.headerSizeHint.height())
return QtCore.QSize(self.headerSizeHint.width(), self.rows[section])
def data(self, *args, **kwargs):
pass # not really required, but provided for consistency
class ScrollAreaLayoutHeaders(QtWidgets.QScrollArea):
_initialized = False
def __init__(self):
super().__init__()
self.hHeader = QtWidgets.QHeaderView(QtCore.Qt.Horizontal, self)
self.vHeader = QtWidgets.QHeaderView(QtCore.Qt.Vertical, self)
self.layoutModel = LayoutModel()
for header in self.hHeader, self.vHeader:
header.setModel(self.layoutModel)
header.setSectionResizeMode(header.Fixed)
self.updateTimer = QtCore.QTimer(
interval=0, timeout=self.updateHeaderSizes, singleShot=True)
def layout(self):
try:
return self.widget().layout()
except AttributeError:
pass
def eventFilter(self, obj, event):
if obj == self.widget() and obj.layout() is not None:
if event.type() in (event.Resize, event.Move):
if self.sender() in (self.verticalScrollBar(), self.horizontalScrollBar()):
self.updateGeometries()
else:
self.updateHeaderSizes()
elif event.type() == event.LayoutRequest:
self.widget().adjustSize()
self.updateTimer.start()
return super().eventFilter(obj, event)
def updateHeaderSizes(self):
layout = self.layout()
if layout is None:
self.layoutModel.setLayoutData([], [])
self.updateGeometries()
return
self._initialized = True
hSizes = []
vSizes = []
layGeo = self.widget().rect()
reverseH = reverseV = False
if isinstance(layout, QtWidgets.QBoxLayout):
count = layout.count()
direction = layout.direction()
geometries = [layout.itemAt(i).geometry() for i in range(count)]
# LeftToRight and BottomToTop layouts always have a first bit set
reverse = direction & 1
if reverse:
geometries.reverse()
lastPos = 0
lastGeo = geometries[0]
if layout.direction() in (layout.LeftToRight, layout.RightToLeft):
if reverse:
reverseH = True
vSizes.append(layGeo.bottom())
lastExt = lastGeo.x() + lastGeo.width()
for geo in geometries[1:]:
newPos = lastExt + (geo.x() - lastExt) / 2
hSizes.append(newPos - lastPos)
lastPos = newPos
lastExt = geo.x() + geo.width()
hSizes.append(layGeo.right() - lastPos - 1)
else:
if reverse:
reverseV = True
hSizes.append(layGeo.right())
lastExt = lastGeo.y() + lastGeo.height()
for geo in geometries[1:]:
newPos = lastExt + (geo.y() - lastExt) / 2
vSizes.append(newPos - lastPos)
lastPos = newPos
lastExt = geo.y() + geo.height()
vSizes.append(layGeo.bottom() - lastPos + 1)
else:
# assume a grid layout
origin = layout.originCorner()
if origin & 1:
reverseH = True
if origin & 2:
reverseV = True
first = layout.cellRect(0, 0)
lastX = lastY = 0
lastRight = first.x() + first.width()
lastBottom = first.y() + first.height()
for c in range(1, layout.columnCount()):
cr = layout.cellRect(0, c)
newX = lastRight + (cr.x() - lastRight) / 2
hSizes.append(newX - lastX)
lastX = newX
lastRight = cr.x() + cr.width()
hSizes.append(layGeo.right() - lastX)
for r in range(1, layout.rowCount()):
cr = layout.cellRect(r, 0)
newY = lastBottom + (cr.y() - lastBottom) / 2
vSizes.append(newY - lastY)
lastY = newY
lastBottom = cr.y() + cr.height()
vSizes.append(layGeo.bottom() - lastY)
hSizes[0] += 2
vSizes[0] += 2
self.layoutModel.setLayoutData(hSizes, vSizes, reverseH, reverseV)
self.updateGeometries()
def updateGeometries(self):
self.hHeader.resizeSections(self.hHeader.ResizeToContents)
self.vHeader.resizeSections(self.vHeader.ResizeToContents)
left = self.vHeader.sizeHint().width()
top = self.hHeader.sizeHint().height()
self.setViewportMargins(left, top, 0, 0)
vg = self.viewport().geometry()
self.hHeader.setGeometry(vg.x(), 0,
self.viewport().width(), top)
self.vHeader.setGeometry(0, vg.y(),
left, self.viewport().height())
self.hHeader.setOffset(self.horizontalScrollBar().value())
self.vHeader.setOffset(self.verticalScrollBar().value())
def sizeHint(self):
if not self._initialized and self.layout():
self.updateHeaderSizes()
hint = super().sizeHint()
if self.widget():
viewHint = self.viewportSizeHint()
if self.horizontalScrollBarPolicy() == QtCore.Qt.ScrollBarAsNeeded:
if viewHint.width() > hint.width():
hint.setHeight(hint.height() + self.horizontalScrollBar().sizeHint().height())
if self.verticalScrollBarPolicy() == QtCore.Qt.ScrollBarAsNeeded:
if viewHint.height() > hint.height():
hint.setWidth(hint.width() + self.verticalScrollBar().sizeHint().width())
hint += QtCore.QSize(
self.viewportMargins().left(), self.viewportMargins().top())
return hint
def resizeEvent(self, event):
super().resizeEvent(event)
QtCore.QTimer.singleShot(0, self.updateGeometries)
Notes:
the code above will cause some level of recursion; that is expected, as resizing the viewport will obviously trigger a resizeEvent, but Qt is smart enough to ignore them whenever sizes are unchanged;
this will only work for basic QBoxLayouts and QGridLayout; it's untested for QFormLayout and the behavior of other custom QLayout subclasses is completely unexpected;

QStyleItemDelegate with Custom Buttons

I have a QTreeWidget and I want to fully customize the way the items look by using a style delegate.
My main issue is that I would like to create a custom button, on the right of my Item, that allows me to collapse and expand the children of that item. The classical "+" button that can be usually found on the left side of most Trees.
I have no problem to paint the button itself, and change its icon depending if the item is expanded or not. The problem is to make it behave like a button ( Activate a command when is pressed, change color when hovered etc..)
What I came up with is to use the editorEvent to check if the mouse has been pressed on the same position as where I draw the button of the current item.
To obtain an hover effect, I edited the mouseMoveEvent of my tree and checked if the mouse is on top of a the button of the item, and if so, repaint the item with the hover on.
My implementation does the job, but I am concerned that I did it completely wrong, without being efficient and that my tree is going to be slow because of this kind of calculation. So I was wondering, if anyone had some suggestions on how to improve the code below.
The Delegate
class styleDelegate(QtWidgets.QStyledItemDelegate):
def __init__(self, parent=None, treeWidget = None):
super(styleDelegate, self).__init__(parent)
self.tree = treeWidget
def paint(self, painter, option, index):
painter.save()
rect = option.rect
# set the pen to draw an outline around the item to divide them.
pen = QPen()
pen.setBrush(QtGui.QColor(43, 43, 43))
pen.setWidthF(1)
painter.setPen(pen)
item = self.tree.itemFromIndex(index)
# set the background color based on the item or if it is selected
if option.state & QStyle.State_Selected:
painter.setBrush(option.palette.highlight())
else:
color = item.color
painter.setBrush(QtGui.QColor(color[0] * 255, color[1] * 255, color[2] * 255))
#draw the colored background
painter.drawRect(rect)
#draw the image
imageScale = 0
margin = 4
imageScale = rect.height() - margin * 2 + 1
painter.drawPixmap(rect.x() + margin, rect.y() + margin , imageScale, imageScale, item.image.scaled(imageScale, imageScale, QtCore.Qt.KeepAspectRatio, QtCore.Qt.SmoothTransformation))
# draw the text
painter.setPen(QtGui.QColor(255, 255, 255))
font = painter.font()
font.setPointSize(9)
painter.setFont(font)
painter.drawText(rect.x() + imageScale + margin * 3, rect.y(), 100, item.scale, Qt.AlignVCenter, item.name)
# draw the expander button only if the item has children
if item.childCount():
# choose the appropriate icon to draw depending on the state of the item.
if item.isExpanded():
path = "checked.png"
if item.hover:
path = "checked_hover.png"
else:
path = "unchecked.png"
if item.hover:
path = "unchecked_hover.png"
image = QtGui.QPixmap.fromImage(QtGui.QImage(path))
size = 20
# define the position of the expander button
positionX = rect.x() + rect.width() - 20
positionY = rect.y() + item.scale / 2 - size/2
painter.drawPixmap(positionX, positionY, size, size, image.scaled(size, size, QtCore.Qt.KeepAspectRatio, QtCore.Qt.SmoothTransformation))
item.expanderStart = QPoint(positionX, positionY)
item.expanderEnd = QPoint(positionX + 20, positionY + 20)
painter.restore()
def editorEvent(self, event, model, option, index):
# if an item is clicked, check if the click happened in the area whee the expander button is drawn.
if event.type() == QEvent.MouseButtonPress:
item = self.tree.itemFromIndex(index)
rect = option.rect
clickX = event.x()
clickY = event.y()
# set the expanded expanded if it was clicked
if clickX > x and clickX < x + w:
if clickY > y and clickY < y + h:
item.setExpanded(not item.isExpanded())
The Tree
class myTree(QtWidgets.QTreeWidget):
def __init__(self, parent):
super(myTree, self).__init__(parent)
self.setMouseTracking(True)
def mouseMoveEvent(self, event)
item = self.itemAt(event.pos())
if item:
if item.childCount():
# get the current hovering state. if the item is already hovered, there is no need to repaint it.
hover = item.hover
if (event.pos.x() > item.expanderStart.x()
and event.pos.x() < item.expanderEnd.x()
and event.pos.y() > item.expanderStart.y()
and event.pos.y() < item.expanderEnd.y())
item.hover = True
else:
item.hover = False
if item.hover != hover:
self.viewport().update(event.pos().x(), event.pos().y(), 20, 20)
I know this can be fully achieved without using delegates, by simply working with Stylesheets or assigning a widget to the Item. However, I didn't get to far with these methods, since I got several problems with them.
I spent loads of time trying to achieve the result I want without success. Maybe I get my Items to look close to what I Want, but never exactly as I imagine them.
The reason I am being so fussy on getting exactly the look I have in mind with delegates is that this QTreeWidget was once a QListWidget, implemented with stylesheets. Now that I am "updgrading" it to a Tree I don't want the user to even notice the difference, but i was not able to replicate the same exact look with sylesheets alone.
Pardon me if the code above has stupid mistake, I have tested the full version and it was working, and I just posted here the relevant stuff.
EDIT:
As requested, this is some code that (At least to me) produces the desired result. However, I wonder if this is the correct way of doing what im doing or not...
from PySide2.QtGui import *
from PySide2.QtCore import *
from PySide2.QtWidgets import *
class styleDelegate(QStyledItemDelegate):
def __init__(self, parent=None, treeWidget = None):
super(styleDelegate, self).__init__(parent)
self.tree = treeWidget
def paint(self, painter, option, index):
painter.save()
rect = option.rect
# set the pen to draw an outline around the item to divide them.
pen = QPen()
pen.setBrush(QColor(43, 43, 43))
pen.setWidthF(1)
painter.setPen(pen)
item = self.tree.itemFromIndex(index)
# set the background color based on the item or if it is selected
if option.state & QStyle.State_Selected:
painter.setBrush(option.palette.highlight())
else:
color = item.color
painter.setBrush(QColor(color[0], color[1], color[2]))
#draw the colored background
painter.drawRect(rect)
#draw the image
margin = 4
imageScale = rect.height() - margin * 2 + 1
painter.drawPixmap(rect.x() + margin, rect.y() + margin , imageScale, imageScale, item.image.scaled(imageScale, imageScale, Qt.KeepAspectRatio, Qt.SmoothTransformation))
# draw the text
painter.setPen(QColor(255, 255, 255))
font = painter.font()
font.setPointSize(9)
painter.setFont(font)
painter.drawText(rect.x() + imageScale + margin * 3, rect.y(), 300, item.scale, Qt.AlignLeft|Qt.AlignVCenter, item.name)
# draw the expander button only if the item has children
if item.childCount():
# choose the appropriate icon to draw depending on the state of the item.
if item.isExpanded():
path = "c:\\test.png"
if item.hover:
path = "c:\\test.png"
else:
path = "c:\\test.png"
if item.hover:
path = "c:\\test.png"
image = QPixmap.fromImage(QImage(path))
size = self.tree.expanderSize
# define the position of the expander button
positionX = rect.x() + rect.width() - size - 10
positionY = rect.y() + item.scale / 2 - size/2
painter.drawPixmap(positionX, positionY, size, size, image.scaled(size, size, Qt.KeepAspectRatio, Qt.SmoothTransformation))
item.expanderStart = QPoint(positionX, positionY)
item.expanderEnd = QPoint(positionX + size, positionY + size)
painter.restore()
def editorEvent(self, event, model, option, index):
# if an item is clicked, check if the click happened in the area whee the expander button is drawn.
if event.type() == QEvent.MouseButtonPress:
item = self.tree.itemFromIndex(index)
if item.childCount():
rect = option.rect
clickX = event.x()
clickY = event.y()
size = self.tree.expanderSize
# this is the rect of the expander button
x = rect.x() + rect.width() - 20
y = rect.y() + item.scale / 2 - size/2
w = size # expander width
h = size # expander height
# set the expanded expanded if it was clicked
if (clickX > item.expanderStart.x()
and clickX < item.expanderEnd.x()
and clickY > item.expanderStart.y()
and clickY < item.expanderEnd.y()):
print "expand"
item.setExpanded(not item.isExpanded())
class myTree(QTreeWidget):
def __init__(self, parent = None):
super(myTree, self).__init__(parent)
self.setMouseTracking(True)
self.setHeaderHidden(True)
self.setRootIsDecorated(False)
self.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
def mouseMoveEvent(self, event):
item = self.itemAt(event.pos())
if item:
if item.childCount():
# get the current hovering state. if the item is already hovered, there is no need to repaint it.
hover = item.hover
if (event.pos() .x() > item.expanderStart.x()
and event.pos() .x() < item.expanderEnd.x()
and event.pos() .y() > item.expanderStart.y()
and event.pos() .y() < item.expanderEnd.y()):
item.hover = True
else:
item.hover = False
if item.hover != hover:
self.viewport().update(event.pos().x(), event.pos().y(), 20, 20)
print "Hover", item.hover
def closeEvent(self, event):
self.deleteLater()
def generateTree():
tree = myTree()
tree.setGeometry(500, 500, 1000, 500)
tree.expanderSize = 50
delegate = styleDelegate(tree, treeWidget = tree)
tree.setItemDelegate(delegate)
for object in ["Aaaaaaa", "Bbbbbbb", "Ccccccc"]:
item = QTreeWidgetItem()
item.name = object
item.image = QPixmap.fromImage(QImage("c:\\test.png"))
item.color = [150, 150, 150]
item.hover = False
item.scale = 100
tree.addTopLevelItem(item)
item.setSizeHint(0, QSize(item.scale, item.scale ))
for child in ["Eeeeee", "Fffffff"]:
childItem = QTreeWidgetItem()
childItem.name = child
childItem.image = QPixmap.fromImage(QImage("c:\\test.png"))
childItem.color = [150, 150, 150]
childItem.scale = 90
item.addChild(childItem)
childItem.setSizeHint(0, QSize(childItem.scale, childItem.scale))
return tree
tree = generateTree()
tree.show()
Note that my monitor is 4k and I quickly Hardcoded most of the sizes, so out of the box this code will produce much bigger widgets on a HD monitor.
Your code has the following errors:
It is not necessary to use QPixmap.fromImage(QImage(path)), you can create a QPixmap directly with the path: QPixmap(path)
If they are the same images, it is better to load it once and reuse it, for example in my solution I do it for the buttons QPixmap.
Do not create dynamic attributes because it generates code coupling, in the case of items you must use the roles.
To know if an item is expanded or you should not use QStyle::State_Open so we avoid coupling and the delegate can be used by other views without making many changes.
Use QRect to delimit a rectangle and so for example you use contains to see if a point is inside the rectangle.
The above are the main observations, in the following part is the solution:
from PySide2 import QtCore, QtGui, QtWidgets
from enum import Enum
ScaleRole= QtCore.Qt.UserRole + 1
expanderSize = 50
class TreeDelegate(QtWidgets.QStyledItemDelegate):
def __init__(self, parent=None):
super(TreeDelegate, self).__init__(parent)
self.pixmap_collapsed = QtGui.QPixmap("collapsed.png")
self.pixmap_collapsed_hover = QtGui.QPixmap("collapsed_hover.png")
self.pixmap_expanded = QtGui.QPixmap("expanded.png")
self.pixmap_expanded_hover = QtGui.QPixmap("expanded_hover.png")
def paint(self, painter, option, index):
image = index.data(QtCore.Qt.DecorationRole)
scale = index.data(ScaleRole)
name = index.data()
painter.save()
rect = option.rect
painter.setPen(QtGui.QPen(brush=QtGui.QColor(43, 43, 43), widthF=1))
if option.state & QtWidgets.QStyle.State_Selected:
painter.setBrush(option.palette.highlight())
else:
painter.setBrush(index.data(QtCore.Qt.BackgroundRole))
painter.drawRect(rect)
margin = 4
image_scale = (rect.height() - margin * 2 + 1)*QtCore.QSize(1, 1)
if image is not None and not image.isNull():
image = image.scaled(image_scale, QtCore.Qt.KeepAspectRatio, QtCore.Qt.SmoothTransformation)
painter.drawPixmap(rect.topLeft() + margin*QtCore.QPoint(1, 1), image)
painter.setPen(QtGui.QColor(255, 255, 255))
font = painter.font()
font.setPointSize(9)
painter.setFont(font)
painter.drawText(QtCore.QRect(rect.topLeft() + QtCore.QPoint(image_scale.width() + 3*margin, 0) , QtCore.QSize(300, scale)),
QtCore.Qt.AlignLeft | QtCore.Qt.AlignVCenter, name)
if index.model().hasChildren(index):
pixmap = self.pixmap_collapsed
if option.state & QtWidgets.QStyle.State_Open:
if option.state & QtWidgets.QStyle.State_MouseOver:
pixmap = self.pixmap_expanded_hover
else:
pixmap = self.pixmap_expanded
else :
if option.state & QtWidgets.QStyle.State_MouseOver:
pixmap = self.pixmap_collapsed_hover
size = expanderSize
pixmap = pixmap.scaled(size*QtCore.QSize(1, 1), QtCore.Qt.KeepAspectRatio, QtCore.Qt.SmoothTransformation)
pos = rect.topRight() - QtCore.QPoint(size+10, (size-scale)/2)
painter.drawPixmap(pos, pixmap)
painter.restore()
class MyTreeItem(QtWidgets.QTreeWidgetItem):
def __init__(self, name, image, color, scale):
super(MyTreeItem, self).__init__([name])
self.setData(0, ScaleRole, scale)
self.setData(0, QtCore.Qt.BackgroundRole, color)
self.setData(0, QtCore.Qt.DecorationRole, image)
class MyTree(QtWidgets.QTreeWidget):
def __init__(self, parent=None):
super(MyTree, self).__init__(parent)
self.setMouseTracking(True)
self.setHeaderHidden(True)
self.setRootIsDecorated(False)
self.setVerticalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOff)
def mousePressEvent(self, event):
if not self.itemsExpandable(): return
index = self.indexAt(event.pos())
if not index.isValid(): return
# restore state
is_expanded = self.isExpanded(index)
QtWidgets.QAbstractItemView.mousePressEvent(self, event)
self.setExpanded(index, is_expanded)
if not self.model().hasChildren(index): return
rect = self.visualRect(index)
size = expanderSize
scale = index.data(ScaleRole)
pos = rect.topRight() - QtCore.QPoint(size+10, (size-scale)/2)
r = QtCore.QRect(pos, size*QtCore.QSize(1, 1))
if r.contains(event.pos()):
self.setExpanded(index, not self.isExpanded(index))
def generate_tree():
tree = MyTree()
scale = 100
delegate = TreeDelegate(tree)
tree.setItemDelegate(delegate)
for text in ["Aaaaaaa", "Bbbbbbb", "Ccccccc"]:
item = MyTreeItem(text, QtGui.QPixmap("image.png"), QtGui.QColor(150, 150, 150), scale)
item.setSizeHint(0, QtCore.QSize(scale, scale))
tree.addTopLevelItem(item)
for child in ["Eeeeee", "Fffffff"]:
childItem = MyTreeItem(child, QtGui.QPixmap("image.png"), QtGui.QColor(150, 150, 150), scale)
childItem.setSizeHint(0, QtCore.QSize(scale, scale))
item.addChild(childItem)
return tree
if __name__ == '__main__':
import sys
app = QtWidgets.QApplication(sys.argv)
tree = generate_tree()
tree.show()
sys.exit(app.exec_())

Can I share the crosshair with two graph in pyqtgraph (pyqt5)

I am implemented two graphs in pyqt5 with pyqtgraph, and want to share cursor between two graphs.
I have a program result at the following:
And I want to share the crosshair like:
Can pyqtgraph do it ? Many thanks.
Following is my code (update), it will show 2 graph and the cursor will only allow move in graph2 only. However, I want the cursor can move and get data from graph1 and graph2.
import sys
from PyQt5.QtWidgets import *
from PyQt5.QtCore import *
from PyQt5.QtGui import *
from pyqtgraph.Qt import QtGui, QtCore
import pyqtgraph as pg
from pyqtgraph import MultiPlotWidget
class GUI_create_main_window(QWidget):
def __init__(self):
super().__init__()
main_layout = QVBoxLayout(self)
self.plot1 = pg.PlotWidget()
main_layout.addWidget(self.plot1)
self. plot2 = pg.PlotWidget()
main_layout.addWidget(self.plot2)
self.draw_cursor()
self.setLayout(main_layout)
self.show()
#hair cross event
def eventFilter(self, source, event):
try:
if (event.type() == QtCore.QEvent.MouseMove and
source is self.plot2.viewport()):
pos = event.pos()
if self.plot2.sceneBoundingRect().contains(pos):
mousePoint = self.vb.mapSceneToView(pos)
self.vLine.setPos(mousePoint.x())
self.hLine.setPos(mousePoint.y())
return QtGui.QWidget.eventFilter(self, source, event)
except Exception as e:
traceback.print_exc()
err = sys.exc_info()[1]
print(str(err))
def draw_cursor(self):
#cross hair
self.vLine = pg.InfiniteLine(angle=90, movable=False, pen=pg.mkPen('k', width=1))
self.hLine = pg.InfiniteLine(angle=0, movable=False, pen=pg.mkPen('k', width=1), label='{value:0.1f}',
labelOpts={'position':0.98, 'color': (200,0,0), 'movable': True, 'fill': (0, 0, 200, 100)})
self.plot2.addItem(self.vLine, ignoreBounds=True)
self.plot2.addItem(self.hLine, ignoreBounds=True)
self.vb = self.plot2.plotItem.vb
#set mouse event
self.plot2.setMouseTracking(True)
self.plot2.viewport().installEventFilter(self)
if __name__ == '__main__':
pg.setConfigOption('background', 'w')
pg.setConfigOption('foreground', 'k')
app = QApplication(sys.argv)
gui = GUI_create_main_window()
currentExitCode = app.exec_()
I know that it is kind of late but after struggling with similar problem I came up with a solution.
First of all we can capture mouse movements on pyqtgraph plots by installing 'SignalProxy' on their scene.
For example:
self.proxy = pg.SignalProxy(self.p.scene().sigMouseMoved, rateLimit=120, slot=self.mouseMoved)
This will call mouseMoved method on when mouse move signal is emitted.
We can get mouse position converted to scene position with
def mouseMoved(self, evt):
pos = evt[0]
mousePoint = self.p.vb.mapSceneToView(pos)
x, y = int(mousePoint.x()), int(mousePoint.y())
now we can set vLine pos to x and hLine pos to y on if we can have access to other graph's plot we can also set their vLine pos to x (since x axis should match)
we can link their x axis together (same movement on x axis is applied on other graph) by:
plot.setXLink(other_plot)
Side notes:
I set up two graphs by subclassing pyqtgraph's GraphicsLayoutWidget and adding plots to them individually I'm sure there should be a way of doing it in a single subclass but doing something like...
self.addPlot(title="Plot 1")
self.addPlot(title="Plot 2")
...creates two plots next to each other rather than stacked on top of
each other - we want to only link vLines anyways.
we can access other graph by something like this:
self.top_graph = TopGraph()
self.bottom_graph = BottomGraph()
self.top_graph.other_graph = self.bottom_graph # set bottom graph in top graph to access it in bottom graph
self.bottom_graph.other_graph = self.top_graph # set top graph in bottom graph to access it in top graph
maybe we can do global variables as well but I'm not sure that it is a good practice.
So it all adds up to:
from PyQt5.QtWidgets import *
from PyQt5.QtCore import *
from PyQt5.QtGui import *
import pyqtgraph as pg
class MainWindow(QMainWindow):
def __init__(self):
super().__init__()
main_widget = QWidget()
main_layout = QVBoxLayout()
main_widget.setLayout(main_layout)
self.setCentralWidget(main_widget)
self.top_graph = TopGraph()
self.bottom_graph = BottomGraph()
self.top_graph.other_graph = self.bottom_graph # set bottom graph in top graph to access it in bottom graph
self.bottom_graph.other_graph = self.top_graph # set top graph in bottom graph to access it in top graph
self.top_graph.p.setXLink(self.bottom_graph.p)
#example plot
x = range(-50, 51)
y1 = [i**2 for i in x]
y2 = [-i**2 for i in x]
self.top_graph.p.plot(x, y1, pen=pg.mkPen("r", width=3))
self.bottom_graph.p.plot(x, y2, pen=pg.mkPen("g", width=3))
splitter = QSplitter(Qt.Vertical)
splitter.addWidget(self.top_graph)
splitter.addWidget(self.bottom_graph)
main_layout.addWidget(splitter)
class TopGraph(pg.GraphicsLayoutWidget):
def __init__(self):
super().__init__()
self.p = self.addPlot()
self.p.hideAxis("bottom")
self.vLine = pg.InfiniteLine(angle=90, movable=False, pen=pg.mkPen('k', width=1))
self.hLine = pg.InfiniteLine(angle=0, movable=False, pen=pg.mkPen('k', width=1), label='{value:0.1f}',
labelOpts={'position':0.98, 'color': (200,0,0), 'movable': True, 'fill': (0, 0, 200, 100)})
self.p.addItem(self.vLine, ignoreBounds=True)
self.p.addItem(self.hLine, ignoreBounds=True)
self.proxy = pg.SignalProxy(self.p.scene().sigMouseMoved, rateLimit=120, slot=self.mouseMoved)
def mouseMoved(self, evt):
pos = evt[0]
if self.p.sceneBoundingRect().contains(pos):
self.hLine.show() # show this graph's h line since we are now in control
mousePoint = self.p.vb.mapSceneToView(pos)
x, y = int(mousePoint.x()), int(mousePoint.y())
self.vLine.setPos(x)
self.hLine.setPos(y)
self.other_graph.vLine.setPos(x)
self.other_graph.hLine.hide() # hide other graphs h line since we don't controll it here
class BottomGraph(pg.GraphicsLayoutWidget):
def __init__(self):
super().__init__()
self.p = self.addPlot()
self.vLine = pg.InfiniteLine(angle=90, movable=False, pen=pg.mkPen('k', width=1))
self.hLine = pg.InfiniteLine(angle=0, movable=False, pen=pg.mkPen('k', width=1), label='{value:0.1f}',
labelOpts={'position':0.98, 'color': (200,0,0), 'movable': True, 'fill': (0, 0, 200, 100)})
self.p.addItem(self.vLine, ignoreBounds=True)
self.p.addItem(self.hLine, ignoreBounds=True)
self.proxy = pg.SignalProxy(self.p.scene().sigMouseMoved, rateLimit=120, slot=self.mouseMoved)
def mouseMoved(self, evt):
pos = evt[0]
if self.p.sceneBoundingRect().contains(pos):
self.hLine.show() # show this graph's h line since we are now in control
mousePoint = self.p.vb.mapSceneToView(pos)
x, y = int(mousePoint.x()), int(mousePoint.y())
self.vLine.setPos(x)
self.hLine.setPos(y)
self.other_graph.vLine.setPos(x)
self.other_graph.hLine.hide() # hide other graphs h line since we don't controll it here
def main():
pg.setConfigOption('background', 'w')
pg.setConfigOption('foreground', 'k')
app = QApplication([])
window = MainWindow()
window.setMinimumSize(600, 400)
window.show()
app.exec()
if __name__ == "__main__":
main()

How to draw a proper grid on PyQt?

Let's consider this little snippet:
import sys
from PyQt5 import QtWidgets
from PyQt5 import QtCore
from PyQt5 import QtGui
from PyQt5.QtWidgets import QMenu
from PyQt5.QtGui import QKeySequence
from PyQt5.QtCore import Qt
from PyQt5.QtGui import QCursor
def create_action(parent, text, slot=None,
shortcut=None, shortcuts=None, shortcut_context=None,
icon=None, tooltip=None,
checkable=False, checked=False):
action = QtWidgets.QAction(text, parent)
if icon is not None:
action.setIcon(QIcon(':/%s.png' % icon))
if shortcut is not None:
action.setShortcut(shortcut)
if shortcuts is not None:
action.setShortcuts(shortcuts)
if shortcut_context is not None:
action.setShortcutContext(shortcut_context)
if tooltip is not None:
action.setToolTip(tooltip)
action.setStatusTip(tooltip)
if checkable:
action.setCheckable(True)
if checked:
action.setChecked(True)
if slot is not None:
action.triggered.connect(slot)
return action
class Settings():
WIDTH = 20
HEIGHT = 15
NUM_BLOCKS_X = 10
NUM_BLOCKS_Y = 14
class QS(QtWidgets.QGraphicsScene):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
width = Settings.NUM_BLOCKS_X * Settings.WIDTH
height = Settings.NUM_BLOCKS_Y * Settings.HEIGHT
self.setSceneRect(0, 0, width, height)
self.setItemIndexMethod(QtWidgets.QGraphicsScene.NoIndex)
class QV(QtWidgets.QGraphicsView):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.view_menu = QMenu(self)
self.create_actions()
def create_actions(self):
act = create_action(self.view_menu, "Zoom in",
slot=self.on_zoom_in,
shortcut=QKeySequence("+"), shortcut_context=Qt.WidgetShortcut)
self.view_menu.addAction(act)
act = create_action(self.view_menu, "Zoom out",
slot=self.on_zoom_out,
shortcut=QKeySequence("-"), shortcut_context=Qt.WidgetShortcut)
self.view_menu.addAction(act)
self.addActions(self.view_menu.actions())
def on_zoom_in(self):
if not self.scene():
return
self.scale(1.5, 1.5)
def on_zoom_out(self):
if not self.scene():
return
self.scale(1.0 / 1.5, 1.0 / 1.5)
def drawBackground(self, painter, rect):
gr = rect.toRect()
start_x = gr.left() + Settings.WIDTH - (gr.left() % Settings.WIDTH)
start_y = gr.top() + Settings.HEIGHT - (gr.top() % Settings.HEIGHT)
painter.save()
painter.setPen(QtGui.QColor(60, 70, 80).lighter(90))
painter.setOpacity(0.7)
for x in range(start_x, gr.right(), Settings.WIDTH):
painter.drawLine(x, gr.top(), x, gr.bottom())
for y in range(start_y, gr.bottom(), Settings.HEIGHT):
painter.drawLine(gr.left(), y, gr.right(), y)
painter.restore()
super().drawBackground(painter, rect)
if __name__ == '__main__':
app = QtWidgets.QApplication(sys.argv)
a = QS()
b = QV()
b.setScene(a)
# b.resize(800,600)
b.show()
sys.exit(app.exec_())
If we run it we can see the number block of grids is ok, as specified it's 8x10:
Now, let's say i set NUM_BLOCKS_X=3 and NUM_BLOCKS_Y=2, the output will be this one:
That's wrong! I definitely don't want that, I'd like the QGraphicsView to be shrinked properly to the grid's settings I've specified.
1st Question: How can i achieve that?
Another thing I'd like to know is, let's consider the posted snippet where the grid is 10x8 and then let's resize the QGraphicsWidget to 800x600, the output will be:
But I'd like to know how i can draw only the QGraphicsScene region. Right now I'm using rect in drawBackground.
So my 2nd question is: How can I draw the grid only inside QGraphicsScene's region?
One of the main problems appears when I zoom out, in that case I'd like to see only the size of the QGraphicsScene, because I'll be adding items only on that region, let's call it "drawable" region. As you can see, right now it's drawing grid lines on "non-drawable" region and that's really confusing
I would draw the grid in the Scene like this:
class QS(QtWidgets.QGraphicsScene):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
width = Settings.NUM_BLOCKS_X * Settings.WIDTH
height = Settings.NUM_BLOCKS_Y * Settings.HEIGHT
self.setSceneRect(0, 0, width, height)
self.setItemIndexMethod(QtWidgets.QGraphicsScene.NoIndex)
for x in range(0,Settings.NUM_BLOCKS_X+1):
xc = x * Settings.WIDTH
self.addLine(xc,0,xc,height)
for y in range(0,Settings.NUM_BLOCKS_Y+1):
yc = y * Settings.HEIGHT
self.addLine(0,yc,width,yc)
EDIT:
Additional visibility/opacity functionality:
class QS(QtWidgets.QGraphicsScene):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.lines = []
self.draw_grid()
self.set_opacity(0.3)
#self.set_visible(False)
#self.delete_grid()
def draw_grid(self):
width = Settings.NUM_BLOCKS_X * Settings.WIDTH
height = Settings.NUM_BLOCKS_Y * Settings.HEIGHT
self.setSceneRect(0, 0, width, height)
self.setItemIndexMethod(QtWidgets.QGraphicsScene.NoIndex)
pen = QPen(QColor(255,0,100), 1, Qt.SolidLine)
for x in range(0,Settings.NUM_BLOCKS_X+1):
xc = x * Settings.WIDTH
self.lines.append(self.addLine(xc,0,xc,height,pen))
for y in range(0,Settings.NUM_BLOCKS_Y+1):
yc = y * Settings.HEIGHT
self.lines.append(self.addLine(0,yc,width,yc,pen))
def set_visible(self,visible=True):
for line in self.lines:
line.setVisible(visible)
def delete_grid(self):
for line in self.lines:
self.removeItem(line)
del self.lines[:]
def set_opacity(self,opacity):
for line in self.lines:
line.setOpacity(opacity)
You need to add import: from PyQt5.QtGui import QPen, QColor
EDIT2:
Painting a rectangle in the scene:
def draw_insert_at_marker(self):
w = Settings.WIDTH * 3
h = Settings.HEIGHT
r = QRectF(7 * Settings.WIDTH, 7 * Settings.HEIGHT, w, h)
gradient = QLinearGradient(r.topLeft(), r.bottomRight())
gradient.setColorAt(1, QColor(255, 255, 255, 0))
gradient.setColorAt(0, QColor(255, 255, 255, 127))
rect = self.addRect(r, Qt.white, gradient)
You can obtain an automatically resizing grid from the library:
grid = QtGui.QGridLayout ()
http://zetcode.com/gui/pyqt4/layoutmanagement/
You can set the border color. It actually serves as an alignment tool for widgets, but maybe it's possible to adapt it to your needs.

Categories

Resources