Related
I am trying to code a gui for highlighting areas of a screen (specifically, greying out areas of an image surrounding a clear rectangle).
I have implemented the generation of a fullscreen transparent widget created after a button press. The widget is covered by a translucent grey rectangle. The user can still see the underlying active screen image which allows them to select a starting point for drawing a rectangle.
The mouse move event after a click event triggers the Update() function which allows the drawing of a new red rectangle.
The problem here is the previously drawn overlay rectangle is disappearing.
How do I fix the following code to draw the red rectangle over the translucent overlay and continually cut the area of the new rectangle from the previous overlay while drawing the rectangle?
import sys
from PyQt5.QtCore import Qt
from PyQt5.QtGui import QBrush, QColor, QPainter, QPen
from PyQt5.QtWidgets import QApplication, QPushButton, QWidget, QDesktopWidget
class MainWidget(QWidget):
def __init__(self):
super().__init__()
# Set the window properties
self.setWindowTitle("Main Widget")
self.setGeometry(100, 100, 200, 200)
# Create a button
self.screenshotButton = QPushButton("Start", self)
self.screenshotButton.move(50, 50)
# Connect the button's clicked signal to the showTransparentWidget slot
self.screenshotButton.clicked.connect(self.openTransparentWidget)
def openTransparentWidget(self):
# Close the main widget
self.close()
# Create and show the transparent widget
self.transparentWidget = TransparentWidget()
self.transparentWidget.show()
class TransparentWidget(QWidget):
def __init__(self):
super().__init__()
# Get the screen dimensions
desktop = QDesktopWidget()
screenWidth = desktop.screenGeometry().width()
screenHeight = desktop.screenGeometry().height()
# Set the size of the widget to the screen dimensions
self.setGeometry(0, 0, screenWidth, screenHeight)
# Set the window flags to make the widget borderless and topmost
self.setWindowFlags(Qt.FramelessWindowHint | Qt.WindowStaysOnTopHint)
# Set the window transparency
self.setAttribute(Qt.WA_TranslucentBackground)
# Initialize the starting and ending positions of the box to -1
self.startX = -1
self.startY = -1
self.endX = -1
self.endY = -1
#call the paintEvent to generate an overlay
self.update()
def mousePressEvent(self, event):
# Store the starting position of the mouse when it is clicked
# Set the flag to True
self.mouseClicked = True
self.startX = event.x()
self.startY = event.y()
print(self.startX, self.startY)
def mouseMoveEvent(self, event):
if self.mouseClicked:
# Store the current position of the mouse as it is being dragged
self.endX = event.x()
self.endY = event.y()
# Redraw the widget to update the box
self.update()
def mouseReleaseEvent(self, event):
# Set the flag to False
self.mouseClicked = False
def paintEvent(self, event):
# Create a QPainter object and set it up for drawing
painter = QPainter(self)
# Draw translucent overlay over the transparent widget
if self.startX == -1 and self.endX == -1:
brush = QBrush(QColor(200, 200, 200, 128))
painter.setBrush(brush)
painter.drawRect(0, 0, self.width(), self.height())
# Set the composition mode to clear
#painter.setCompositionMode(QPainter.CompositionMode_Clear)
# Draw the box if the starting and ending positions are valid
if self.startX != -1 and self.endX != -1:
# Calculate the top-left and bottom-right corners of the box
topLeftX = min(self.startX, self.endX)
topLeftY = min(self.startY, self.endY)
bottomRightX = max(self.startX, self.endX)
bottomRightY = max(self.startY, self.endY)
# Set the composition mode to source over - these options seem to have no effect
#painter.setCompositionMode(QPainter.CompositionMode_SourceOver)
#painter.setCompositionMode(QPainter.CompositionMode_Clear)
#painter.setCompositionMode(QPainter.CompositionMode_DestinationOut)
pen = QPen(QColor(255 ,0, 0))
brush = QBrush(QColor(255, 255, 255, 0))
painter.setPen(pen)
painter.setBrush(brush)
# Draw the empty box (eraseRect also not working)
painter.drawRect(topLeftX, topLeftY, bottomRightX - topLeftX, bottomRightY - topLeftY)
app = QApplication(sys.argv)
mainWidget = MainWidget()
mainWidget.show()
sys.exit(app.exec_())
Edit: Here's a sample image I found that shows what I am trying to achieve. (It's actually from a snipping tool which is very similar to what I am trying to achieve)
Whenever paintEvent is called the entire widget is redrawn.
To overcome this, when drawing anything new, also re-draw the previous item.
The short solution is to update paintEventto draw the overlay and clear the new rectangle in the same call.
brush = QBrush(QColor(200, 200, 200, 128))
painter.setBrush(brush)
painter.drawRect(0, 0, self.width(), self.height())
painter.setCompositionMode(QPainter.CompositionMode_Clear)
Thanks to #musicamante for your support via the comments section.
Here is the full code:
import sys
from PyQt5.QtCore import Qt
from PyQt5.QtGui import QBrush, QColor, QPainter, QPen
from PyQt5.QtWidgets import QApplication, QPushButton, QWidget, QDesktopWidget
class MainWidget(QWidget):
def __init__(self):
super().__init__()
# Set the window properties
self.setWindowTitle("Main Widget")
self.setGeometry(100, 100, 200, 200)
# Create a button
self.screenshotButton = QPushButton("Start", self)
self.screenshotButton.move(50, 50)
# Connect the button's clicked signal to the showTransparentWidget slot
self.screenshotButton.clicked.connect(self.openTransparentWidget)
def openTransparentWidget(self):
# Close the main widget
self.close()
# Create and show the transparent widget
self.transparentWidget = TransparentWidget()
self.transparentWidget.show()
class TransparentWidget(QWidget):
def __init__(self):
super().__init__()
# Get the screen dimensions
desktop = QDesktopWidget()
screenWidth = desktop.screenGeometry().width()
screenHeight = desktop.screenGeometry().height()
# Set the size of the widget to the screen dimensions
self.setGeometry(0, 0, screenWidth, screenHeight)
# Set the window flags to make the widget borderless and topmost
self.setWindowFlags(Qt.FramelessWindowHint | Qt.WindowStaysOnTopHint)
# Set the window transparency
self.setAttribute(Qt.WA_TranslucentBackground)
# Initialize the starting and ending positions of the box to -1
self.startX = -1
self.startY = -1
self.endX = -1
self.endY = -1
#call the paintEvent to generate an overlay
self.update()
def mousePressEvent(self, event):
# Store the starting position of the mouse when it is clicked
# Set the flag to True
self.mouseClicked = True
self.startX = event.x()
self.startY = event.y()
print(self.startX, self.startY)
def mouseMoveEvent(self, event):
if self.mouseClicked:
# Store the current position of the mouse as it is being dragged
self.endX = event.x()
self.endY = event.y()
# Redraw the widget to update the box
self.update()
def mouseReleaseEvent(self, event):
# Set the flag to False
self.mouseClicked = False
def paintEvent(self, event):
# Create a QPainter object and set it up for drawing
painter = QPainter(self)
# Draw translucent overlay over the transparent widget
if self.startX == -1 and self.endX == -1:
brush = QBrush(QColor(200, 200, 200, 128))
painter.setBrush(brush)
painter.drawRect(0, 0, self.width(), self.height())
# Set the composition mode to clear
#painter.setCompositionMode(QPainter.CompositionMode_Clear)
# Draw the box if the starting and ending positions are valid
if self.startX != -1 and self.endX != -1:
# Calculate the top-left and bottom-right corners of the box
topLeftX = min(self.startX, self.endX)
topLeftY = min(self.startY, self.endY)
bottomRightX = max(self.startX, self.endX)
bottomRightY = max(self.startY, self.endY)
brush = QBrush(QColor(200, 200, 200, 128))
painter.setBrush(brush)
painter.drawRect(0, 0, self.width(), self.height())
painter.setCompositionMode(QPainter.CompositionMode_Clear)
pen = QPen(QColor(255 ,0, 0))
brush = QBrush(QColor(0, 0, 0, 0))
painter.setPen(pen)
painter.setBrush(brush)
# Draw the empty box
painter.drawRect(topLeftX, topLeftY, bottomRightX - topLeftX, bottomRightY - topLeftY)
app = QApplication(sys.argv)
mainWidget = MainWidget()
mainWidget.show()
sys.exit(app.exec_())
UI drawing (at the low level) normally happens using a frame buffer, which is eventually cleared in a specific area in which new painting is going to happen.
This means that you cannot rely on contents previously drawn in another paint event: even when requesting to update a specific region of the widget (ie: using update(QRect)), that region will be cleared from the buffer, and previous contents doesn't exist any more, and the buffer is also cleared anyway whenever the window is hidden and shown again, like after minimizing and restoring it, or after switching virtual desktop.
In your case, it means that the "background" rectangle will only be painted at start up (when the coordinates are -1), not after that.
The solution is to always draw all the contents, and eventually cut out the area using setClipRegion().
class TransparentWidget(QWidget):
area = reference = None
def __init__(self):
super().__init__()
self.setWindowFlags(Qt.FramelessWindowHint | Qt.WindowStaysOnTopHint)
self.setAttribute(Qt.WA_TranslucentBackground)
self.setMouseTracking(True)
screenArea = QRect()
for screen in QApplication.screens():
screenArea |= screen.geometry()
self.setGeometry(screenArea)
def mousePressEvent(self, event):
if event.button() != Qt.LeftButton:
return
pos = event.pos()
if self.area:
span = QRect(-5, -5, 10, 10)
if span.translated(self.area.topLeft()).contains(pos):
self.reference = self.area.setTopLeft
elif span.translated(self.area.topRight()).contains(pos):
self.reference = self.area.setTopRight
elif span.translated(self.area.bottomRight()).contains(pos):
self.reference = self.area.setBottomRight
elif span.translated(self.area.bottomLeft()).contains(pos):
self.reference = self.area.setBottomLeft
else:
self.reference = None
if not self.reference:
self.area = QRect(pos, QSize(1, 1))
self.reference = self.area.setBottomRight
self.update()
def mouseMoveEvent(self, event):
if self.reference:
self.reference(event.pos())
self.update()
elif self.area:
pos = event.pos()
span = QRect(-5, -5, 10, 10)
cursor = None
if span.translated(self.area.topLeft()).contains(pos):
cursor = Qt.SizeFDiagCursor
elif span.translated(self.area.topRight()).contains(pos):
cursor = Qt.SizeBDiagCursor
elif span.translated(self.area.bottomRight()).contains(pos):
cursor = Qt.SizeFDiagCursor
elif span.translated(self.area.bottomLeft()).contains(pos):
cursor = Qt.SizeBDiagCursor
if cursor is not None:
self.setCursor(cursor)
else:
self.unsetCursor()
def mouseReleaseEvent(self, event):
self.reference = None
if self.area is not None:
self.area = self.area.normalized()
self.update()
def paintEvent(self, event):
painter = QPainter(self)
if self.area is not None:
r = QRegion(self.rect())
r ^= QRegion(self.area.normalized().adjusted(1, 1, 0, 0))
painter.setClipRegion(r)
painter.fillRect(self.rect(), QColor(200, 200, 200, 128))
if self.area is not None:
painter.setPen(QColor(255 ,0, 0))
painter.drawRect(self.area.normalized())
Notes:
QDesktopWidget is obsolete in Qt5, use QScreen instead;
you should always consider the case of multiple screen computers; if you specifically do not want to show your widget in all screens, then just use `showFullScreen();
whenever possible and it makes sense, use Qt objects functions, which are normally quite fast and provide better readability (for instance, using QPoint, QRect and functions like QRect.normalized());
calling self.update() in the __init__ is pointless: update() doesn't immediately redraw the widget, it only schedules an update, and since the first painting will happen anyway as soon as the widget is shown, there's no point in doing it;
I'm trying to learn it by re-making an old command line C program I've got for working with pixel art.
At the moment, the main window starts as a single QLabel set to show a 300 x 300 scaled up version of a 10 x 10 white image.
I'm using the resizeEvent (I've also tried using paintEvent with the same problem) to rescale the image to fill the window as the window size is increased.
My question is, how do I rescale the image to fit in the window as the window size is decreased? As it stands, the window can't be resized smaller than the widget displaying the image. Essentially, I can make the window (and image) bigger, but never smaller.
My code for this so far is below. As it stands it's only working based on changes to window width, just to keep it simple while I'm working this out. Is there a way to allow the window to be resized to be smaller than the largest widget? Or is there a better way to approach this problem?
#Create white 10*10 image
image = QImage(10,10,QImage.Format.Format_ARGB32)
image_scaled = QImage()
image.fill(QColor(255,255,255))
class Window(QMainWindow):
#scale image to change in window width (image is window width * window width square)
def resizeEvent(self,event):
if self.imageLabel.width()>self.imageLabel.height():
self.image_scaled = image.scaled(self.imageLabel.width(),self.imageLabel.width())
self.pixmap = QPixmap.fromImage(self.image_scaled)
self.imageLabel.setPixmap(self.pixmap)
QWidget.resizeEvent(self, event)
def __init__(self, parent=None):
super().__init__(parent)
self.setGeometry(100,100,300,300)
self.imageLabel = QLabel()
self.setCentralWidget(self.imageLabel)
self.image_scaled = image.scaled(self.imageLabel.width(),self.imageLabel.width())
self.pixmap = QPixmap.fromImage(self.image_scaled)
self.imageLabel.setPixmap(self.pixmap)
app = QApplication(sys.argv)
win = Window()
win.show()
sys.exit(app.exec_())
While the OP proposed solution might work, it has an important drawback: it uses a QScrollArea for the wrong purpose (since it's never used for scrolling). That approach creates unnecessary overhead while resizing, as the view will need to compute lots of things about its contents before "finishing" the resize event (including scroll bar ranges and geometries) that, in the end, will never be actually used.
The main problem comes from the fact that QLabel doesn't allow resizing to a size smaller than the original pixmap set. To work around this issue, the simplest solution is to create a custom QWidget subclass that draws the pixmap on its own.
class ImageViewer(QWidget):
pixmap = None
_sizeHint = QSize()
ratio = Qt.KeepAspectRatio
transformation = Qt.SmoothTransformation
def __init__(self, pixmap=None):
super().__init__()
self.setPixmap(pixmap)
def setPixmap(self, pixmap):
if self.pixmap != pixmap:
self.pixmap = pixmap
if isinstance(pixmap, QPixmap):
self._sizeHint = pixmap.size()
else:
self._sizeHint = QSize()
self.updateGeometry()
self.updateScaled()
def setAspectRatio(self, ratio):
if self.ratio != ratio:
self.ratio = ratio
self.updateScaled()
def setTransformation(self, transformation):
if self.transformation != transformation:
self.transformation = transformation
self.updateScaled()
def updateScaled(self):
if self.pixmap:
self.scaled = self.pixmap.scaled(self.size(), self.ratio, self.transformation)
self.update()
def sizeHint(self):
return self._sizeHint
def resizeEvent(self, event):
self.updateScaled()
def paintEvent(self, event):
if not self.pixmap:
return
qp = QPainter(self)
r = self.scaled.rect()
r.moveCenter(self.rect().center())
qp.drawPixmap(r, self.scaled)
class Window(QMainWindow):
def __init__(self, parent=None):
super().__init__(parent)
self.imageLabel = ImageViewer(QPixmap.fromImage(image))
self.setCentralWidget(self.imageLabel)
Found a solution. Turns out putting the image inside a QScrollArea widget allows the window to be made smaller than the image it contains even if the scroll bars are disabled. This then allows the image to be rescaled to fit the window as the window size is reduced.
class Window(QMainWindow):
#scale image to change in window width (image is window width * window width square)
def resizeEvent(self,event):
self.image_scaled = image.scaled(self.scroll.width(),self.scroll.height())
self.pixmap = QPixmap.fromImage(self.image_scaled)
self.imageLabel.setPixmap(self.pixmap)
QMainWindow.resizeEvent(self, event)
def __init__(self, parent=None):
super().__init__(parent)
self.setGeometry(100,100,200,200)
self.imageLabel = QLabel()
self.scroll = QScrollArea()
self.scroll.setWidget(self.imageLabel)
self.setCentralWidget(self.scroll)
self.scroll.setWidgetResizable(True)
self.scroll.setVerticalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOff)
self.scroll.setHorizontalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOff)
self.image_scaled = image.scaled(self.scroll.width(),self.scroll.width())
self.pixmap = QPixmap.fromImage(self.image_scaled)
self.imageLabel.setPixmap(self.pixmap)
app = QApplication(sys.argv)
win = Window()
win.show()
sys.exit(app.exec_())
I want to modify Screen-Snip code from GitHub/harupy/snipping-tool so that every screen-snip has a ratio of 3 x 2. (I will save as 600 x 400 px image later)
I'm not sure how to modify self.end dynamically so that the user clicks and drags with a 3 x 2 ratio. The mouse position will define the x coordinate, and the y coordinate will be int(x * 2/3)
Any suggestions on how to do this? I promise I've been researching this, and I just can't seem to "crack the code" of modifying only the y coordinate of self.end
Here is the code:
import sys
import PyQt5
from PyQt5 import QtWidgets, QtCore, QtGui
import tkinter as tk
from PIL import ImageGrab
import numpy as np
import cv2 # package is officially called opencv-python
class MyWidget(QtWidgets.QWidget):
def __init__(self):
super().__init__()
root = tk.Tk()
screen_width = root.winfo_screenwidth()
screen_height = root.winfo_screenheight()
self.setGeometry(0, 0, screen_width, screen_height)
self.setWindowTitle(' ')
self.begin = QtCore.QPoint()
self.end = QtCore.QPoint()
self.setWindowOpacity(0.3)
QtWidgets.QApplication.setOverrideCursor(
QtGui.QCursor(QtCore.Qt.CrossCursor)
)
self.setWindowFlags(QtCore.Qt.FramelessWindowHint)
print('Capture the screen...')
self.show()
def paintEvent(self, event):
qp = QtGui.QPainter(self)
qp.setPen(QtGui.QPen(QtGui.QColor('black'), 3))
qp.setBrush(QtGui.QColor(128, 128, 255, 128))
qp.drawRect(QtCore.QRect(self.begin, self.end)) ##### This seems like the place I should modify. #########
def mousePressEvent(self, event):
self.begin = event.pos()
self.end = self.begin
self.update()
def mouseMoveEvent(self, event):
self.end = event.pos()
self.update()
def mouseReleaseEvent(self, event):
self.close()
x1 = min(self.begin.x(), self.end.x())
y1 = min(self.begin.y(), self.end.y())
x2 = max(self.begin.x(), self.end.x())
y2 = max(self.begin.y(), self.end.y())
img = ImageGrab.grab(bbox=(x1, y1, x2, y2))
img.save('capture.png')
img = cv2.cvtColor(np.array(img), cv2.COLOR_BGR2RGB)
cv2.imshow('Captured Image', img)
cv2.waitKey(0)
cv2.destroyAllWindows()
if __name__ == '__main__':
app = QtWidgets.QApplication(sys.argv)
window = MyWidget()
window.show()
app.aboutToQuit.connect(app.deleteLater)
sys.exit(app.exec_())
You don't need to "change the y coordinate", you just need to use the correct arguments to create the rectangle.
There are various ways to initialize a QRect, you are using the two points, another one (and more common) is to use the coordinates of the origin and the size of the rectangle.
Once you know the width, you can compute the height, and make it negative if the y of the end point is above the begin.
Note that in this way you could get a "negative" rectangle (negative width, with the "right" edge actually at the left, the same for the height/bottom), so it's usually better to use normalized, which also allows you to get the correct coordinates of the rectangle for screen grabbing.
class MyWidget(QtWidgets.QWidget):
# ...
def getRect(self):
# a commodity function that always return a correctly sized
# rectangle, with normalized coordinates
width = self.end.x() - self.begin.x()
height = abs(width * 2 / 3)
if self.end.y() < self.begin.y():
height *= -1
return QtCore.QRect(self.begin.x(), self.begin.y(),
width, height).normalized()
def paintEvent(self, event):
qp = QtGui.QPainter(self)
qp.setPen(QtGui.QPen(QtGui.QColor('black'), 3))
qp.setBrush(QtGui.QColor(128, 128, 255, 128))
qp.drawRect(self.getRect())
def mouseReleaseEvent(self, event):
self.close()
rect = self.getRect()
img = ImageGrab.grab(bbox=(
rect.topLeft().x(),
rect.topLeft().y(),
rect.bottomRight().x(),
rect.bottomRight().y()
))
# ...
I suggest you to use a delayed setGeometry as in some systems (specifically Linux), the "final" geometry is actually applied only as soon as the window is correctly mapped from the window manager, especially if the window manager tends to apply a geometry on its own when the window is shown the first time. For example, I have two screens, and your window got "centered" on my main screen, making it shifted by half width of the other screen.
Also consider that importing Tk just for the screen size doesn't make much sense, since Qt already provides all necessary tools.
You can use something like that:
class MyWidget(QtWidgets.QWidget):
# ...
def showEvent(self, event):
if not event.spontaneous():
# delay the geometry on the "next" cycle of the Qt event loop;
# this should take care of positioning issues for systems that
# try to move newly created windows on their own
QtCore.QTimer.singleShot(0, self.resetPos)
def resetPos(self):
rect = QtCore.QRect()
# create a rectangle that is the sum of the geometries of all available
# screens; the |= operator acts as `rect = rect.united(screen.geometry())`
for screen in QtWidgets.QApplication.screens():
rect |= screen.geometry()
self.setGeometry(rect)
Here I wrote this code but did not work:
import sys
from PyQt4 import QtGui, QtCore
class CricleImage(QtCore.QObject):
def __init__(self):
super(CricleImage, self).__init__()
self.pix = QtGui.QGraphicsPixmapItem(QtGui.QPixmap("bird(01).jpg"))
#drawRoundCircle
rect = self.pix.boundingRect()
self.gri = QtGui.QGraphicsRectItem(rect)
self.gri.setPen(QtGui.QColor('red'))
if __name__ == '__main__':
myQApplication = QtGui.QApplication(sys.argv)
IMG = CricleImage()
#scene
scene = QtGui.QGraphicsScene(0, 0, 400, 300)
scene.addItem(IMG.pix)
#view
view = QtGui.QGraphicsView(scene)
view.show()
sys.exit(myQApplication.exec_())
One possible solution is to overwrite the paint() method of the QGraphicsPixmapItem and use setClipPath to restrict the painting region:
from PyQt4 import QtCore, QtGui
class CirclePixmapItem(QtGui.QGraphicsPixmapItem):
#property
def radius(self):
if not hasattr(self, "_radius"):
self._radius = 0
return self._radius
#radius.setter
def radius(self, value):
if value >= 0:
self._radius = value
self.update()
def paint(self, painter, option, widget=None):
painter.save()
rect = QtCore.QRectF(QtCore.QPointF(), 2 * self.radius * QtCore.QSizeF(1, 1))
rect.moveCenter(self.boundingRect().center())
path = QtGui.QPainterPath()
path.addEllipse(rect)
painter.setClipPath(path)
super().paint(painter, option, widget)
painter.restore()
if __name__ == "__main__":
import sys
app = QtGui.QApplication(sys.argv)
pixmap = QtGui.QPixmap("logo.jpg")
scene = QtGui.QGraphicsScene()
view = QtGui.QGraphicsView(scene)
view.setRenderHints(
QtGui.QPainter.Antialiasing | QtGui.QPainter.SmoothPixmapTransform
)
it = CirclePixmapItem(pixmap)
scene.addItem(it)
it.radius = pixmap.width() / 2
view.show()
sys.exit(app.exec_())
Update:
# ...
view = QtGui.QGraphicsView(
scene, alignment=QtCore.Qt.AlignTop | QtCore.Qt.AlignLeft
)
# ...
view.show()
it.setPos(80, 80)
sys.exit(app.exec_())
Second possible solution:
import sys
#from PyQt4 import QtCore, QtGui
from PyQt5.QtWidgets import *
from PyQt5.QtCore import *
from PyQt5.QtGui import *
class Label(QLabel):
def __init__(self, *args, antialiasing=True, **kwargs):
super(Label, self).__init__(*args, **kwargs)
self.Antialiasing = antialiasing
self.setMaximumSize(200, 200)
self.setMinimumSize(200, 200)
self.radius = 100
self.target = QPixmap(self.size())
self.target.fill(Qt.transparent) # Fill the background with transparent
# Upload image and zoom to control level
p = QPixmap("head2.jpg").scaled(
200, 200, Qt.KeepAspectRatioByExpanding, Qt.SmoothTransformation)
painter = QPainter(self.target)
if self.Antialiasing:
# antialiasing
painter.setRenderHint(QPainter.Antialiasing, True)
painter.setRenderHint(QPainter.HighQualityAntialiasing, True)
painter.setRenderHint(QPainter.SmoothPixmapTransform, True)
path = QPainterPath()
path.addRoundedRect(
0, 0, self.width(), self.height(), self.radius, self.radius)
# pruning
painter.setClipPath(path)
painter.drawPixmap(0, 0, p)
self.setPixmap(self.target)
class Window(QWidget):
def __init__(self, *args, **kwargs):
super(Window, self).__init__(*args, **kwargs)
layout = QHBoxLayout(self)
layout.addWidget(Label(self))
self.setStyleSheet("background: green;")
if __name__ == "__main__":
app = QApplication(sys.argv)
w = Window()
w.show()
sys.exit(app.exec_())
Another approach, slightly different from the one provided by eyllanesc. While this might seem much more complicated than that, I believe that it offers a better implementation and interface, with the addition of better performance.
In this case, instead of overriding the paint method (that is run everytime the item is painted, which happens very often), I'm using the shape() function along with the QGraphicsItem.ItemClipsToShape flag, that allows to limit the painting only within the boundaries of the path shape.
What shape() does is to return a QPainterPath that includes only the "opaque" portions of an item that will react to mouse events and collision detection (with the scene boundaries and its other items). In the case of a QGraphicsPixmapItem this also considers the possible mask (for example, a PNG based pixmap with transparent areas, or an SVG image). By setting the ItemClipsToShape we can ensure that the painting will only cover the parts of the image that are within that shape.
The main advantage of this approach is that mouse interaction and collision detection with other items honors the actual circle shape of the item.
This means that if you click outside the circle (but still within the rectangle area of the full image), the item will not receive the event. Also, if the image supports masking (a PNG with transparent areas) which by default would not be part of the shape, this method will take that into account.
Also, by "caching" the shape we are also speeding up the painting process a bit (since Qt will take care of it, without any processing done using python).
class CircleClipPixmapItem(QtGui.QGraphicsPixmapItem):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.setFlag(self.ItemClipsToShape)
self.updateRect()
def updateRect(self):
baseRect = super().boundingRect()
minSize = min(baseRect.width(), baseRect.height())
self._boundingRect = QtCore.QRectF(0, 0, minSize, minSize)
self._boundingRect.moveCenter(baseRect.center())
self._shape = QtGui.QPainterPath()
self._shape.addEllipse(self._boundingRect)
# the shape might include transparent areas, using the & operator
# I'm ensuring that _shape only includes the areas that intersect
# the shape provided by the base implementation
self._shape &= super().shape()
def setPixmap(self, pm):
super().setPixmap(pm)
# update the shape to reflect the new image size
self.updateRect()
def setShapeMode(self, mode):
super().setShapeMode(mode)
# update the shape with the new mode
self.updateRect()
def boundingRect(self):
return self._boundingRect
def shape(self):
return self._shape
Keep in mind that there's a catch about both methods: if the aspect ratio of the image differs very much from 1:1, you'll always end up with some positioning issues. With my image, for example, it will always be shown 60 pixel right from the actual item position. If you want to avoid that, the updateRect function will be slightly different and, unfortunately, you'll have to override the paint() function (while still keeping it a bit faster than other options):
def updateRect(self):
baseRect = super().boundingRect()
minSize = min(baseRect.width(), baseRect.height())
self._boundingRect = QtCore.QRectF(0, 0, minSize, minSize)
# the _boundingRect is *not* centered anymore, but a new rect is created
# as a reference for both shape intersection and painting
refRect= QtCore.QRectF(self._boundingRect)
refRect.moveCenter(baseRect.center())
# note the minus sign!
self._reference = -refRect.topLeft()
self._shape = QtGui.QPainterPath()
self._shape.addEllipse(self._boundingRect)
self._shape &= super().shape().translated(self._reference)
# ...
def paint(self, painter, option, widget):
# we are going to translate the painter to the "reference" position,
# let's save its state before that
painter.save()
painter.translate(self._reference)
super().paint(painter, option, widget)
painter.restore()
This will make the boundingRect (and resulting internal shape) position the whole item at the top-left of the item position.
The following image shows the differences between the two approaches; I've used a PNG with transparent areas to better explain the whole concept.
On the top there is the source image, in the middle the paint() override approach, and finally the shape() implementation at the bottom.
While there seems to be no difference between the two methods, as shown on the examples on the left, on the right I've highlighted the actual boundaries of each item, by showing their boundingRect (in blue), shape (in red), which will be used for mouse events, collision detection and paint clipping; the green circle shows the overall circle used for both shape and painting.
The examples in the middle show the positioning based on the original image size, while on the right you can see the absolute positioning based on the effective circle size as explained above.
Drawing a circle around the image
Unfortunately, the ItemClipsToShape flag doesn't support antialiasing for clipping: if we just draw a circle after painting the image the result will be ugly. On the left you can see that the circle is very pixellated and does not overlap perfectly on the image. On the right the correct painting.
To support that, the flag must not be set, and the paint function will be a bit different.
class CircleClipPixmapItem(QtGui.QGraphicsPixmapItem):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# we don't need this anymore:
# self.setFlag(self.ItemClipsToShape)
# always set the shapeMode to the bounding rect without any masking:
# if the image has transparent areas they will be clickable anyway
self.setShapeMode(self.BoundingRectShape)
self.updateRect()
self.pen = QtGui.QPen(QtCore.Qt.red, 2)
# ...
def setPen(self, pen):
self.pen = pen
self.update()
def paint(self, painter, option, widget):
# we are going to translate the painter to the "reference" position,
# and we are also changing the pen, let's save the state before that
painter.save()
painter.translate(.5, .5)
painter.setRenderHints(painter.Antialiasing)
# another painter save "level"
painter.save()
# apply the clipping to the painter
painter.setClipPath(self._shape)
painter.translate(self._reference)
super().paint(painter, option, widget)
painter.restore()
painter.setPen(self.pen)
# adjust the rectangle to precisely match the circle to the image
painter.drawEllipse(self._boundingRect.adjusted(.5, .5, -.5, -.5))
painter.restore()
# restore the state of the painter
I'm working on a custom Qt widget for a 'sanity' tool, after running the check, the header color of my widget that contain a QLabel will change. My current problem is that I would like to have my label color change from black to white relative to the background color.
What I also want to do is something like the fusion style offer, if my header if only half filled, the text will be black and white to contrast with the background. (Really cool for a QProgressBar)
I've currently tried to do this by testing with some CSS example but I don't really know how CSS works and it seems to be different into Qt stylesheet.
Of course, i've mostly tried to do this through stylesheet but I would like to understand how to do it with QPalette.
Thanks for you help.
If your text is drawn directly in the widget (like in a QProgressBar), you just have to deal with the color used to draw the background. It's quite easy with a QPainter and QPainter::setClipPath method:
class ProgressBar(QLabel):
def __init__(self):
super().__init__()
self.progression = 0
self.startTimer(200)
def paintEvent(self, event):
text = "This is a text"
super().paintEvent(event)
painter = QPainter(self)
self.setFont(QFont("Helvetica", 24))
boundingRect = QRect(0, 0, self.width(), self.height())
progressionRect = boundingRect.adjusted(0, 0, - self.width() * (1 - self.progression), 0)
leftRect = boundingRect.adjusted(progressionRect.width(), 0, 0, 0)
painter.setBrush(Qt.black)
painter.drawRect(progressionRect)
painter.setClipRect(leftRect)
painter.drawText(boundingRect, Qt.AlignCenter,text)
painter.setPen(Qt.white)
painter.setClipRect(progressionRect)
painter.drawText(boundingRect, Qt.AlignCenter, text)
def timerEvent(self, event):
self.progression = min(1.0, self.progression + 0.02)
self.update()
if __name__ == "__main__":
app = QApplication(sys.argv)
p = ProgressBar()
p.resize(640, 480)
p.show()
sys.exit(app.exec_())
Since, your label is another widget in the header, you have to find the right color. If the label knows that its parent is your header (and you have the good accessor in the progression value), you can reuse the solution above.
If you need a generic way to draw a label on a widget by inverting the color, you could draw the parents render in a image, invert the colors and use it to paint the text.
A quick example as explanation:
class Window(QLabel):
def __init__(self):
super().__init__()
self.progression = 0
self.startTimer(200)
def paintEvent(self, event):
super().paintEvent(event)
painter = QPainter(self)
boundingRect = QRect(0, 0, self.width(), self.height() * self.progression)
painter.setBrush(Qt.black)
painter.drawRect(boundingRect)
def timerEvent(self, event):
self.progression = min(1.0, self.progression + 0.02)
self.update()
class ContrastedText(QFrame):
def __init__(self, text="", parent=None):
super().__init__(parent)
self.text = text
def paintEvent(self, event):
super().paintEvent(event)
painter = QPainter(self)
self.setFont(QFont("Helvetica", 24))
boundingRect = QRect(0, 0, self.width(), self.height())
fm = QFontMetrics(painter.font())
textBoundingRect = fm.boundingRect(boundingRect, Qt.AlignCenter, self.text)
# No parent. The text is black
if not self.parent():
painter.drawText(boundingRect, Qt.AlignCenter, self.text)
return
# Get the parents render and invert its color to have the good constrast
render = QPixmap(self.parent().size()).toImage()
self.parent().render(render, QPoint(), QRegion(), QWidget.DrawWindowBackground)
render.invertPixels(QImage.InvertRgba)
# Define a clip path containing the text and draw the image
path = QPainterPath()
path.addText(textBoundingRect.topLeft(), painter.font(), self.text)
painter.setClipPath(path)
painter.drawImage(-self.mapTo(self.parent(), QPoint(0, 0)), render)
if __name__ == "__main__":
app = QApplication(sys.argv)
window = Window()
layout = QVBoxLayout(window)
label = ContrastedText("This is a text")
layout.addWidget(label)
window.resize(640, 480)
window.show()
sys.exit(app.exec_())