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.
Related
I have window size issue according to the display resolution.
To dynamic control for window size, I set the size policy as Preferred
In case of 1280 X 1024 resolution, the auto adjusting window size function works well.
Low resolution such as 1280 X 800, the vertical window size is slightly larger than actual resolution.
But I found out the window size fit the display resolution, when i changed screen resolution in windows display configuration.
Could you share your comment or suggest resize the window according to display resolution ?
Qt provides a QScreen interface, which allows access to the "portion" of the visible desktop (theoretically) shown in each physical screen.
"Screens" can be retrieved from the QGuiApplication in various ways, so that we can have an interface to do various things:
access to all screens(), including the main one (primaryScreen();
signals that notify whenever screens are added or removed;
full and available geometries (the latter being the actual geometry excluding things like system menus or task bar), and various change notifications;
Considering the above, it's pretty easy to create a window that automatically adapts to screen changes.
In the following example, I've created a basic QWidget that adapts to the current main screen (or the cursor position) and accepts a scale ratio based on the screen size, and an aspect ratio that properly sets the size based on the screen.
class AutoResizeWindow(QtWidgets.QWidget):
def __init__(self, scale=2/3, aspectRatio=None):
super().__init__()
self.scale = scale
self.aspectRatio = aspectRatio
font = self.font()
font.setPointSize(font.pointSize() * 2)
font.setBold(True)
self.sizeLabel = QtWidgets.QLabel(font=font, alignment=QtCore.Qt.AlignCenter)
self.resizeButton = QtWidgets.QPushButton('Update to screen size')
layout = QtWidgets.QVBoxLayout(self)
layout.addWidget(self.sizeLabel)
layout.addWidget(self.resizeButton)
self.updateScreens()
self.updateSize()
self.resizeButton.clicked.connect(self.updateSize)
QtWidgets.QApplication.instance().screenAdded.connect(self.updateScreens)
QtWidgets.QApplication.instance().screenRemoved.connect(self.updateSize)
def updateScreens(self):
for screen in QtWidgets.QApplication.screens():
try:
screen.availableGeometryChanged.connect(
self.updateSize, QtCore.Qt.UniqueConnection)
except TypeError:
# already connected
pass
def updateSize(self):
if not self.isVisible():
screen = QtWidgets.QApplication.screenAt(QtGui.QCursor.pos())
else:
center = self.geometry().center()
screen = QtWidgets.QApplication.screenAt(center)
if not center in screen.geometry():
screen = QtWidgets.QApplication.screenAt(QtGui.QCursor.pos())
screenGeo = screen.geometry()
if self.aspectRatio:
baseSize = QtCore.QSize(round(self.aspectRatio * 100), 100)
else:
baseSize = screenGeo.size()
newSize = baseSize.scaled(screenGeo.size(), QtCore.Qt.KeepAspectRatio)
newSize *= self.scale
windowGeo = QtCore.QRect(QtCore.QPoint(), newSize)
windowGeo.moveCenter(screenGeo.center())
self.setGeometry(windowGeo)
self.updateLabel()
def updateLabel(self):
screen = QtWidgets.QApplication.screenAt(self.geometry().center())
screenIndex = QtWidgets.QApplication.screens().index(screen)
screenSize = screen.size()
self.sizeLabel.setText('''
Screen {index} ("{name}")<br/>
Size: {sw}x{sh} ({sr:.02f}:1)<br/><br/>
Window size: {ww}x{wh} ({wr:.02f}:1)
'''.format(
index=screenIndex,
name = screen.name(),
sw = screenSize.width(),
sh = screenSize.height(),
sr = screenSize.width() / screenSize.height(),
ww = self.width(),
wh = self.height(),
wr = self.width() / self.height()
))
def moveEvent(self, event):
self.updateLabel()
def resizeEvent(self, event):
super().resizeEvent(event)
self.updateLabel()
Important notes:
by default, Qt resizes top-level windows to 2/3 of the screen size (unless any content forces a bigger size); overriding sizeHint() is not enough, as Qt will always limit the size to 2/3 of the screen width or height;
with the above code, switching screens can result in recursion problems, depending on the DPI scaling;
depending on the OS and screen layout, the geometry() and availableGeometry() might not always correspond to the real value for extended ("virtual") desktops;
this question is tagged for pyqt, which, unlike pyside, implements "magic methods" for some classes; among these, it supports __contains__() for both QRect and QRectF, allowing the usage of point in rect (which actually calls rect.contains(point) internally); I believe that the PyQt syntax is better, smart and more pythonic, but, if you use PySide, you must use the full Qt compliant syntax, otherwise you'll get an exception (because in considers the target object as an iterator if __contains__ is not defined):
if not screen.geometry().contains(center):
How can i fix the QLabel to not clip the text when resizing? This is a widget that will be placed inside a QDialog eventually. So the resizing of the Dialog will happen if a user resizes the main dialog.
'''
Main Navigation bar
'''
################################################################################
# imports
################################################################################
import os
import sys
import inspect
from PySide2 import QtWidgets, QtCore, QtGui
################################################################################
# widgets
################################################################################
class Context(QtWidgets.QWidget):
def __init__(self):
super(Context, self).__init__()
# controls
self.uiThumbnail = QtWidgets.QLabel()
self.uiThumbnail.setMinimumSize(QtCore.QSize(100, 75))
self.uiThumbnail.setMaximumSize(QtCore.QSize(100, 75))
self.uiThumbnail.setScaledContents(True)
self.uiThumbnail.setAlignment(QtCore.Qt.AlignLeading | QtCore.Qt.AlignLeft | QtCore.Qt.AlignTop)
self.uiThumbnail.setObjectName('thumbnail')
self.uiDetailsText = QtWidgets.QLabel()
self.uiDetailsText.setAlignment(QtCore.Qt.AlignLeading | QtCore.Qt.AlignLeft | QtCore.Qt.AlignTop)
self.uiDetailsText.setWordWrap(True)
self.uiDetailsText.setTextInteractionFlags(QtCore.Qt.TextBrowserInteraction)
self.uiDetailsText.setOpenExternalLinks(True)
self.uiMenuButton = QtWidgets.QPushButton()
self.uiMenuButton.setFixedSize(QtCore.QSize(24, 24))
self.uiMenuButton.setFocusPolicy(QtCore.Qt.NoFocus)
# header layout
self.headerLayout = QtWidgets.QHBoxLayout()
self.headerLayout.setSpacing(6)
self.headerLayout.setContentsMargins(6, 6, 6, 6)
self.headerLayout.setAlignment(QtCore.Qt.AlignTop)
self.headerLayout.addWidget(self.uiThumbnail)
self.headerLayout.addWidget(self.uiDetailsText)
self.headerLayout.addWidget(self.uiMenuButton)
self.headerLayout.setAlignment(self.uiThumbnail, QtCore.Qt.AlignTop)
self.headerLayout.setAlignment(self.uiMenuButton, QtCore.Qt.AlignTop)
# frames
self.headerFrame = QtWidgets.QFrame()
self.headerFrame.setObjectName('panel')
self.headerFrame.setFrameShape(QtWidgets.QFrame.StyledPanel)
self.headerFrame.setFrameShadow(QtWidgets.QFrame.Raised)
self.headerFrame.setLayout(self.headerLayout)
# layout
self.mainLayout = QtWidgets.QHBoxLayout()
self.mainLayout.setSpacing(6)
self.mainLayout.setContentsMargins(0, 0, 0, 0)
self.mainLayout.addWidget(self.headerFrame)
self.setLayout(self.mainLayout)
self.setStyleSheet('''
#thumbnail {
background-color: rgb(70,70,70);
}
#panel {
background-color: rgb(120,120,120);
border-radius:3px;
}
''')
self.updateContext()
# methods
def updateContext(self):
self.uiDetailsText.setText('''
<span style="font-size:14px;">
<b>A title goes here which can wrap</b>
</span>
<br>
<span style="font-size:11px;">
<b>Status:</b> Additional details go here
<br>
<b>User:</b>
User information goes here
<br>
<b>About:</b> Some more information
<br>
<b>Date:</b> 2021-07-03
<br>
</span>
''')
################################################################################
# main
################################################################################
if __name__ == '__main__':
pass
app = QtWidgets.QApplication(sys.argv)
ex = Context()
ex.resize(500,70)
ex.show()
sys.exit(app.exec_())
I tried adding this and it didn't help at all...
# methods
def resizeEvent(self, event):
newHeight = self.uiDetailsText.heightForWidth(self.uiDetailsText.width())
self.uiDetailsText.setMaximumHeight(newHeight)
event.accept()
The size policy of a QLabel is always Preferred (in both directions), and if word wrapping or rich text is used, the text layout engine will try to find an optimal width based on the contents. This unfortunately creates some issues in layout managers, as explained in the layout documentation:
The use of rich text in a label widget can introduce some problems to the layout of its parent widget. Problems occur due to the way rich text is handled by Qt's layout managers when the label is word wrapped.
Also consider the following line:
self.headerLayout.setAlignment(QtCore.Qt.AlignTop)
it will align the layout item (headerLayout) to the top of the headerFrame, which creates problems with the size hint of the label, since the size policy is Preferred. It's normally unnecessary (and often discouraged) to set the alignment of a layout if it's the top level layout of a widget.
Unfortunately, there's no easy solution for these situations, because:
to allow "free" resizing, the label cannot have any size constraints;
as soon as a widget is mapped, the size hint is just that: a hint; if the user resizes a window that contains wrapped text, the size choosen by the user is respected, even if that results in partially hiding the text;
the size hint considers the heightForWidth of children only when the layout is activated, after that the top level window will ignore any hint and will only honor the user choice, limited by the minimum size (or minimum size hint) of widgets;
heightForWidth() is called only for widgets that do not have a layout, otherwise the layout item's heightForWidth() will be called;
There are workarounds, but they are not always reliable.
A possible solution is to resize the top level window whenever the height doesn't respect the widget's heightForWidth(). Note that this is not completely reliable, and it's not 100% safe, as it requires to call a resize inside a resize event. If any of the parent has some functions that acts on delayed calls related to layouts (including changing the geometry), it might cause recursion problems.
class Context(QtWidgets.QWidget):
resizing = False
# ...
def resizeEvent(self, event):
super().resizeEvent(event)
if self.resizing:
return
diff = self.heightForWidth(event.size().width()) - self.height()
if diff > 0:
self.resizing = True
target = self
while not target.windowFlags() & (QtCore.Qt.Window | QtCore.Qt.SubWindow):
target = target.parent()
target.resize(target.width(), target.height() + diff)
self.resizing = False
Note that for this to work you still have to set the Expanding vertical size policy or remove the line for the layout alignment.
Most importantly, this can only be used only for a single widget in a window, so you should consider implementing it in the top level widget.
I have a bunch of QTextEdits in a QVBoxLayout in a QScrollArea.
The texts can often get very long and the horizontal space is limited by design, and QTextEdit automatically wraps text in multiple lines which is good.
I want to automatically resize the QTextEdit to fit to the wrapped text, the text itself will always be in one line, and the wrapped text can have multiple lines, I want the QTextEdits to fit to the height of wrapped lines.
By hours upon hours of Google searching, I have found a solution, but it doesn't work as expected, there can sometimes be one extra line at the bottom, I will post example code below:
from PyQt6.QtCore import *
from PyQt6.QtGui import *
from PyQt6.QtWidgets import *
font = QFont('Noto Serif', 9)
class Editor(QTextEdit):
doubleClicked = pyqtSignal(QTextEdit)
def __init__(self):
super().__init__()
self.setReadOnly(True)
self.setFont(font)
self.textChanged.connect(self.autoResize)
self.margins = self.contentsMargins()
self.setAttribute(Qt.WidgetAttribute.WA_DontShowOnScreen)
#pyqtSlot(QMouseEvent)
def mouseDoubleClickEvent(self, e: QMouseEvent) -> None:
self.doubleClicked.emit(self)
def autoResize(self):
self.show()
height = int(self.document().size().height() + self.margins.top() + self.margins.bottom())
self.setFixedHeight(height)
class Window(QMainWindow):
def __init__(self):
super().__init__()
self.resize(405, 720)
frame = self.frameGeometry()
center = self.screen().availableGeometry().center()
frame.moveCenter(center)
self.move(frame.topLeft())
self.centralwidget = QWidget(self)
self.vbox = QVBoxLayout(self.centralwidget)
self.scrollArea = QScrollArea(self.centralwidget)
self.scrollArea.setWidgetResizable(True)
self.scrollAreaWidgetContents = QWidget()
self.scrollArea.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOn)
self.verticalLayout = QVBoxLayout(self.scrollAreaWidgetContents)
self.verticalLayout.setAlignment(Qt.AlignmentFlag.AlignTop)
self.scrollArea.setWidget(self.scrollAreaWidgetContents)
self.scrollArea.setAlignment(Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignTop)
self.vbox.addWidget(self.scrollArea)
self.setCentralWidget(self.centralwidget)
def addItems():
items = [
"L'Estro Armonico No. 9 in D Major\u2014III. Allegro",
"L'Estro Armonico No. 6 in A Minor\u2014III. Presto",
"L'Estro Armonico No. 6 in A Minor\u2014I. Allegro",
"L'Estro Armonico No. 1 in D major\u2014I. Allegro",
"12 Concertos Op.3 \u2014 L'estro Armonico \u2014 Concerto No. 6 In A Minor For Solo Violin RV 356\u2014Presto",
"Ultimate Mozart\u2014 The Essential Masterpieces",
"Serenade in G K.525 Eine kleine Nachtmusik\u20141. Allegro",
"Vivaldi\u2014 L'estro Armonico",
"Academy of St. Martin in the Fields",
"Are You With Me \u2014 Reality"
]
for i in items:
textbox = Editor()
textbox.setText(i)
window.verticalLayout.addWidget(textbox)
app = QApplication([])
window = Window()
window.show()
addItems()
app.exec()
Note you will need Noto Serif for it to run correctly (and of course you can just replace it), and of course you need PyQt6.
In the example, the first seven textboxs all have one extra empty line at the bottom, and the last three don't have it.
What caused the extra line and how to remove it?
Update:
I have to set Qt.WidgetAttribute.WA_DontShowOnScreen because if I don't set it, calling .show() of QTextEdit will cause the QTextEdit show up in the middle of the screen and quickly disappear, and it's annoying.
I have to call .show() because calling document().size() without show(), the values will all be 0 and I don't know why it is like this.
The problem comes from the fact that you're trying to set the height too early. In fact, if you add a print(self.show()) just after show(), you'll see that all will show a default size (probably, 256x192).
This depends on two aspects:
when a widget is shown the first time, it's not yet completely "mapped" in the OS window management, so it will use default sizes depending on many aspects;
you're setting the text before adding it to the layout, so the QTextEdit will know nothing about the required size of the parent;
Then another problem arises: if the window is resized, the contents will not adapt until the text is changed.
In order to properly set a vertical height based on the contents, you should set the document's textWidth, and also call autoResize everytime the widget is resized.
class Editor(QTextEdit):
def __init__(self):
super().__init__()
self.setReadOnly(True)
self.setFont(font)
self.textChanged.connect(self.autoResize)
def autoResize(self):
self.document().setTextWidth(self.viewport().width())
margins = self.contentsMargins()
height = int(self.document().size().height() + margins.top() + margins.bottom())
self.setFixedHeight(height)
def resizeEvent(self, event):
self.autoResize()
Note that:
the margins should be dynamically accessed, not stored in the __init__;
mouseDoubleClickEvent is a function that is called on a mouse event, it's not (nor it should) be a slot, so using the pyqtSlot decorator is pointless;
while conceptually fine for a "main" layout like in the case of a layout for the scroll area contents, setting the alignment of a layout doesn't set the alignment of its items, but only that of the layout; while the result is often the same, in practice it's very different (so the result is not always the same, especially if more layouts are added to the same parent layout);
double click in text fields is very commonly used for advanced selection (usually, select the word under the cursor), and choosing to prevent such action (thus changing a known UI convention) should be taken into careful consideration;
I am trying to create a simple interface like this:
from PyQt5 import QtWidgets,QtGui
class program():
def __init__(self):
self.window = QtWidgets.QWidget()
self.window.setWindowTitle("how many click")
self.text = QtWidgets.QLabel(self.window)
self.text.setText(("not clicked"))
self.text.setGeometry(240,200,300,50)
self.text2 = QtWidgets.QLabel(self.window)
self.picture = QtWidgets.QLabel(self.window)
self.button=QtWidgets.QPushButton(self.window)
self.button.setText("click")
self.button.setFont(QtGui.QFont('',10))
self.button.setGeometry(250,100,200,50)
self.window.setGeometry(600,200,800,600)
self.window.show()
self.count=0
self.button.clicked.connect(self.click)
def click(self):
self.count+= 1
self.text.setText(((f"you clicked {self.count} times")))
self.text.setFont(QtGui.QFont('',10))
if self.count == 5:
self.text2.setText(("You clicked too much"))
self.text2.setGeometry(250, 250, 300, 50)
self.picture.setPixmap(QtGui.QPixmap("C:/Users/Administrator/Desktop/mypic.png"))
self.picture.move(300, 300)
app = QtWidgets.QApplication(sys.argv)
run= program()
sys.exit(app.exec_())
In this code my picture appears when I click 5 times to button but picture becomes very tiny as in pic1. However when I write setPixmap and picture.move codes into init function picture becomes normal size as in pic2.
pic1:
pic2:
The simple solution to your issue is to add the following line after setting the pixmap:
self.picture.adjustSize()
The direct reason is that when when the widget is shown the label has not yet a pixmap, so its geometry is already set to its minimum size (defaults to 100x30). Then, when the pixmap is set, the label doesn't automatically update its size.
The logical reason is that you are using fixed geometries for your widget, and this approach is generaly discouraged for lots of reasons, with the most important being the fact that elements within a window should always adapt their geometries (size and position) to the size of the parent, possibly by occupying all the available space and preventing the elements to become invisible if the window is resized to a smaller size.
To avoid that, you should always use layout managers (in your case, a QVBoxLayout could be enough).
For example:
class program():
def __init__(self):
self.window = QtWidgets.QWidget()
# ...
layout = QtWidgets.QVBoxLayout(self.window)
layout.addWidget(self.text)
layout.addWidget(self.text2)
layout.addWidget(self.picture)
layout.addWidget(self.button)
# it's good practice to always show the window *after* adding all elements
self.window.show()
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_())