I have two subclassed QGraphicsRectItems that are supposed to be connected with a line that adjusts based on the position of the textboxes.
In the diagramscene example of the Qt docus the itemChanged method of a subclassed QGraphicsPolygonItem calls a updatePosition method of the connected arrow which calls setLine to update the arrow's position. In my case I cannot call setLine as I am subclassing QGraphicsItem instead of QGraphicsLineItem.
How should I implement updatePosition method in the Arrow class below to update the position of my QGraphicsItem? The following is a runnable example that shows what happens currently when the textboxes are clicked and moved.
import sys
from PyQt4.QtGui import *
from PyQt4.QtCore import *
class Arrow(QGraphicsItem):
def __init__(self, startItem, endItem, parent=None, scene=None):
super().__init__(parent, scene)
self.startItem = startItem
self.endItem = endItem
def boundingRect(self):
p1 = self.startItem.pos() + self.startItem.rect().center()
p3 = self.endItem.pos() + self.endItem.rect().center()
bounds = p3 - p1
size = QSizeF(abs(bounds.x()), abs(bounds.y()))
return QRectF(p1, size)
def paint(self, painter, option, widget=None):
p1 = self.startItem.pos() + self.startItem.rect().center()
p3 = self.endItem.pos() + self.endItem.rect().center()
pen = QPen()
pen.setWidth(1)
painter.setRenderHint(QPainter.Antialiasing)
if self.isSelected():
pen.setStyle(Qt.DashLine)
else:
pen.setStyle(Qt.SolidLine)
pen.setColor(Qt.black)
painter.setPen(pen)
painter.drawLine(QLineF(p1, p3))
painter.setBrush(Qt.NoBrush)
def updatePosition(self):
#Not sure what to do here...
class TextBox(QGraphicsRectItem):
def __init__(self, text, position, rect=QRectF(0, 0, 200, 100),
parent=None, scene=None):
super().__init__(rect, parent, scene)
self.setFlags(QGraphicsItem.ItemIsFocusable |
QGraphicsItem.ItemIsMovable |
QGraphicsItem.ItemIsSelectable)
self.text = QGraphicsTextItem(text, self)
self.setPos(position)
self.arrows = []
def paint(self, painter, option, widget=None):
painter.setPen(Qt.black)
painter.setRenderHint(QPainter.Antialiasing)
painter.setBrush(Qt.white)
painter.drawRect(self.rect())
def addArrow(self, arrow):
self.arrows.append(arrow)
def itemChange(self, change, value):
if change == QGraphicsItem.ItemPositionChange:
for arrow in self.arrows:
arrow.updatePosition()
return value
if __name__ == "__main__":
app = QApplication(sys.argv)
view = QGraphicsView()
scene = QGraphicsScene()
scene.setSceneRect(0, 0, 500, 1000)
view.setScene(scene)
textbox1 = TextBox("item 1", QPointF(50, 50), scene=scene)
textbox1.setZValue(1)
textbox2 = TextBox("item 2", QPointF(100, 500), scene=scene)
textbox2.setZValue(1)
arrow = Arrow(textbox1, textbox2, scene=scene)
arrow.setZValue(0)
textbox1.addArrow(arrow)
textbox2.addArrow(arrow)
view.show()
sys.exit(app.exec_())
The position of the item doesn't actually matter - it can remain at 0,0 - providing the bounding box is correct (which it will be according to your Arrow::boundingBox implementation). Hence, I think if you simply trigger a bounding box change, and a redraw in updatePosition, everything will work as you want.
Of course, if you care about the position of the arrow being at the head or tail of the line, you can move it in updatePosition, and adjust the bounding box / paint coordinates accordingly - but that's entirely up to you if that makes sense or not.
Related
I have some custom items in the scene. I would like to allow the user to connect the two items using the mouse. I checked an answer in this question but there wasn't a provision to let users connect the two points. (Also, note that item must be movable)
Here is a demonstration of how I want it to be:
I want the connection between the two ellipses as shown above
Can I know how this can be done?
While the solution proposed by JacksonPro is fine, I'd like to provide a slightly different concept that adds some benefits:
improved object structure and control;
more reliable collision detection;
painting is slightly simplified by making it more object-compliant;
better readability (mostly by using less variables and functions);
clearer connection creation (the line "snaps" to control points);
possibility to have control points on both sides (also preventing connections on the same side) and to remove a connection if already exists (by "connecting" again the same points);
connections between multiple control points;
it's not longer ;-)
The idea is to have control points that are actual QGraphicsItem objects (QGraphicsEllipseItem) and children of CustomItem.
This not only simplifies painting, but also improves object collision detection and management: there is no need for a complex function to draw the new line, and creating an ellipse that is drawn around its pos ensures that we already know the targets of the line by getting their scenePos(); this also makes it much more easy to detect if the mouse cursor is actually inside a control point or not.
Note that for simplification reasons I set some properties as class members. If you want to create subclasses of the item for more advanced or customized controls, those parameters should be created as instance attributes; in that case, you might prefer to inherit from QGraphicsRectItem: even if you'll still need to override the painting in order to draw a rounded rect, it will make it easier to set its properties (pen, brush and rectangle) and even change them during runtime, so that you only need to access those properties within paint(), while also ensuring that updates are correctly called when Qt requires it.
from PyQt5 import QtCore, QtGui, QtWidgets
class Connection(QtWidgets.QGraphicsLineItem):
def __init__(self, start, p2):
super().__init__()
self.start = start
self.end = None
self._line = QtCore.QLineF(start.scenePos(), p2)
self.setLine(self._line)
def controlPoints(self):
return self.start, self.end
def setP2(self, p2):
self._line.setP2(p2)
self.setLine(self._line)
def setStart(self, start):
self.start = start
self.updateLine()
def setEnd(self, end):
self.end = end
self.updateLine(end)
def updateLine(self, source):
if source == self.start:
self._line.setP1(source.scenePos())
else:
self._line.setP2(source.scenePos())
self.setLine(self._line)
class ControlPoint(QtWidgets.QGraphicsEllipseItem):
def __init__(self, parent, onLeft):
super().__init__(-5, -5, 10, 10, parent)
self.onLeft = onLeft
self.lines = []
# this flag **must** be set after creating self.lines!
self.setFlags(self.ItemSendsScenePositionChanges)
def addLine(self, lineItem):
for existing in self.lines:
if existing.controlPoints() == lineItem.controlPoints():
# another line with the same control points already exists
return False
self.lines.append(lineItem)
return True
def removeLine(self, lineItem):
for existing in self.lines:
if existing.controlPoints() == lineItem.controlPoints():
self.scene().removeItem(existing)
self.lines.remove(existing)
return True
return False
def itemChange(self, change, value):
for line in self.lines:
line.updateLine(self)
return super().itemChange(change, value)
class CustomItem(QtWidgets.QGraphicsItem):
pen = QtGui.QPen(QtCore.Qt.red, 2)
brush = QtGui.QBrush(QtGui.QColor(31, 176, 224))
controlBrush = QtGui.QBrush(QtGui.QColor(214, 13, 36))
rect = QtCore.QRectF(0, 0, 100, 100)
def __init__(self, left=False, right=False, *args, **kwargs):
super().__init__(*args, **kwargs)
self.setFlags(self.ItemIsMovable)
self.controls = []
for onLeft, create in enumerate((right, left)):
if create:
control = ControlPoint(self, onLeft)
self.controls.append(control)
control.setPen(self.pen)
control.setBrush(self.controlBrush)
if onLeft:
control.setX(100)
control.setY(35)
def boundingRect(self):
adjust = self.pen.width() / 2
return self.rect.adjusted(-adjust, -adjust, adjust, adjust)
def paint(self, painter, option, widget=None):
painter.save()
painter.setPen(self.pen)
painter.setBrush(self.brush)
painter.drawRoundedRect(self.rect, 4, 4)
painter.restore()
class Scene(QtWidgets.QGraphicsScene):
startItem = newConnection = None
def controlPointAt(self, pos):
mask = QtGui.QPainterPath()
mask.setFillRule(QtCore.Qt.WindingFill)
for item in self.items(pos):
if mask.contains(pos):
# ignore objects hidden by others
return
if isinstance(item, ControlPoint):
return item
if not isinstance(item, Connection):
mask.addPath(item.shape().translated(item.scenePos()))
def mousePressEvent(self, event):
if event.button() == QtCore.Qt.LeftButton:
item = self.controlPointAt(event.scenePos())
if item:
self.startItem = item
self.newConnection = Connection(item, event.scenePos())
self.addItem(self.newConnection)
return
super().mousePressEvent(event)
def mouseMoveEvent(self, event):
if self.newConnection:
item = self.controlPointAt(event.scenePos())
if (item and item != self.startItem and
self.startItem.onLeft != item.onLeft):
p2 = item.scenePos()
else:
p2 = event.scenePos()
self.newConnection.setP2(p2)
return
super().mouseMoveEvent(event)
def mouseReleaseEvent(self, event):
if self.newConnection:
item = self.controlPointAt(event.scenePos())
if item and item != self.startItem:
self.newConnection.setEnd(item)
if self.startItem.addLine(self.newConnection):
item.addLine(self.newConnection)
else:
# delete the connection if it exists; remove the following
# line if this feature is not required
self.startItem.removeLine(self.newConnection)
self.removeItem(self.newConnection)
else:
self.removeItem(self.newConnection)
self.startItem = self.newConnection = None
super().mouseReleaseEvent(event)
def main():
import sys
app = QtWidgets.QApplication(sys.argv)
scene = Scene()
scene.addItem(CustomItem(left=True))
scene.addItem(CustomItem(left=True))
scene.addItem(CustomItem(right=True))
scene.addItem(CustomItem(right=True))
view = QtWidgets.QGraphicsView(scene)
view.setRenderHints(QtGui.QPainter.Antialiasing)
view.show()
sys.exit(app.exec_())
if __name__ == '__main__':
main()
A small suggestion: I've seen that you have the habit of always creating objects in the paint method, even if those values are normally "hardcoded"; one of the most important aspects of the Graphics View framework is its performance, which can be obviously partially degraded by python, so if you have properties that are constant during runtime (rectangles, pens, brushes) it's usually better to make them more "static", at least as instance attributes, in order to simplify the painting as much as possible.
For this, you might have to implement your own scene class by inheriting QGraphicsScene and overriding the mouse events.
Here is the code which you may improve:
import sys
from PyQt5 import QtWidgets, QtCore, QtGui
class CustomItem(QtWidgets.QGraphicsItem):
def __init__(self, pointONLeft=False, *args, **kwargs):
super().__init__(*args, **kwargs)
self.ellipseOnLeft = pointONLeft
self.point = None
self.endPoint =None
self.isStart = None
self.line = None
self.setAcceptHoverEvents(True)
self.setFlag(self.ItemIsMovable)
self.setFlag(self.ItemSendsGeometryChanges)
def addLine(self, line, ispoint):
if not self.line:
self.line = line
self.isStart = ispoint
def itemChange(self, change, value):
if change == self.ItemPositionChange and self.scene():
self.moveLineToCenter(value)
return super(CustomItem, self).itemChange(change, value)
def moveLineToCenter(self, newPos): # moves line to center of the ellipse
if self.line:
if self.ellipseOnLeft:
xOffset = QtCore.QRectF(-5, 30, 10, 10).x() + 5
yOffset = QtCore.QRectF(-5, 30, 10, 10).y() + 5
else:
xOffset = QtCore.QRectF(95, 30, 10, 10).x() + 5
yOffset = QtCore.QRectF(95, 30, 10, 10).y() + 5
newCenterPos = QtCore.QPointF(newPos.x() + xOffset, newPos.y() + yOffset)
p1 = newCenterPos if self.isStart else self.line.line().p1()
p2 = self.line.line().p2() if self.isStart else newCenterPos
self.line.setLine(QtCore.QLineF(p1, p2))
def containsPoint(self, pos): # checks whether the mouse is inside the ellipse
x = self.mapToScene(QtCore.QRectF(-5, 30, 10, 10).adjusted(-0.5, 0.5, 0.5, 0.5)).containsPoint(pos, QtCore.Qt.OddEvenFill) or \
self.mapToScene(QtCore.QRectF(95, 30, 10, 10).adjusted(0.5, 0.5, 0.5, 0.5)).containsPoint(pos,
QtCore.Qt.OddEvenFill)
return x
def boundingRect(self):
return QtCore.QRectF(-5, 0, 110, 110)
def paint(self, painter, option, widget):
pen = QtGui.QPen(QtCore.Qt.red)
pen.setWidth(2)
painter.setPen(pen)
painter.setBrush(QtGui.QBrush(QtGui.QColor(31, 176, 224)))
painter.drawRoundedRect(QtCore.QRectF(0, 0, 100, 100), 4, 4)
painter.setBrush(QtGui.QBrush(QtGui.QColor(214, 13, 36)))
if self.ellipseOnLeft: # draws ellipse on left
painter.drawEllipse(QtCore.QRectF(-5, 30, 10, 10))
else: # draws ellipse on right
painter.drawEllipse(QtCore.QRectF(95, 30, 10, 10))
# ------------------------Scene Class ----------------------------------- #
class Scene(QtWidgets.QGraphicsScene):
def __init__(self):
super(Scene, self).__init__()
self.startPoint = None
self.endPoint = None
self.line = None
self.graphics_line = None
self.item1 = None
self.item2 = None
def mousePressEvent(self, event):
self.line = None
self.graphics_line = None
self.item1 = None
self.item2 = None
self.startPoint = None
self.endPoint = None
if self.itemAt(event.scenePos(), QtGui.QTransform()) and isinstance(self.itemAt(event.scenePos(),
QtGui.QTransform()), CustomItem):
self.item1 = self.itemAt(event.scenePos(), QtGui.QTransform())
self.checkPoint1(event.scenePos())
if self.startPoint:
self.line = QtCore.QLineF(self.startPoint, self.endPoint)
self.graphics_line = self.addLine(self.line)
self.update_path()
super(Scene, self).mousePressEvent(event)
def mouseMoveEvent(self, event):
if event.buttons() & QtCore.Qt.LeftButton and self.startPoint:
self.endPoint = event.scenePos()
self.update_path()
super(Scene, self).mouseMoveEvent(event)
def filterCollidingItems(self, items): # filters out all the colliding items and returns only instances of CustomItem
return [x for x in items if isinstance(x, CustomItem) and x != self.item1]
def mouseReleaseEvent(self, event):
if self.graphics_line:
self.checkPoint2(event.scenePos())
self.update_path()
if self.item2 and not self.item1.line and not self.item2.line:
self.item1.addLine(self.graphics_line, True)
self.item2.addLine(self.graphics_line, False)
else:
if self.graphics_line:
self.removeItem(self.graphics_line)
super(Scene, self).mouseReleaseEvent(event)
def checkPoint1(self, pos):
if self.item1.containsPoint(pos):
self.item1.setFlag(self.item1.ItemIsMovable, False)
self.startPoint = self.endPoint = pos
else:
self.item1.setFlag(self.item1.ItemIsMovable, True)
def checkPoint2(self, pos):
item_lst = self.filterCollidingItems(self.graphics_line.collidingItems())
contains = False
if not item_lst: # checks if there are any items in the list
return
for self.item2 in item_lst:
if self.item2.containsPoint(pos):
contains = True
self.endPoint = pos
break
if not contains:
self.item2 = None
def update_path(self):
if self.startPoint and self.endPoint:
self.line.setP2(self.endPoint)
self.graphics_line.setLine(self.line)
def main():
app = QtWidgets.QApplication(sys.argv)
scene = Scene()
item1 = CustomItem(True)
scene.addItem(item1)
item2 = CustomItem()
scene.addItem(item2)
view = QtWidgets.QGraphicsView(scene)
view.setViewportUpdateMode(view.FullViewportUpdate)
view.setMouseTracking(True)
view.show()
sys.exit(app.exec_())
if __name__ == '__main__':
main()
Explanation of the above code:
I make my own custom Item by inheriting the QGraphicsItem. pointONLeft=False is to check which side the ellipse is to be drawn. If pointONLeft=True, then red circle that you see in the question's image will be drawn on the left.
The addLine, itemChange and moveLineToCenter methods are taken from here. I suggest you go through that answer before moving on.
The containsPoint method inside the CustomItem checks whether the mouse is inside the circle. This method will be accessed from the custom Scene, if the mouse is inside the circle it will disable the movement by using CustomiItem.setFlag(CustomItem.ItemIsMovable, False).
To draw the line I use the QLineF provided by PyQt. If you want to know how to draw a straight line by dragging I suggest you refer this. while the explanation is for qpainterpath same can be applied here.
The collidingItems() is a method provided by QGraphicsItem. It returns all the items that are colliding including the line itself. So, I created the filterCollidingItems to filter out only the items that are instances of CustomItem.
(Also, note that collidingItems() returns the colliding items in the reverse order they are inserted i,e if CustomItem1 is inserted first and CustomItem second then if the line collides the second item will be returned first. So if two items are on each other and the line is colliding then the last inserted item will become item2 you can change this by changing the z value)
Readers can add suggestions or queries in the comments. If you have a better answer, feel free to write.
Please consider the following code:
from PyQt5.QtWidgets import *
from PyQt5.QtGui import *
from PyQt5.QtCore import *
class Gallery(QScrollArea):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.setFixedWidth(175)
self.setSizePolicy(QSizePolicy.Ignored, QSizePolicy.Expanding)
self.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
self.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
# Set widget and layout
self._scroll_widget = QWidget()
self._layout = QVBoxLayout()
self._layout.setContentsMargins(0, 0, 0, 0)
self._layout.setSpacing(25)
self._scroll_widget.setLayout(self._layout)
self.setWidget(self._scroll_widget)
self.setWidgetResizable(True)
# Stretch
self._layout.addStretch(1) # Stretch above widgets
self._layout.addStretch(1) # Stretch below widgets
# Initialize ---------------------------------|
for _ in range(10):
self.add_item()
def resizeEvent(self, event: QResizeEvent) -> None:
super().resizeEvent(event)
# Calculate Margins --------------------|
children = self._scroll_widget.findChildren(QLabel)
first_widget = children[0]
last_widget = children[-1]
self._layout.setContentsMargins(
0,
int(event.size().height() / 2 - first_widget.size().height() / 2),
0,
int(event.size().height() / 2 - last_widget.size().height() / 2)
)
def add_item(self) -> None:
widget = QLabel()
widget.setStyleSheet('background: #22FF88')
widget.setFixedSize(90, 125)
child_count = len(
self._scroll_widget.findChildren(QLabel)
)
self._layout.insertWidget(1 + child_count, widget,
alignment=Qt.AlignCenter)
if __name__ == '__main__':
app = QApplication([])
window = Gallery()
window.show()
app.exec()
Currently, the margins of the layout are dynamically set, so that, no matter the size of the window, the first and last item are always vertically centered:
What I want to achieve now is that whenever I scroll (either by mousewheel or with the arrow-keys, as the scrollbars are disabled) the next widget should take the position in the vertical center, i.e. I want to switch the scrolling mode from a per pixel to a per-widget-basis, so that no matter how far I scroll, I will never land between two widgets.
How can this be done?
I found that QAbstractItemView provides the option the switch the ScrollMode to ScrollPerItem, though I'm not sure if that's what I need because I was a bit overwhelmed when trying to subclass QAbstractItemView.
Edit:
This shows the delay I noticed after adapting #musicamante's answer:
It's not really disrupting, but I don't see it in larger projects, so I suppose something is not working as it should.
Since most of the features QScrollArea provides are actually going to be ignored, subclassing from it doesn't give lots of benefits. On the contrary, it could make things much more complex.
Also, using a layout isn't very useful: the "container" of the widget is not constrained by the scroll area, and all features for size hinting and resizing are almost useless in this case.
A solution could be to just set all items as children of the "scroll area", that could be even a basic QWidget or QFrame, but for better styling support I chose to use a QAbstractScrollArea.
The trick is to compute the correct position of each child widget, based on its geometry. Note that I'm assuming all widgets have a fixed size, otherwise you might need to use their sizeHint, minimumSizeHint or minimum sizes, and check for their size policies.
Here's a possible implementation (I changed the item creation in order to correctly show the result):
from random import randrange
class Gallery(QAbstractScrollArea):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.setFixedWidth(175)
self.items = []
self.currentIndex = -1
for _ in range(10):
widget = QLabel(str(len(self.items) + 1),
self, alignment=Qt.AlignCenter)
widget.setStyleSheet('background: #{:02x}{:02x}{:02x}'.format(
randrange(255), randrange(255), randrange(255)))
widget.setFixedSize(randrange(60, 100), randrange(50, 200))
self.addItem(widget)
def addItem(self, widget):
self.insertItem(len(self.items), widget)
def insertItem(self, index, widget):
widget.setParent(self.viewport())
widget.show()
self.items.insert(index, widget)
if len(self.items) == 1:
self.currentIndex = 0
self.updateGeometry()
def setCurrentIndex(self, index):
if not self.items:
self.currentIndex = -1
return
self.currentIndex = max(0, min(index, len(self.items) - 1))
self.updateGeometry()
def stepBy(self, step):
self.setCurrentIndex(self.currentIndex + step)
def updateGeometry(self):
super().updateGeometry()
if not self.items:
return
rects = []
y = 0
for i, widget in enumerate(self.items):
rect = widget.rect()
rect.moveTop(y)
rects.append(rect)
if i == self.currentIndex:
centerY = rect.center().y()
y = rect.bottom() + 25
centerY -= self.height() / 2
centerX = self.width() / 2
for widget, rect in zip(self.items, rects):
widget.setGeometry(rect.translated(centerX - rect.width() / 2, -centerY))
def sizeHint(self):
return QSize(175, 400)
def resizeEvent(self, event: QResizeEvent):
self.updateGeometry()
def wheelEvent(self, event):
if event.angleDelta().y() < 0:
self.stepBy(1)
else:
self.stepBy(-1)
This picture is a pretty good representation of what I'm trying to emulate.
The goal is to create items or widgets, looking like the example above, that a user could create on a QSlider by a MouseDoubleClicked event, and which would remain at the Tick position it was originally created (it would remain immobile).
I've already made a few attempts using either QLabels with Pixmaps or a combination of QGraphicsItems and QGraphicsView, in vain.
Still, I have the feeling that I'm most likely over complicating things, and that there might be a simpler way to achieve that.
What would be your approach to make those "markers"?
EDIT: I've tried my best to edit one of my previous attempts, in order to make it a Minimal Reproducible Example. Might still be too long though, but here it goes.
import random
from PySide2 import QtCore, QtGui, QtWidgets
class Marker(QtWidgets.QLabel):
def __init__(self, parent=None):
super(Marker, self).__init__(parent)
self._slider = None
self.setAcceptDrops(True)
pix = QtGui.QPixmap(30, 30)
pix.fill(QtGui.QColor("transparent"))
paint = QtGui.QPainter(pix)
slider_color = QtGui.QColor(random.randint(130, 180), random.randint(130, 180), random.randint(130, 180))
handle_pen = QtGui.QPen(QtGui.QColor(slider_color.darker(200)))
handle_pen.setWidth(3)
paint.setPen(handle_pen)
paint.setBrush(QtGui.QBrush(slider_color, QtCore.Qt.SolidPattern))
points = QtGui.QPolygon([
QtCore.QPoint(5, 5),
QtCore.QPoint(5, 19),
QtCore.QPoint(13, 27),
QtCore.QPoint(21, 19),
QtCore.QPoint(21, 5),
])
paint.drawPolygon(points)
del paint
self.setPixmap(pix)
class myTimeline(QtWidgets.QWidget):
def __init__(self, parent=None):
super(myTimeline, self).__init__(parent)
layout = QtWidgets.QGridLayout(self)
self.slider = QtWidgets.QSlider(QtCore.Qt.Horizontal)
self.slider.setMinimum(0)
self.slider.setMaximum(50)
self.slider.setTickPosition(QtWidgets.QSlider.TicksAbove)
self.slider.setTickInterval(1)
self.slider.setSingleStep(1)
self.slider.setAcceptDrops(True)
self.resize(self.width(), 50)
layout.addWidget(self.slider)
def create_marker(self):
bookmark = Marker(self)
opt = QtWidgets.QStyleOptionSlider()
self.slider.initStyleOption(opt)
rect = self.slider.style().subControlRect(
QtWidgets.QStyle.CC_Slider,
opt,
QtWidgets.QStyle.SC_SliderHandle,
self.slider
)
bookmark.move(rect.center().x(), 0)
bookmark.show()
def mouseDoubleClickEvent(self, event):
self.create_marker()
Your approach is indeed correct, it only has a few issues:
The geometry of the marker should be updated to reflect its contents. This is required as QLabel is a very special type of widget, and usually adapts its size only when added to a layout or is a top level window.
The marker pixmap is not correctly aligned (it's slightly on the left of its center).
The marker positioning should not only use the rect center, but also the marker width and the slider position (since you are adding the slider to the parent and there's a layout, the slider is actually off by the size of the layout's contents margins).
The markers should be repositioned everytime the widget is resized, so they should keep a reference to their value.
Markers should be transparent for mouse events, otherwise they would block mouse control on the slider.
Given the above, I suggest you the following:
class Marker(QtWidgets.QLabel):
def __init__(self, value, parent=None):
super(Marker, self).__init__(parent)
self.value = value
# ...
# correctly centered polygon
points = QtGui.QPolygon([
QtCore.QPoint(7, 5),
QtCore.QPoint(7, 19),
QtCore.QPoint(15, 27),
QtCore.QPoint(22, 19),
QtCore.QPoint(22, 5),
])
paint.drawPolygon(points)
del paint
self.setPixmap(pix)
# this is important!
self.adjustSize()
class myTimeline(QtWidgets.QWidget):
def __init__(self, parent=None):
# ...
self.markers = []
def mouseDoubleClickEvent(self, event):
self.create_marker()
def create_marker(self):
bookmark = Marker(self.slider.value(), self)
bookmark.show()
bookmark.installEventFilter(self)
self.markers.append(bookmark)
self.updateMarkers()
def updateMarkers(self):
opt = QtWidgets.QStyleOptionSlider()
self.slider.initStyleOption(opt)
for marker in self.markers:
opt.sliderValue = opt.sliderPosition = marker.value
rect = self.slider.style().subControlRect(
QtWidgets.QStyle.CC_Slider,
opt,
QtWidgets.QStyle.SC_SliderHandle,
)
marker.move(rect.center().x() - marker.width() / 2 + self.slider.x(), 0)
def eventFilter(self, source, event):
if event.type() == QtCore.QEvent.MouseButtonPress:
return True
return super().eventFilter(source, event)
def resizeEvent(self, event):
super().resizeEvent(event)
self.updateMarkers()
I have made a custom widget similar to QPushbutton or label. I would like to let the user resize the widget when the mouse is over the edge of the widget. How can I do this?
(Note: I am not looking for Splitter window)
An image editing software, you have a dedicated "space" for the image, and the user is free to do anything she/he wants within the boundaries of that space. When a widget is placed within a layout-managed container (as it normally should) that can represent multiple issues. Not only you've to implement the whole mouse interaction to resize the widget, but you also need to notify the possible parent widget(s) about the resizing.
That said, what you're trying to achieve can be done, with some caveats.
The following is a very basic implementation of a standard QWidget that is able to resize itself, while notifying its parent widget(s) about the size hint modifications. Note that this is not complete, and its behavior doesn't correctly respond to mouse movements whenever they happen on the top or left edges of the widget. Moreover, while it (could) correctly resize the parent widget(s) while increasing its size, the resize doesn't happen when shrinking. This can theoretically be achieved by setting a minimumSize() and manually calling adjustSize() but, in order to correctly provide all the possible features required by a similar concept, you'll need to do the whole implementation by yourself.
from PyQt5 import QtCore, QtGui, QtWidgets
Left, Right = 1, 2
Top, Bottom = 4, 8
TopLeft = Top|Left
TopRight = Top|Right
BottomRight = Bottom|Right
BottomLeft = Bottom|Left
class ResizableLabel(QtWidgets.QWidget):
resizeMargin = 4
# note that the Left, Top, Right, Bottom constants cannot be used as class
# attributes if you want to use list comprehension for better performance,
# and that's due to the variable scope behavior on Python 3
sections = [x|y for x in (Left, Right) for y in (Top, Bottom)]
cursors = {
Left: QtCore.Qt.SizeHorCursor,
Top|Left: QtCore.Qt.SizeFDiagCursor,
Top: QtCore.Qt.SizeVerCursor,
Top|Right: QtCore.Qt.SizeBDiagCursor,
Right: QtCore.Qt.SizeHorCursor,
Bottom|Right: QtCore.Qt.SizeFDiagCursor,
Bottom: QtCore.Qt.SizeVerCursor,
Bottom|Left: QtCore.Qt.SizeBDiagCursor,
}
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.startPos = self.section = None
self.rects = {section:QtCore.QRect() for section in self.sections}
# mandatory for cursor updates
self.setMouseTracking(True)
# just for demonstration purposes
background = QtGui.QPixmap(3, 3)
background.fill(QtCore.Qt.transparent)
qp = QtGui.QPainter(background)
pen = QtGui.QPen(QtCore.Qt.darkGray, .5)
qp.setPen(pen)
qp.drawLine(0, 2, 2, 0)
qp.end()
self.background = QtGui.QBrush(background)
def updateCursor(self, pos):
for section, rect in self.rects.items():
if pos in rect:
self.setCursor(self.cursors[section])
self.section = section
return section
self.unsetCursor()
def adjustSize(self):
del self._sizeHint
super().adjustSize()
def minimumSizeHint(self):
try:
return self._sizeHint
except:
return super().minimumSizeHint()
def mousePressEvent(self, event):
if event.button() == QtCore.Qt.LeftButton:
if self.updateCursor(event.pos()):
self.startPos = event.pos()
return
super().mousePressEvent(event)
def mouseMoveEvent(self, event):
if self.startPos is not None:
delta = event.pos() - self.startPos
if self.section & Left:
delta.setX(-delta.x())
elif not self.section & (Left|Right):
delta.setX(0)
if self.section & Top:
delta.setY(-delta.y())
elif not self.section & (Top|Bottom):
delta.setY(0)
newSize = QtCore.QSize(self.width() + delta.x(), self.height() + delta.y())
self._sizeHint = newSize
self.startPos = event.pos()
self.updateGeometry()
elif not event.buttons():
self.updateCursor(event.pos())
super().mouseMoveEvent(event)
self.update()
def mouseReleaseEvent(self, event):
super().mouseReleaseEvent(event)
self.updateCursor(event.pos())
self.startPos = self.section = None
self.setMinimumSize(0, 0)
def resizeEvent(self, event):
super().resizeEvent(event)
outRect = self.rect()
inRect = self.rect().adjusted(self.resizeMargin, self.resizeMargin, -self.resizeMargin, -self.resizeMargin)
self.rects[Left] = QtCore.QRect(outRect.left(), inRect.top(), self.resizeMargin, inRect.height())
self.rects[TopLeft] = QtCore.QRect(outRect.topLeft(), inRect.topLeft())
self.rects[Top] = QtCore.QRect(inRect.left(), outRect.top(), inRect.width(), self.resizeMargin)
self.rects[TopRight] = QtCore.QRect(inRect.right(), outRect.top(), self.resizeMargin, self.resizeMargin)
self.rects[Right] = QtCore.QRect(inRect.right(), self.resizeMargin, self.resizeMargin, inRect.height())
self.rects[BottomRight] = QtCore.QRect(inRect.bottomRight(), outRect.bottomRight())
self.rects[Bottom] = QtCore.QRect(inRect.left(), inRect.bottom(), inRect.width(), self.resizeMargin)
self.rects[BottomLeft] = QtCore.QRect(outRect.bottomLeft(), inRect.bottomLeft()).normalized()
# ---- optional, mostly for demonstration purposes ----
def paintEvent(self, event):
super().paintEvent(event)
qp = QtGui.QPainter(self)
if self.underMouse() and self.section:
qp.save()
qp.setPen(QtCore.Qt.lightGray)
qp.setBrush(self.background)
qp.drawRect(self.rect().adjusted(0, 0, -1, -1))
qp.restore()
qp.drawText(self.rect(), QtCore.Qt.AlignCenter, '{}x{}'.format(self.width(), self.height()))
def enterEvent(self, event):
self.update()
def leaveEvent(self, event):
self.update()
class Test(QtWidgets.QWidget):
def __init__(self):
super().__init__()
layout = QtWidgets.QGridLayout(self)
for row in range(3):
for column in range(3):
if (row, column) == (1, 1):
continue
layout.addWidget(QtWidgets.QPushButton(), row, column)
label = ResizableLabel()
layout.addWidget(label, 1, 1)
if __name__ == '__main__':
import sys
app = QtWidgets.QApplication(sys.argv)
w = Test()
w.show()
sys.exit(app.exec_())
I have user-adjustable annotations in a graphics scene. The size/rotation of annotations is handled by dragging corners of a rectangle about the annotation. I'm using a custom rect (instead of the boundingRect) so it follows the rotation of the parent annotation. The control corners are marked by two ellipses whose parent is the rect so transformations of rect/ellipse/annotation are seamless.
I want to detect when the cursor is over one of the corners, which corner it is, and the exact coordinates. For this task it seems that I should filter the hoverevents with the parent rect using a sceneEventFilter.
I've tried umpty zilch ways of implementing the sceneEventFilter to no avail. All events go directly to the hoverEnterEvent function. I've only found a few bits of example code that do something like this but I'm just plain stuck. btw, I'm totally self taught on Python and QT over the past 3 months, so please bear with me. I'm sure I'm missing something very basic. The code is a simplified gui with two ellipses. We're looking to capture events in the sceneEventFilter but always goes to hoverEnterEvent.
from pyqtgraph.Qt import QtGui, QtCore
import pyqtgraph as pg
from PyQt5.QtWidgets import QGraphicsScene, QGraphicsView, QGraphicsItem
import sys
class myHandle(QtGui.QGraphicsEllipseItem):
def __init__(self, parent = None):
super(myHandle, self).__init__(parent)
def addTheHandle(self, h_parent = 'null', kind = 'null'):
handle_w = 40
if kind == 'scaling handle':
handle_x = h_parent.boundingRect().topRight().x() - handle_w/2
handle_y = h_parent.boundingRect().topRight().y() - handle_w/2
if kind == 'rotation handle':
handle_x = h_parent.boundingRect().topLeft().x() - handle_w/2
handle_y = h_parent.boundingRect().topLeft().y() - handle_w/2
the_handle = QtGui.QGraphicsEllipseItem(QtCore.QRectF(handle_x, handle_y, handle_w, handle_w))
the_handle.setPen(QtGui.QPen(QtGui.QColor(255, 100, 0), 3))
the_handle.setParentItem(h_parent)
the_handle.setAcceptHoverEvents(True)
the_handle.kind = kind
return the_handle
class myRect(QtGui.QGraphicsRectItem):
def __init__(self, parent = None):
super(myRect, self).__init__(parent)
def rectThing(self, boundingrectangle):
self.setAcceptHoverEvents(True)
self.setRect(boundingrectangle)
mh = myHandle()
rotation_handle = mh.addTheHandle(h_parent = self, kind = 'rotation handle')
scaling_handle = mh.addTheHandle(h_parent = self, kind = 'scaling handle')
self.installSceneEventFilter(rotation_handle)
self.installSceneEventFilter(scaling_handle)
return self, rotation_handle, scaling_handle
def sceneEventFilter(self, event):
print('scene ev filter')
return False
def hoverEnterEvent(self, event):
print('hover enter event')
class Basic(QtGui.QMainWindow):
def __init__(self):
super(Basic, self).__init__()
self.initUI()
def eventFilter(self, source, event):
return QtGui.QMainWindow.eventFilter(self, source, event)
def exit_the_program(self):
pg.exit()
def initUI(self):
self.resize(300, 300)
self.centralwidget = QtGui.QWidget()
self.setCentralWidget(self.centralwidget)
self.h_layout = QtGui.QHBoxLayout(self.centralwidget)
self.exit_program = QtGui.QPushButton('Exit')
self.exit_program.clicked.connect(self.exit_the_program)
self.h_layout.addWidget(self.exit_program)
self.this_scene = QGraphicsScene()
self.this_view = QGraphicsView(self.this_scene)
self.this_view.setMouseTracking(True)
self.this_view.viewport().installEventFilter(self)
self.h_layout.addWidget(self.this_view)
self.circle = self.this_scene.addEllipse(QtCore.QRectF(40, 40, 65, 65), QtGui.QPen(QtCore.Qt.black))
mr = myRect()
the_rect, rotation_handle, scaling_handle = mr.rectThing(self.circle.boundingRect())
the_rect.setPen(QtGui.QPen(QtCore.Qt.black))
the_rect.setParentItem(self.circle)
self.this_scene.addItem(the_rect)
self.this_scene.addItem(rotation_handle)
self.this_scene.addItem(scaling_handle)
def main():
app = QtGui.QApplication([])
main = Basic()
main.show()
sys.exit(app.exec_())
if __name__ == '__main__':
main()
The main problem is that you are installing the event filter of the target items on the rectangle: the event filter of the rectangle will never receive anything. Moreover, sceneEventFilter accepts two arguments (the watched item and the event), but you only used one.
What you should do is to install the event filter of the rectangle on the target items:
rotation_handle.installSceneEventFilter(self)
scaling_handle.installSceneEventFilter(self)
That said, if you want to use those ellipse items for scaling or rotation of the source circle, your approach is a bit wrong to begin with.
from math import sqrt
# ...
class myRect(QtGui.QGraphicsRectItem):
def __init__(self, parent):
super(myRect, self).__init__(parent)
self.setRect(parent.boundingRect())
# rotation is usually based on the center of an object
self.parentItem().setTransformOriginPoint(self.parentItem().rect().center())
# a rectangle that has a center at (0, 0)
handleRect = QtCore.QRectF(-20, -20, 40, 40)
self.rotation_handle = QtGui.QGraphicsEllipseItem(handleRect, self)
self.scaling_handle = QtGui.QGraphicsEllipseItem(handleRect, self)
# position the handles by centering them at the right corners
self.rotation_handle.setPos(self.rect().topLeft())
self.scaling_handle.setPos(self.rect().topRight())
for source in (self.rotation_handle, self.scaling_handle):
# install the *self* event filter on the handles
source.installSceneEventFilter(self)
source.setPen(QtGui.QPen(QtGui.QColor(255, 100, 0), 3))
def sceneEventFilter(self, source, event):
if event.type() == QtCore.QEvent.GraphicsSceneMouseMove:
# map the handle event position to the ellipse parent item; we could
# also map to "self", but using the parent is more consistent
localPos = self.parentItem().mapFromItem(source, event.pos())
if source == self.rotation_handle:
# create a temporary line to get the rotation angle
line = QtCore.QLineF(self.boundingRect().center(), localPos)
# add the current rotation to the angle between the center and the
# top left corner, then subtract the new line angle
self.parentItem().setRotation(135 + self.parentItem().rotation() - line.angle())
# note that I'm assuming that the ellipse is a circle, so the top
# left angle will always be at 135°; if it's not a circle, the
# rect width and height won't match and the angle will be
# different, so you'll need to compute that
# parentRect = self.parentItem().rect()
# oldLine = QtCore.QLineF(parentRect.center(), parentRect.topLeft())
# self.parentItem().setRotation(
# oldLine.angle() + self.parentItem().rotation() - line.angle())
elif source == self.scaling_handle:
# still assuming a perfect circle, so the rectangle is a square;
# the line from the center to the top right corner is used to
# compute the square side size, which is the double of a
# right-triangle cathetus where the hypotenuse is the line
# between the center and any of its corners;
# if the ellipse is not a perfect circle, you'll have to
# compute both of the catheti
hyp = QtCore.QLineF(self.boundingRect().center(), localPos)
size = sqrt(2) * hyp.length()
rect = QtCore.QRectF(0, 0, size, size)
rect.moveCenter(self.rect().center())
self.parentItem().setRect(rect)
self.setRect(rect)
# update the positions of both handles
self.rotation_handle.setPos(self.rect().topLeft())
self.scaling_handle.setPos(self.rect().topRight())
return True
elif event.type() == QtCore.QEvent.GraphicsSceneMousePress:
# return True to the press event (which is almost as setting it as
# accepted, so that it won't be processed further more by the scene,
# allowing the sceneEventFilter to capture the following mouseMove
# events that the watched graphics items will receive
return True
return super(myRect, self).sceneEventFilter(source, event)
class Basic(QtGui.QMainWindow):
# ...
def initUI(self):
# ...
self.circle = self.this_scene.addEllipse(QtCore.QRectF(40, 40, 65, 65), QtGui.QPen(QtCore.Qt.black))
mr = myRect(self.circle)
self.this_scene.addItem(mr)