How to add red circles next to items in QTreeView (breakpoint style) - python

I have something like this:
I can't show you more, but this is a simple QTreeView with QStandardItems in it. The items in the figure have a parent item which has a parent item as well.
When I activate the breakpoint on a item I have this:
which is ok but I also would like to add a circle next it as the majority of IDEs do (I took as example PyCharm):
The problem is that I have no idea how to do it. Anyone can help?

A possible solution is to override the drawRow method of QTreeView and use the information from the QModelIndex to do the painting:
import sys
from PySide2.QtCore import Qt, QRect
from PySide2.QtGui import QColor, QStandardItem, QStandardItemModel
from PySide2.QtWidgets import QAbstractItemView, QApplication, QTreeView
IS_BREAKPOINT_ROLE = Qt.UserRole + 1
class TreeView(QTreeView):
def drawRow(self, painter, option, index):
super().drawRow(painter, option, index)
if index.column() == 0:
if not index.data(IS_BREAKPOINT_ROLE):
return
rect = self.visualRect(index)
if not rect.isNull():
margin = 4
r = QRect(0, rect.top(), rect.height(), rect.height()).adjusted(
margin, margin, -margin, -margin
)
painter.setBrush(QColor("red"))
painter.drawEllipse(r)
def main(args):
app = QApplication(args)
view = TreeView()
view.setSelectionBehavior(QAbstractItemView.SelectRows)
model = QStandardItemModel()
model.setHorizontalHeaderLabels(["col1", "col2"])
view.setModel(model)
counter = 0
for i in range(10):
item1 = QStandardItem("Child 1-{}".format(i))
item2 = QStandardItem("Child 2-{}".format(i))
for j in range(10):
child1 = QStandardItem("Child {}-1".format(counter))
child2 = QStandardItem("Child {}-2".format(counter))
child1.setData(counter % 2 == 0, IS_BREAKPOINT_ROLE)
item1.appendRow([child1, child2])
counter += 1
model.appendRow([item1, item2])
view.show()
view.resize(320, 240)
view.expandAll()
sys.exit(app.exec_())
if __name__ == "__main__":
main(sys.argv)

I'd like to propose an alternate solution based on the answer by eyllanesc, which adds a left margin to the viewport, avoiding painting over the hierarchy lines (which could hide expanding decoration arrows for parent items that still need to show the circle).
Some important notes:
the left margin is created using setViewportMargins(), but all item views automatically reset those margins when calling updateGeometries() (which happens almost everytime the layout is changed), so that method needs overriding;
painting on the margins means that painting does not happen in the viewport, so we cannot implement paintEvent() (which by default is called for updates on the viewport); this results in implementing the drawing in the event() instead;
updates must be explicitly called when the scroll bar change or items are expanded/collapsed, but Qt only updates the region interested by items that have been actually "changed" (thus possibly excluding other "shifted" items); in order to request the update on the full extent we need to call the base implementation of QWidget (not that of the view, as that method is overridden);
class TreeView(QTreeView):
leftMargin = 14
def __init__(self, *args, **kwargs):
super().__init__()
self.leftMargin = self.fontMetrics().height()
self.verticalScrollBar().valueChanged.connect(self.updateLeftMargin)
self.expanded.connect(self.updateLeftMargin)
self.collapsed.connect(self.updateLeftMargin)
def updateLeftMargin(self):
QWidget.update(self,
QRect(0, 0, self.leftMargin + self.frameWidth(), self.height()))
def setModel(self, model):
if self.model() != model:
if self.model():
self.model().dataChanged.disconnect(self.updateLeftMargin)
super().setModel(model)
model.dataChanged.connect(self.updateLeftMargin)
def updateGeometries(self):
super().updateGeometries()
margins = self.viewportMargins()
if margins.left() < self.leftMargin:
margins.setLeft(margins.left() + self.leftMargin)
self.setViewportMargins(margins)
def event(self, event):
if event.type() == event.Paint:
pos = QPoint()
index = self.indexAt(pos)
qp = QPainter(self)
border = self.frameWidth()
bottom = self.height() - border * 2
qp.setClipRect(QRect(border, border, self.leftMargin, bottom))
top = .5
if self.header().isVisible():
top += self.header().height()
qp.translate(.5, top)
qp.setBrush(Qt.red)
qp.setRenderHints(qp.Antialiasing)
deltaY = self.leftMargin / 2 - border
circle = QRect(
border + 1, 0, self.leftMargin - 2, self.leftMargin - 2)
row = 0
while index.isValid():
rect = self.visualRect(index)
if index.data(IS_BREAKPOINT_ROLE):
circle.moveTop(rect.center().y() - deltaY)
qp.drawEllipse(circle)
row += 1
pos.setY(rect.bottom() + 2)
if pos.y() > bottom:
break
index = self.indexAt(pos)
return super().event(event)

Related

How can I limit the GraphicsScene in order to only be able to draw on the image loaded?

I have a script that is loading an image and displaying it in a QGraphicsView, it also sets the size of the GraphicsView to the dimensions of the image.
I need to be able to zoom in / out while only being able to draw on the image. Currently I can zoom in / out but can draw anywhere on the GraphicsView, meaning that when the image is zoomed out I can draw outside of it as seen below.
What can I do to make it to where the RubberBand can only be drawn on the image?
Here is my GraphicsView:
from PyQt5.QtWidgets import QGraphicsScene, QGraphicsView, QGraphicsItem
from PyQt5.QtCore import Qt
from PyQt5.QtGui import QBrush, QPen
from SelectionBand import *
from time import sleep
class CustomGraphicsView(QGraphicsView):
def __init__(self, parent=None):
super(CustomGraphicsView, self).__init__()
self.setScene(QGraphicsScene(self))
self.setAlignment(Qt.AlignLeft | Qt.AlignTop)
self.scene().setBackgroundBrush(Qt.gray)
self.brush = QBrush(Qt.green)
self.pen = QPen(Qt.blue)
def setImage(self, image):
self.img = self.scene().addPixmap(image)
self.resize(self.img.boundingRect().width(),self.img.boundingRect().height())
self.scene().setSceneRect(self.img.boundingRect())
def wheelEvent(self, event):
if event.modifiers() & Qt.ControlModifier:
x = event.angleDelta().y() / 120
if x > 0:
self.scale(1.05, 1.05)
elif x < 0:
self.scale(.95, .95)
else:
super().wheelEvent(event)
def mousePressEvent(self, event):
pos = self.mapToScene(event.pos())
self.xPos = pos.x()
self.yPos = pos.y()
self.band = SelectionBand(self)
self.band.setGeometry(pos.x(), pos.y(), 0, 0)
item = self.scene().addWidget(self.band)
item.setFlag(QGraphicsItem.ItemIsMovable)
item.setZValue(1)
def mouseMoveEvent(self, event):
pos = self.mapToScene(event.pos())
self.width = pos.x() - self.xPos
self.height = pos.y() - self.yPos
if self.width < 0 and self.height < 0:
self.band.setGeometry(pos.x(), pos.y(), abs(self.width), abs(self.height))
elif self.width < 0:
self.band.setGeometry(pos.x(), self.yPos, abs(self.width), abs(self.height))
elif self.height < 0:
self.band.setGeometry(self.xPos, pos.y(), abs(self.width), abs(self.height))
else: self.band.setGeometry(self.xPos, self.yPos, abs(self.width), abs(self.height))
Here is the RubberBand code:
from typing import ItemsView
from PyQt5 import QtWidgets
from PyQt5.QtWidgets import QApplication, QHBoxLayout, QRubberBand, QWidget,QSizeGrip
from PyQt5.QtCore import QEasingCurve, Qt, QEvent
from PyQt5.QtGui import QCursor, QHoverEvent, QPixmap
import sys
# Functionality:
# - On initial mouse click
# Create new RubberBand, select area until mouse is released
# - Allow for resizing when selected
class SelectionBand(QWidget):
def __init__(self, parent=None):
super(SelectionBand, self).__init__()
# self.setMouseTracking=True
self.draggable = True
self.dragging_threshold = 5
self.borderRadius = 5
self.setWindowFlags(Qt.SubWindow)
layout = QHBoxLayout(self)
layout.setContentsMargins(0,0,0,0)
layout.addWidget(
QSizeGrip(self), 0,
Qt.AlignRight | Qt.AlignBottom)
self._band = QtWidgets.QRubberBand(
QtWidgets.QRubberBand.Rectangle, self)
self._band.show()
self.show()
def resizeEvent(self, event):
self._band.resize(self.size())
Since you're using the scene rect as a reference, you must check that the geometry of the selection is within the scene margins, and you should also consider the minimum size of the rubber band widget.
Also, instead of making complex computations to check if the width or height are "negative", you can create a QRectF using the two points (the clicked position and that received from the mouse movement), then use the normalized() function to get the same rectangle but with positive width and height.
class CustomGraphicsView(QGraphicsView):
def mousePressEvent(self, event):
self.startPos = self.mapToScene(event.pos())
if not self.band:
self.band = SelectionBand(self)
self.bandItem = self.scene().addWidget(self.band)
self.bandItem.setFlag(QGraphicsItem.ItemIsMovable)
self.bandItem.setZValue(1)
sceneRect = self.sceneRect()
if self.startPos.x() < sceneRect.x():
self.startPos.setX(sceneRect.x())
elif self.startPos.x() > sceneRect.right() - self.band.minimumWidth():
self.startPos.setX(sceneRect.right() - self.band.minimumWidth())
if self.startPos.y() < sceneRect.y():
self.startPos.setY(sceneRect.y())
elif self.startPos.y() > sceneRect.bottom() - self.band.minimumHeight():
self.startPos.setY(sceneRect.bottom() - self.band.minimumHeight())
self.bandItem.setPos(self.startPos)
def mouseMoveEvent(self, event):
mousePos = self.mapToScene(event.pos())
# create a normalized rectangle based on the two points
rect = QRectF(self.startPos, mousePos).normalized()
if rect.width() < self.band.minimumWidth():
rect.setWidth(self.band.minimumWidth())
if rect.x() < self.startPos.x():
rect.moveLeft(self.startPos.x())
if rect.height() < self.band.minimumHeight():
rect.setHeight(self.band.minimumHeight())
if rect.y() < self.startPos.y():
rect.moveBottom(self.startPos.y())
sceneRect = self.sceneRect()
if rect.x() < sceneRect.x():
rect.setX(sceneRect.x())
elif rect.x() > sceneRect.right() - self.band.minimumWidth():
rect.setX(sceneRect.right() - self.band.minimumWidth())
if rect.right() > sceneRect.right():
rect.setRight(sceneRect.right())
if rect.y() < sceneRect.y():
rect.setY(sceneRect.y())
elif rect.y() > sceneRect.bottom() - self.band.minimumHeight():
rect.setY(sceneRect.bottom() - self.band.minimumHeight())
if rect.bottom() > sceneRect.bottom():
rect.setBottom(sceneRect.bottom())
self.bandItem.setGeometry(rect)
Consider that using a QWidget for the selection is not a good idea (and your implementation is a bit flawed anyway): you're not using most of the features of a QWidget (except for the size grip, which is not used anyway), so using it is a bit pointless.
You could instead use a subclass of QGraphicsRectItem. This has many advantages: not only it is more coherent for its usage and for the graphics view framework, but, for instance, you can use a cosmetic pen for the border, which will always be clearly shown even when zooming out.
class SelectionBandItem(QGraphicsRectItem):
_minimumWidth = _minimumHeight = 1
def __init__(self):
super().__init__()
color = QApplication.palette().highlight().color()
pen = QPen(color, 1)
pen.setCosmetic(True)
self.setPen(pen)
color.setAlpha(128)
self.setBrush(color)
self.setFlag(QGraphicsItem.ItemIsMovable)
def minimumWidth(self):
return self._minimumWidth
def minimumHeight(self):
return self._minimumHeight
def setGeometry(self, rect):
# we cannot use setRect, as it will only set the drawn rectangle but
# not the position of the item, which makes it less intuitive; it's
# better to use the given rectangle to set the position and then
# resize the item's rectangle
self.setPos(rect.topLeft())
current = self.rect()
current.setSize(rect.size())
self.setRect(current)
def geometry(self):
return self.rect().translated(self.pos())
class CustomGraphicsView(QGraphicsView):
# ...
def mousePressEvent(self, event):
self.startPos = self.mapToScene(event.pos())
if not self.band:
self.band = SelectionBandItem()
self.scene().addItem(self.band)
sceneRect = self.sceneRect()
if self.startPos.x() < sceneRect.x():
self.startPos.setX(sceneRect.x())
elif self.startPos.x() > sceneRect.right():
self.startPos.setX(sceneRect.right())
if self.startPos.y() < sceneRect.y():
self.startPos.setY(sceneRect.y())
elif self.startPos.y() > sceneRect.bottom():
self.startPos.setY(sceneRect.bottom())
self.band.setPos(self.startPos)
def mouseMoveEvent(self, event):
# all of the above, except for this:
self.band.setGeometry(rect)
Then, if you want to add support for the size grip, implement that in the mouseMoveEvent of the SelectionBandItem class.
Unrelated notes:
self.width and self.height are properties of all Qt widgets, you should not overwrite them with other values, especially if those values are not related to self; also, since you're continuously changing those values in the mouseMoveEvent, there's really no point in making them instance attributes: if you need access to the size of the band from other functions, just use self.band.width() or self.band.height();
there's no point in dividing the angle delta by 120 if you're just checking if it's greater or less than 0;
new items are always added on top of the item stack at 0 z-level, so there's little use in setting it to 1;
setMouseTracking is a function and not a variable;
when adding new widgets to a scene, you should not call show in their __init__, as it causes them to show as a normal window for an instant;
the stretch argument of QBoxLayout.addWidget already defaults to zero, if you want to specify the alignment it's better to explicitly use the keyword; also, if you add a widget to a layout there's no need for the parent argument: layout.addWidget(QSizeGrip(), alignment=Qt.AlignRight|Qt.AlignBottom);
if you're adding the graphics view to a layout, calling its resize() is completely useless; the scope of a layout manager is to manage the layout by setting the position and size of all its items, so manually requesting any geometry change is conceptually wrong; while it can resize the widget, as soon as any of the parent or sibling is resized, the layout will override that request;

QScrollArea: Scroll from item to item

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)

Increasing addAction icon size PyQt5

I have QLineEdit in which I wanted to add a clear button at the end of it. I enabled clear button in QLineEdit, it was working fine. I need to add a custom clear button at the end of the QLineEdit, so I used addAction() of QLineEdit and added my custom icon. The problem is that I can't find a solution to increase the size, I tried increasing the image size and it's not working.
class TextBox(QFrame):
def __init__(self, parent):
super(TextBox, self).__init__(parent=parent)
self.setObjectName("textBox")
self.isActive = False
self.lineEdit = QLineEdit()
self.lineEdit.addAction(QIcon("assets/icons/clear#3x.png"), QLineEdit.TrailingPosition)
A QIcon does not have a specific size, as it's only "decided" by the widget that uses it. While most widgets that use icons have a iconSize property, the icons of actions in a QLineEdit are shown in a different way.
Up until Qt 5.11 (excluded), the size was hardcoded to 16 pixels if the line edit was smaller than 34 pixels or 32 if it was taller.
Starting from Qt 5.11 the size is retrieved using the style (through pixelMetric()), and this can be overridden using a proxy style:
class Proxy(QtWidgets.QProxyStyle):
def pixelMetric(self, metric, opt=None, widget=None):
if (metric == self.PM_SmallIconSize and
isinstance(widget, QtWidgets.QLineEdit)):
size = widget.property('iconSize')
if size is not None:
return size
return widget.fontMetrics().height()
return super().pixelMetric(metric, opt, widget)
class LineEdit(QtWidgets.QLineEdit):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.setProperty('iconSize', 64)
# ...
For previous versions of Qt, though, things are a bit tricky. The only solution I came up with is to install event filters on all QToolButton that are children of the line edit (every action uses an internal QToolButton, including the clear action), manually set their geometry (required for correct click actions) and paint it in the event filter.
The following includes the proxystyle implementation in case the current version correctly supports it as explained before:
from PyQt5 import QtWidgets, QtCore, QtGui
if int(QtCore.QT_VERSION_STR.split('.')[1]) > 11:
IconSizeFix = False
else:
IconSizeFix = True
class Proxy(QtWidgets.QProxyStyle):
def pixelMetric(self, metric, opt=None, widget=None):
if (metric == self.PM_SmallIconSize and
isinstance(widget, QtWidgets.QLineEdit)):
size = widget.property('iconSize')
if size is not None:
return size
return widget.fontMetrics().height()
return super().pixelMetric(metric, opt, widget)
class LineEdit(QtWidgets.QLineEdit):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.setProperty('iconSize', 64)
self.setClearButtonEnabled(True)
self.addAction(QtGui.QIcon("icon.png"), self.TrailingPosition)
font = self.font()
font.setPointSize(48)
self.setFont(font)
def checkButtons(self):
for button in self.findChildren(QtWidgets.QToolButton):
button.installEventFilter(self)
def actionEvent(self, event):
super().actionEvent(event)
if IconSizeFix:
self.checkButtons()
def eventFilter(self, source, event):
if event.type() == QtCore.QEvent.Paint:
if (source.defaultAction().objectName() == '_q_qlineeditclearaction' and
not self.text()):
return True
qp = QtGui.QPainter(source)
state = QtGui.QIcon.Disabled
if source.isEnabled():
state = QtGui.QIcon.Active if source.isDown() else QtGui.QIcon.Normal
iconSize = QtCore.QSize(*[self.property('iconSize')] * 2)
qp.drawPixmap(source.rect(), source.icon().pixmap(
self.windowHandle(), iconSize, state, QtGui.QIcon.Off))
return True
return super().eventFilter(source, event)
def resizeEvent(self, event):
if not IconSizeFix:
return
self.checkButtons()
buttons = self.findChildren(QtWidgets.QToolButton)
if not buttons:
return
left = []
right = []
center = self.rect().center().x()
for button in buttons:
geo = button.geometry()
if geo.center().x() < center:
left.append(button)
else:
right.append(button)
left.sort(key=lambda x: x.geometry().x())
right.sort(key=lambda x: x.geometry().x())
iconSize = self.property('iconSize')
margin = iconSize / 4
top = (self.height() - iconSize) / 2
leftMargin = rightMargin = 0
if left:
x = margin
leftEdge = left[-1].geometry().right()
for button in left:
geo = QtCore.QRect(x, top, iconSize, iconSize)
button.setGeometry(geo)
x += iconSize + margin
leftMargin = x - leftEdge - margin
if right:
rightEdge = self.width() - margin
x = rightEdge - len(right) * iconSize - (len(right) - 1) * margin
rightMargin = self.width() - rightEdge + margin
for button in right:
geo = QtCore.QRect(x, top, iconSize, iconSize)
button.setGeometry(geo)
x += iconSize + margin
self.setTextMargins(leftMargin, 0, rightMargin, 0)
if __name__ == '__main__':
import sys
app = QtWidgets.QApplication(sys.argv)
app.setStyle(Proxy())
w = LineEdit()
w.show()
sys.exit(app.exec_())
Consider the following:
using the pre-5.11 workaround the positioning is not pixel-perfect, I tried to mimic what QLineEdit does to keep the code as simple as possible;
the painting is not exactly the same, most importantly the icon is missing the "highlight" shade when clicked, and if the style uses fade in/out effects for the clear button those effects won't be available;
the QProxyStyle method also affects the sizeHint of the QLineEdit, so it can not be smaller than the icon size, so use it with care;

Print items to pdf

I have a window with a QGraphicsScene as painter, and i want to render its elements to a pdf file on press of a button.
def generateReport(self):
lineList = {}
for i in self.circleList:
for j,k in i.lineItems:
if j not in lineList:
lineList[j] = [i, k]
printed = QPdfWriter("Output.pdf")
printed.setPageSize(QPagedPaintDevice.A4)
printer = QPainter(printed)
self.painter.render(printer)
for i,j in enumerate(lineList):
# j.scene().render(printer)
# lineList[j][0].scene().render(printer)
# lineList[j][1].scene().render(printer)
printer.drawText(0, self.painter.height() + i*200, f'{j.nameItem.toPlainText()}: {lineList[j][0].m_items[4].toPlainText()}, {lineList[j][1].m_items[4].toPlainText()}')
printer.end()
nameItem on j is the name label for the line, m_items[4] is the name label for each circle.
My issue is that i cant seem to get the exact height of the rendered scene, moreover I have zero clue as to how i could overflow the text to the next page should the contents not fit in one.
it would be lovely if i could somehow render every line and its corresponding circles seperately for each connection, stored in lineList
note: the line is a child of every circle , and the names of every line and circle are children of theirs, implemented much in the same way as the answer to my previous question where in lies my final issue of the grip items also being rendered.
I have discovered that I can create a new scene, move each item one by one and render it out to the pdf but this raises two separate issues
I cant add a line break and avoid overdrawing the new render over the previous one, and
I cant position the text as addText doesnt take positional arguments.
MRE:
import random
from fbs_runtime.application_context.PyQt5 import ApplicationContext
from PyQt5.QtCore import Qt
from PyQt5.QtGui import QPainter, QPdfWriter, QBrush, QPagedPaintDevice
from PyQt5.QtWidgets import (QDialog, QGraphicsScene,
QGraphicsView, QGridLayout,
QPushButton, QGraphicsEllipseItem)
class gui(QDialog):
def __init__(self, parent=None):
super(gui, self).__init__(parent)
self.resize(1280, 720)
self.painter = QGraphicsScene(0, 0, self.width() - 50, self.height() - 70)
self.painter.setBackgroundBrush(QBrush(Qt.white))
self.canvas = QGraphicsView(self.painter)
mainLayout = QGridLayout()
mainLayout.addWidget(self.canvas, 0, 0, -1, -1)
self.setLayout(mainLayout)
#property
def circleList(self):
return [item for item in self.painter.items() if isinstance(item, QGraphicsEllipseItem)]
def newCircle(self):
self.painter.addEllipse( random.randint(100, 400), random.randint(100, 400), 50 + random.random() * 200, 50 + random.random() * 200)
def generateReport(self):
printed = QPdfWriter("Output.pdf")
printed.setPageSize(QPagedPaintDevice.A4)
printer = QPainter(printed)
self.painter.render(printer)
for i,j in enumerate(self.circleList):
printer.drawText(0, printer.viewport().height() + i*200, 'test')
printer.end()
if __name__ == "__main__":
app = ApplicationContext()
test = gui()
test.newCircle()
test.newCircle()
test.newCircle()
test.generateReport()
test.show()
exit(app.app.exec_())
if possible , the ability to print, test then circle for all circles would be decent enough for me.
Incorrect output example:
To understand what painting is like, you have to understand how QGraphicsScene::render() method works:
void QGraphicsScene::render(QPainter *painter, const QRectF &target = QRectF(), const QRectF &source = QRectF(), Qt::AspectRatioMode aspectRatioMode = Qt::KeepAspectRatio)
Renders the source rect from scene into target, using painter. This
function is useful for capturing the contents of the scene onto a
paint device, such as a QImage (e.g., to take a screenshot), or for
printing with QPrinter. For example:
QGraphicsScene scene;
scene.addItem(...
...
QPrinter printer(QPrinter::HighResolution);
printer.setPaperSize(QPrinter::A4);
QPainter painter(&printer);
scene.render(&painter);
If source is a null rect, this function will use sceneRect() to
determine what to render. If target is a null rect, the dimensions of
painter's paint device will be used.
The source rect contents will be transformed according to
aspectRatioMode to fit into the target rect. By default, the aspect
ratio is kept, and source is scaled to fit in target.
See also QGraphicsView::render().
In your case, if the source is not passed, the entire sceneRect (0, 0, 1230, 650) will be copied and painted on the pdf page, if the sizes do not match, the sizes will be scaled. So from the above it follows that if you want to print an item then you must pass as source the space it occupies in the scene and hide the other items, and the target is the place where you want to paint, which involves calculating the new position based on where the previous item was printed.
Considering the above, a possible solution is the following:
import random
from PyQt5 import QtCore, QtGui, QtWidgets
class Gui(QtWidgets.QDialog):
def __init__(self, parent=None):
super(Gui, self).__init__(parent)
self.resize(1280, 720)
self.scene = QtWidgets.QGraphicsScene(
0, 0, self.width() - 50, self.height() - 70
)
self.scene.setBackgroundBrush(QtGui.QBrush(QtCore.Qt.white))
self.canvas = QtWidgets.QGraphicsView(self.scene)
mainLayout = QtWidgets.QGridLayout(self)
mainLayout.addWidget(self.canvas)
#property
def circleList(self):
return [
item
for item in self.scene.items()
if isinstance(item, QtWidgets.QGraphicsEllipseItem)
]
def newCircle(self):
self.scene.addEllipse(
random.randint(100, 400),
random.randint(100, 400),
50 + random.random() * 200,
50 + random.random() * 200,
)
def generateReport(self):
printer = QtGui.QPdfWriter("Output.pdf")
printer.setPageSize(QtGui.QPagedPaintDevice.A4)
printer.setResolution(100)
painter = QtGui.QPainter(printer)
delta = 20
f = painter.font()
f.setPixelSize(delta)
painter.setFont(f)
# hide all items
last_states = []
for item in self.scene.items():
last_states.append(item.isVisible())
item.setVisible(False)
target = QtCore.QRectF(0, 0, printer.width(), 0)
for i, item in enumerate(self.circleList):
item.setVisible(True)
source = item.mapToScene(item.boundingRect()).boundingRect()
target.setHeight(source.height())
if target.bottom() > printer.height():
printer.newPage()
target.moveTop(0)
self.scene.render(painter, target, source)
f = painter.font()
f.setPixelSize(delta)
painter.drawText(
QtCore.QRectF(
target.bottomLeft(), QtCore.QSizeF(printer.width(), delta + 5)
),
"test",
)
item.setVisible(False)
target.setTop(target.bottom() + delta + 20)
# restore visibility
for item, state in zip(self.scene.items(), last_states):
item.setVisible(state)
painter.end()
if __name__ == "__main__":
import sys
app = QtWidgets.QApplication(sys.argv)
w = Gui()
for _ in range(200):
w.newCircle()
w.generateReport()
w.show()
sys.exit(app.exec_())

Python PyQt5 - Is it possible to add a Button to press inside QTreeView?

I want to add a QPushButton to the tree view that ends with .pdf and when I click it I want to return the path for that Index it's assigned at.
This might not even be possible with the Native QTreeView but if anyone could guide me in the right direction that would be awesome!
To conclude more of what I would want is to have a QPushButton appear where that red square is below.
Current code for the "Tree View":
from PyQt5.QtMultimediaWidgets import *
from PyQt5.QtMultimedia import *
from PyQt5.QtWidgets import *
from PyQt5.QtCore import *
from PyQt5.QtGui import *
from PyQt5 import *
import os, sys
class MainMenu(QWidget):
def __init__(self, parent = None):
super(MainMenu, self).__init__(parent)
self.model = QFileSystemModel()
self.model.setRootPath(QDir.rootPath())
self.model.setFilter(QDir.NoDotAndDotDot | QDir.AllEntries | QDir.Dirs | QDir.Files)
self.proxy_model = QSortFilterProxyModel(recursiveFilteringEnabled = True, filterRole = QFileSystemModel.FileNameRole)
self.proxy_model.setSourceModel(self.model)
self.model.setReadOnly(False)
self.model.setNameFilterDisables(False)
self.indexRoot = self.model.index(self.model.rootPath())
self.treeView = QTreeView(self)
self.treeView.setModel(self.proxy_model)
self.treeView.setRootIndex(self.indexRoot)
self.treeView.setAnimated(True)
self.treeView.setIndentation(20)
self.treeView.setSortingEnabled(True)
self.treeView.setDragEnabled(False)
self.treeView.setAcceptDrops(False)
self.treeView.setDropIndicatorShown(True)
self.treeView.setEditTriggers(QTreeView.NoEditTriggers)
for i in range(1, self.treeView.model().columnCount()):
self.treeView.header().hideSection(i)
if __name__ == '__main__':
app = QApplication(sys.argv)
main = MainMenu()
main.show()
sys.exit(app.exec_())
For this you'll probably need an item delegate.
The idea is that you are going to leave basic item painting to the base class paint() function, and then paint a virtual button over it.
To achieve that, QStyleOptionButton is used against the view style (obtained from the option argument): you create a style option, init it from the view (option.widget, which will apply the basic rectangle of the widget, the font, palette, etc.), adjust the rectangle to suit your needs and finally paint it.
To better implement drawing (mouse hover effects, but also to ensure correct painting update), you'll also need to set mouse tracking to True for the tree view. This, amongst other checks explained in the code, allows you to draw the virtual button, including its hover or pressed states.
Finally, when the button is released and the mouse is within its boundaries, a buttonClicked signal is emitted, with the current index as argument.
class TreeButtonDelegate(QtWidgets.QStyledItemDelegate):
buttonClicked = QtCore.pyqtSignal(QtCore.QModelIndex, int)
def __init__(self, fsModel, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fsModel = fsModel
self.clickedPaths = {}
self._mousePos = None
self._pressed = False
self.minimumButtonWidth = 32
def getOption(self, option, index):
btnOption = QtWidgets.QStyleOptionButton()
# initialize the basic options with the view
btnOption.initFrom(option.widget)
clickedCount = self.clickedPaths.get(self.fsModel.filePath(index), 0)
if clickedCount:
btnOption.text = '{}'.format(clickedCount)
else:
btnOption.text = 'NO'
# the original option properties should never be touched, so we can't
# directly use it's "rect"; let's create a new one from it
btnOption.rect = QtCore.QRect(option.rect)
# adjust it to the minimum size
btnOption.rect.setLeft(option.rect.right() - self.minimumButtonWidth)
style = option.widget.style()
# get the available space for the contents of the button
textRect = style.subElementRect(
QtWidgets.QStyle.SE_PushButtonContents, btnOption)
# get the margins between the contents and the border, multiplied by 2
# since they're used for both the left and right side
margin = style.pixelMetric(
QtWidgets.QStyle.PM_ButtonMargin, btnOption) * 2
# the width of the current button text
textWidth = btnOption.fontMetrics.width(btnOption.text)
if textRect.width() < textWidth + margin:
# if the width is too small, adjust the *whole* button rect size
# to fit the contents
btnOption.rect.setLeft(btnOption.rect.left() - (
textWidth - textRect.width() + margin))
return btnOption
def editorEvent(self, event, model, option, index):
# map the proxy index to the fsModel
srcIndex = index.model().mapToSource(index)
# I'm just checking if it's a file, if you want to check the extension
# you might need to use fsModel.fileName(srcIndex)
if not self.fsModel.isDir(srcIndex):
if event.type() in (QtCore.QEvent.Enter, QtCore.QEvent.MouseMove):
self._mousePos = event.pos()
# request an update of the current index
option.widget.update(index)
elif event.type() == QtCore.QEvent.Leave:
self._mousePos = None
elif (event.type() in (QtCore.QEvent.MouseButtonPress, QtCore.QEvent.MouseButtonDblClick)
and event.button() == QtCore.Qt.LeftButton):
# check that the click is within the virtual button rectangle
if event.pos() in self.getOption(option, srcIndex).rect:
self._pressed = True
option.widget.update(index)
if event.type() == QtCore.QEvent.MouseButtonDblClick:
# do not send double click events
return True
elif event.type() == QtCore.QEvent.MouseButtonRelease:
if self._pressed and event.button() == QtCore.Qt.LeftButton:
# emit the click only if the release is within the button rect
if event.pos() in self.getOption(option, srcIndex).rect:
filePath = self.fsModel.filePath(srcIndex)
count = self.clickedPaths.setdefault(filePath, 0)
self.buttonClicked.emit(index, count + 1)
self.clickedPaths[filePath] += 1
self._pressed = False
option.widget.update(index)
return super().editorEvent(event, model, option, index)
def paint(self, painter, option, index):
super().paint(painter, option, index)
srcIndex = index.model().mapToSource(index)
if not self.fsModel.isDir(srcIndex):
btnOption = self.getOption(option, srcIndex)
# remove the focus rectangle, as it will be inherited from the view
btnOption.state &= ~QtWidgets.QStyle.State_HasFocus
if self._mousePos is not None and self._mousePos in btnOption.rect:
# if the style supports it, some kind of "glowing" border
# will be shown on the button
btnOption.state |= QtWidgets.QStyle.State_MouseOver
if self._pressed == QtCore.Qt.LeftButton:
# set the button pressed state
btnOption.state |= QtWidgets.QStyle.State_On
else:
# ensure that there's no mouse over state (see above)
btnOption.state &= ~QtWidgets.QStyle.State_MouseOver
# finally, draw the virtual button
option.widget.style().drawControl(
QtWidgets.QStyle.CE_PushButton, btnOption, painter)
class MainMenu(QWidget):
def __init__(self, parent = None):
super(MainMenu, self).__init__(parent)
# ...
self.treeView = QTreeView(self)
self.treeView.setMouseTracking(True)
# ...
self.treeDelegate = TreeDelegate(self.model)
self.treeView.setItemDelegateForColumn(0, self.treeDelegate)
self.treeDelegate.buttonClicked.connect(self.treeButtonClicked)
# ...
def treeButtonClicked(self, index, count):
print('{} clicked {} times'.format(index.data(), count))
Note: I implemented the click counter as you asked in the comments (and used an helper function to accomodate the longer function that computes the button size accordingly), just remember that this doesn't take into account the possibility of files renamed, removed and/or recreated (or files renamed overwriting an existing one). To obtain that you'll need to use a more complex approach than a simple path-based dictionary, possibly by implementing QFileSystemWatcher and checking for files removed/renamed.
Also note that to speed up things a bit I'm adding the source filesystem model to the init of the delegate so that it doesn't need to be found each time it's required for painting or mouse tracking.

Categories

Resources