QGraphicsItem don't change pen of parent when chaning child - python

I have a multiple QGraphicsItems that are in a parent-child hierarchy. I'm trying to get highlighting of an item on mouse hover to work on an item basis, meaning that if the mouse hovers over an item it should highlight.
The highlighting works fine, but if I'm performing the highlighting on a child, then the highlighting is also automatically happening on the parent of such, which is not desired. Here is a code example of the problem
from PySide2.QtCore import Qt
from PySide2.QtGui import QPen
from PySide2.QtWidgets import QGraphicsItem, \
QGraphicsScene, QGraphicsView, QGraphicsLineItem, QGraphicsSceneHoverEvent, \
QGraphicsRectItem, QMainWindow, QApplication
class TextBox(QGraphicsRectItem):
def __init__(self, parent: QGraphicsItem, x: float, y: float, width: float, height: float):
super().__init__(parent)
self.setParentItem(parent)
self.setAcceptHoverEvents(True)
pen = QPen(
Qt.white,
2,
Qt.SolidLine,
Qt.RoundCap,
Qt.RoundJoin
)
self.setPen(pen)
self.setRect(x, y, width, height)
def hoverEnterEvent(self, event: QGraphicsSceneHoverEvent):
super().hoverEnterEvent(event)
current_pen = self.pen()
current_pen.setWidth(5)
self.setPen(current_pen)
def hoverLeaveEvent(self, event: QGraphicsSceneHoverEvent):
super().hoverLeaveEvent(event)
current_pen = self.pen()
current_pen.setWidth(2)
self.setPen(current_pen)
class LineItem(QGraphicsLineItem):
def __init__(
self,
x1_pos: float,
x2_pos: float,
y1_pos: float,
y2_pos: float,
parent: QGraphicsItem = None
):
super().__init__()
self.setParentItem(parent)
self.setAcceptHoverEvents(True)
pen = QPen(
Qt.white,
2,
Qt.SolidLine,
Qt.RoundCap,
Qt.RoundJoin
)
self.setPen(pen)
self.setLine(
x1_pos,
y1_pos,
x2_pos,
y2_pos
)
def hoverEnterEvent(self, event: QGraphicsSceneHoverEvent):
super().hoverEnterEvent(event)
current_pen = self.pen()
current_pen.setWidth(5)
self.setPen(current_pen)
def hoverLeaveEvent(self, event: QGraphicsSceneHoverEvent):
super().hoverLeaveEvent(event)
current_pen = self.pen()
current_pen.setWidth(2)
self.setPen(current_pen)
class DiagramScene(QGraphicsScene):
def __init__(self, parent=None):
super().__init__(parent)
self.setBackgroundBrush(Qt.black)
line_item = LineItem(0, 200, 0, 0)
box = TextBox(line_item, 0, 0, 20, 20)
self.addItem(line_item)
class GraphicsView(QGraphicsView):
def __init__(self):
self.scene = DiagramScene()
super().__init__(self.scene)
class MainWindow(QMainWindow):
def __init__(self):
super(MainWindow, self).__init__()
self.setCentralWidget(GraphicsView())
if __name__ == '__main__':
import sys
app = QApplication(sys.argv)
mainWindow = MainWindow()
mainWindow.setGeometry(100, 100, 800, 500)
mainWindow.show()
sys.exit(app.exec_())
When hovering over the line (which is the parent) then only the line gets highlighted. However, when hovering over the rectangle then both get highlighted. I assume it has something to do with that the rectangle is a child of the line item.
I would like to keep the parent-child hierarchy because I have to calculate child positions based on the parent positions which is easier that way.
Is there a way to not cascade the highlighting of the child item up to the parent as?

A possible solution is to check if the event position is actually within the item's boundingRect(), but this would only work for items that extend only vertically and horizontally. Since lines can also have a certain angle, it's better to check against the shape() instead:
def hoverEnterEvent(self, event: QGraphicsSceneHoverEvent):
super().hoverEnterEvent(event)
if self.shape().contains(event.pos()):
current_pen = self.pen()
current_pen.setWidth(5)
self.setPen(current_pen)
The "leave" part is a bit trickier, though: the parent doesn't receive a leave event when hovering on a child, and it requires adding a scene event filter on all children.
Since a scene event filter can only be installed when an item is on a scene, you must try to install the filter both when a child is added or when the parent is added to a scene.
To simplify things, I created two similar pens with different widths, so that you only need to use setPen() instead of continuously getting the current one and change it.
class LineItem(QGraphicsLineItem):
def __init__(
self,
x1_pos: float,
x2_pos: float,
y1_pos: float,
y2_pos: float,
parent: QGraphicsItem = None
):
super().__init__()
self.setParentItem(parent)
self.setAcceptHoverEvents(True)
self.normalPen = QPen(
Qt.green,
2,
Qt.SolidLine,
Qt.RoundCap,
Qt.RoundJoin
)
self.hoverPen = QPen(self.normalPen)
self.hoverPen.setWidth(5)
self.setPen(self.normalPen)
self.setLine(
x1_pos,
y1_pos,
x2_pos,
y2_pos
)
def hoverEnterEvent(self, event: QGraphicsSceneHoverEvent):
super().hoverEnterEvent(event)
if self.shape().contains(event.pos()):
self.setPen(self.hoverPen)
def hoverLeaveEvent(self, event: QGraphicsSceneHoverEvent):
super().hoverLeaveEvent(event)
self.setPen(self.normalPen)
def itemChange(self, change, value):
if change == self.ItemChildAddedChange and self.scene():
value.installSceneEventFilter(self)
elif change == self.ItemChildRemovedChange:
value.removeSceneEventFilter(self)
elif change == self.ItemSceneHasChanged and self.scene():
for child in self.childItems():
child.installSceneEventFilter(self)
return super().itemChange(change, value)
def sceneEventFilter(self, child, event):
if event.type() == event.GraphicsSceneHoverEnter:
self.setPen(self.normalPen)
elif (event.type() == event.GraphicsSceneHoverLeave and
self.shape().contains(child.mapToParent(event.pos()))):
self.setPen(self.hoverPen)
return super().sceneEventFilter(child, event)

Related

Drawing Line from QLabel to QLabel in PyQt

I'm fairly new to PyQt
I'm trying to drawing a line from 1 QLabel to another.
My 2 QLabel are located on another QLabel which acts as an image in my GUI.
I've managed to track the mouse event and move the label around, but I cannot draw the line between them using QPainter.
Thank you in advance :)
This is my MouseTracking class
class MouseTracker(QtCore.QObject):
positionChanged = QtCore.pyqtSignal(QtCore.QPoint)
def __init__(self, widget):
super().__init__(widget)
self._widget = widget
self.widget.setMouseTracking(True)
self.widget.installEventFilter(self)
#property
def widget(self):
return self._widget
def eventFilter(self, o, e):
if e.type() == QtCore.QEvent.MouseMove:
self.positionChanged.emit(e.pos())
return super().eventFilter(o, e)
This is my DraggableLabel class:
class DraggableLabel(QLabel):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.LabelIsMoving = False
self.setStyleSheet("border-color: rgb(238, 0, 0); border-width : 2.0px; border-style:inset; background: transparent;")
self.origin = None
# self.setDragEnabled(True)
def mousePressEvent(self, event):
if not self.origin:
# update the origin point, we'll need that later
self.origin = self.pos()
if event.button() == Qt.LeftButton:
self.LabelIsMoving = True
self.mousePos = event.pos()
# print(event.pos())
def mouseMoveEvent(self, event):
if event.buttons() == Qt.LeftButton:
# move the box
self.move(self.pos() + event.pos() - self.mousePos)
# print(event.pos())
def mouseReleaseEvent(self, event):
if event.button() == Qt.LeftButton:
print(event.pos())
def paintEvent(self, event):
painter = QPainter()
painter.setBrush(Qt.red)
# painter.setPen(qRgb(200,0,0))
painter.drawLine(10, 10, 200, 200)
This is my custom class for the QTabwigdet (since I need to control and track the position of 2 QLabels whenever the user add/insert a new Tab)
class DynamicTab(QWidget):
def __init__(self):
super(DynamicTab, self).__init__()
# self.count = 0
self.setMouseTracking(True)
self.setAcceptDrops(True)
self.bool = True
self.layout = QVBoxLayout(self)
self.label = QLabel()
self.layout.addChildWidget(self.label)
self.icon1 = DraggableLabel(parent=self)
#pixmap for icon 1
pixmap = QPixmap('icon1.png')
# currentTab.setLayout(QVBoxLayout())
# currentTab.layout.setWidget(QRadioButton())
self.icon1.setPixmap(pixmap)
self.icon1.setScaledContents(True)
self.icon1.setFixedSize(20, 20)
self.icon2 = DraggableLabel(parent=self)
pixmap = QPixmap('icon1.png')
# currentTab.setLayout(QVBoxLayout())
# currentTab.layout.setWidget(QRadioButton())
self.icon2.setPixmap(pixmap)
self.icon2.setScaledContents(True)
self.icon2.setFixedSize(20, 20)
#self.label.move(event.x() - self.label_pos.x(), event.y() - self.label_pos.y())
MainWindow and main method:
class UI_MainWindow(QMainWindow):
def __init__(self):
super(UI_MainWindow, self).__init__()
self.setWindowTitle("QHBoxLayout")
self.PictureTab = QTabWidget
def __setupUI__(self):
# super(UI_MainWindow, self).__init__()
self.setWindowTitle("QHBoxLayout")
loadUi("IIML_test2.ui", self)
self.tabChanged(self.PictureTab)
# self.tabChanged(self.tabWidget)
self.changeTabText(self.PictureTab, index=0, TabText="Patient1")
self.Button_ImportNew.clicked.connect(lambda: self.insertTab(self.PictureTab))
# self.PictureTab.currentChanged.connect(lambda: self.tabChanged(QtabWidget=self.PictureTab))
# self.tabWidget.currentChanged.connect(lambda: self.tabChanged(QtabWidget=self.tabWidget))
def tabChanged(self, QtabWidget):
QtabWidget.currentChanged.connect(lambda : print("Tab was changed to ", QtabWidget.currentIndex()))
def changeTabText(self, QTabWidget, index, TabText):
QTabWidget.setTabText(index, TabText)
def insertTab(self, QtabWidget):
# QFileDialog.getOpenFileNames(self, 'Open File', '.')
QtabWidget.addTab(DynamicTab(), "New Tab")
# get number of active tab
count = QtabWidget.count()
# change the view to the last added tab
currentTab = QtabWidget.widget(count-1)
QtabWidget.setCurrentWidget(currentTab)
pixmap = QPixmap('cat.jpg')
#currentTab.setLayout(QVBoxLayout())
#currentTab.layout.setWidget(QRadioButton())
# currentTab.setImage("cat.jpg")
currentTab.label.setPixmap(pixmap)
currentTab.label.setScaledContents(True)
currentTab.label.setFixedSize(self.label.width(), self.label.height())
tracker = MouseTracker(currentTab.label)
tracker.positionChanged.connect(self.on_positionChanged)
self.label_position = QtWidgets.QLabel(currentTab.label, alignment=QtCore.Qt.AlignCenter)
self.label_position.setStyleSheet('background-color: white; border: 1px solid black')
currentTab.label.show()
# print(currentTab.label)
#QtCore.pyqtSlot(QtCore.QPoint)
def on_positionChanged(self, pos):
delta = QtCore.QPoint(30, -15)
self.label_position.show()
self.label_position.move(pos + delta)
self.label_position.setText("(%d, %d)" % (pos.x(), pos.y()))
self.label_position.adjustSize()
# def SetupUI(self, MainWindow):
#
# self.setLayout(self.MainLayout)
if __name__ == '__main__':
app = QApplication(sys.argv)
UI_MainWindow = UI_MainWindow()
UI_MainWindow.__setupUI__()
widget = QtWidgets.QStackedWidget()
widget.addWidget(UI_MainWindow)
widget.setFixedHeight(900)
widget.setFixedWidth(1173)
widget.show()
try:
sys.exit(app.exec_())
except:
print("Exiting")
My concept: I have a DynamicTab (QTabWidget) which acts as a picture opener (whenever the user press Import Now). The child of this Widget are 3 Qlabels: self.label is the picture it self and two other Qlabels are the icon1 and icon2 which I'm trying to interact/drag with (Draggable Label)
My Problem: I'm trying to track my mouse movement and custom the painter to paint accordingly. I'm trying that out by telling the painter class to paint whenever I grab the label and move it with my mouse (Hence, draggable). However, I can only track the mouse position inside the main QLabel (the main picture) whenever I'm not holding or clicking my left mouse.
Any help will be appreciated here.
Thank you guys.
Painting can only happen within the widget rectangle, so you cannot draw outside the boundaries of DraggableLabel.
The solution is to create a further custom widget that shares the same parent, and then draw the line that connects the center of the other two.
In the following example I install an event filter on the two draggable labels which will update the size of the custom widget based on them (so that its geometry will always include those two geometries) and call self.update() which schedules a repainting. Note that since the widget is created above the other two, it might capture mouse events that are intended for the others; to prevent that, the Qt.WA_TransparentForMouseEvents attribute must be set.
class Line(QWidget):
def __init__(self, obj1, obj2, parent):
super().__init__(parent)
self.obj1 = obj1
self.obj2 = obj2
self.obj1.installEventFilter(self)
self.obj2.installEventFilter(self)
self.setAttribute(Qt.WA_TransparentForMouseEvents)
def eventFilter(self, obj, event):
if event.type() in (event.Move, event.Resize):
rect = self.obj1.geometry() | self.obj2.geometry()
corner = rect.bottomRight()
self.resize(corner.x(), corner.y())
self.update()
return super().eventFilter(obj, event)
def paintEvent(self, event):
painter = QPainter(self)
painter.setRenderHint(painter.Antialiasing)
painter.setPen(QColor(200, 0, 0))
painter.drawLine(
self.obj1.geometry().center(),
self.obj2.geometry().center()
)
class DynamicTab(QWidget):
def __init__(self):
# ...
self.line = Line(self.icon1, self.icon2, self)
Notes:
to simplify things, I only use resize() (not setGeometry()), in this way the widget will always be placed on the top left corner of the parent and we can directly get the other widget's coordinates without any conversion;
the custom widget is placed above the other two because it is added after them; if you want to place it under them, use self.line.lower();
the painter must always be initialized with the paint device argument, either by using QPainter(obj) or painter.begin(obj), otherwise no painting will happen (and you'll get lots of errors in the output);
do not use layout.addChildWidget() (which is used internally by the layout), but the proper addWidget() function of the layout;
the stylesheet border syntax can be shortened with border: 2px inset rgb(238, 0, 0);;
the first lines of insertTab could be simpler: currentTab = DynamicTab() QtabWidget.addTab(currentTab, "New Tab");
currentTab.label.setFixedSize(self.label.size());
QMainWindow is generally intended as a top level widget, it's normally discouraged to add it to a QStackedWidget; note that if you did that because of a Youtube tutorial, that tutorial is known for suggesting terrible practices (like the final try/except block) which should not be followed;
only classes and constants should have capitalized names, not variables and functions which should always start with a lowercase letter;

Run Method on Widget Move

I am attempting to design a label class that inherits from the PyQt5 base QLabel class that is able to track another widget. Here is the current code for my class:
class AttachedLabel(QLabel):
def __init__(self, attachedTo, *args, side="left", ** kwargs):
super().__init__(*args, **kwargs) # Run parent initialization
# Define instance variables
self.attached = attachedTo
self.side = side
# Update label position
self.updatePos()
def updatePos(self):
# Get "attached widget" position and dimensions
x = self.attached.geometry().x()
y = self.attached.geometry().y()
aWidth = self.attached.geometry().width()
aHeight = self.attached.geometry().height()
# Get own dimensions
width = self.geometry().width()
height = self.geometry().height()
if self.side == "top": # Above of attached widget
self.setGeometry(x, y-height, width, height)
elif self.side == "bottom": # Below attached widget
self.setGeometry(x, y+height+aHeight, width, height)
elif self.side == "right": # Right of attached widget
self.setGeometry(x + width + aWidth, y, width, height)
else: # Left of attached widget
self.setGeometry(x - width, y, width, height)
I want to be able to instantiate the label like so:
AttachedLabel(self.pushButton, self.centralwidget)
where self.pushButton is the widget it is supposed to be following. The issue is that I don't know how to detect when the widget moves in order to run my updatePos() function. I would ideally only update the label position when the other widget moves, but I want to refrain from havign to add extra code to the class of the widget that is being tracked. I have tried overriding the paintEvent, but that only triggers when the object itself needs to be redrawn, so it doesn't even function as a sub-optimal solution.
Is there some built-in method I can use/override to detect when the widget moves or when the screen itself is updated?
You have to use an eventFilter intersecting the QEvent::Move event and you should also track the resize through the QEvent::Resize event.
from dataclasses import dataclass, field
import random
from PyQt5 import QtCore, QtWidgets
class GeometryTracker(QtCore.QObject):
geometryChanged = QtCore.pyqtSignal()
def __init__(self, widget):
super().__init__(widget)
self._widget = widget
self.widget.installEventFilter(self)
#property
def widget(self):
return self._widget
def eventFilter(self, source, event):
if self.widget is source and event.type() in (
QtCore.QEvent.Move,
QtCore.QEvent.Resize,
):
self.geometryChanged.emit()
return super().eventFilter(source, event)
#dataclass
class TrackerManager:
widget1: field(default_factory=QtWidgets.QWidget)
widget2: field(default_factory=QtWidgets.QWidget)
alignment: QtCore.Qt.Alignment = QtCore.Qt.AlignLeft
enabled: bool = True
valid_alignments = (
QtCore.Qt.AlignLeft,
QtCore.Qt.AlignRight,
QtCore.Qt.AlignHCenter,
QtCore.Qt.AlignTop,
QtCore.Qt.AlignBottom,
QtCore.Qt.AlignVCenter,
)
def __post_init__(self):
self._traker = GeometryTracker(self.widget1)
self._traker.geometryChanged.connect(self.update)
if not any(self.alignment & flag for flag in self.valid_alignments):
raise ValueError("alignment is not valid")
def update(self):
if not self.enabled:
return
r = self.widget1.rect()
p1 = r.center()
c1 = r.center()
if self.alignment & QtCore.Qt.AlignLeft:
p1.setX(r.left())
if self.alignment & QtCore.Qt.AlignRight:
p1.setX(r.right())
if self.alignment & QtCore.Qt.AlignTop:
p1.setY(r.top())
if self.alignment & QtCore.Qt.AlignBottom:
p1.setY(r.bottom())
p2 = self.convert_position(p1)
c2 = self.convert_position(c1)
g = self.widget2.geometry()
g.moveCenter(c2)
if self.alignment & QtCore.Qt.AlignLeft:
g.moveRight(p2.x())
if self.alignment & QtCore.Qt.AlignRight:
g.moveLeft(p2.x())
if self.alignment & QtCore.Qt.AlignTop:
g.moveBottom(p2.y())
if self.alignment & QtCore.Qt.AlignBottom:
g.moveTop(p2.y())
self.widget2.setGeometry(g)
def convert_position(self, point):
gp = self.widget1.mapToGlobal(point)
if self.widget2.isWindow():
return gp
return self.widget2.parent().mapFromGlobal(gp)
class MainWindow(QtWidgets.QMainWindow):
def __init__(self, parent=None):
super().__init__(parent)
self.button = QtWidgets.QPushButton("Press me", self)
self.label = QtWidgets.QLabel(
"Tracker\nLabel", self, alignment=QtCore.Qt.AlignCenter
)
self.label.setAttribute(QtCore.Qt.WA_TransparentForMouseEvents, True)
self.label.setFixedSize(200, 200)
self.label.setStyleSheet(
"background-color: salmon; border: 1px solid black; font-size: 40pt;"
)
self.resize(640, 480)
self.manager = TrackerManager(
widget1=self.button,
widget2=self.label,
alignment=QtCore.Qt.AlignRight | QtCore.Qt.AlignVCenter,
)
self.move_button()
def move_button(self):
pos = QtCore.QPoint(*random.sample(range(400), 2))
animation = QtCore.QPropertyAnimation(
targetObject=self.button,
parent=self,
propertyName=b"pos",
duration=1000,
startValue=self.button.pos(),
endValue=pos,
)
animation.finished.connect(self.move_button)
animation.start(QtCore.QAbstractAnimation.DeleteWhenStopped)
def main():
import sys
app = QtWidgets.QApplication(sys.argv)
w = MainWindow()
w.show()
sys.exit(app.exec_())
if __name__ == "__main__":
main()

how to make an overriden QGraphicsTextItem editable & movable?

I am using PyQt and I'm trying to re-implement a QGraphicsTextItem, but it seems I'm missing something.
I would like to make the NodeTag item's text editable. I have tried setting flags such as Qt.TextEditorInteraction and QGraphicsItem.ItemIsMovable , but those seem to be ignored...
Here is a Minimal Reproducible Example :
import sys
from PyQt5.QtWidgets import QGraphicsScene, QGraphicsView, QMainWindow, QApplication, QGraphicsItem, QGraphicsTextItem
from PyQt5.QtCore import *
from PyQt5.QtGui import QPen
class NodeTag(QGraphicsTextItem):
def __init__(self,text):
QGraphicsTextItem.__init__(self,text)
self.text = text
self.setPos(0,0)
self.setTextInteractionFlags(Qt.TextEditorInteraction)
# self.setFlag(QGraphicsItem.ItemIsFocusable, True) # All these flags are ignored...
# self.setFlag(QGraphicsItem.ItemIsSelectable, True)
self.setFlag(QGraphicsItem.ItemIsMovable, True)
def boundingRect(self):
return QRectF(0,0,80,25)
def paint(self,painter,option,widget):
painter.setPen(QPen(Qt.blue, 2, Qt.SolidLine))
painter.drawRect(self.boundingRect())
painter.drawText(self.boundingRect(),self.text)
def mousePressEvent(self, event):
print("CLICK!")
# self.setTextInteractionFlags(Qt.TextEditorInteraction) # make text editable on click
# self.setFocus()
class GView(QGraphicsView):
def __init__(self, parent, *args, **kwargs):
super().__init__(*args, **kwargs)
self.parent = parent
self.setGeometry(100, 100, 700, 450)
self.show()
class Scene(QGraphicsScene):
def __init__(self, parent):
super().__init__(parent)
self.parent = parent
tagItem = NodeTag("myText") # create a NodeTag item
self.addItem(tagItem)
class MainWindow(QMainWindow):
def __init__(self):
super().__init__() # create default constructor for QWidget
self.setGeometry(900, 70, 1000, 800)
self.createGraphicView()
self.show()
def createGraphicView(self):
self.scene = Scene(self)
gView = GView(self)
scene = Scene(gView)
gView.setScene(scene)
# Set the main window's central widget
self.setCentralWidget(gView)
# Run program
if __name__ == '__main__':
app = QApplication(sys.argv)
window = MainWindow()
sys.exit(app.exec_())
As you can see I have tried overriding the mousePressEvent and setting flags there too, but no luck so far.
Any help appreciated!
All QGraphicsItem subclasses have a paint method, and all items that paint some contents have that method overridden so that they can actually paint themselves.
The mechanism is the same as standard QWidgets, for which there is a paintEvent (the difference is that paint of QGraphicsItem receives an already instanciated QPainter), so if you want to do further painting other than what the class already provides, the base implementation must be called.
Consider that painting always happen from bottom to top, so everything that needs to be drawn behind the base painting has to be done before calling super().paint(), and everything that is going to be drawn in front of the default painting has to be placed after.
Depending on the situation, overriding might require that the default base implementation is called anyway, and that's important in your case for boundingRect too. QGraphicsTextItem automatically resizes itself when its contents change, so you should not always return a fixed QRect. If you need to have a minimum size, the solution is to merge a minimum rectangle with those provided by the default boundingRect() function.
Then, editing on a QGraphicsTextItem happens when the item gets focused, but since you also want to be able to move the item, things get trickier as both actions are based on mouse clicks. If you want to be able to edit the text with a single click, the solution is to make the item editable only when the mouse button has been released and has not been moved by some amount of pixels (the startDragDistance() property), otherwise the item is moved with the mouse. This obviously makes the ItemIsMovable flag useless, as we're going to take care of the movement internally.
Finally, since a minimum size is provided, we also need to override the shape() method in order to ensure that collision and clicks are correctly mapped, and return a QPainterPath that includes the whole bounding rect (for normal QGraphicsItem that should be the default behavior, but that doesn't happen with QGraphicsRectItem).
Here's a full implementation of what described above:
class NodeTag(QGraphicsTextItem):
def __init__(self, text):
QGraphicsTextItem.__init__(self, text)
self.startPos = None
self.isMoving = False
# the following is useless, not only because we are leaving the text
# painting to the base implementation, but also because the text is
# already accessible using toPlainText() or toHtml()
#self.text = text
# this is unnecessary too as all new items always have a (0, 0) position
#self.setPos(0, 0)
def boundingRect(self):
return super().boundingRect() | QRectF(0, 0, 80, 25)
def paint(self, painter, option, widget):
# draw the border *before* (as in "behind") the text
painter.setPen(QPen(Qt.blue, 2, Qt.SolidLine))
painter.drawRect(self.boundingRect())
super().paint(painter, option, widget)
def shape(self):
shape = QPainterPath()
shape.addRect(self.boundingRect())
return shape
def focusOutEvent(self, event):
# this is required in order to allow movement using the mouse
self.setTextInteractionFlags(Qt.NoTextInteraction)
def mousePressEvent(self, event):
if (event.button() == Qt.LeftButton and
self.textInteractionFlags() != Qt.TextEditorInteraction):
self.startPos = event.pos()
else:
super().mousePressEvent(event)
def mouseMoveEvent(self, event):
if self.startPos:
delta = event.pos() - self.startPos
if (self.isMoving or
delta.manhattanLength() >= QApplication.startDragDistance()):
self.setPos(self.pos() + delta)
self.isMoving = True
return
super().mouseMoveEvent(event)
def mouseReleaseEvent(self, event):
if (not self.isMoving and
self.textInteractionFlags() != Qt.TextEditorInteraction):
self.setTextInteractionFlags(Qt.TextEditorInteraction)
self.setFocus()
# the following lines are used to correctly place the text
# cursor at the mouse cursor position
cursorPos = self.document().documentLayout().hitTest(
event.pos(), Qt.FuzzyHit)
textCursor = self.textCursor()
textCursor.setPosition(cursorPos)
self.setTextCursor(textCursor)
super().mouseReleaseEvent(event)
self.startPos = None
self.isMoving = False
As a side note, remember that QGraphicsTextItem supports rich text formatting, so even if you want more control on the text painting process you should not use QPainter.drawText(), because you'd only draw the plain text. In fact, QGraphicsTextItem draws its contents using the drawContents() function of the underlying text document.
Try it:
...
class NodeTag(QGraphicsTextItem):
def __init__(self, text, parent=None):
super(NodeTag, self).__init__(parent)
self.text = text
self.setPlainText(text)
self.setFlag(QGraphicsItem.ItemIsMovable)
self.setFlag(QGraphicsItem.ItemIsSelectable)
def focusOutEvent(self, event):
self.setTextInteractionFlags(QtCore.Qt.NoTextInteraction)
super(NodeTag, self).focusOutEvent(event)
def mouseDoubleClickEvent(self, event):
if self.textInteractionFlags() == QtCore.Qt.NoTextInteraction:
self.setTextInteractionFlags(QtCore.Qt.TextEditorInteraction)
super(NodeTag, self).mouseDoubleClickEvent(event)
def paint(self,painter,option,widget):
painter.setPen(QPen(Qt.blue, 2, Qt.SolidLine))
painter.drawRect(self.boundingRect())
# painter.drawText(self.boundingRect(),self.text)
super().paint(painter, option, widget)
...

Overriding QCompleter popup position

There have been similar questions asked about overriding the QCompleter popup position but i'll still not found a working solution. I simply want to move the popup down around 5px (I have some specific styling requirements)
I've tried subclassing a QListView and using that as my popup using setPopup(). I then override the showEvent and move the popup down in Y. I also do this on the resizeEvent since I believe this is triggered when items are filtered and the popup resizes. However this doesn't work.. I then used a singleshot timer to trigger the move after 1ms. This does kind of work but it seems quite inconsistent - the first time it shows is different to subsequent times or resizing.
Below is my latest attempt (trying to hack it by counting the number of popups..), hopefully someone can show me what i'm doing wrong or a better solution
import sys
import os
from PySide2 import QtCore, QtWidgets, QtGui
class QPopup(QtWidgets.QListView):
def __init__(self, parent=None):
super(QPopup, self).__init__(parent)
self.popups = 0
def offset(self):
y = 3 if self.popups < 2 else 7
print('y: {}'.format(y))
self.move(self.pos().x(), self.pos().y() + y)
self.popups += 1
def showEvent(self, event):
print('show')
# self.offset()
QtCore.QTimer.singleShot(1, self.offset)
def resizeEvent(self, event):
print('resize')
# self.offset()
QtCore.QTimer.singleShot(1, self.offset)
class MyDialog(QtWidgets.QDialog):
def __init__(self, parent=None):
super(MyDialog, self).__init__(parent)
self.create_widgets()
self.create_layout()
self.create_connections()
def create_widgets(self):
self.le = QtWidgets.QLineEdit('')
self.completer = QtWidgets.QCompleter(self)
self.completer.setCaseSensitivity(QtCore.Qt.CaseInsensitive)
self.completer.setCompletionMode(QtWidgets.QCompleter.PopupCompletion)
self.completer.setMaxVisibleItems(10)
self.completer.setFilterMode(QtCore.Qt.MatchContains)
self.completer.setPopup(QPopup())
popup = QPopup(self)
self.completer.setPopup(popup)
self.model = QtCore.QStringListModel()
self.completer.setModel(self.model)
self.le.setCompleter(self.completer)
self.completer.model().setStringList(['one','two','three'])
def create_layout(self):
main_layout = QtWidgets.QVBoxLayout(self)
main_layout.addWidget(self.le)
def create_connections(self):
pass
if __name__ == "__main__":
app = QtWidgets.QApplication(sys.argv)
my_dialog = MyDialog()
my_dialog.show() # Show the UI
sys.exit(app.exec_())
One solution could be to make a subclass of QLineEdit and override keyPressEvent to display the popup with an offset:
PySide2.QtWidgets.QCompleter.complete([rect=QRect()])
For PopupCompletion and QCompletion::UnfilteredPopupCompletion modes, calling this function displays the popup displaying the current completions. By default, if rect is not specified, the popup is displayed on the bottom of the widget() . If rect is specified the popup is displayed on the left edge of the rectangle.
see doc.qt.io -> QCompleter.complete.
Complete, self-contained example
The rect is calculated based on the y-position of the cursor rect. The height of the popup window is not changed. The width is adjusted to the width of the ZLineEdit widget.
rect = QtCore.QRect(0,
self.cursorRect().y() + 4,
self.width(),
self.completer().widget().height())
Your code, slightly modified using the points mentioned above, could look like this:
import sys
from PySide2 import QtCore, QtWidgets
from PySide2.QtWidgets import QLineEdit, QDialog, QCompleter
class ZLineEdit(QLineEdit):
def __init__(self, string, parent=None):
super().__init__(string, parent)
def keyPressEvent(self, event):
super().keyPressEvent(event)
if len(self.text()) > 0:
rect = QtCore.QRect(0,
self.cursorRect().y() + 4,
self.width(),
self.completer().widget().height())
self.completer().complete(rect)
class MyDialog(QDialog):
def __init__(self, parent=None):
super().__init__(parent)
self.le = ZLineEdit('')
autoList = ['one', 'two', 'three']
self.completer = QCompleter(autoList, self)
self.setup_widgets()
self.create_layout()
self.create_connections()
def setup_widgets(self):
self.completer.setCaseSensitivity(QtCore.Qt.CaseInsensitive)
self.completer.setCompletionMode(QtWidgets.QCompleter.PopupCompletion)
self.completer.setMaxVisibleItems(10)
self.completer.setFilterMode(QtCore.Qt.MatchContains)
self.le.setCompleter(self.completer)
def create_layout(self):
main_layout = QtWidgets.QVBoxLayout(self)
main_layout.addWidget(self.le)
def create_connections(self):
pass
if __name__ == "__main__":
app = QtWidgets.QApplication(sys.argv)
my_dialog = MyDialog()
my_dialog.show()
sys.exit(app.exec_())
Test
On the left side you see the default behavior. On the right side the popup is moved down 4px:

QGraphicesItem emit a signal upon hoverEnterEvent

What is the best method/practice for emitting a signal upon entering either a QGraphicsWidget or a QGraphicsItem ?
In my MWE I would like to trigger a call to MainWindow.update, from Square.hoverEnterEvent, whenever the user mouse(s) over an item in a QGraphicsScene. The trouble is that QGraphicsItem/Widget is not really designed to emit signals. Instead these classes are setup to handle events passed down to them from QGraphicsScene. QGraphicsScene handles the case that the user has selected an item but does not appear to handle mouse entry events, At least there is no mechanism for entryEvent to percolate up to the parent widget/window.
import sys
from PyQt5.QtWidgets import QWidget, QApplication, qApp, QMainWindow, QGraphicsScene, QGraphicsView, QStatusBar, QGraphicsWidget, QStyle
from PyQt5.QtCore import Qt, QSizeF
class Square(QGraphicsWidget) :
"""
doc string
"""
def __init__(self,*args, name = None, **kvps) :
super().__init__(*args, **kvps)
self.radius = 5
self.name = name
self.setAcceptHoverEvents(True)
def sizeHint(self, hint, size):
size = super().sizeHint(hint, size)
print(size)
return QSizeF(50,50)
def paint(self, painter, options, widget):
self.initStyleOption(options)
ink = options.palette.highLight() if options.state == QStyle.State_Selected else options.palette.button()
painter.setBrush(ink) # ink
painter.drawRoundedRect(self.rect(), self.radius, self.radius)
def hoverEnterEvent(self, event) :
print("Enter Event")
super().hoverEnterEvent(event)
class MainWindow(QMainWindow):
def __init__(self, *args, **kvps) :
super().__init__(*args, **kvps)
# Status bar
self.stat = QStatusBar(self)
self.setStatusBar(self.stat)
self.stat.showMessage("Started")
# Widget(s)
self.data = QGraphicsScene(self)
self.view = QGraphicsView(self.data, self)
item = self.data.addItem(Square())
self.view.ensureVisible(self.data.sceneRect())
self.setCentralWidget(self.view)
# Visibility
self.showMaximized()
def update(self, widget) :
self.stat.showMessage(str(widget.name))
if __name__ == "__main__" :
# Application
app = QApplication(sys.argv)
# Scene Tests
main = MainWindow()
main.show()
# Loop
sys.exit(app.exec_())
The docs state that QGraphicsItem is not designed to emit signals, instead it is meant to respond to the events sent to it by QGraphicsScene. In contrast it seems that QGraphicsWidget is designed to do so but I'm not entirely sure where the entry point ought to be. Personally I feel QGraphicsScene should really be emitting these signals, from what I understand of the design, but am not sure where the entry point ought to be in this case either.
Currently I see the following possible solutions, with #3 being the preferred method. I was wondering if anyone else had a better strategy :
Create a QGraphicsScene subclass, let's call it Scene, to each QGraphicsItem/QGraphicsWidget and call a custom trigger/signal upon the Scene from each widget. Here I would have to subclass any item I intend on including within the scene.
Set Mainwindow up as the event filter for each item in the scene or upon the scene itself and calling MainWindow.update.
Set Mainwindow.data to be a subclass of QGraphicsScene, let's call it Scene, and let it filter it's own events emitting a hoverEntry signal. hoverEntry is then connected to MainWindow.update as necessary.
As Murphy's Law would have it Ekhumoro already provides an answer.
It seems one should subclass QGraphicsScene and add the necessary signal. this is then triggered from the QGraphicsItem/Widget. This requires that all items within a scene be sub-classed to ensure that they call the corresponding emit function but it seems must do this do this anyhow when working with the graphics scene stuff.
I'll not mark this as answered for a bit in case some one has a better suggestion.
import sys
from PyQt5.QtWidgets import QWidget, QApplication, qApp, QMainWindow, QGraphicsScene, QGraphicsView, QStatusBar, QGraphicsWidget, QStyle, QGraphicsItem
from PyQt5.QtCore import Qt, QSizeF, pyqtSignal
class Square(QGraphicsWidget) :
"""
doc string
"""
def __init__(self,*args, name = None, **kvps) :
super().__init__(*args, **kvps)
self.radius = 5
self.name = name
self.setAcceptHoverEvents(True)
self.setFlag(self.ItemIsSelectable)
self.setFlag(self.ItemIsFocusable)
def sizeHint(self, hint, size):
size = super().sizeHint(hint, size)
print(size)
return QSizeF(50,50)
def paint(self, painter, options, widget):
self.initStyleOption(options)
ink = options.palette.highLight() if options.state == QStyle.State_Selected else options.palette.button()
painter.setBrush(ink) # ink
painter.drawRoundedRect(self.rect(), self.radius, self.radius)
def hoverEnterEvent(self, event) :
super().hoverEnterEvent(event)
self.scene().entered.emit(self)
self.update()
class GraphicsScene(QGraphicsScene) :
entered = pyqtSignal([QGraphicsItem],[QGraphicsWidget])
class MainWindow(QMainWindow):
def __init__(self, *args, **kvps) :
super().__init__(*args, **kvps)
# Status bar
self.stat = QStatusBar(self)
self.setStatusBar(self.stat)
self.stat.showMessage("Started")
# Widget(s)
self.data = GraphicsScene(self)
self.data.entered.connect(self.itemInfo)
self.data.focusItemChanged.connect(self.update)
self.view = QGraphicsView(self.data, self)
item = Square(name = "A")
item.setPos( 50,0)
self.data.addItem(item)
item = Square(name = "B")
item.setPos(-50,0)
self.data.addItem(item)
self.view.ensureVisible(self.data.sceneRect())
self.setCentralWidget(self.view)
# Visibility
self.showMaximized()
def itemInfo(self, item):
print("Here it is -> ", item)
if __name__ == "__main__" :
# Application
app = QApplication(sys.argv)
# Scene Tests
main = MainWindow()
main.show()
# Loop
sys.exit(app.exec_())
The magic lines of interest are then the QGrahicsScene subclass.
class GraphicsScene(QGraphicsScene) :
entered = pyqtSignal([QGraphicsItem],[QGraphicsWidget])
The QGraphicsWidget.hoverEnterEvent triggers the entered signal. (This is where I got stuck)
def hoverEnterEvent(self, event) :
...
self.scene().entered.emit(self)
...
And the switcheroo from self.data = QGraphicsScene(...) to self.data = GraphicsScene in the MainWindow's init function.

Categories