Qgraphicsview items not being placed where they should be - python

I recently created a program that will create QgraphicsEllipseItems whenever the mouse is clicked. That part works! However, it's not in the exact place where my cursor is. It seems to be slightly higher than where my mouse cursor is. I do have a QGraphicsRectItem created so maybe the two items are clashing with each other and moving off of one another? How can I get these circles to be placed on top of the rectangle item? Here's the code
class MyView(QtGui.QGraphicsView):
def __init__(self):
QtGui.QGraphicsView.__init__(self)
self.scene = QtGui.QGraphicsScene(self)
self.item = QtGui.QGraphicsRectItem(400, 400, 400, 400)
self.scene.addItem(self.item)
self.setScene(self.scene)
def paintMarkers(self):
self.cursor = QtGui.QCursor()
self.x = self.cursor.pos().x()
self.y = self.cursor.pos().y()
self.circleItem = QtGui.QGraphicsEllipseItem(self.x,self.y,10,10)
self.scene.addItem(self.circleItem)
self.circleItem.setPen(QtGui.QPen(QtCore.Qt.red, 1.5))
self.setScene(self.scene)
class Window(QtGui.QMainWindow):
def __init__(self):
#This initializes the main window or form
super(Window,self).__init__()
self.setGeometry(50,50,1000,1000)
self.setWindowTitle("Pre-Alignment system")
self.view = MyView()
self.setCentralWidget(self.view)
def mousePressEvent(self,QMouseEvent):
self.view.paintMarkers()
Much thanks!

There are two issues with the coordinates you are using to place the QGraphics...Items. The first is that the coordinates from QCursor are global screen coordinates, so you need to use self.mapFromGlobal() to convert them to coordinates relative to the QGraphicsView.
Secondly, you actually want the coordinates relative to the current QGraphicsScene, as this is where you are drawing the item. This is because the scene can be offset from the view (for example panning around a scene that is bigger than a view). To do this, you use self.mapToScene() on the coordinates relative to the QGraphicsView.
I would point out that typically you would draw something on the QGraphicsScene in response to some sort of mouse event in the QGraphicsView, which requires reimplementing things like QGraphicsView.mouseMoveEvent or QGraphicsView.mousePressEvent. These event handlers are passed a QEvent which contains the mouse coordinates relative to the view, and so you don't need to do the global coordinates transformation I mentioned in the first paragraph in these cases.
Update
I've just seen your other question, and now understand some of the issue a bit better. You shouldn't be overriding the mouse event in the main window. Instead override it in the view. For example:
class MyView(QtGui.QGraphicsView):
def __init__(self):
QtGui.QGraphicsView.__init__(self)
self.scene = QtGui.QGraphicsScene(self)
self.item = QtGui.QGraphicsRectItem(400, 400, 400, 400)
self.scene.addItem(self.item)
self.setScene(self.scene)
def paintMarkers(self, event):
# event position is in coordinates relative to the view
# so convert them to scene coordinates
p = self.mapToScene(event.x(), event.y())
self.circleItem = QtGui.QGraphicsEllipseItem(0,0,10,10)
self.circleItem.setPos(p.x()-self.circleItem.boundingRect().width()/2.0,
p.y()-self.circleItem.boundingRect().height()/2.0)
self.scene.addItem(self.circleItem)
self.circleItem.setPen(QtGui.QPen(QtCore.Qt.red, 1.5))
# self.setScene(self.scene) # <-- this line should not be needed here
# Note, I've renamed the second argument `event`. Otherwise you locally override the QMouseEvent class
def mousePressEvent(self, event):
self.paintMarkers(event)
# you may want to preserve the default mouse press behaviour,
# in which case call the following
return QGraphicsView.mousePressEvent(self, event)
Here we have not needed to use QWidget.mapFromGlobal() (what I covered in the first paragraph) because we use a mouse event from the QGraphicsView which returns coordinates relative to that widget only.
Update 2
Note: I've updated how the item is created/placed in the above code based on this answer.

Related

PyQt5 left click not working for mouseMoveEvent

I'm trying to learn PyQt5, and I've got this code:
import sys
from PyQt5.QtWidgets import *
from PyQt5.QtCore import *
from PyQt5.QtGui import *
class MainWindow(QMainWindow):
def __init__(self):
super().__init__()
self.label = QLabel()
canvas = QPixmap(400, 300)
canvas.fill(Qt.white)
self.label.setPixmap(canvas)
self.setCentralWidget(self.label)
def mouseMoveEvent(self, e):
painter = QPainter(self.label.pixmap())
painter.drawPoint(e.x(), e.y())
painter.end()
self.update()
app = QApplication(sys.argv)
window = MainWindow()
window.show()
app.exec()
And I can draw using right click to draw, but when I left click, it drags the window instead of drawing. This even happens when I make the window fullscreen so I can't move it. How can I stop it from dragging the window so it will draw instead?
In some configurations (specifically, on Linux, and depending on the window manager settings), dragging the left mouse button on an empty (non interactive) area of a QMainWindow allows dragging the whole window.
To prevent that, the mouse move event has to be accepted by the child widget that receives it.
While this can be achieved with an event filter, it's usually better to use a subclass, and this is even more important whenever the widget has to deal with mouse events it receives, exactly like in this case.
Another aspect that has to be considered is that just updating the QLabel pixmap is not completely sufficient, because it doesn't automatically force its update. Also, since Qt 5.15, QLabel.pixmap() doesn't return a pointer to the pixmap, but rather its copy. This means that you should always keep a local reference to the pixmap for the whole time required to access it (otherwise your program will crash), and then call setPixmap() again with the updated pixmap after "ending" the painter. This will automatically schedule an update of the label.
The above may be a bit confusing if you're not used to languages that allow pointers as arguments, but, in order to clarify how it works, you can consider the pixmap() property similarly to the text() one:
text = self.label.text()
text += 'some other text'
The above will obviously not change the text of the label, most importantly because, in most languages (including Python) strings are always immutable objects, so text += ... actually replaces the text reference with another string object.
To clarify, consider the following:
text1 = text2 = self.label.text()
text1 += 'some other text'
print(text1 == text2)
Which will return False.
Now consider this instead:
list1 = list2 = []
list1 += ['item']
print(list1 == list2)
Which will return True, because list is a mutable type, and in python changing the content of a mutable type will affect any reference to it[1], since they refer to the same object.
Until Qt < 5.15, the pixmap of QLabel behaved similarly to a list, meaning that any painting on the label.pixmap() would actually change the content of the displayed pixmap (requiring label.update() to actually show the change). After Qt 5.15 this is no longer valid, as the returned pixmap behaves similarly to a returned string: altering its contents won't change the label's pixmap.
So, the proper way to update the pixmap is to:
handle the mouse event in the label instance (either by subclassing or using an event filter), and not in a parent;
get the pixmap, keep its reference until painting has completed, and call setPixmap() afterwards (mandatory since Qt 5.15, but also suggested anyway);
Finally, QLabel has an alignment property that, when using a pixmap, is used to set the alignment of the pixmap to the available space that the layout manager provides. The default is left aligned and vertically centered (Qt.AlignLeft|Qt.AlignVCenter).
QLabel also features the scaledContents property, which always scales the pixmap to the current size of the label (not considering the aspect ratio).
The above means one of the following:
the pixmap will always be displayed at its actual size, and eventually aligned within its available space;
if the scaledContents property is True, the alignment is ignored and the pixmap will be always scaled to the full extent of its available space; whenever that property is True, the resulting pixmap is also cached, so you have to clear its cache every time (at least, with Qt5);
if you need to always keep aspect ratio, using QLabel is probably pointless, and you may prefer a plain QWidget that actively draws the pixmap within a paintEvent() override;
Considering the above, here is a possible implementation of the label (ignoring the ratio):
class PaintLabel(QLabel):
def mouseMoveEvent(self, event):
pixmap = self.pixmap()
if pixmap is None:
return
pmSize = pixmap.size()
if not pmSize.isValid():
return
pos = event.pos()
scaled = self.hasScaledContents()
if scaled:
# scale the mouse position to the actual pixmap size
pos = QPoint(
round(pos.x() * pmSize.width() / self.width()),
round(pos.y() * pmSize.height() / self.height())
)
else:
# translate the mouse position depending on the alignment
alignment = self.alignment()
dx = dy = 0
if alignment & Qt.AlignRight:
dx += pmSize.width() - self.width()
elif alignment & Qt.AlignHCenter:
dx += round((pmSize.width() - self.width()) / 2)
if alignment & Qt.AlignBottom:
dy += pmSize.height() - self.height()
elif alignment & Qt.AlignVCenter:
dy += round((pmSize.height() - self.height()) // 2)
pos += QPoint(dx, dy)
painter = QPainter(pixmap)
painter.drawPoint(pos)
painter.end()
# this will also force a scheduled update
self.setPixmap(pixmap)
if scaled:
# force pixmap cache clearing
self.setScaledContents(False)
self.setScaledContents(True)
def minimumSizeHint(self):
# just for example purposes
return QSize(10, 10)
And here is an example of its usage:
class MainWindow(QMainWindow):
def __init__(self):
super().__init__()
self.label = PaintLabel()
canvas = QPixmap(400, 300)
canvas.fill(Qt.white)
self.label.setPixmap(canvas)
self.hCombo = QComboBox()
for i, hPos in enumerate(('Left', 'HCenter', 'Right')):
hAlign = getattr(Qt, 'Align' + hPos)
self.hCombo.addItem(hPos, hAlign)
if self.label.alignment() & hAlign:
self.hCombo.setCurrentIndex(i)
self.vCombo = QComboBox()
for i, vPos in enumerate(('Top', 'VCenter', 'Bottom')):
vAlign = getattr(Qt, 'Align' + vPos)
self.vCombo.addItem(vPos, vAlign)
if self.label.alignment() & vAlign:
self.vCombo.setCurrentIndex(i)
self.scaledChk = QCheckBox('Scaled')
central = QWidget()
mainLayout = QVBoxLayout(central)
panel = QHBoxLayout()
mainLayout.addLayout(panel)
panel.addWidget(self.hCombo)
panel.addWidget(self.vCombo)
panel.addWidget(self.scaledChk)
mainLayout.addWidget(self.label)
self.setCentralWidget(central)
self.hCombo.currentIndexChanged.connect(self.updateLabel)
self.vCombo.currentIndexChanged.connect(self.updateLabel)
self.scaledChk.toggled.connect(self.updateLabel)
def updateLabel(self):
self.label.setAlignment(Qt.AlignmentFlag(
self.hCombo.currentData() | self.vCombo.currentData()
))
self.label.setScaledContents(self.scaledChk.isChecked())
if __name__ == '__main__':
import sys
app = QApplication(sys.argv)
window = MainWindow()
window.show()
sys.exit(app.exec())
Note that if you need more advanced control over the pixmap display and painting (including aspect ratio, but also proper zoom capabilities and any possible complex feature), then the common suggestion is to completely ignore QLabel, as said above: either use a basic QWidget, or consider the more complex (but much more powerful) Graphics View Framework. This will also allow proper editing features, as you can add non-destructive editing that will show ("paint") the result without affecting the actual, original object.
[1]: The above is based on the fact that a function or operator can actually mutate the object: the += operator actually calls the __add__ magic method that, in the case of lists, updates the contents of the same list.

PyQt creating label that follows mouse

What I want to archive is a label that is created as soon as a button is pressed and follows the mouse until there is a 'click'.
My problem with that is that I can't seem to get the 'setMouseTracking(True)' command at the right widget...
import sys
from PyQt5 import QtCore, QtGui, QtWidgets, uic
from PyQt5.QtCore import Qt
class MainWindow(QtWidgets.QMainWindow):
def __init__(self):
super().__init__()
self.setGeometry(0,0,1000,1100)
self.main = QtWidgets.QLabel()
self.setCentralWidget(self.main)
self.label = QtWidgets.QLabel()
canvas = QtGui.QPixmap(900, 900)
canvas.fill(QtGui.QColor('#ffffff')) # Fill entire canvas.
self.label.setPixmap(canvas)
# self.last_x, self.last_y = None, None
self.button = QtWidgets.QPushButton('create Block')
self.button.clicked.connect(self.buttonAction)
vbox = QtWidgets.QVBoxLayout()
vbox.addWidget(self.label)
vbox.addWidget(self.button)
self.main.setLayout(vbox)
# self.label.setMouseTracking(True)
self.setWindowTitle('testing')
def mouseMoveEvent(self, e):
# if self.last_x is None: # First event.
# self.last_x = e.x()
# self.last_y = e.y()
# return # Ignore the first time.
# painter = QtGui.QPainter(self.label.pixmap())
# painter.drawLine(self.last_x, self.last_y, e.x(), e.y())
# painter.end()
try:
self.image.move(e.x(), e.y())
except:
pass
self.update()
# Update the origin for next time.
# self.last_x = e.x()
# self.last_y = e.y()
def mouseReleaseEvent(self, e):
# self.last_x = None
# self.last_y = None
def buttonAction(self):
block = QtGui.QPixmap(20, 20)
block.fill(QtGui.QColor('blue'))
self.image = QtWidgets.QLabel(self.label)
self.image.setPixmap(block)
self.image.move(20,20)
self.image.show()
app = QtWidgets.QApplication(sys.argv)
window = MainWindow()
window.show()
app.exec_()
I don't know if my problem is that I attach setMouseTracking(True) to the wrong widget or if it is something else entirerly.
With clicking action it works, but that is not what I intend to do...
Edit: fixed some of the code issues
For clarification what my problem is: I have a canvas and a button inside an layout, as soon as the button is clicked a new canvas shall be created that follows the mouse pointer 'until' I click. So I don't want any kind of drag-and-drop action, but instead a small canvas that is following the mouse pointer.
This is needed as I intend to use the little canvas to show what an graphic would look like at a certain canvas position without printing it there. So the little canvas is something like a template.
There are some conceptual problems in your logic.
First of all, the mouse tracking only works for the widget it's set on. Also, if the widget accepts the mouse move event, the parent will not receive it.
In your case you are not receiving it because you are implementing the mouseMoveEvent in the main window, which by default ignores it if no mouse button is pressed (like most widgets).
While you could try to set it on the "target" widget and the parent (in your case, the canvas and the main window), you'll certainly have some issues at a certain point if any underlying widget accepts mouse movements; since you're going to need the "preview" only on the actual "canvas", there's no need to create a new widget, as you can just directly paint on the canvas instead, and finally draw on the actual pixmap only when needed.
This is a possible implementation:
class Canvas(QtWidgets.QLabel):
def __init__(self):
super().__init__()
pixmap = QtGui.QPixmap(900, 900)
pixmap.fill(QtCore.Qt.white)
self.setPixmap(pixmap)
self.setMouseTracking(True)
self.preview = False
def startPreview(self):
self.preview = True
self.update()
def drawMiniCanvas(self, pos):
pm = self.pixmap()
qp = QtGui.QPainter(pm)
qp.setBrush(QtCore.Qt.blue)
if self.size() != pm.size():
# if the pixmap is smaller than the actual size of the canvas, the position
# must be translated to its contents before painting
alignment = self.alignment()
pmRect = pm.rect()
if alignment == QtCore.Qt.AlignCenter:
pmRect.moveCenter(self.rect().center())
else:
if alignment & QtCore.Qt.AlignHCenter:
pmRect.moveLeft((self.width() - pm.width()) / 2)
elif alignment & QtCore.Qt.AlignRight:
pmRect.moveRight(self.width())
if alignment & QtCore.Qt.AlignVCenter:
pmRect.moveTop((self.height() - pm.height()) / 2)
elif alignment & QtCore.Qt.AlignBottom:
pmRect.moveBottom(self.height())
pos -= pmRect.topLeft()
qp.drawRect(pos.x(), pos.y(), 20, 20)
qp.end()
self.setPixmap(pm)
def mouseMoveEvent(self, event):
if self.preview:
self.update()
def mousePressEvent(self, event):
if self.preview:
if event.button() == QtCore.Qt.LeftButton:
self.drawMiniCanvas(event.pos())
self.preview = False
def paintEvent(self, event):
super().paintEvent(event)
if self.preview:
qp = QtGui.QPainter(self)
qp.drawRect(self.rect().adjusted(0, 0, -1, -1))
pos = self.mapFromGlobal(QtGui.QCursor.pos())
qp.setBrush(QtCore.Qt.blue)
qp.drawRect(pos.x(), pos.y(), 20, 20)
class MainWindow(QtWidgets.QMainWindow):
def __init__(self):
super().__init__()
self.setGeometry(0,0,1000,1100)
self.main = QtWidgets.QLabel()
self.setCentralWidget(self.main)
self.canvas = Canvas()
self.button = QtWidgets.QPushButton('create Block')
self.button.clicked.connect(self.canvas.startPreview)
vbox = QtWidgets.QVBoxLayout()
vbox.addWidget(self.canvas)
vbox.addWidget(self.button)
self.main.setLayout(vbox)
self.setWindowTitle('testing')
Note that I have left the main widget as a QLabel as per your code, but I strongly suggest to avoid so: QLabel has a complex management of its size, and even if you add a layout to it, the layout requirements will always be ignored; you should use a QWidget instead.
Finally, while the above code works, it's just a simple example based on your question; if you want to create a drawing tool, you should not use a QLabel, and for various reasons: for example, if you want to support scaling to fit the contents, not only the coordinate computation in drawMiniCanvas won't work (due to the scaling), but it will also not paint anything at all, and that's due to the way QLabel caches its contents whenever setScaledContents(True) is used (also, it won't respect the aspect ratio).
For advanced and interactive painting, it's usually better to use a QGraphicsScene shown inside a QGraphicsView.

Change cursor shape on event key

I'm trying to change the cursor shape on key event:
When i press 'C', i want to display a LineCursor,
when i press 'S', i want to display a CrossCursor, and
when i press 'N', i want to display the standard ArrowCursor.
The cursor change only if it leave the canvas and return to it,
but not if the cursor stay in the canvas.
self.update() on the canvas don't work
Here the code to reproduce the problem :
from PyQt5.QtCore import *
from PyQt5.QtGui import *
from PyQt5.QtWidgets import *
import sys
class MainWindow(QMainWindow):
def __init__(self, parent=None):
super(MainWindow, self).__init__(parent)
self.setObjectName("MainWindow")
self.resize(942, 935)
self.centralwidget = QWidget(self)
self.centralwidget.setObjectName("centralwidget")
self.horizontalLayout = QHBoxLayout(self.centralwidget)
self.horizontalLayout.setObjectName("horizontalLayout")
self.MainView = QGraphicsView(self.centralwidget)
self.MainView.setObjectName("MainView")
self.horizontalLayout.addWidget(self.MainView)
self.setCentralWidget(self.centralwidget)
self.setWindowTitle("MainWindow")
self.scene = QGraphicsScene( 0.,0., 1240., 1780. )
self.canvas = Canvas()
self.widget = QWidget()
box_layout = QVBoxLayout()
self.widget.setLayout(box_layout)
box_layout.addWidget(self.canvas)
self.scene.addWidget(self.widget)
self.MainView.setScene(self.scene)
self.MainView.setRenderHints(QPainter.Antialiasing)
self.MainView.fitInView(0, 0, 45, 55, Qt.KeepAspectRatio)
self.show()
empty = QPixmap(1240, 1748)
empty.fill(QColor(Qt.white))
self.canvas.newPixmap(empty)
def keyPressEvent(self, e):
key = e.key()
if key == Qt.Key_C:
self.canvas.setCutCursor()
elif key == Qt.Key_N:
self.canvas.setNormalCursor()
elif key == Qt.Key_S:
self.canvas.setSelectionCursor()
class Canvas(QLabel):
def __init__(self):
super().__init__()
sizePolicy = QSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed)
self.setSizePolicy(sizePolicy)
self.setAlignment(Qt.AlignLeft)
self.setAlignment(Qt.AlignTop)
def newPixmap(self, pixmap):
self.setPixmap(pixmap)
def setCutCursor(self):
newCursor = QPixmap(500,3)
newCursor.fill(QColor("#000000"))
self.setCursor(QCursor(newCursor))
def setSelectionCursor(self):
self.setCursor(Qt.CrossCursor)
def setNormalCursor(self):
self.setCursor(QCursor(Qt.ArrowCursor))
if __name__ == '__main__':
app = QApplication(sys.argv)
mainWindow = MainWindow()
sys.exit(app.exec_())
It seems to be an old bug that was never resolved: setCursor on QGraphicsView don't work when add QWidget on the QGraphicsScene
There is a possible workaround, but it's far from perfect.
First of all, you have to consider that while dealing with a QGraphicsScene and its view[s] is not easy when dealing with mouse events and widget proxies, mostly because of the multiple nested levels of events and interaction between the actual view (and its parent, up to the top level window) and the proxy itself, which is an abstraction of the widget you added to the scene. While Qt devs did a huge amount of work to make it as transparent as possible, at some point you will probably face some unexpected or undesired behavior that is usually hard to fix or work around, and that's also because a graphics scene might be visualized in more than a single view.
Besides the aforementioned bug, you have to consider that a graphics view uses QWidget.setCursor internally whenever any of its items call setCursor on themselves, and since the view is a very complex widget, at some point it might even try to "restore" the cursor if it thinks it should (even if it shouldn't).
Finally, some events which also have something to do with focus might become in the way of all that.
The first workaround is to set the cursor to the view itself (or, better, the view's viewport, which is the actual widget that shows the scene contents). To ensure that, we obviously need to check if the cursor is inside the canvas.
Unfortunately, because of the event handling written above, this could become a bit messy, as some events are even delayed by at least a cycle within the main Qt event loop; the result is that while setting a cursor the first time might work, setting it again might not, and even if it would, it's possible that the cursor will not be applied until the mouse is moved at least by one pixel.
As a second workaround, we need an event filter to bypass all that and check the cursor whenever the mouse is moved within the viewport margins.
class MainWindow(QMainWindow):
def __init__(self, parent=None):
super(MainWindow, self).__init__(parent)
# ...
self.show()
empty = QPixmap(1240, 1748)
empty.fill(QColor(Qt.darkGray))
self.canvas.newPixmap(empty)
# install an event filter on the view's viewport;
# this is very, *VERY* important: on the *VIEWPORT*!
# if you install it on the view, it will *not* work
self.MainView.viewport().installEventFilter(self)
def insideCanvasRect(self, pos):
canvasRect = self.canvas.rect()
# translate the canvas rect to its top level window to get the actual
# geometry according to the scene; we can't use canvas.geometry(), as
# geometry() is based on the widget's parent coordinates, and that
# parent could also have any number of parents in turn;
canvasRect.translate(self.canvas.mapTo(self.canvas.window(), QPoint(0, 0)))
# map the geometry to the view's transformation, which probably uses
# some scaling, but also translation *and* shearing; the result is a
# polygon, as with shearing you could transform a rectangle to an
# irregular quadrilateral
polygon = self.MainView.mapFromScene(QRectF(canvasRect))
# tell if the point is within the resulting polygon
return polygon.containsPoint(pos, Qt.WindingFill)
def eventFilter(self, source, event):
if source == self.MainView.viewport() and (
(event.type() == QEvent.MouseMove and not event.buttons()) or
(event.type() == QEvent.MouseButtonRelease)
):
# process the event
super(MainWindow, self).eventFilter(source, event)
if self.insideCanvasRect(event.pos()):
source.setCursor(self.canvas.cursor())
else:
source.unsetCursor()
# usually a mouse move event within the view's viewport returns False,
# but in that case the event would be propagated to the parents, up
# to the top level window, which might reset the *previous* cursor
# at some point, no matter if we try to avoid that; to prevent that
# we return True to avoid propagation.
# Note that this will prevent any upper-level filtering and *could*
# also create some issues for the drag and drop framework
if event.type() == QEvent.MouseMove:
return True
return super(MainWindow, self).eventFilter(source, event)
def keyPressEvent(self, e):
# send the canvas a fake leave event
QApplication.sendEvent(self.canvas, QEvent(QEvent.Leave))
key = e.key()
if key == Qt.Key_C:
self.canvas.setCutCursor()
elif key == Qt.Key_N:
self.canvas.setNormalCursor()
elif key == Qt.Key_S:
self.canvas.setSelectionCursor()
pos = self.canvas.rect().center()
event = QEnterEvent(pos, self.canvas.mapTo(self.canvas.window(), pos), self.canvas.mapToGlobal(pos))
# send a fake enter event (mapped to the center of the widget, just to be sure)
QApplication.sendEvent(self.canvas, event)
# if we're inside the widget, set the view's cursor, otherwise it will not
# be set until the mouse is moved
if self.insideCanvasRect(self.MainView.viewport().mapFromGlobal(QCursor.pos())):
self.MainView.viewport().setCursor(self.canvas.cursor())

Making Highly Customized, Hoverable, Overlappable, Widgets

I want to up my game in UI design using PyQt5. I feel like the resources for UI customization in PyQt5 are not easy to find. It is possible to try and make personalized widget, but the overall method seems non-standardized.
I need to build an arrow widget that is hoverable, overlappable with other widgets and highly customized. As I read in this tutorial and some other posts, it possible to do exactly what you need using paintEvent. Thus that is what I tried, but overall, I feel like the method is quite messy, and I'd like some guidelines on building complex Customized, general widget. Here's what I have:
Customized Shape: I built my code based on this
Hoverable property: I read everywhere that modifying the projects styleSheet is usually the way to go, especially if you want to make your Widget general and adapt to colors, the problem is that I wasn't able to find how to use properly self.palette to fetch the current colors of the QApplication styleSheet. I feel like i's have to maybe use enterEvent and leaveEvent, but I tried to redraw the whole widget with a painter in those functions and it said
QPainter::begin: Painter already active
QWidget::paintEngine: Should no longer be called
QPainter::begin: Paint device returned engine == 0, type: 1
QPainter::setRenderHint: Painter must be active to set rendering hints
Overlappable Property: I found a previous post which seemed to have found a solution: create a second widget that is children of the main widget, in order to be able to move the children around. I tried that but it seems that it doesn't want to move, no matter the position I give the widget.
Here is my code:
import sys
from PyQt5.QtWidgets import QWidget, QHBoxLayout, QGraphicsDropShadowEffect, QApplication, QFrame, QPushButton
from PyQt5.QtCore import Qt, QPoint, QLine
from PyQt5.QtGui import QPainter, QPen, QColor, QPalette
class MainWidget(QWidget):
def __init__(self):
super(MainWidget, self).__init__()
self.resize(500, 500)
self.layout = QHBoxLayout()
self.setLayout(self.layout)
self.myPush = QPushButton()
self.layout.addWidget(self.myPush)
self.arrow = ArrowWidget(self)
position = QPoint(-40, 0)
self.layout.addWidget(self.arrow)
self.arrow.move(position)
class ArrowWidget(QWidget):
def __init__(self, parent=None):
super(ArrowWidget, self).__init__(parent)
self.setWindowFlag(Qt.FramelessWindowHint)
self.setAttribute(Qt.WA_TranslucentBackground)
self.w = 200
self.h = 200
self.blurRadius = 20
self.xO = 0
self.yO = 20
self.resize(self.w, self.h)
self.layout = QHBoxLayout()
# myFrame = QFrame()
# self.layout.addWidget(myFrame)
self.setLayout(self.layout)
self.setStyleSheet("QWidget:hover{border-color: rgb(255,0,0);background-color: rgb(255,50,0);}")
shadow = QGraphicsDropShadowEffect(blurRadius=self.blurRadius, xOffset=self.xO, yOffset=self.yO)
self.setGraphicsEffect(shadow)
def paintEvent(self, event):
painter = QPainter(self)
painter.setRenderHint(QPainter.Antialiasing)
painter.begin(self)
# painter.setBrush(self.palette().window())
# painter.setPen(QPen(QPalette, 5))
ok = self.frameGeometry().width()/2-self.blurRadius/2-self.xO/2
oky = self.frameGeometry().height()/2-self.blurRadius/2-self.yO/2
painter.drawEllipse(QPoint(self.frameGeometry().width()/2-self.blurRadius/2-self.xO/2, self.frameGeometry().height()/2-self.blurRadius/2-self.yO/2), self.w/2-self.blurRadius/2-self.yO/2-self.xO/2, self.h/2-self.blurRadius/2-self.yO/2-self.xO/2)
painter.drawLines(QLine(ok-25, oky-50, ok+25, oky), QLine(ok+25, oky, ok-25, oky+50))
painter.end()
if __name__ == '__main__':
app = QApplication(sys.argv)
testWidget = MainWidget()
testWidget.show()
sys.exit(app.exec_())
If someone could help me make this work and explain along the way to help us better understand the structure of customized widgets and explain a better method that isn't messy like this one, I believe it would be a plus to the beginners like me using PyQt5 as a main Framework for UI making.
There is no "standard" method for custom widgets, but usually paintEvent overriding is required.
There are different issues in your example, I'll try and address to them.
Overlapping
If you want a widget to be "overlappable", it must not be added to a layout. Adding a widget to a layout will mean that it will have its "slot" within the layout, which in turn will try to compute its sizes (based on the widgets it contains); also, normally a layout has only one widget per "layout slot", making it almost impossible to make widget overlap; the QGridLayout is a special case which allows (by code only, not using Designer) to add more widget to the same slot(s), or make some overlap others. Finally, once a widget is part of a layout, it cannot be freely moved nor resized (unless you set a fixedSize).
The only real solution to this is to create the widget with a parent. This will make it possible to use move() and resize(), but only within the boundaries of the parent.
Hovering
While it's true that most widgets can use the :hover selector in the stylesheet, it only works for standard widgets, which do most of their painting by themself (through QStyle functions). About this, while it's possible to do some custom painting with stylesheets, it's generally used for very specific cases, and even in this case there is no easy way to access to the stylesheet properties.
In your case, there's no need to use stylesheets, but just override enterEvent and leaveEvent, set there any color you need for painting and then call self.update() at the end.
Painting
The reason you're getting those warnings is because you are calling begin after declaring the QPainter with the paint device as an argument: once it's created it automatically calls begin with the device argument. Also, it usually is not required to call end(), as it is automatically called when the QPainter is destroyed, which happens when the paintEvent returns since it's a local variable.
Example
I created a small example based on your question. It creates a window with a button and a label within a QGridLayout, and also uses a QFrame set under them (since it's been added first), showing the "overlapping" layout I wrote about before. Then there's your arrow widget, created with the main window as parent, and that can be moved around by clicking on it and dragging it.
import sys
from PyQt5 import QtCore, QtGui, QtWidgets
class ArrowWidget(QtWidgets.QWidget):
def __init__(self, parent=None):
super().__init__(parent)
# since the widget will not be added to a layout, ensure
# that it has a fixed size (otherwise it'll use QWidget default size)
self.setFixedSize(200, 200)
self.blurRadius = 20
self.xO = 0
self.yO = 20
shadow = QtWidgets.QGraphicsDropShadowEffect(blurRadius=self.blurRadius, xOffset=self.xO, yOffset=self.yO)
self.setGraphicsEffect(shadow)
# create pen and brush colors for painting
self.currentPen = self.normalPen = QtGui.QPen(QtCore.Qt.black)
self.hoverPen = QtGui.QPen(QtCore.Qt.darkGray)
self.currentBrush = self.normalBrush = QtGui.QColor(QtCore.Qt.transparent)
self.hoverBrush = QtGui.QColor(128, 192, 192, 128)
def mousePressEvent(self, event):
if event.buttons() == QtCore.Qt.LeftButton:
self.mousePos = event.pos()
def mouseMoveEvent(self, event):
# move the widget based on its position and "delta" of the coordinates
# where it was clicked. Be careful to use button*s* and not button
# within mouseMoveEvent
if event.buttons() == QtCore.Qt.LeftButton:
self.move(self.pos() + event.pos() - self.mousePos)
def enterEvent(self, event):
self.currentPen = self.hoverPen
self.currentBrush = self.hoverBrush
self.update()
def leaveEvent(self, event):
self.currentPen = self.normalPen
self.currentBrush = self.normalBrush
self.update()
def paintEvent(self, event):
qp = QtGui.QPainter(self)
qp.setRenderHints(qp.Antialiasing)
# painting is not based on "pixels", to get accurate results
# translation of .5 is required, expecially when using 1 pixel lines
qp.translate(.5, .5)
# painting rectangle is always 1px smaller than the actual size
rect = self.rect().adjusted(0, 0, -1, -1)
qp.setPen(self.currentPen)
qp.setBrush(self.currentBrush)
# draw an ellipse smaller than the widget
qp.drawEllipse(rect.adjusted(25, 25, -25, -25))
# draw arrow lines based on the center; since a QRect center is a QPoint
# we can add or subtract another QPoint to get the new positions for
# top-left, right and bottom left corners
qp.drawLine(rect.center() + QtCore.QPoint(-25, -50), rect.center() + QtCore.QPoint(25, 0))
qp.drawLine(rect.center() + QtCore.QPoint(25, 0), rect.center() + QtCore.QPoint(-25, 50))
class MainWidget(QtWidgets.QWidget):
def __init__(self):
super().__init__()
layout = QtWidgets.QGridLayout()
self.setLayout(layout)
self.button = QtWidgets.QPushButton('button')
layout.addWidget(self.button, 0, 0)
self.label = QtWidgets.QLabel('label')
self.label.setAlignment(QtCore.Qt.AlignCenter)
layout.addWidget(self.label, 0, 1)
# create a frame that uses as much space as possible
self.frame = QtWidgets.QFrame()
self.frame.setFrameShape(self.frame.StyledPanel|self.frame.Raised)
self.frame.setSizePolicy(QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Expanding)
# add it to the layout, ensuring it spans all rows and columns
layout.addWidget(self.frame, 0, 0, layout.rowCount(), layout.columnCount())
# "lower" the frame to the bottom of the widget's stack, otherwise
# it will be "over" the other widgets, preventing them to receive
# mouse events
self.frame.lower()
self.resize(640, 480)
# finally, create your widget with a parent, *without* adding to a layout
self.arrowWidget = ArrowWidget(self)
# now you can place it wherever you want
self.arrowWidget.move(220, 140)
if __name__ == '__main__':
app = QtWidgets.QApplication(sys.argv)
testWidget = MainWidget()
testWidget.show()
sys.exit(app.exec_())

Deleting an item from a scene

I currently am having an issue with a delete button I created that is linked to a QGraphicsScene class. The button is created in the Window class not the MyView class. I am trying to have the user be able to delete marks that were made on a scene but right now it is only deleting the last ellipse item I create and nothing else. The error that pops up usually says that the other objects you are trying to delete are in a different scene. Also the location of the circle object that wants to be deleted is important. So if the user has the cursor over a particular circle that circle item should delete and nothing else. Here's my code:
import sys
from PyQt4 import QtGui, QtCore
#this sets the scene for drawing and the microscope image
class MyView(QtGui.QGraphicsView):
def __init__(self,window):
QtGui.QGraphicsView.__init__(self)
self.window = window
self.scene = QtGui.QGraphicsScene(self)
self.item = QtGui.QGraphicsRectItem(400, 400, 400, 400)
self.scene.addItem(self.item)
self.setScene(self.scene)
def paintMarkers(self,event):
##self.cursor = QtGui.QCursor()
#self.cursor.setShape(2)
p = self.mapToScene(event.x(),event.y())
self.circleItem = QtGui.QGraphicsEllipseItem(p.x(),p.y(),5,5)
self.scene.addItem(self.circleItem)
self.circleItem.setPen(QtGui.QPen(QtCore.Qt.red, 1.5))
#self.setScene(self.scene)
def deleteMarkers(self):
self.scene.removeItem(self.circleItem)
#print "Hello world"
#def mousePressEvent(self,QMouseEvent):
#self.paintMarkers()
def mousePressEvent(self,event):
if self.window.btnPaintDot.isChecked():
self.paintMarkers(event)
if self.window.btnDeleteMarks.isChecked():
self.deleteMarkers()
return QtGui.QGraphicsView.mousePressEvent(self,event)
class Window(QtGui.QMainWindow):
def __init__(self):
#This initializes the main window or form
super(Window,self).__init__()
self.setGeometry(50,50,1000,1000)
self.setWindowTitle("Pre-Alignment system")
self.view = MyView()
self.setCentralWidget(self.view)
#makes deletemarks button checked when pressed
def paintDeleteMarks(self):
if self.btnDeleteMarks.isChecked():
self.btnPaintDot.setChecked(False)
self.btnPaintPolygon.setChecked(False)
self.btnPaintPolygon.setChecked(False)
self.btnDeleteMarks.setChecked(True)
else:
self.btnDeleteMarks.setChecked(False)
Much thanks please ask questions if my explanation needs more...well explaining.
If you carefully read over your code, you will see that you are deleting the item stored in self.circleItem. The item stored in that variable is always only the last one created (you overwrite the variable each time you create a new item).
You need to modify your code so that it finds items based on the current x-y coordinate of the mouse event. Use QGraphicsScene.itemAt() to find the item at a particular x-y coordinate (remember to correctly transform the coordinates relative to the scene before looking up the item at that location).
Here is the code that fixed the issue thanks to three_pineapples!
import sys
from PyQt4 import QtGui, QtCore
#this sets the scene for drawing and the microscope image
class MyView(QtGui.QGraphicsView):
def __init__(self,window):
QtGui.QGraphicsView.__init__(self)
self.window = window
self.scene = QtGui.QGraphicsScene(self)
self.item = QtGui.QGraphicsRectItem(400, 400, 400, 400)
self.scene.addItem(self.item)
self.setScene(self.scene)
def paintMarkers(self,event):
##self.cursor = QtGui.QCursor()
#self.cursor.setShape(2)
p = self.mapToScene(event.x(),event.y())
if (p.x() > 400 and p.x() < 800) and (p.y() > 400 and p.y() < 800):
self.circleItem = QtGui.QGraphicsEllipseItem(p.x(),p.y(),5,5)
self.scene.addItem(self.circleItem)
self.circleItem.setPen(QtGui.QPen(QtCore.Qt.red, 1.5))
#self.setScene(self.scene)
def deleteMarkers(self,event):
p = self.mapToScene(event.x(),event.y())
if self.scene.itemAt(p.x(),p.y()) != self.item:
self.scene.removeItem(self.scene.itemAt(p.x(),p.y()))
#print "Hello world"
#def mousePressEvent(self,QMouseEvent):
#self.paintMarkers()
def mousePressEvent(self,event):
if self.window.btnPaintDot.isChecked():
self.paintMarkers(event)
if self.window.btnDeleteMarks.isChecked():
self.deleteMarkers(event)
return QtGui.QGraphicsView.mousePressEvent(self,event)

Categories

Resources