1. Intro
I'm working in Python 3.7 on Windows 10 and use PyQt5 for the GUI. In my application, I got a QScrollArea() with an array of buttons inside. When clicked, a button has to move outside the area. I use a QPropertyAnimation() to show the movement.
2. Minimal, Reproducible Example
I've created a small application for testing. The application shows a small QScrollArea() with a bunch of buttons inside. When you click on a button, it will move to the right:
Here is the code:
from PyQt5.QtWidgets import *
from PyQt5.QtGui import *
from PyQt5.QtCore import *
import sys
class MyButton(QPushButton):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.setFixedWidth(300)
self.setFixedHeight(30)
self.clicked.connect(self.animate)
return
def animate(self):
self.anim = QPropertyAnimation(self, b'position')
self.anim.setDuration(3000)
self.anim.setStartValue(QPointF(self.pos().x(), self.pos().y()))
self.anim.setEndValue(QPointF(self.pos().x() + 200, self.pos().y() - 20))
self.anim.start()
return
def _set_pos_(self, pos):
self.move(pos.x(), pos.y())
return
position = pyqtProperty(QPointF, fset=_set_pos_)
class CustomMainWindow(QMainWindow):
def __init__(self):
super().__init__()
self.setGeometry(100, 100, 600, 300)
self.setWindowTitle("ANIMATION TEST")
# OUTER FRAME
# ============
self.frm = QFrame()
self.frm.setStyleSheet("""
QFrame {
background: #d3d7cf;
border: none;
}
""")
self.lyt = QHBoxLayout()
self.frm.setLayout(self.lyt)
self.setCentralWidget(self.frm)
# BUTTON FRAME
# =============
self.btn_frm = QFrame()
self.btn_frm.setStyleSheet("""
QFrame {
background: #ffffff;
border: none;
}
""")
self.btn_frm.setFixedWidth(400)
self.btn_frm.setFixedHeight(200)
self.btn_lyt = QVBoxLayout()
self.btn_lyt.setAlignment(Qt.AlignTop)
self.btn_lyt.setSpacing(5)
self.btn_frm.setLayout(self.btn_lyt)
# SCROLL AREA
# ============
self.scrollArea = QScrollArea()
self.scrollArea.setStyleSheet("""
QScrollArea {
border-style: solid;
border-width: 1px;
}
""")
self.scrollArea.setWidget(self.btn_frm)
self.scrollArea.setWidgetResizable(True)
self.scrollArea.setFixedWidth(400)
self.scrollArea.setFixedHeight(150)
self.scrollArea.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOn)
self.lyt.addWidget(self.scrollArea)
# ADD BUTTONS TO BTN_LAYOUT
# ==========================
self.btn_lyt.addWidget(MyButton("Foo"))
self.btn_lyt.addWidget(MyButton("Bar"))
self.btn_lyt.addWidget(MyButton("Baz"))
self.btn_lyt.addWidget(MyButton("Qux"))
self.show()
return
if __name__== '__main__':
app = QApplication(sys.argv)
QApplication.setStyle(QStyleFactory.create('Plastique'))
myGUI = CustomMainWindow()
sys.exit(app.exec_())
3. The problem
When the button moves, it stays in the QScrollArea(). I would need to get it on top of everything:
4. Solution (almost)
Thank you #magrif for pointing me in the right direction. Thanks to your suggestions, I got something working.
So I changed the animate() function into this:
def animate(self):
self.anim = QPropertyAnimation(self, b'position')
self.anim.setDuration(3000)
startpoint = self.mapToGlobal(self.pos())
endpoint = self.mapToGlobal(QPoint(self.pos().x() + 200, self.pos().y() - 20))
self.setWindowFlags(Qt.Popup)
self.show()
self.anim.setStartValue(QPointF(startpoint.x(), startpoint.y()))
self.anim.setEndValue(QPointF(endpoint.x(), endpoint.y()))
self.anim.start()
QTimer.singleShot(1000, self.hide)
return
Note that I install a single-shot timer to hide() the button after one second. That's because the Qt eventloop is blocked as long as this button behaves as a "popup" (because of self.setWindowFlags(Qt.Popup)). Anyway, the one-shot timer works good enough for me.
Unfortunately I got one issue left. When I click on the first button Foo, it starts its animation (almost) from where it was sitting initially. If I click on one of the other buttons - like Baz - it suddenly jumps down about 100 pixels and starts its animation from there.
I think this has something to do with the startpoint = self.mapToGlobal(self.pos()) function and the fact that those buttons are sitting in a QScrollArea(). But I don't know how to fix this.
5. Objective
My purpose is to build a rightmouse-click-menu like this:
When the user clicks on Grab and move, the button should disappear from the QScrollArea() and move quickly towards the mouse. When it arrives at the mouse pointer, the button should fade out and the drag-and-drop operation can start.
Note: The following question related to this topic is this one:Qt: How to perform a drag-and-drop without holding down the mouse button?
The position of a widget is relative to its parent, so you should not use startpoint = self.mapToGlobal(self.pos()), but startpoint = self.mapToGlobal(QPoint()), since for the widget the position of the topLeft is the QPoint(0, 0).
So if you want to use the #magrif solution you should change it to:
def animate(self):
startpoint = self.mapToGlobal(QPoint())
self.setWindowFlags(Qt.Popup)
self.show()
anim = QPropertyAnimation(
self,
b"pos",
self,
duration=3000,
startValue=startpoint,
endValue=startpoint + QPoint(200, -20),
finished=self.deleteLater,
)
anim.start()
But the drawback is that while the animation is running you can not interact with the window.
Another solution is to change the parent of the QFrame to the window itself:
def animate(self):
startpoint = self.window().mapFromGlobal(self.mapToGlobal(QPoint()))
self.setParent(self.window())
anim = QPropertyAnimation(
self,
b"pos",
self,
duration=3000,
startValue=startpoint,
endValue=startpoint + QPoint(200, -20),
finished=self.deleteLater,
)
anim.start()
self.show()
Note: it is not necessary to create the qproperty position since the qproperty pos already exists.
Calculate global coordinates for button using mapToGlobal().
Set flag Qt.Popup using setWindowFlags() and show()
Init start and end values relate global coordinates from (1), and start animation
ps At C++ at works :)
Related
I am trying to code an application using PySide6 and as a part of the project, I would like to implement my own title bar.
The issue is that when I click on the "Maximize" button, and then on the "Resize" button, the style of the "Maximize" button seems to persist as if the pointer of the mouse was hovering over it. By hiding the button, the leaveEvent is not called (it is normally called when the pointer of the mouse leaves the boundaries of the widget), but even if I manually call the leaveEvent (with a QEvent.Leave payload) and the update method on the maximize button, its style is still not updated.
You will find a working example below:
import sys
from PySide6.QtCore import Slot
from PySide6.QtWidgets import QApplication, QWidget, QFrame, QPushButton, QLabel, QHBoxLayout
def dict_to_stylesheet(properties: dict[str, dict[str, str]]) -> str:
stylesheet = ""
for q_object in properties:
stylesheet += q_object + " { "
for style_property in properties[q_object]:
stylesheet += f"{style_property}: {properties[q_object][style_property]}; "
stylesheet += " } "
return stylesheet
class MainWindow(QWidget):
def __init__(self):
super().__init__()
# ---------- Attributes ----------
self.mouse_position = None
# ---------- Styling attributes ----------
self.width = 1080
self.height = 720
self.minimum_width = 960
self.minimum_height = 540
self.background_color = "#EFEFEF"
self.dict_stylesheet = {
"QWidget": {
"background-color": self.background_color
}
}
# ---------- UI elements ----------
self.title_bar = TitleBar(self)
# ---------- Layout ----------
self.layout = QHBoxLayout(self)
# ---------- Initialize UI ----------
self.setup_ui()
def setup_ui(self):
# ---------- QMainWindow (self) ----------
self.setMinimumSize(self.minimum_width, self.minimum_height)
self.resize(self.width, self.height)
self.setStyleSheet(dict_to_stylesheet(self.dict_stylesheet))
# ---------- Layout ----------
self.layout.setSpacing(0)
self.layout.setContentsMargins(0, 0, 0, 0)
self.layout.addWidget(self.title_bar)
class TitleBar(QFrame):
def __init__(self, main_window):
super().__init__()
# ---------- Attributes ----------
self.main_window = main_window
# ---------- Styling attributes ----------
self.height = 30
self.background_color = "#AAAAAA"
self.dict_stylesheet = {
"QFrame": {
"background-color": self.background_color,
},
"QPushButton": {
"border": "none",
"background-color": self.background_color,
"margin-left": "5px",
"margin-right": "5px",
"padding-left": "2px",
"padding-right": "2px"
},
"QPushButton:hover": {
"background-color": "#888888",
},
"QPushButton:pressed": {
"background-color": "#666666"
}
}
# ---------- UI elements ----------
# QPushButtons
self.minimize_button = QPushButton("Minimize")
self.maximize_button = QPushButton("Maximize")
self.resize_button = QPushButton("Resize")
self.close_button = QPushButton("Close")
# QLabels
self.title_label = QLabel("A title")
# ---------- Layout ----------
self.layout = QHBoxLayout(self)
# ---------- Event handling ----------
self.minimize_button.clicked.connect(self.minimize_app)
self.maximize_button.clicked.connect(self.maximize_app)
self.resize_button.clicked.connect(self.resize_app)
self.close_button.clicked.connect(self.close_app)
# ---------- Initialize UI ----------
self.setup_ui()
def setup_ui(self):
# ---------- QFrame (self) ----------
self.setFixedHeight(self.height)
self.setStyleSheet(dict_to_stylesheet(self.dict_stylesheet))
# ---------- Title QLabel ----------
self.title_label.setFixedHeight(self.height)
self.title_label.setStyleSheet("margin-left: 5px")
# ---------- QPushButtons ----------
self.minimize_button.setFixedHeight(self.height)
self.maximize_button.setFixedHeight(self.height)
self.resize_button.setFixedHeight(self.height)
self.resize_button.setHidden(True)
self.close_button.setFixedHeight(self.height)
# ---------- Layout ----------
self.layout.setSpacing(0)
self.layout.setContentsMargins(0, 0, 0, 0)
self.layout.addWidget(self.title_label)
self.layout.addStretch(1)
self.layout.addWidget(self.minimize_button)
self.layout.addWidget(self.maximize_button)
self.layout.addWidget(self.resize_button)
self.layout.addWidget(self.close_button)
#Slot()
def minimize_app(self):
self.main_window.showMinimized()
#Slot()
def maximize_app(self):
self.main_window.showMaximized()
# Update layout
self.toggle_resize_and_maximize_buttons()
#Slot()
def resize_app(self):
self.main_window.showNormal()
# Update layout
self.toggle_resize_and_maximize_buttons()
#Slot()
def close_app(self):
self.main_window.close()
def toggle_resize_and_maximize_buttons(self) -> None:
hide_maximize_button = True if self.maximize_button.isVisible() else False
hide_resize_button = not hide_maximize_button
self.maximize_button.setHidden(hide_maximize_button)
self.resize_button.setHidden(hide_resize_button)
if __name__ == '__main__':
app = QApplication()
main = MainWindow()
main.show()
sys.exit(app.exec())
Steps to reproduce the undesired behavior:
Click on the "Maximize" button
Click on the "Resize" button
If you have any idea as how to fix this bug, I would appreciate it.
I cannot always reproduce the issue, but I see the point.
The problem resides on the fact that changing the window state (maximizing or "normalizing") is not directly done by Qt, but it's a request done to the OS, so the resizing might not happen instantly. The result is that, during that process, the widget (or, better, Qt) still believes that the widget is under the mouse cursor, even if it isn't, and that's because Qt hasn't processed the whole resizing event queue yet.
Right now I don't see a direct solution to that (and I doubt that there would be, as it completely depends on the underlying window manager), but when the widget receives a resize event we do know that the widget has been resized. This means that we can check for the geometry and cursor position and update the Qt.WA_UnderMouse attribute (which is normally set by Qt after the resizing has been completed) on our own.
class TitleBar(QFrame):
# ...
def resizeEvent(self, event):
super().resizeEvent(event)
cursor = QCursor.pos()
for button in self.resize_button, self.maximize_button:
rect = button.rect().translated(button.mapToGlobal(QPoint()))
if not button.isVisible():
continue
underMouse = rect.contains(cursor)
if button.testAttribute(Qt.WA_UnderMouse) != underMouse:
button.setAttribute(Qt.WA_UnderMouse, underMouse)
button.update()
Unfortunately, this has some drawbacks. The resizing can be done from an external source, and the button could still be virtually under the mouse even if it's actually partially covered by another window.
A possible approach would be to check for the actual widget under the mouse using QApplication.widgetAt() whenever the window is resized, but, as the documentation also suggests, the function can be slow. If opaque resizing is enabled (all widget contents are dynamically resized when resizing the window from the edges), the function is actually very costly in terms of performance, as even a single pixel change in size will potentially check the whole widget tree for the given coordinates.
Since you will probably be interested in updating the buttons only when the window state changes, the work around is to set an internal state flag for the state change and call updates only when required by installing an event filter on the main window (the parent of the title bar) that eventually triggers the function when the state has actually been changed:
class TitleBar(QFrame):
windowStateChange = False
def __init__(self, main_window):
super().__init__()
main_window.installEventFilter(self)
# ...
def eventFilter(self, obj, event):
if event.type() == QEvent.Type.WindowStateChange and obj.windowState():
self.windowStateChange = True
elif event.type() == QEvent.Type.Resize and self.windowStateChange:
self.windowStateChange = False
cursor = QCursor.pos()
widgetUnderMouse = QApplication.widgetAt(cursor)
for button in self.resize_button, self.maximize_button:
if button.isVisible():
underMouse = widgetUnderMouse == button
if underMouse != button.testAttribute(Qt.WA_UnderMouse):
button.setAttribute(Qt.WA_UnderMouse, underMouse)
button.update()
return super().eventFilter(obj, event)
Note that the following line is unnecessarily complex:
hide_maximize_button = True if self.maximize_button.isVisible() else False
isVisible() already returns a boolean value; change it to:
hide_maximize_button = self.maximize_button.isVisible()
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.
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_())
1. Objective
My purpose is to build a rightmouse-click-menu like this:
When the user clicks on Grab and move, the button should disappear from the QScrollArea() and move quickly towards the mouse. When it arrives at the mouse pointer, the button should fade out and the drag-and-drop operation can start.
2. Minimal, Reproducible Example
I got something working, but it isn't perfect yet. Please copy-paste the code below and run it with Python 3.x (I use Python 3.7) and PyQt5.
Note: To make the line pixmap = QPixmap("my_pixmap.png") work properly, let it refer to an existing png-image on your computer.
from PyQt5.QtWidgets import *
from PyQt5.QtGui import *
from PyQt5.QtCore import *
import sys
class MyButton(QPushButton):
'''
A special push button.
'''
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.setFixedWidth(300)
self.setFixedHeight(30)
self.setContextMenuPolicy(Qt.CustomContextMenu)
self.customContextMenuRequested.connect(self.showMenu)
return
def showMenu(self, pos):
'''
Show this popup menu when the user clicks with the right mouse button.
'''
menu = QMenu()
menuAction_01 = menu.addAction("action 01")
menuAction_02 = menu.addAction("action 02")
menuAction_03 = menu.addAction("action 03")
menuAction_04 = menu.addAction("action 04")
menuAction_grab = menu.addAction("grab")
action = menu.exec_(self.mapToGlobal(pos))
if action == menuAction_01:
print("clicked on action 01")
elif action == menuAction_02:
print("clicked on action 02")
elif action == menuAction_03:
print("clicked on action 03")
elif action == menuAction_04:
print("clicked on action 04")
elif action == menuAction_grab:
print("clicked on grab")
# 1. Start animation
# -> button moves to mouse pointer
self.animate()
# 2. After animation finishes (about 1 sec)
# -> start drag operation
QTimer.singleShot(1000, self.start_drag)
return
def animate(self):
'''
The button removes itself from the QScrollArea() and flies to the mouse cursor.
For more details, see the anser of #eyllanesc at
https://stackoverflow.com/questions/56216698/how-display-a-qpropertyanimation-on-top-of-the-qscrollarea
'''
startpoint = self.window().mapFromGlobal(self.mapToGlobal(QPoint()))
endpoint = self.window().mapFromGlobal(QCursor.pos())
self.setParent(self.window())
anim = QPropertyAnimation(
self,
b"pos",
self,
duration=1000,
startValue=startpoint,
endValue=endpoint,
finished=self.hide,
)
anim.start()
self.show()
return
def start_drag(self):
'''
Start the drag operation.
'''
drag = QDrag(self)
pixmap = QPixmap("my_pixmap.png")
pixmap = pixmap.scaledToWidth(100, Qt.SmoothTransformation)
drag.setPixmap(pixmap)
mimeData = QMimeData()
mimeData.setText("Foobar")
drag.setMimeData(mimeData)
dropAction = drag.exec(Qt.CopyAction | Qt.MoveAction)
return
class CustomMainWindow(QMainWindow):
def __init__(self):
super().__init__()
self.setGeometry(100, 100, 600, 300)
self.setWindowTitle("ANIMATION TEST")
# OUTER FRAME
# ============
self.frm = QFrame()
self.frm.setStyleSheet("""
QFrame {
background: #d3d7cf;
border: none;
}
""")
self.lyt = QHBoxLayout()
self.frm.setLayout(self.lyt)
self.setCentralWidget(self.frm)
# BUTTON FRAME
# =============
self.btn_frm = QFrame()
self.btn_frm.setStyleSheet("""
QFrame {
background: #ffffff;
border: none;
}
""")
self.btn_frm.setFixedWidth(400)
self.btn_frm.setFixedHeight(200)
self.btn_lyt = QVBoxLayout()
self.btn_lyt.setAlignment(Qt.AlignTop)
self.btn_lyt.setSpacing(5)
self.btn_frm.setLayout(self.btn_lyt)
# SCROLL AREA
# ============
self.scrollArea = QScrollArea()
self.scrollArea.setStyleSheet("""
QScrollArea {
border-style: solid;
border-width: 1px;
}
""")
self.scrollArea.setWidget(self.btn_frm)
self.scrollArea.setWidgetResizable(True)
self.scrollArea.setFixedWidth(400)
self.scrollArea.setFixedHeight(150)
self.scrollArea.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOn)
self.lyt.addWidget(self.scrollArea)
# ADD BUTTONS TO BTN_LAYOUT
# ==========================
self.btn_lyt.addWidget(MyButton("Foo"))
self.btn_lyt.addWidget(MyButton("Bar"))
self.btn_lyt.addWidget(MyButton("Baz"))
self.btn_lyt.addWidget(MyButton("Qux"))
self.show()
self.setAcceptDrops(True)
return
def dropEvent(self, event):
event.acceptProposedAction()
print("dropEvent at {0!s}".format(event))
return
def dragLeaveEvent(self, event):
event.accept()
return
def dragEnterEvent(self, event):
event.acceptProposedAction()
return
if __name__== '__main__':
app = QApplication(sys.argv)
QApplication.setStyle(QStyleFactory.create('Plastique'))
myGUI = CustomMainWindow()
sys.exit(app.exec_())
Run the script and you will see a small window with a few buttons in a QScrollArea():
STEP 1: Click on one of the buttons with your right mouse button. You should see a popup menu. Click on "grab".
STEP 2: The button moves to your mouse pointer. Don't move the mouse pointer.
STEP 3: As soon as your mouse pointer is over the button (don't move the mouse, wait for the button to arrive), click and hold the mouse button down.
STEP 4: Now move the mouse (while holding the mouse button down). You should be in a drag-and-drop operation, with the pixmap locked to your mouse!
Okay, it works, but there are a few downsides.
3. Problem
At the end of the animation, the flying button is under your mouse pointer. But if you move your mouse pointer a tiny bit, the button disappears and you miss the drag-and-drop operation.
In other words, what I got now is not very robust. The user can easily miss the drag-and-drop operation.
NOTE: Apparently the problem I describe here only appears on Windows (not on Linux). But I got to make this thing work on Windows...
4. Potential solution
I believe the following approach would be better, and still intuitive to the user:
As soon as the button arrives under the mouse pointer (the end of the animation), the button fades away. The drag-and-drop operation starts automatically, without the need to click and hold down the mouse button. The drag continues while you move the mouse pointer, until you click somewhere. That mouse press is the dropEvent().
Do you know how to implement this? Or perhaps you have another approach in mind?
5. Notes
My question is actually the sequel of this one:
How display a QPropertyAnimation() on top of the QScrollArea()?
Thank you #eyllanesc for solving that one ^_^
1. Solution
Before presenting a solution, I want to express my gratitude to Mr. #eyllanesc for helping me. Without his help, I wouldn't have a solution right now.
from PyQt5.QtWidgets import *
from PyQt5.QtGui import *
from PyQt5.QtCore import *
import sys, functools
class MyButton(QPushButton):
'''
A special push button.
'''
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.setFixedWidth(300)
self.setFixedHeight(30)
self.setContextMenuPolicy(Qt.CustomContextMenu)
self.customContextMenuRequested.connect(self.showMenu)
self.dragStartPosition = 0
self.set_style(False)
return
def set_style(self, blink):
if blink:
background = "#d3d7cf"
else:
background = "#2e3436"
self.setStyleSheet(f"""
QPushButton {{
/* white on red */
background-color:{background};
color:#ffffff;
border-color:#888a85;
border-style:solid;
border-width:1px;
border-radius: 6px;
font-family:Courier;
font-size:10pt;
padding:2px 2px 2px 2px;
}}
""")
self.update()
return
def showMenu(self, pos):
'''
Show this popup menu when the user clicks with the right mouse button.
'''
menu = QMenu()
menuAction_01 = menu.addAction("action 01")
menuAction_02 = menu.addAction("action 02")
menuAction_03 = menu.addAction("action 03")
menuAction_04 = menu.addAction("action 04")
menuAction_grab = menu.addAction("grab")
action = menu.exec_(self.mapToGlobal(pos))
if action == menuAction_01:
print("clicked on action 01")
elif action == menuAction_02:
print("clicked on action 02")
elif action == menuAction_03:
print("clicked on action 03")
elif action == menuAction_04:
print("clicked on action 04")
elif action == menuAction_grab:
print("clicked on grab")
# Start animation -> button moves to mouse pointer
self.animate()
return
def animate(self):
'''
The button removes itself from the QScrollArea() and flies to the mouse cursor.
For more details, see the anser of #eyllanesc at
https://stackoverflow.com/questions/56216698/how-display-a-qpropertyanimation-on-top-of-the-qscrollarea
'''
def start():
startpoint = self.window().mapFromGlobal(self.mapToGlobal(QPoint()))
endpoint = self.window().mapFromGlobal(QCursor.pos() - QPoint(int(self.width()/2), int(self.height()/2)))
self.setParent(self.window())
anim = QPropertyAnimation(
self,
b"pos",
self,
duration=500,
startValue=startpoint,
endValue=endpoint,
finished=blink,
)
anim.start()
self.show()
return
def blink():
# Flash the button to catch attention
self.setText("GRAB ME")
QTimer.singleShot(10, functools.partial(self.set_style, True))
QTimer.singleShot(100, functools.partial(self.set_style, False))
QTimer.singleShot(200, functools.partial(self.set_style, True))
QTimer.singleShot(300, functools.partial(self.set_style, False))
QTimer.singleShot(400, functools.partial(self.set_style, True))
QTimer.singleShot(500, functools.partial(self.set_style, False))
finish()
return
def finish():
# After two seconds, hide the button
# (even if user did not grab it)
QTimer.singleShot(2000, self.hide)
return
start()
return
def start_drag(self):
'''
Start the drag operation.
'''
# 1. Start of drag-and-drop operation
# => button must disappear
self.hide()
# 2. Initiate drag-and-drop
drag = QDrag(self)
pixmap = QPixmap("my_pixmap.png")
pixmap = pixmap.scaledToWidth(100, Qt.SmoothTransformation)
drag.setPixmap(pixmap)
mimeData = QMimeData()
mimeData.setText("Foobar")
drag.setMimeData(mimeData)
dropAction = drag.exec(Qt.CopyAction | Qt.MoveAction)
return
def mousePressEvent(self, event):
'''
Left or Right mouseclick
'''
def leftmouse():
print("left mouse click")
self.dragStartPosition = event.pos()
return
def rightmouse():
print("right mouse click")
return
if event.button() == Qt.LeftButton:
leftmouse()
return
if event.button() == Qt.RightButton:
rightmouse()
return
return
def mouseMoveEvent(self, event):
'''
Mouse move event
'''
event.accept()
if event.buttons() == Qt.NoButton:
return
if self.dragStartPosition is None:
return
if (event.pos() - self.dragStartPosition).manhattanLength() < QApplication.startDragDistance():
return
self.start_drag()
return
class CustomMainWindow(QMainWindow):
def __init__(self):
super().__init__()
self.setGeometry(100, 100, 600, 300)
self.setWindowTitle("ANIMATION & DRAG AND DROP")
# OUTER FRAME
# ============
self.frm = QFrame()
self.frm.setStyleSheet("""
QFrame {
background: #d3d7cf;
border: none;
}
""")
self.lyt = QHBoxLayout()
self.frm.setLayout(self.lyt)
self.setCentralWidget(self.frm)
# BUTTON FRAME
# =============
self.btn_frm = QFrame()
self.btn_frm.setStyleSheet("""
QFrame {
background: #ffffff;
border: none;
}
""")
self.btn_frm.setFixedWidth(400)
self.btn_frm.setFixedHeight(200)
self.btn_lyt = QVBoxLayout()
self.btn_lyt.setAlignment(Qt.AlignTop)
self.btn_lyt.setSpacing(5)
self.btn_frm.setLayout(self.btn_lyt)
# SCROLL AREA
# ============
self.scrollArea = QScrollArea()
self.scrollArea.setStyleSheet("""
QScrollArea {
border-style: solid;
border-width: 1px;
}
""")
self.scrollArea.setWidget(self.btn_frm)
self.scrollArea.setWidgetResizable(True)
self.scrollArea.setFixedWidth(400)
self.scrollArea.setFixedHeight(150)
self.scrollArea.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOn)
self.lyt.addWidget(self.scrollArea)
# ADD BUTTONS TO BTN_LAYOUT
# ==========================
self.btn_lyt.addWidget(MyButton("Foo"))
self.btn_lyt.addWidget(MyButton("Bar"))
self.btn_lyt.addWidget(MyButton("Baz"))
self.btn_lyt.addWidget(MyButton("Qux"))
self.show()
self.setAcceptDrops(True)
return
def dropEvent(self, event):
event.acceptProposedAction()
print("dropEvent at {0!s}".format(event))
return
def dragLeaveEvent(self, event):
event.accept()
return
def dragEnterEvent(self, event):
event.acceptProposedAction()
return
if __name__== '__main__':
app = QApplication(sys.argv)
QApplication.setStyle(QStyleFactory.create('Plastique'))
myGUI = CustomMainWindow()
sys.exit(app.exec_())
This is what I changed:
I improved the animation. Not the top left corner but the middle of the button flies to your mouse pointer when you click "grab" in the rightmouse menu. This improvement makes it much easier to grab the button once the animation has finished.
At the end of the animation, the button flashes for a short moment to catch the user's attention. The text on the button changes into "GRAB ME". The button's self.hide() function is NOT called for two seconds. So the user has two seconds time to initiate a drag-and-drop operation.
Initiating the drag-and-drop operation happens in the usual way: hold down the leftmouse button and move the mouse pointer.
If the user doesn't do anything for two seconds, the button will just disappear. Otherwise, the button would just sit there indefinitely.
2. Results
It works like a charm. Just copy past the code in a .py file and run it with Python 3.x (I got Python 3.7) and PyQt5:
3. Conclusion
I know that this solution is not exactly what I aimed at from the beginning: performing a drag-and-drop without holding down the mouse button. Nevertheless, I think the new approach is better because it's closer to the general intuition what a drag-and-drop actually is.
I have realized a python simple application, without any animation on it.
Now I want to add a simple animation, triggered by a signal (a button click for example), which on trigger enlarges the width of the windows and shows a new text area with some text in it.
Honestly, I am quite new to python/pyqt4, and I do not know much about the animation framework.
I tried to add this to my class code, for example in a method called clicking on the about menu :) :
self.anim = QPropertyAnimation(self, "size")
self.anim.setDuration(2500)
self.anim.setStartValue(QSize(self.width(), self.height()))
self.anim.setEndValue(QSize(self.width()+100, self.height()))
self.anim.start()
and this enlarge my window as I want.
Unfortunately I have no idea how to insert a new text area, avoiding the widgets already present to fill the new space (actually, when the window enlarge, the widgets use
all the spaces, thus enlarging themselves)
Could someone help me knowing how to add the text area appearance animation?
Any help is appreciated...really...
One way to achieve this is to animate the maximumWidth property on both the window and the text-edit.
The main difficulty is doing it in a way that plays nicely with standard layouts whilst also allowing resizing of the window. Avoiding flicker during the animation is also quite tricky.
The following demo is almost there (the animation is slightly jerky at the beginning and end):
from PyQt4 import QtGui, QtCore
class Window(QtGui.QDialog):
def __init__(self):
QtGui.QDialog.__init__(self)
self._offset = 200
self._closed = False
self._maxwidth = self.maximumWidth()
self.widget = QtGui.QWidget(self)
self.listbox = QtGui.QListWidget(self.widget)
self.button = QtGui.QPushButton('Slide', self.widget)
self.button.clicked.connect(self.handleButton)
self.editor = QtGui.QTextEdit(self)
self.editor.setMaximumWidth(self._offset)
vbox = QtGui.QVBoxLayout(self.widget)
vbox.setContentsMargins(0, 0, 0, 0)
vbox.addWidget(self.listbox)
vbox.addWidget(self.button)
layout = QtGui.QHBoxLayout(self)
layout.addWidget(self.widget)
layout.addWidget(self.editor)
layout.setSizeConstraint(QtGui.QLayout.SetMinAndMaxSize)
self.animator = QtCore.QParallelAnimationGroup(self)
for item in (self, self.editor):
animation = QtCore.QPropertyAnimation(item, 'maximumWidth')
animation.setDuration(800)
animation.setEasingCurve(QtCore.QEasingCurve.OutCubic)
self.animator.addAnimation(animation)
self.animator.finished.connect(self.handleFinished)
def handleButton(self):
for index in range(self.animator.animationCount()):
animation = self.animator.animationAt(index)
width = animation.targetObject().width()
animation.setStartValue(width)
if self._closed:
self.editor.show()
animation.setEndValue(width + self._offset)
else:
animation.setEndValue(width - self._offset)
self._closed = not self._closed
self.widget.setMinimumSize(self.widget.size())
self.layout().setSizeConstraint(QtGui.QLayout.SetFixedSize)
self.animator.start()
def handleFinished(self):
if self._closed:
self.editor.hide()
self.layout().setSizeConstraint(QtGui.QLayout.SetMinAndMaxSize)
self.widget.setMinimumSize(0, 0)
self.setMaximumWidth(self._maxwidth)
if __name__ == '__main__':
import sys
app = QtGui.QApplication(sys.argv)
window = Window()
window.move(500, 300)
window.show()
sys.exit(app.exec_())