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;
Related
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.
I am trying to dynamically load a wxGrid with a pandas Dataframe depending on what table is selected in the combobox. I can get the grid to load on intialization, but can't figure out how to get refresh the GridTableClass and grid. Right now I am trying to test with a random Dataframe.
class PageOne(wx.Panel):
def __init__(self, parent):
wx.Panel.__init__(self, parent)
#wx.StaticText(self, -1, "This is a PageOne object", (20,20))
class PageTwo(wx.Panel):
def __init__(self, parent):
wx.Panel.__init__(self, parent)
#wx.StaticText(self, -1, "This is a PageTwo object", (40, 40))
class PageThree(wx.Panel):
def __init__(self, parent):
wx.Panel.__init__(self, parent)
#wx.StaticText(self, -1, "This is a PageThree object", (60, 60))
class DataTable(gridlib.GridTableBase):
def __init__(self, data):
gridlib.GridTableBase.__init__(self)
self.data = data
#self.colnames = colnames
#Store the row and col length to see if table has changed in size
self._rows = self.GetNumberRows()
self._cols = self.GetNumberCols()
self.odd=gridlib.GridCellAttr()
self.odd.SetBackgroundColour((217,217,217))
self.even=gridlib.GridCellAttr()
self.even.SetBackgroundColour((255,255,255))
def GetAttr(self, row, col, kind):
attr = [self.even, self.odd][row % 2]
attr.IncRef()
return attr
def GetNumberRows(self):
return len(self.data)
def GetNumberCols(self):
return len(self.data.columns) + 1
def IsEmptyCell(self, row, col):
return False
def GetValue(self, row, col):
#if col == 0:
# return None #self.data.index[row]
return self.data.iloc[row, col-1]
def SetValue(self, row, col, value):
self.data.iloc[row, col - 1] = value
def GetColLabelValue(self, col):
if col == 0:
return None
#pass
#return 'Index' if self.data.index.name is None else self.data.index.name
return self.data.columns[col - 1] #[col-1]
#return None
#---------------------------------------------------------------------------
class DataGrid(gridlib.Grid):
def __init__(self, parent, data): # data
gridlib.Grid.__init__(self, parent, - 1) #,colnames,-1 # data
#data = pd.DataFrame(np.random.randint(0,100,size=(200, 5)),columns=list('EFGHD'))
#data.reset_index(drop=True, inplace=True)
table = DataTable(data)
print ("passed")
# The second parameter means that the grid is to take ownership of the
# table and will destroy it when done. Otherwise you would need to keep
# a reference to it and call it's Destroy method later.
self.SetTable(table, True)
self.Bind(gridlib.EVT_GRID_CELL_RIGHT_CLICK, self.OnCellRightClick)
def OnCellRightClick(self, event):
print ("OnCellRightClick: (%d,%d)\n" % (event.GetRow(), event.GetCol()))
#-------------------------------------------------------------------------------
class MainFrame(wx.Frame):
def __init__(self, parent, data): # (self, parent, data):
wx.Frame.__init__(self, parent, -1, "Varkey Foundation") #, size=(640,480))
#Create a panel
self.p = wx.Panel(self)
self.Maximize(True)
#Create blank dataframe
data = pd.DataFrame() #pd.DataFrame(np.random.randint(0,100,size=(200, 5)),columns=list('EFGHD')
#data = pd.DataFrame(np.random.randint(0,100,size=(200, 5)),columns=list('EFGHD'))
#data.reset_index(drop=True, inplace=True)
self.data = DataTable(data)
self.nb = wx.Notebook(self.p)
self.p.SetBackgroundColour( wx.Colour( 0, 0, 0 ) ) # 38,38,38
self.nb.SetBackgroundColour(wx.Colour(58, 56, 56) )
#self.SetBackgroundColour( wx.Colour( 255, 255, 56 ) )
#create the page windows as children of the notebook
self.page1 = PageOne(self.nb)
self.page2 = PageTwo(self.nb)
self.page3 = PageThree(self.nb)
# add the pages to the notebook with the label to show on the tab
self.nb.AddPage(self.page1, "Data")
self.nb.AddPage(self.page2, "Analyze")
self.nb.AddPage(self.page3, "Change Log")
#Create the grid and continue layout
self.grid = DataGrid(self.page1, data)
#grid.SetReadOnly(5,5, True)
#CreateFonts
self.b_font = wx.Font(14,wx.ROMAN,wx.NORMAL,wx.BOLD, True)
self.lbl_font = wx.Font(14,wx.ROMAN,wx.NORMAL,wx.NORMAL, True)
self.cb_font = wx.Font(11,wx.SCRIPT,wx.ITALIC,wx.NORMAL, True)
self.h_font = wx.Font(18,wx.DECORATIVE,wx.ITALIC,wx.BOLD, True)
#Create Title bmp
ico = wx.Icon('varkey_bmp.bmp', wx.BITMAP_TYPE_ICO) #'varkey_frame.bmp'
self.SetIcon(ico)
#Page 1 sizers and widgets
self.title = wx.StaticText(self.page1,label="TITLE",style = wx.ALIGN_CENTER | wx.ST_NO_AUTORESIZE)
self.title.SetForegroundColour((255,255,255))
self.title.SetFont(self.h_font)
self.p1_sizer = wx.BoxSizer(wx.VERTICAL)
self.p1_sizer.Add(self.title,0,wx.EXPAND,5)
self.p1_sizer.Add(self.grid,3,wx.EXPAND | wx.ALL ,25)
#self.p1_sizer.Add(self.btn_new,-0,wx.ALIGN_CENTER,5)
self.page1.SetSizer(self.p1_sizer)
#Page 2 sizers and widgets
self.analyze_grid = gridlib.Grid(self.page2)
self.analyze_grid.CreateGrid(0, 10)
self.p2_sizer = wx.BoxSizer(wx.VERTICAL)
self.p2_sizer.Add(self.analyze_grid,1,wx.EXPAND)
self.page2.SetSizer(self.p2_sizer)
#Page 3 sizers and widgets
self.log_grid = gridlib.Grid(self.page3)
self.log_grid.CreateGrid(0, 9)
self.log_grid.EnableEditing(False)
self.p3_sizer = wx.BoxSizer(wx.VERTICAL)
self.p3_sizer.Add(self.log_grid,1,wx.EXPAND)
self.page3.SetSizer(self.p3_sizer)
#Create widgets for top sizer
#Insert Image
self.staticbitmap = wx.StaticBitmap(self.p)
self.staticbitmap.SetBitmap(wx.Bitmap('varkey_logo2.jpg'))
self
self.lbl_user = wx.StaticText(self.p,label="Username:")
self.lbl_password = wx.StaticText(self.p,label="Password:")
self.lbl_interaction = wx.StaticText(self.p,label="Interaction:")
self.lbl_table = wx.StaticText(self.p,label="Table:")
#SetForground colors
self.lbl_user.SetForegroundColour((255,255,255))
self.lbl_password.SetForegroundColour((255,255,255))
self.lbl_interaction.SetForegroundColour((255,255,255))
self.lbl_table.SetForegroundColour((255,255,255))
#Set Fonts
self.lbl_user.SetFont(self.lbl_font)
self.lbl_password.SetFont(self.lbl_font)
self.lbl_interaction.SetFont(self.lbl_font)
self.lbl_table.SetFont(self.lbl_font)
self.tc_user =wx.TextCtrl(self.p,value='cmccall95',size = (130,25))
self.tc_password =wx.TextCtrl(self.p,value='Achilles95', style=wx.TE_PASSWORD | wx.TE_PROCESS_ENTER,size = (130,25))
#self.tc_password.Bind(wx.EVT_TEXT_ENTER,self.onLogin)
self.tc_user.SetFont(self.cb_font)
self.tc_password.SetFont(self.cb_font)
self.btn_login = wx.Button(self.p,label="Login", size=(105,30))
self.btn_login.SetBackgroundColour(wx.Colour(198, 89, 17))
self.btn_login.SetFont(self.b_font)
self.btn_login.Bind(wx.EVT_BUTTON, self.onLogin) #connect_mysql
self.btn_logout = wx.Button(self.p,label="Logout",size=(105,30))
self.btn_logout.SetBackgroundColour(wx.Colour(192,0,0))
self.btn_logout.SetFont(self.b_font)
#self.btn_logout.Bind(wx.EVT_BUTTON, self.onLogout)
self.combo_interaction = wx.ComboBox(self.p, size = (160,25),style = wx.CB_READONLY | wx.CB_SORT | wx.CB_SORT)
#self.combo_interaction.Bind(wx.EVT_COMBOBOX, self.onComboInteraction)
self.combo_table = wx.ComboBox(self.p, size = (160,25),style = wx.CB_READONLY | wx.CB_SORT | wx.CB_SORT)
#self.combo_table.Bind(wx.EVT_COMBOBOX, self.onHideCommands)
self.combo_interaction.SetFont(self.cb_font)
self.combo_table.SetFont(self.cb_font)
#self.combo_table.Bind(wx.EVT_COMBOBOX ,self.OnComboTable)
self.btn_load = wx.Button(self.p,label="Load Table", size=(105,30))
self.btn_load.SetBackgroundColour(wx.Colour(31, 216, 6))
self.btn_load.SetFont(self.b_font)
#self.btn_load.Bind(wx.EVT_BUTTON, self.onLoadData)
self.btn_load.Bind(wx.EVT_BUTTON, self.test_return)
self.lc_change = wx.ListCtrl(self.p,-1,style = wx.TE_MULTILINE | wx.LC_REPORT | wx.LC_VRULES)
self.lc_change.InsertColumn(0,"User ID")
self.lc_change.InsertColumn(1,"Status")
self.lc_change.InsertColumn(2,"Description")
self.lc_change.InsertColumn(3,"Date/Time")
#Set column widths
self.lc_change.SetColumnWidth(0, 75)
self.lc_change.SetColumnWidth(1, 75)
self.lc_change.SetColumnWidth(2, 450)
self.lc_change.SetColumnWidth(3, 125)
#Create Filler text
self.lbl_filler = wx.StaticText(self.p,label="",size = (125,20))
#Create FlexGridSizers(For top half)
self.left_fgs = wx.FlexGridSizer(3,4,25,15)
self.left_fgs.AddMany([(self.lbl_user,1,wx.ALIGN_LEFT | wx.LEFT,15),(self.tc_user,1,wx.EXPAND),(self.lbl_interaction,1,wx.ALIGN_RIGHT|wx.RIGHT, 10),(self.combo_interaction,1,wx.EXPAND),
(self.lbl_password,1,wx.ALIGN_LEFT| wx.LEFT,15),(self.tc_password,1,wx.EXPAND),(self.lbl_table,1,wx.ALIGN_RIGHT|wx.RIGHT, 10),(self.combo_table),
(self.btn_login,2,wx.EXPAND),(self.btn_logout,1,wx.EXPAND),(self.lbl_filler,1,wx.EXPAND),(self.btn_load,1)])
#Create Top Sizer
self.top_sizer = wx.BoxSizer(wx.HORIZONTAL)
self.top_sizer.Add(self.left_fgs,proportion = 1, flag = wx.ALL|wx.EXPAND,border = 30)
self.top_sizer.Add(self.staticbitmap,2,wx.TOP | wx.RIGHT, border = 40) #30
self.top_sizer.Add(self.lc_change,2,wx.RIGHT|wx.EXPAND ,30)
#create Bottom Sizer
self.bottom_sizer = wx.BoxSizer(wx.VERTICAL)
self.bottom_sizer.Add(self.nb,proportion = 5, flag = wx.LEFT |wx.RIGHT | wx.EXPAND,border = 30)
self.mainsizer = wx.BoxSizer(wx.VERTICAL)
self.mainsizer.Add(self.top_sizer,proportion = 0, flag = wx.ALL|wx.EXPAND,border = 5)
self.mainsizer.Add(self.bottom_sizer,proportion = 1,flag = wx.ALL|wx.EXPAND,border = 5)
#self.mainsizer.Add(self.status_sizer,proportion =0,flag = wx.BOTTOM|wx.ALIGN_CENTER_HORIZONTAL, border = 15)
self.p.SetSizerAndFit(self.mainsizer)
def test_reload(self, event):
data = pd.DataFrame() #pd.DataFrame(np.random.randint(0,100,size=(200, 5)),columns=list('EFGHD')
data = pd.DataFrame(np.random.randint(0,100,size=(200, 5)),columns=list('EFGHD'))
#data.reset_index(drop=True, inplace=True)
#self.data = DataTable(data)
self.table.data = self.data
self.grid.Refresh()
#Some more functions.......
if __name__ == '__main__':
import sys
app = wx.App()
frame = MainFrame(None, sys.stdout) # (None, sys.stdout)
frame.Show(True)
app.MainLoop()
I've tried many different variations of the function and can't understand how this should work. From my understanding, I should the table and then call the grid to refresh.
def test_reload(self, event):
data = pd.DataFrame() #pd.DataFrame(np.random.randint(0,100,size=(200, 5)),columns=list('EFGHD')
data = pd.DataFrame(np.random.randint(0,100,size=(200, 5)),columns=list('EFGHD'))
self.table.data = self.data
self.grid.Refresh()
I managed to find a solution. Hopefully this helps someone else out.
To reload the grid, I first create new data. Then I destroy the grid, create a new grid, and insert it in place of the old grid. You must then call Layout(). The Freeze() and Thaw() methods are to prevent screen flickering.
def test_reload(self, event):
self.Freeze()
#Create new data
data = pd.DataFrame(np.random.randint(0,100,size=(40000, 5)),columns=list('EFGHD'))
#Destroy and create grid with new data assigned
self.grid.Destroy()
self.grid = DataGrid(self.page1, data)
#Insert grid into existing sizer
self.p1_sizer.Insert(1,self.grid,1,wx.RIGHT| wx.LEFT|wx.EXPAND, 20)
self.p1_sizer.Layout()
self.Thaw()
I'm sure there is a better method out there, but this works and is instant.
Kivy Gui have a transition animation to switch between windows (going back and forth also) we can do it easily in kivy. But in PyQt5 I did't find out any way to transit between window (with animation) and going back and forth to a window again and again is also not working. So, is there any way to do like Kivy do transitions, going back and forth to a window easily in PyQt5.
Qt doesn't provide a similar effect on its own, but it still can be achieved using a subclass of a QStackedWidget (which behaves similarly to a QTabWidget, but without any QTabBar).
In the following example I'll show you how to implement a basic "swap" transition between two widgets that are added to a QStackedWidget, the next widget will scroll from right to left if the index is greater than the current, and vice versa.
class TransitionWidget(QtWidgets.QStackedWidget):
_nextIndex = _nextWidget = None
_orientation = QtCore.Qt.Horizontal
def __init__(self):
super().__init__()
self._animation = QtCore.QVariantAnimation(
startValue=0., endValue=1., duration=250)
self._animation.valueChanged.connect(self._aniUpdate)
self._animation.finished.connect(self._aniFinished)
self._animation.setEasingCurve(QtCore.QEasingCurve.InOutQuart)
def setDuration(self, duration):
self._animation.setDuration(duration)
def setCurve(self, curve):
if isinstance(curve, QtCore.QEasingCurve):
self._animation.setEasingCurve(curve)
def setOrientation(self, orientation):
self._orientation = orientation
def getRange(self, prevIndex, nextIndex):
rect = self.rect()
currentStart = nextEnd = QtCore.QPoint()
if self._orientation == QtCore.Qt.Horizontal:
if prevIndex < nextIndex:
currentEnd = QtCore.QPoint(-rect.width(), 0)
nextStart = QtCore.QPoint(rect.width(), 0)
else:
currentEnd = QtCore.QPoint(rect.width(), 0)
nextStart = QtCore.QPoint(-rect.width(), 0)
else:
if prevIndex < nextIndex:
currentEnd = QtCore.QPoint(0, -rect.width())
nextStart = QtCore.QPoint(0, rect.width())
else:
currentEnd = QtCore.QPoint(0, rect.width())
nextStart = QtCore.QPoint(0, -rect.width())
return currentStart, currentEnd, nextStart, nextEnd
def setCurrentIndex(self, index):
if index == self.currentIndex():
return
# prepare the next widget changes
if self._nextWidget is not None:
self._nextWidget.hide()
self._nextIndex = index
self._nextWidget = self.widget(index)
self._nextWidget.show()
rect = self.rect()
rect.translate(self.rect().topRight())
self._nextWidget.setGeometry(rect)
self._nextWidget.raise_()
self._animation.start()
def _aniFinished(self):
super().setCurrentIndex(self._nextIndex)
self._nextIndex = self._nextWidget = None
def _aniUpdate(self, value):
if not self._animation.state():
return
currentStart, currentEnd, nextStart, nextEnd = self.getRange(self.currentIndex(), self._nextIndex)
rect = self.rect()
self.currentWidget().setGeometry(rect.translated(QtCore.QLineF(currentStart, currentEnd).pointAt(value).toPoint()))
self._nextWidget.setGeometry(rect.translated(QtCore.QLineF(nextStart, nextEnd).pointAt(value).toPoint()))
self.update()
Example code:
if __name__ == '__main__':
import sys
app = QtWidgets.QApplication(sys.argv)
mainWidget = QtWidgets.QWidget()
mainLayout = QtWidgets.QHBoxLayout(mainWidget)
transitionWidget = TransitionWidget()
mainLayout.addWidget(transitionWidget)
pageCount = 10
for page in range(pageCount):
widget = QtWidgets.QWidget()
layout = QtWidgets.QGridLayout(widget)
pageLabel = QtWidgets.QLabel('Page {}'.format(page + 1))
layout.addWidget(pageLabel, 0, 0, 1, 2)
prevBtn = QtWidgets.QPushButton('Previous')
if not page:
prevBtn.setEnabled(False)
layout.addWidget(prevBtn)
nextBtn = QtWidgets.QPushButton('Next')
layout.addWidget(nextBtn)
if page == pageCount - 1:
nextBtn.setEnabled(False)
transitionWidget.addWidget(widget)
prevBtn.clicked.connect(lambda _, page=page: transitionWidget.setCurrentIndex(page - 1))
nextBtn.clicked.connect(lambda _, page=page: transitionWidget.setCurrentIndex(page + 1))
sep = QtWidgets.QFrame(frameShape=QtWidgets.QFrame.VLine)
mainLayout.addWidget(sep)
orientationCombo = QtWidgets.QComboBox()
orientationLayout = QtWidgets.QFormLayout()
mainLayout.addLayout(orientationLayout)
orientationCombo.addItems(['Horizontal', 'Vertical'])
orientationCombo.currentIndexChanged.connect(lambda o: transitionWidget.setOrientation(o + 1))
orientationLayout.addRow('Orientation', orientationCombo)
durationSpin = QtWidgets.QSpinBox(minimum=50, maximum=1000, singleStep=50, suffix='ms')
orientationLayout.addRow('Duration', durationSpin)
durationSpin.setValue(transitionWidget._animation.duration())
durationSpin.valueChanged.connect(transitionWidget.setDuration)
mainWidget.show()
sys.exit(app.exec_())
How to implement closing your own cell editor when it loses focus? Since the built-in wx editors work.
Now my editor closes only if you select another cell in the grid. And, for example, the cell editor (0, 1) closes if you click on the button, and not just when you click on another cell.
My editor and renderer:
#!/usr/bin/env python
# -*- coding: utf-8 -*-
import wx
import wx.grid
class GridCellColourEditor(wx.grid.GridCellEditor):
def __init__(self):
super().__init__()
def Create(self, parent, id, evtHandler):
self._cp = wx.ColourPickerCtrl(parent, id)
self.SetControl(self._cp)
if evtHandler:
self._cp.PushEventHandler(evtHandler)
def BeginEdit(self, row, col, grid):
self.startValue = grid.GetTable().GetValue(row, col)
self._cp.SetColour(self.startValue)
self._cp.SetFocus()
def EndEdit(self, row, col, grid, oldval):
val = self._cp.GetColour().GetAsString(wx.C2S_HTML_SYNTAX)
if val != oldval:
return val
else:
return None
def ApplyEdit(self, row, col, grid):
val = self._cp.GetColour().GetAsString(wx.C2S_HTML_SYNTAX)
grid.GetTable().SetValue(row, col, val)
def Reset(self):
self._cp.SetColour(self.startValue)
def Clone(self):
return GridCellColourEditor()
class GridCellColourRenderer(wx.grid.GridCellRenderer):
def __init__(self):
super().__init__()
def Draw(self, grid, attr, dc, rect, row, col, isSelected):
if grid.IsEnabled():
bgColour = grid.GetDefaultCellBackgroundColour()
else:
bgColour = grid.GetBackgroundColour()
dc.SetBrush(wx.Brush(bgColour, wx.SOLID))
dc.SetPen(wx.TRANSPARENT_PEN)
dc.DrawRectangle(rect)
colour = grid.GetTable().GetValue(row, col)
x = rect.x + 3
y = rect.y + 3
width = rect.width - 6
height = rect.height - 6
dc.SetBrush(wx.Brush(colour, wx.SOLID))
dc.SetPen(wx.Pen(wx.BLACK))
dc.DrawRoundedRectangle(x, y, width, height, 3)
def GetBestSize(self, grid, attr, dc, row, col):
return attr.GetSize()
def Clone(self):
return GridCellColourRenderer()
class Frame(wx.Frame):
def __init__(self):
super().__init__(None)
vbox = wx.BoxSizer(wx.VERTICAL)
self.grid = wx.grid.Grid(self, size=(100, 50))
vbox.Add(self.grid, flag=wx.EXPAND)
self.grid.CreateGrid(1, 2)
self.grid.SetCellEditor(0, 0, GridCellColourEditor())
self.grid.SetCellRenderer(0, 0, GridCellColourRenderer())
self.grid.SetCellEditor(0, 1, wx.grid.GridCellTextEditor())
self.grid.SetCellRenderer(0, 1, wx.grid.GridCellStringRenderer())
btn = wx.Button(self, -1, 'For kill focus')
vbox.Add(btn, 0, wx.ALL, 10)
self.SetSizer(vbox)
app = wx.App()
frame = Frame()
frame.Show()
app.MainLoop()
Why, if wx.TextCtrl is used as wx.Control, then the cell editor successfully closes when focus is lost. And if you use wx.ColourPickerCtrl, the editor does not close?
It solved my problem
class GridCellColourEditor(wx.grid.GridCellEditor):
def Create(self, parent, id, evtHandler):
self._parent = parent
self._colourDialog = None
self._colourButton = wx.Button(parent, id, "")
self.SetControl(self._colourButton)
newEventHandler = wx.EvtHandler()
if evtHandler:
self._colourButton.PushEventHandler(newEventHandler)
self._colourButton.Bind(wx.EVT_BUTTON, self.OnClick)
def OnClick(self, event):
self._colourButton.SetFocus()
self.ShowColourDialog()
def SetSize(self, rect):
self._colourButton.SetSize(rect.x, rect.y,
rect.width + 2, rect.height + 2,
wx.SIZE_ALLOW_MINUS_ONE)
def Clone(self):
return GridCellColourEditor()
def BeginEdit(self, row, col, grid):
self._grid = grid
self._row = row
self._col = col
self._parent.Bind(wx.EVT_LEFT_DOWN, self.OnParentLeftDown)
self._parent.Bind(wx.EVT_KILL_FOCUS, self.OnParentKillFocus)
self.startValue = grid.GetTable().GetValue(row, col)
self.endValue = self.startValue
self._colourButton.SetBackgroundColour(self.startValue)
def EndEdit(self, row, col, grid, oldval):
self._parent.Unbind(wx.EVT_LEFT_DOWN)
self._parent.Unbind(wx.EVT_KILL_FOCUS)
if self.endValue != self.startValue:
return self.endValue
else:
return None
def ApplyEdit(self, row, col, grid):
val = self.endValue.GetAsString(wx.C2S_HTML_SYNTAX)
grid.GetTable().SetValue(row, col, val)
def Reset(self):
self._colourButton.SetBackgroundColour(self.startValue)
def ShowColourDialog(self):
colourDialog = wx.ColourDialog(self._parent)
self._colourDialog = colourDialog
colourDialog.GetColourData().SetColour(self.startValue)
if colourDialog.ShowModal() == wx.ID_OK:
data = colourDialog.GetColourData()
colour = data.GetColour()
self._colourButton.SetBackgroundColour(colour)
self.endValue = colour
self._parent.SetFocus()
del self._colourDialog
self._colourDialog = None
def OnParentLeftDown(self, event: wx.MouseEvent):
def CheckCellChange():
row = self._grid.GetGridCursorRow()
col = self._grid.GetGridCursorCol()
if self._row == row and self._col == col:
# клик в области сетки, но ячейка не изменилась
self._grid.CloseEditControl()
wx.CallAfter(CheckCellChange)
event.Skip()
def OnParentKillFocus(self, event: wx.FocusEvent):
if self._parent.FindFocus() != self._colourButton:
self._grid.CloseEditControl()
event.Skip()
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))