I don't see that there's any way to define a stylesheet for a specific QTreeWidgetItem, so I'm resolved to attempt to figure my solution out using either SetBackground or SetForeground. The effect I'm trying to achieve is border around a single widgetitem for a treeview, but I can't figure out how to draw one manually with a QBrush, if that's even how to go about it. Any ideas? Thanks in advance.
You can't draw a border with a QTreeWidgetItem alone, you have to use a delegate.
And you can store a border style in each item for which you want to have a border under a custom "role" to be able to retrieve it and use it inside the delegate.
Here is a complete working and commented example:
import sys
from PyQt4 import QtGui, QtCore
class BorderItemDelegate(QtGui.QStyledItemDelegate):
def __init__(self, parent, borderRole):
super(BorderItemDelegate, self).__init__(parent)
self.borderRole = borderRole
def sizeHint(self, option, index):
size = super(BorderItemDelegate, self).sizeHint(option, index)
pen = index.data(self.borderRole).toPyObject()
if pen is not None:
# Make some room for the border
# When width is 0, it is a cosmetic pen which
# will be 1 pixel anyways, so set it to 1
width = max(pen.width(), 1)
size = size + QtCore.QSize(2 * width, 2 * width)
return size
def paint(self, painter, option, index):
pen = index.data(self.borderRole).toPyObject()
# copy the rect for later...
rect = QtCore.QRect(option.rect)
if pen is not None:
width = max(pen.width(), 1)
# ...and remove the extra room we added in sizeHint...
option.rect.adjust(width, width, -width, -width)
# ...before painting with the base class method...
super(BorderItemDelegate, self).paint(painter, option, index)
# ...then paint the borders
if pen is not None:
painter.save()
# The pen is drawn centered on the rectangle lines
# with pen.width()/2 width on each side of these lines.
# So, rather than shifting the drawing of pen.width()/2
# we double the pen width and clip the part that would
# go outside the rect.
painter.setClipRect(rect, QtCore.Qt.ReplaceClip);
pen.setWidth(2 * width)
painter.setPen(pen)
painter.drawRect(rect)
painter.restore()
if __name__ == "__main__":
app = QtGui.QApplication(sys.argv)
mainWindow = QtGui.QMainWindow()
mainWindow.resize(300,300);
# Define the Qt.ItemDataRole we will be using
MyBorderRole = QtCore.Qt.UserRole + 1
# Create and populate the view
treeWidget = QtGui.QTreeWidget(mainWindow)
for i in range(3):
item = QtGui.QTreeWidgetItem(["Item %d"%(i)])
treeWidget.addTopLevelItem(item)
treeWidget.expandItem(item);
for j in range(10):
subItem = QtGui.QTreeWidgetItem(["SubItem %d %d"%(i,j)])
pen = QtGui.QPen(QtGui.QColor.fromHsv(j*25, 255, 255))
pen.setWidth(j)
# Store the border pen in the item as the role we defined
subItem.setData(0, MyBorderRole, pen)
item.addChild(subItem)
# Pass the role where we stored the border pen to the delegate constructor
delegate = BorderItemDelegate(treeWidget, MyBorderRole)
treeWidget.setItemDelegate(delegate)
mainWindow.setCentralWidget(treeWidget)
mainWindow.show()
sys.exit(app.exec_())
Related
I'm converting QGraphicsItem's into rasterized masks at their location in a QGraphicsScene. I'm using those masks for further processing of a video (taking the average intensity inside the mask). To achieve this, I'm painting each item in the scene one by one on a QImage, which has a size just big enough to envelop the item. Everything works well enough, but the items in the scene disappear. That is because I'm removing the pen from the item when I paint it on the QImage. I set the original pen back when I'm done, but the items don't reappear on the scene.
How can I "refresh" the scene to make the items reappear, or alternatively, prevent the items form disappearing altogether?
I couldn't really find anything of people running into this problem. So maybe I'm just doing something fundamentally wrong. Any suggestions are welcome.
Here's my code:
class MyThread(QtCore.QThread):
def __init__(self, scene):
super().__init__()
self.scene = scene
def run(self):
for item in self.scene.items():
# Render the ROI item to create a rasterized mask.
qimage = self.qimage_from_shape_item(item)
# do some stuff
#staticmethod
def qimage_from_shape_item(item: QtWidgets.QAbstractGraphicsShapeItem) -> QtGui.QImage:
# Get items pen and brush to set back later.
pen = item.pen()
brush = item.brush()
# Remove pen, set brush to white.
item.setPen(QtGui.QPen(QtCore.Qt.NoPen))
item.setBrush(QtCore.Qt.white)
# Determine the bounding box in pixel coordinates.
top = int(item.scenePos().y() + item.boundingRect().top())
left = int(item.scenePos().x() + item.boundingRect().left())
bottom = int(item.scenePos().y() + item.boundingRect().bottom()) + 1
right = int(item.scenePos().x() + item.boundingRect().right()) + 1
size = QtCore.QSize(right - left, bottom - top)
# Initialize qimage, use 8-bit grayscale.
qimage = QtGui.QImage(size, QtGui.QImage.Format_Grayscale8)
qimage.fill(QtCore.Qt.transparent)
painter = QtGui.QPainter(qimage)
painter.setRenderHint(QtGui.QPainter.Antialiasing)
# Offset the painter to paint item in its correct pixel location.
painter.translate(item.scenePos().x() - left, item.scenePos().y() - top)
# Paint the item.
item.paint(painter, QtWidgets.QStyleOptionGraphicsItem())
# Set the pen and brush back.
item.setPen(pen)
item.setBrush(brush)
# Set the pixel coordinate offset of the item to the QImage.
qimage.setOffset(QtCore.QPoint(left, top))
return qimage
if __name__ == '__main__':
app = QtWidgets.QApplication(sys.argv)
widget = QtWidgets.QWidget()
layout = QtWidgets.QVBoxLayout(widget)
view = QtWidgets.QGraphicsView()
layout.addWidget(view)
scene = QtWidgets.QGraphicsScene()
view.setScene(scene)
thread = MyThread(scene)
view.setFixedSize(400, 300)
scene.setSceneRect(0, 0, 400, 300)
rect_item = QtWidgets.QGraphicsRectItem()
p = QtCore.QPointF(123.4, 56.78)
rect_item.setPos(p)
r = QtCore.QRectF(0., 0., 161.8, 100.)
rect_item.setRect(r)
scene.addItem(rect_item)
button = QtWidgets.QPushButton("Get masks")
layout.addWidget(button)
button.clicked.connect(thread.start)
widget.show()
sys.exit(app.exec_())
The problem is that you "can only create and use GUI widgets on main thread", see more information in this SO answer here.
The way I solved it was to take the GUI interaction part, i.e. qimage_from_shape_item(), out of the thread and deal with it in the main loop. I suppose it's still not great that I'm using the items directly, although there is no visible flicker effect or anything from temporarily setting NoPen.
An alternative might have been to use QGraphicsScene::render; however, I don't know how to render the items one by one without interacting with the other items on the scene.
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 am trying to create a simple gui that displays the (memory) layout of some components of a device, but I am having a really hard time enforcing the policy I want to the displayed area.
Let me first show what I have so far (my code became quite large, but I changed/narrowed the code down to the minimal required for anyone to be able to run it):
#!/usr/local/bin/python3
from PyQt5.QtCore import *
from PyQt5.QtGui import *
from PyQt5.QtWidgets import *
import sys
class Register(QGraphicsRectItem):
RegisterSize = 125
NameColor = QColor(Qt.blue)
ValueColor = QColor(0, 154, 205)
def __init__(self, name, value, pos, parent = None):
super(Register, self).__init__(parent)
self.setPos(pos)
self.width = Register.RegisterSize
self.height = 0
self.set_register_name(name)
self.set_register_value(value)
self.setRect(0, 0, self.width, self.height)
def set_register_name(self, name):
self.text_item = QGraphicsTextItem(name, self)
self.text_item.setDefaultTextColor(Register.NameColor)
self.height += self.text_item.boundingRect().height()
def set_register_value(self, value):
self.value_item = QGraphicsTextItem(str(value), self)
self.value_item.setDefaultTextColor(Register.ValueColor)
self.value_item.setPos(self.text_item.boundingRect().bottomLeft())
self.height += self.value_item.boundingRect().height()
class Title(QGraphicsTextItem):
TitleFont = 'Times New Roman'
TitleFontSize = 18
TitleColor = QColor(Qt.red)
def __init__(self, title, parent = None):
super(Title, self).__init__(title, parent)
self.setFont(QFont(Title.TitleFont, Title.TitleFontSize))
self.setDefaultTextColor(Title.TitleColor)
class Component(QGraphicsItem):
def __init__(self, parent = None):
super(Component, self).__init__(parent)
self.width = Register.RegisterSize * 4
self.height = 0
self.add_title()
self.add_registers()
self.rect = QRectF(0, 0, self.width, self.height)
def add_title(self):
self.title = Title('Component Layout', self)
self.title.setPos((self.width - self.title.boundingRect().width()) / 2, 0)
self.height += self.title.boundingRect().height()
def add_registers(self):
y_coor = self.height
x_coor = 0
for i in range(64):
register = Register('register {0:d}'.format(i), i, QPointF(x_coor, y_coor), self)
x_coor = ((i + 1) % 4) * Register.RegisterSize
if (i + 1) % 4 == 0:
y_coor += register.rect().height()
self.height = y_coor
def boundingRect(self):
return self.rect.adjusted(-1, -1, 1, 1)
def paint(self, painter, option, widget):
pen = QPen(Qt.blue)
painter.setPen(pen)
painter.drawRect(self.rect)
class Device(QGraphicsItem):
LeftMargin = 50
RightMargin = 50
TopMargin = 20
BottomMargin = 20
def __init__(self, parent = None):
super(Device, self).__init__(parent)
self.width = Device.LeftMargin + Device.RightMargin
self.height = Device.TopMargin + Device.BottomMargin
component = Component(self)
component.setPos(QPointF(Device.LeftMargin, Device.TopMargin))
self.width += component.boundingRect().width()
self.height += component.boundingRect().height()
self.rect = QRectF(0, 0, self.width, self.height)
def paint(self, painter, option, widget):
pass
def boundingRect(self):
return self.rect.adjusted(-1, -1, 1, 1)
class MainForm(QDialog):
def __init__(self, parent = None):
super(MainForm, self).__init__(parent)
self.scene = QGraphicsScene(parent)
self.view = QGraphicsView(self)
self.view.setScene(self.scene)
self.scene.addItem(Device())
self.resize(700, 900)
def run_app():
app = QApplication(sys.argv)
form = MainForm()
form.show()
app.exec_()
if __name__ == '__main__':
run_app()
This code, when launched, displays the following:
I don't mind the vertical scrollbar, since I intend to add more Components to the Device, and they won't all fit, what bothers me is the horizontal scrollbar.
Why does it appear without me explicitly asking?
It's not like there's no room in the window for the QGraphicsView to display the content.
Moreover, I noticed that the horizontal (and vertical) scrollbars do not appear, when only the Component is added to the QGraphicsView:
self.scene.addItem(Component()) # << previously was self.scene.addItem(Device())
Now the scrollbars do not appear:
Also; when I instead change the following lines:
LeftMargin = 0 # previously was 50
RightMargin = 0 # previously was 50
TopMargin = 0 # previously was 20
BottomMargin = 0 # previously was 20
Scrollbars do not appear. (I probably crossed some boundary with these margins added?)
I know I can control the scrollbars policy with the QGraphicsView.setHorizontalScrollBarPolicy() to make the horizontal scrollbar always off, but that raises another problem: When there's no way to scroll right, the vertical scrollbar "steals" some of the pixels from the display, making Device.RightMargin != Device.LeftMargin. Also, I am curious about what's the size boundary above which the horizontal/vertical scrollbars appear.
So, this is the policy I want to enforce:
I want the displayed area to always have a minimum height of X pixels (regardless of Device()'s height), and for vertical scrollbar to appear only if the Device() height passes these X pixels boundary (I'll determine Device()'s height by summing all Component()s heights)
I want QGraphicsView to never show horizontal scrollbar (the width Device()'s width is fixed and independent of the number of Component()s).
Whenever vertical scrollbar is needed, I don't want it to take up pixels from my display area.
I want to know what is the boundary (in pixels) above which scrollbars will appear (when I don't specify scrollbar policy).
EDIT:
After playing with it a bit, I figured something:
The unwanted horizontal scroll bar appears only because the vertical one appears and steals some of the display space.
According to the doc, the default policy for the horizontal scroll bar is Qt::ScrollBarAsNeeded, which means: "shows a scroll bar when the content is too large to fit and not otherwise.", but it doesn't state what is considered "too large".
When I played around with the margins (Device.TopMargin/Device.BottomMargin), I discovered that the vertical scroll bar appears (and consequently the horizontal one) when Device.boundingRect().height() crosses the 786 pixels boundary.
I couldn't figure out where did this number came from or how to control it.
I believe you are looking for setFixedWidth() and setFixedHeight()
class MainForm(QDialog):
def __init__(self, parent = None):
super(MainForm, self).__init__(parent)
self.scene = QGraphicsScene(parent)
self.view = QGraphicsView(self)
self.view.setScene(self.scene)
self.scene.addItem(Device())
self.resize(700, 900)
self.view.setFixedWidth(650) # <-
self.view.setFixedHeight(500) # <- these two lines will set Device dimensions
self.setFixedWidth(700) # <- this will fix window width
When you set fixed width to view it must be greater than its content (left margin + Device + right margin), otherwise horizontal scroll bar will be displayed. This is why you did not get the horizontal scroll bar when margins were zero.
Generally, the scrollbar will appear when your current view can't display the content.
The vertical scroll bar will take some space from inside the window, and I believe that you do not have control over that, so you should reserve some place for that, too. The behavior of vertical scroll bar depends on your windows system, e.g. on Mac it hovers over and disappear when unneeded, so it does not takes space at all.
I recommend to do the layout in QT Designer. I find it much easier to do it visually, testing it immediately and only introduce small changes in the generated code.
I cannot figure this out for the life of me, but I've boiled this down to a self contained problem.
What I am trying to do, is draw a QGraphicsRectItem around the items that are selected in a QGraphicsScene. After the rect is drawn it can be moved in a way that moves all of the items together. I've looked into QGraphicsItemGroup already and decided it is not feasible in my real use case.
The problem: I've been able to accomplish everything mentioned above, except I can't get the rect item to be positioned properly i.e. it is the right size and by moving it all items are moved but it is not lined up with the united bounding rect of the selected items. I've tried to keep everything in scene coordinates so I'm not sure why there is an offset.
Why does there appear to be an offset and how can this be mitigated?
Here is the runnable code that can be tested by ctrl-clicking or rubber band selection (I know this is a good amount of code but the relevant sections are commented).
#####The line where the position of the rect item is set is marked like this#####
from PyQt4.QtGui import *
from PyQt4.QtCore import *
import sys
class DiagramScene(QGraphicsScene):
def __init__(self, parent=None):
super().__init__(parent)
self.selBox = None
self.selectionChanged.connect(self.onSelectionChange)
#pyqtSlot()
def onSelectionChange(self):
count = 0
items = self.selectedItems()
# Get bounding rect of all selected Items
for item in self.selectedItems():
if count == 0:
rect = item.mapRectToScene(item.boundingRect())
else:
rect = rect.unite(item.mapRectToScene(item.boundingRect()))
count += 1
if count > 0:
if self.selBox:
# Update selBox if items are selected and already exists
self.selBox.setRect(rect)
self.selBox.items = items
else:
# Instantiate selBox if items are selected and does not already exist
self.selBox = DiagramSelBox(rect, items)
##### Set position of selBox to topLeft corner of united rect #####
self.selBox.setPos(rect.topLeft())
self.addItem(self.selBox)
elif self.selBox:
# Remove selBox from scene if no items are selected and box is drawn
self.removeItem(self.selBox)
del self.selBox
self.selBox = None
class DiagramSelBox(QGraphicsRectItem):
def __init__(self, bounds, items, parent=None, scene=None):
super().__init__(bounds, parent, scene)
self.setFlag(QGraphicsItem.ItemIsSelectable, True)
self.pressPos = None
self.items = items
def paint(self, painter, option, widget=None):
pen = QPen(Qt.DashLine)
painter.setPen(pen)
painter.drawRect(self.rect())
def mousePressEvent(self, e):
# Get original position of selBox when clicked
self.pressPos = self.pos()
# mouseEvent is not passed on to scene so item selection
# does not change
def mouseMoveEvent(self, e):
super().mouseMoveEvent(e)
if self.pressPos:
# Move selBox is original position is set
newPos = self.mapToScene(e.pos()) - self.rect().center()
self.setPos(newPos)
def mouseReleaseEvent(self, e):
# Update position of all selected items
change = self.scenePos() - self.pressPos
for item in self.items:
item.moveBy(change.x(), change.y())
super().mouseReleaseEvent(e)
if __name__ == "__main__":
app = QApplication(sys.argv)
view = QGraphicsView()
view.setDragMode(QGraphicsView.RubberBandDrag)
scene = DiagramScene()
scene.setSceneRect(0, 0, 500, 500)
rect1 = scene.addRect(20, 20, 100, 50)
rect2 = scene.addRect(80, 80, 100, 50)
rect3 = scene.addRect(140, 140, 100, 50)
rect1.setFlag(QGraphicsItem.ItemIsSelectable, True)
rect2.setFlag(QGraphicsItem.ItemIsSelectable, True)
rect3.setFlag(QGraphicsItem.ItemIsSelectable, True)
view.setScene(scene)
view.show()
sys.exit(app.exec_())
I don't have PyQt installed, but I've run into similar issues with the regular QT and QGraphicsRectItem.
I think you've mixed some things up regarding the coordinate system. The bounding-rect of every QGraphicsItem is in local coordinates. The Point (0,0) in local-coordinates appears at the scene on the coordinates given by QGraphicsItem::pos() (scene-coordiantes).
QGraphicsRectItem is a bit special, because we normally don't touch pos at all (so we leave it at 0,0) and pass a rect in scene-coordinates to setRect. QGraphicsRectItem::setRect basically set's the bounding rect to the passed value. So if you don't call setPos (in onSelectionChange) at all, and only pass scene-coordinates to setRect you should be fine.
The mouseEvents in DiagramSelBox need to be adjusted as well. My approach would look like this:
mousePress: store the difference between e.pos (mapped to scene) and self.rect.topLeft() in self.diffPos and copy self.rect.topLeft to self.startPos
mouseMove: ensure that the difference between e.pos (mapped to scene) and self.rect.topLeft() stays the same, by moving self.rect around (use self.diffPos for the calculation)
mouseRelease: move the items by the difference between self.rect.topLeft() and self.startPos.
Hope that helps to get you started.
I would like to add some text to the left end side, the right end side and on the slider as in the figure below
I don't understand how I can add text on top of a widget
here the minimal example of the Qscrollbar (without texts)
from PyQt5.QtGui import *
from PyQt5.QtCore import *
from PyQt5.QtWidgets import *
import sys
class Viewer(QMainWindow):
def __init__(self, parent=None):
super(Viewer, self).__init__()
self.parent = parent
self.centralWidget = QWidget()
self.setCentralWidget(self.centralWidget)
self.mainVBOX_param_scene = QVBoxLayout()
self.paramPlotV = QVBoxLayout()
self.horizontalSliders = QScrollBar(Qt.Horizontal)
self.horizontalSliders.setMinimum(0)
self.horizontalSliders.setMaximum(10)
self.horizontalSliders.setPageStep(1)
self.paramPlotV.addWidget(self.horizontalSliders)
self.centralWidget.setLayout(self.paramPlotV)
def main():
app = QApplication(sys.argv)
app.setStyle('Windows')
ex = Viewer(app)
ex.showMaximized()
sys.exit(app.exec())
if __name__ == '__main__':
main()
There are two possible approaches, and both of them use QStyle to get the geometry of the slider and the subPage/addPage rectangles (the "spaces" outside the slider and within its buttons, if they are visible).
Subclass QScrollBar and override paintEvent()
Here we override the paintEvent() of the scroll bar, call the base class implementation (which paints the scroll bar widget) and draw the text over it.
To get the rectangle where we're going to draw, we create a QStyleOptionSlider, which is a QStyleOption sub class used for any slider based widget (including scroll bars); a QStyleOption contains all the information QStyle needs to draw graphical elements, and its subclasses allow QStyle to find out how to draw complex elements such as scroll bars or control the behavior against any mouse event.
class PaintTextScrollBar(QScrollBar):
preText = 'pre text'
postText = 'post text'
sliderText = 'slider'
def paintEvent(self, event):
# call the base class paintEvent, which will draw the scrollbar
super().paintEvent(event)
# create a suitable styleoption and "init" it to this instance
option = QStyleOptionSlider()
self.initStyleOption(option)
painter = QPainter(self)
# get the slider rectangle
sliderRect = self.style().subControlRect(QStyle.CC_ScrollBar,
option, QStyle.SC_ScrollBarSlider, self)
# if the slider text is wider than the slider width, adjust its size;
# note: it's always better to add some horizontal margin for text
textWidth = self.fontMetrics().width(self.sliderText)
if textWidth > sliderRect.width():
sideWidth = (textWidth - sliderRect.width()) / 2
sliderRect.adjust(-sideWidth, 0, sideWidth, 0)
painter.drawText(sliderRect, Qt.AlignCenter,
self.sliderText)
# get the "subPage" rectangle and draw the text
subPageRect = self.style().subControlRect(QStyle.CC_ScrollBar,
option, QStyle.SC_ScrollBarSubPage, self)
painter.drawText(subPageRect, Qt.AlignLeft|Qt.AlignVCenter, self.preText)
# get the "addPage" rectangle and draw its text
addPageRect = self.style().subControlRect(QStyle.CC_ScrollBar,
option, QStyle.SC_ScrollBarAddPage, self)
painter.drawText(addPageRect, Qt.AlignRight|Qt.AlignVCenter, self.postText)
This approach is very effective and may be fine for most simple cases, but there will be problems whenever the text is wider than the size of the slider handle, since Qt decides the extent of the slider based on its overall size and the range between its minimum and maximum values.
While you can adjust the size of the rectangle you're drawing text (as I've done in the example), it will be far from perfect: whenever the slider text is too wide it might draw over the "pre" and "post" text, and make the whole scrollbar very ugly if the slider is near the edges, since the text might cover the arrow buttons:
Note: the result of a "non adjusted" text rectangle would be the same as the first scroll bar in the image above, with the text "clipped" to the slider geometry.
Use a proxy style
QProxyStyle is a QStyle descendant that makes subclassing easier by providing an easy way to override only methods of an existing style.
The function we're most interested in is drawComplexControl(), which is what Qt uses to draw complex controls like spin boxes and scroll bars. By implementing this function only, the behavior will be exactly the same as the paintEvent() method explained above, as long as you apply the custom style to a standard QScrollBar.
What a (proxy) style could really help with is being able to change the overall appearance and behavior of almost any widget.
To be able to take the most of its features, I've implemented another QScrollBar subclass, allowing much more customization, while overriding other important QProxyStyle functions.
class TextScrollBarStyle(QProxyStyle):
def drawComplexControl(self, control, option, painter, widget):
# call the base implementation which will draw anything Qt will ask
super().drawComplexControl(control, option, painter, widget)
# check if control type and orientation match
if control == QStyle.CC_ScrollBar and option.orientation == Qt.Horizontal:
# the option is already provided by the widget's internal paintEvent;
# from this point on, it's almost the same as explained above, but
# setting the pen might be required for some styles
painter.setPen(widget.palette().color(QPalette.WindowText))
margin = self.frameMargin(widget) + 1
sliderRect = self.subControlRect(control, option,
QStyle.SC_ScrollBarSlider, widget)
painter.drawText(sliderRect, Qt.AlignCenter, widget.sliderText)
subPageRect = self.subControlRect(control, option,
QStyle.SC_ScrollBarSubPage, widget)
subPageRect.setRight(sliderRect.left() - 1)
painter.save()
painter.setClipRect(subPageRect)
painter.drawText(subPageRect.adjusted(margin, 0, 0, 0),
Qt.AlignLeft|Qt.AlignVCenter, widget.preText)
painter.restore()
addPageRect = self.subControlRect(control, option,
QStyle.SC_ScrollBarAddPage, widget)
addPageRect.setLeft(sliderRect.right() + 1)
painter.save()
painter.setClipRect(addPageRect)
painter.drawText(addPageRect.adjusted(0, 0, -margin, 0),
Qt.AlignRight|Qt.AlignVCenter, widget.postText)
painter.restore()
def frameMargin(self, widget):
# a helper function to get the default frame margin which is usually added
# to widgets and sub widgets that might look like a frame, which usually
# includes the slider of a scrollbar
option = QStyleOptionFrame()
option.initFrom(widget)
return self.pixelMetric(QStyle.PM_DefaultFrameWidth, option, widget)
def subControlRect(self, control, option, subControl, widget):
rect = super().subControlRect(control, option, subControl, widget)
if (control == QStyle.CC_ScrollBar
and isinstance(widget, StyledTextScrollBar)
and option.orientation == Qt.Horizontal):
if subControl == QStyle.SC_ScrollBarSlider:
# get the *default* groove rectangle (the space in which the
# slider can move)
grooveRect = super().subControlRect(control, option,
QStyle.SC_ScrollBarGroove, widget)
# ensure that the slider is wide enough for its text
width = max(rect.width(),
widget.sliderWidth + self.frameMargin(widget))
# compute the position of the slider according to the
# scrollbar value and available space (the "groove")
pos = self.sliderPositionFromValue(widget.minimum(),
widget.maximum(), widget.sliderPosition(),
grooveRect.width() - width)
# return the new rectangle
return QRect(grooveRect.x() + pos,
(grooveRect.height() - rect.height()) / 2,
width, rect.height())
elif subControl == QStyle.SC_ScrollBarSubPage:
# adjust the rectangle based on the slider
sliderRect = self.subControlRect(
control, option, QStyle.SC_ScrollBarSlider, widget)
rect.setRight(sliderRect.left())
elif subControl == QStyle.SC_ScrollBarAddPage:
# same as above
sliderRect = self.subControlRect(
control, option, QStyle.SC_ScrollBarSlider, widget)
rect.setLeft(sliderRect.right())
return rect
def hitTestComplexControl(self, control, option, pos, widget):
if control == QStyle.CC_ScrollBar:
# check click events against the resized slider
sliderRect = self.subControlRect(control, option,
QStyle.SC_ScrollBarSlider, widget)
if pos in sliderRect:
return QStyle.SC_ScrollBarSlider
return super().hitTestComplexControl(control, option, pos, widget)
class StyledTextScrollBar(QScrollBar):
def __init__(self, sliderText='', preText='', postText=''):
super().__init__(Qt.Horizontal)
self.setStyle(TextScrollBarStyle())
self.preText = preText
self.postText = postText
self.sliderText = sliderText
self.sliderTextMargin = 2
self.sliderWidth = self.fontMetrics().width(sliderText) + self.sliderTextMargin + 2
def setPreText(self, text):
self.preText = text
self.update()
def setPostText(self, text):
self.postText = text
self.update()
def setSliderText(self, text):
self.sliderText = text
self.sliderWidth = self.fontMetrics().width(text) + self.sliderTextMargin + 2
def setSliderTextMargin(self, margin):
self.sliderTextMargin = margin
self.sliderWidth = self.fontMetrics().width(self.sliderText) + margin + 2
def sizeHint(self):
# give the scrollbar enough height for the font
hint = super().sizeHint()
if hint.height() < self.fontMetrics().height() + 4:
hint.setHeight(self.fontMetrics().height() + 4)
return hint
There's a lot of difference between using the basic paintEvent override, applying the style to a standard QScrollBar and using a full "style-enabled" scroll bar with a fully implemented subclass; as you can see it's always possible that the current style (or the baseStyle chosen for the custom proxy style) might not be very friendly in its appearance:
What changes between the two (three) approaches and what you will finally decide to use depends on your needs; if you need to add other features to the scroll bar (or add more control to text contents or their apparance) and the text is not very wide, you might want to go with subclassing; on the other hand, the QProxyStyle approach might be useful to control other aspects or elements too.
Remember that if the QStyle is not set before the QApplication constructor, it's possible that the applied style won't be perfect to work with: as opposed with QFont and QPalette, QStyle is not propagated to the children of the QWidget it's applied to (meaning that the new proxy style has to be notified about the parent style change and behave accordingly).
class HLine(QFrame):
def __init__(self):
super().__init__()
self.setFrameShape(self.HLine|self.Sunken)
class Example(QWidget):
def __init__(self):
QWidget.__init__(self)
layout = QVBoxLayout(self)
layout.addWidget(QLabel('Base subclass with paintEvent override, small text:'))
shortPaintTextScrollBar = PaintTextScrollBar(Qt.Horizontal)
layout.addWidget(shortPaintTextScrollBar)
layout.addWidget(QLabel('Same as above, long text (text rect adjusted to text width):'))
longPaintTextScrollBar = PaintTextScrollBar(Qt.Horizontal)
longPaintTextScrollBar.sliderText = 'I am a very long slider'
layout.addWidget(longPaintTextScrollBar)
layout.addWidget(HLine())
layout.addWidget(QLabel('Base QScrollBar with drawComplexControl override of proxystyle:'))
shortBasicScrollBar = QScrollBar(Qt.Horizontal)
layout.addWidget(shortBasicScrollBar)
shortBasicScrollBar.sliderText = 'slider'
shortBasicScrollBar.preText = 'pre text'
shortBasicScrollBar.postText = 'post text'
shortBasicScrollBar.setStyle(TextScrollBarStyle())
layout.addWidget(QLabel('Same as above, long text (text rectangle based on slider geometry):'))
longBasicScrollBar = QScrollBar(Qt.Horizontal)
layout.addWidget(longBasicScrollBar)
longBasicScrollBar.sliderText = 'I am a very long slider'
longBasicScrollBar.preText = 'pre text'
longBasicScrollBar.postText = 'post text'
longBasicScrollBar.setStyle(TextScrollBarStyle())
layout.addWidget(HLine())
layout.addWidget(QLabel('Subclasses with full proxystyle implementation, all available styles:'))
for styleName in QStyleFactory.keys():
scrollBar = StyledTextScrollBar()
layout.addWidget(scrollBar)
scrollBar.setSliderText('Long slider with {} style'.format(styleName))
scrollBar.setStyle(TextScrollBarStyle(QStyleFactory.create(styleName)))
scrollBar.valueChanged.connect(self.setScrollBarPreText)
scrollBar.setPostText('Post text')
for scrollBar in self.findChildren(QScrollBar):
scrollBar.setValue(7)
def setScrollBarPreText(self, value):
self.sender().setPreText(str(value))
if __name__ == '__main__':
app = QApplication(sys.argv)
example = Example()
example.show()
sys.exit(app.exec_())